diff --git a/.github/blob-size-allowlist.txt b/.github/blob-size-allowlist.txt index 23fb9d39734..68583370990 100644 --- a/.github/blob-size-allowlist.txt +++ b/.github/blob-size-allowlist.txt @@ -7,5 +7,6 @@ codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.js codex-rs/tui/tests/fixtures/oss-story.jsonl codex-rs/tui_app_server/tests/fixtures/oss-story.jsonl codex-rs/tui/src/app.rs +code-rs/tui/tests/fixtures/oss-story.jsonl code-rs/core/src/codex/streaming.rs code-rs/tui/src/chatwidget.rs diff --git a/build-fast.sh b/build-fast.sh index f958b86d964..23732246c64 100755 --- a/build-fast.sh +++ b/build-fast.sh @@ -303,10 +303,18 @@ fi echo "Cache bucket: ${CACHE_KEY} (${CACHE_KEY_SOURCE})" CLI_PACKAGE="$(sed -En 's/^name[[:space:]]*=[[:space:]]*"(.*)"/\1/p' cli/Cargo.toml | head -n1)" +CLI_BIN="$(awk -F'"' 'BEGIN{inbin=0} /^\[\[bin\]\]/{inbin=1; next} inbin && /^[[:space:]]*name[[:space:]]*=/{print $2; exit}' cli/Cargo.toml)" +if [ -z "${CLI_BIN}" ]; then + case "${WORKSPACE_DIR}" in + code-rs) CLI_BIN="code" ;; + codex-rs) CLI_BIN="codex" ;; + *) CLI_BIN="${CLI_PACKAGE}" ;; + esac +fi TUI_PACKAGE="$(sed -En 's/^name[[:space:]]*=[[:space:]]*"(.*)"/\1/p' tui/Cargo.toml | head -n1)" EXEC_PACKAGE="$(sed -En 's/^name[[:space:]]*=[[:space:]]*"(.*)"/\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)" +CRATE_PREFIX="${CLI_BIN}" +EXEC_BIN="$(awk -F'"' 'BEGIN{inbin=0} /^\[\[bin\]\]/{inbin=1; next} inbin && /^[[:space:]]*name[[:space:]]*=/{print $2; exit}' exec/Cargo.toml)" if [ -z "${EXEC_BIN}" ]; then EXEC_BIN="${EXEC_PACKAGE}" fi diff --git a/code-rs/.cargo/audit.toml b/code-rs/.cargo/audit.toml new file mode 100644 index 00000000000..9f029ada1d7 --- /dev/null +++ b/code-rs/.cargo/audit.toml @@ -0,0 +1,11 @@ +[advisories] +# Reviewed 2026-04-15. Keep this list in sync with ../deny.toml. +ignore = [ + "RUSTSEC-2024-0388", # derivative 2.2.0 via starlark; upstream crate is unmaintained + "RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map; upstream crate is unmaintained + "RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained + "RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it + "RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it + "RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net + "RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net +] diff --git a/code-rs/.cargo/config.toml b/code-rs/.cargo/config.toml new file mode 100644 index 00000000000..5d5eb8fd6ff --- /dev/null +++ b/code-rs/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "link-arg=/STACK:8388608"] + +# MSVC emits a warning about code that may trip "Cortex-A53 MPCore processor bug #843419" (see +# https://developer.arm.com/documentation/epm048406/latest) which is sometimes emitted by LLVM. +# Since Arm64 Windows 10+ isn't supported on that processor, it's safe to disable the warning. +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "link-arg=/STACK:8388608", "-C", "link-arg=/arm64hazardfree"] + +[target.'cfg(all(windows, target_env = "gnu"))'] +rustflags = ["-C", "link-arg=-Wl,--stack,8388608"] diff --git a/code-rs/.config/nextest.toml b/code-rs/.config/nextest.toml new file mode 100644 index 00000000000..86d00e4637f --- /dev/null +++ b/code-rs/.config/nextest.toml @@ -0,0 +1,46 @@ +[profile.default] +# Do not increase, fix your test instead +slow-timeout = { period = "15s", terminate-after = 2 } + +[test-groups.app_server_protocol_codegen] +max-threads = 1 + +[test-groups.app_server_integration] +max-threads = 1 + +[test-groups.core_apply_patch_cli_integration] +max-threads = 1 + +[test-groups.windows_sandbox_legacy_sessions] +max-threads = 1 + +[[profile.default.overrides]] +# Do not add new tests here +filter = 'test(rmcp_client) | test(humanlike_typing_1000_chars_appears_live_no_placeholder)' +slow-timeout = { period = "1m", terminate-after = 4 } + +[[profile.default.overrides]] +filter = 'test(approval_matrix_covers_all_modes)' +slow-timeout = { period = "30s", terminate-after = 2 } + +[[profile.default.overrides]] +filter = 'package(codex-app-server-protocol) & (test(typescript_schema_fixtures_match_generated) | test(json_schema_fixtures_match_generated) | test(generate_ts_with_experimental_api_retains_experimental_entries) | test(generated_ts_optional_nullable_fields_only_in_params) | test(generate_json_filters_experimental_fields_and_methods))' +test-group = 'app_server_protocol_codegen' + +[[profile.default.overrides]] +# These integration tests spawn a fresh app-server subprocess per case. +# Keep the library unit tests parallel. +filter = 'package(codex-app-server) & kind(test)' +test-group = 'app_server_integration' + +[[profile.default.overrides]] +# These tests exercise full Codex turns and apply_patch execution, and they are +# sensitive to Windows runner process-startup stalls when many cases launch at once. +filter = 'package(codex-core) & kind(test) & test(apply_patch_cli)' +test-group = 'core_apply_patch_cli_integration' + +[[profile.default.overrides]] +# These tests create restricted-token Windows child processes and private desktops. +# Serialize them to avoid exhausting Windows session/global desktop resources in CI. +filter = 'package(codex-windows-sandbox) & test(legacy_)' +test-group = 'windows_sandbox_legacy_sessions' diff --git a/code-rs/.github/workflows/cargo-audit.yml b/code-rs/.github/workflows/cargo-audit.yml new file mode 100644 index 00000000000..0c41471b657 --- /dev/null +++ b/code-rs/.github/workflows/cargo-audit.yml @@ -0,0 +1,26 @@ +name: Cargo audit + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + defaults: + run: + working-directory: codex-rs + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - name: Install cargo-audit + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + - name: Run cargo audit + run: cargo audit --deny warnings diff --git a/code-rs/.gitignore b/code-rs/.gitignore index e9962537683..e31566047d7 100644 --- a/code-rs/.gitignore +++ b/code-rs/.gitignore @@ -1,4 +1,5 @@ /target/ +/target-*/ # Recommended value of CARGO_TARGET_DIR when using Docker as explained in .devcontainer/README.md. /target-amd64/ diff --git a/code-rs/BUILD.bazel b/code-rs/BUILD.bazel new file mode 100644 index 00000000000..c32068a8261 --- /dev/null +++ b/code-rs/BUILD.bazel @@ -0,0 +1,17 @@ +exports_files([ + "clippy.toml", +]) + +filegroup( + name = "workspace-files", + srcs = glob( + [ + "*", + ".cargo/**", + ], + exclude = [ + "BUILD.bazel", + ], + ), + visibility = ["//visibility:public"], +) diff --git a/code-rs/Cargo.lock b/code-rs/Cargo.lock index 7dcb92b62ab..10b5cc2351c 100644 --- a/code-rs/Cargo.lock +++ b/code-rs/Cargo.lock @@ -12,6 +12,154 @@ dependencies = [ "regex", ] +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "bitflags 2.10.0", + "bytes", + "bytestring", + "derive_more 2.1.1", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "mime", + "percent-encoding", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +dependencies = [ + "actix-codec", + "actix-http", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "bytes", + "bytestring", + "cfg-if", + "derive_more 2.1.1", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.2", + "time", + "tracing", + "url", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -28,34 +176,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "agent-client-protocol" -version = "0.4.7" +name = "aead" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76133c067c37ae7a3641c3ad1ec88f36aac06cea5f9b3b49b5c29f18214f9101" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "agent-client-protocol-schema", - "anyhow", - "async-broadcast", - "async-trait", - "futures", - "log", - "parking_lot", - "schemars 1.0.4", - "serde", - "serde_json", + "crypto-common", + "generic-array", ] [[package]] -name = "agent-client-protocol-schema" -version = "0.4.11" +name = "aes" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61be4454304d7df1a5b44c4ae55e707ffe72eac4dfb1ef8762510ce8d8f6d924" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "anyhow", - "derive_more 2.0.1", - "schemars 1.0.4", - "serde", - "serde_json", + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "age" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf640be7658959746f1f0f2faab798f6098a9436a8e18e148d18bc9875e13c4b" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hmac", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom 7.1.3", + "pin-project", + "rand 0.8.5", + "rust-embed", + "scrypt", + "sha2", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "io_tee", + "nom 7.1.3", + "rand 0.8.5", + "secrecy", + "sha2", ] [[package]] @@ -65,6 +246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -72,22 +254,13 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "allocative" version = "0.3.4" @@ -109,7 +282,7 @@ checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -118,6 +291,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -181,35 +376,62 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "app_test_support" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "codex-app-server-protocol", + "codex-config", + "codex-core", + "codex-features", + "codex-login", + "codex-models-manager", + "codex-protocol", + "codex-utils-cargo-bin", + "core_test_support", + "serde", + "serde_json", + "shlex", + "tokio", + "uuid", + "wiremock", +] [[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arboard" @@ -218,7 +440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", - "image 0.25.8", + "image", "log", "objc2", "objc2-app-kit", @@ -233,22 +455,14 @@ dependencies = [ ] [[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" +name = "arc-swap" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "rustversion", ] -[[package]] -name = "arrayvec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" - [[package]] name = "arrayvec" version = "0.7.6" @@ -271,47 +485,42 @@ dependencies = [ ] [[package]] -name = "askama" -version = "0.12.1" +name = "asn1-rs" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ - "askama_derive", - "askama_escape", - "humansize", + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", "num-traits", - "percent-encoding", + "rusticata-macros", + "thiserror 2.0.18", + "time", ] [[package]] -name = "askama_derive" -version = "0.12.5" +name = "asn1-rs-derive" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ - "askama_parser", - "basic-toml", - "mime", - "mime_guess", "proc-macro2", "quote", - "serde", - "syn 2.0.108", + "syn 2.0.114", + "synstructure", ] [[package]] -name = "askama_escape" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" - -[[package]] -name = "askama_parser" -version = "0.2.1" +name = "asn1-rs-impl" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ - "nom 7.1.3", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -326,9 +535,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" dependencies = [ "anstyle", "bstr", @@ -339,6 +548,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -364,2513 +579,6294 @@ dependencies = [ ] [[package]] -name = "async-compression" -version = "0.4.32" +name = "async-executor" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ - "compression-codecs", - "compression-core", - "futures-core", + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", "pin-project-lite", - "tokio", + "slab", ] [[package]] -name = "async-stream" -version = "0.3.6" +name = "async-fs" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", + "async-lock", + "blocking", + "futures-lite", ] [[package]] -name = "async-stream-impl" -version = "0.3.6" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "event-listener", + "event-listener-strategy", + "pin-project-lite", ] [[package]] -name = "async-tungstenite" -version = "0.27.0" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5359381fd414fbdb272c48f2111c16cb0bb3447bfacd59311ff3736da9f6664" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "futures-io", - "futures-util", - "log", - "pin-project-lite", - "tokio", - "tungstenite", + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "async-recursion" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] -name = "autocfg" -version = "1.5.0" +name = "async-signal" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] [[package]] -name = "av1-grain" -version = "0.2.4" +name = "async-stream" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ - "anyhow", - "arrayvec 0.7.6", - "log", - "nom 7.1.3", - "num-rational 0.4.2", - "v_frame", + "async-stream-impl", + "futures-core", + "pin-project-lite", ] [[package]] -name = "avif-serialize" -version = "0.8.6" +name = "async-stream-impl" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ - "arrayvec 0.7.6", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "axum" -version = "0.8.6" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "axum-core" -version = "0.5.5" +name = "asynk-strim" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" dependencies = [ - "bytes", "futures-core", - "http", - "http-body", - "http-body-util", - "mime", "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", ] [[package]] -name = "backtrace" -version = "0.3.76" +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", + "num-traits", ] [[package]] -name = "base64" -version = "0.13.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "base64" -version = "0.22.1" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", +name = "aws-config" +version = "1.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-signin", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "base64-simd", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "p256", + "rand 0.8.5", + "ring", + "sha2", + "time", + "tokio", + "tracing", + "url", + "uuid", + "zeroize", ] [[package]] -name = "basic-toml" -version = "0.1.10" +name = "aws-credential-types" +version = "1.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" dependencies = [ - "serde", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", ] [[package]] -name = "beef" -version = "0.5.2" +name = "aws-lc-rs" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] [[package]] -name = "bincode" -version = "1.3.3" +name = "aws-lc-sys" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ - "serde", + "cc", + "cmake", + "dunce", + "fs_extra", ] [[package]] -name = "bit-set" -version = "0.5.3" +name = "aws-runtime" +version = "1.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "d81b5b2898f6798ad58f484856768bca817e3cd9de0974c24ae0f1113fe88f1b" dependencies = [ - "bit-vec", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", ] [[package]] -name = "bit-vec" -version = "0.6.3" +name = "aws-sdk-signin" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "c084bd63941916e1348cb8d9e05ac2e49bdd40a380e9167702683184c6c6be53" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] [[package]] -name = "bit_field" -version = "0.10.3" +name = "aws-sdk-sso" +version = "1.91.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +checksum = "8ee6402a36f27b52fe67661c6732d684b2635152b676aa2babbfb5204f99115d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] [[package]] -name = "bitflags" -version = "1.3.2" +name = "aws-sdk-ssooidc" +version = "1.93.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "a45a7f750bbd170ee3677671ad782d90b894548f4e4ae168302c57ec9de5cb3e" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] [[package]] -name = "bitflags" -version = "2.10.0" +name = "aws-sdk-sts" +version = "1.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "55542378e419558e6b1f398ca70adb0b2088077e79ad9f14eb09441f2f7b2164" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] [[package]] -name = "bitstream-io" -version = "2.6.0" +name = "aws-sigv4" +version = "1.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" +checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "aws-smithy-async" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" dependencies = [ - "generic-array", + "futures-util", + "pin-project-lite", + "tokio", ] [[package]] -name = "bstr" -version = "1.12.0" +name = "aws-smithy-http" +version = "0.62.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" dependencies = [ - "memchr", - "regex-automata", - "serde", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", ] [[package]] -name = "built" -version = "0.7.7" +name = "aws-smithy-http-client" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] [[package]] -name = "bumpalo" -version = "3.19.0" +name = "aws-smithy-json" +version = "0.61.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] [[package]] -name = "bytemuck" -version = "1.24.0" +name = "aws-smithy-observability" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "17f616c3f2260612fe44cede278bafa18e73e6479c4e393e2c4518cf2a9a228a" +dependencies = [ + "aws-smithy-runtime-api", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "aws-smithy-query" +version = "0.60.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "ae5d689cf437eae90460e944a58b5668530d433b4ff85789e69d2f2a556e057d" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] [[package]] -name = "byteorder-lite" -version = "0.1.0" +name = "aws-smithy-runtime" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +checksum = "a392db6c583ea4a912538afb86b7be7c5d8887d91604f50eb55c262ee1b4a5f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] [[package]] -name = "bytes" -version = "1.10.1" +name = "aws-smithy-runtime-api" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "cassowary" -version = "0.3.0" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] -name = "castaway" -version = "0.2.4" +name = "aws-smithy-types" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" dependencies = [ - "rustversion", + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", ] [[package]] -name = "cc" -version = "1.2.41" +name = "aws-smithy-xml" +version = "0.60.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", + "xmlparser", ] [[package]] -name = "cesu8" -version = "1.1.0" +name = "aws-types" +version = "1.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] [[package]] -name = "cfg-expr" -version = "0.15.8" +name = "axum" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "smallvec", - "target-lexicon", + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] [[package]] -name = "cfg_aliases" -version = "0.1.1" +name = "backtrace" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "chardetng" -version = "0.1.17" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" dependencies = [ - "cfg-if", - "encoding_rs", - "memchr", + "outref", + "vsimd", ] [[package]] -name = "chromiumoxide" -version = "0.7.0" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8380ce7721cc895fe8a184c49d615fe755b0c9a3d7986355cee847439fff907f" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" dependencies = [ - "async-tungstenite", - "base64 0.22.1", - "cfg-if", - "chromiumoxide_cdp", - "chromiumoxide_types", - "dunce", - "fnv", - "futures", - "futures-timer", - "pin-project-lite", - "reqwest", "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tracing", - "url", - "which", - "winreg 0.52.0", ] [[package]] -name = "chromiumoxide_cdp" -version = "0.7.0" +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bincode" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadbfb52fa0aeca43626f6c42ca04184b108b786f8e45198dc41a42aedcf2e50" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ - "chromiumoxide_pdl", - "chromiumoxide_types", "serde", - "serde_json", ] [[package]] -name = "chromiumoxide_pdl" -version = "0.7.0" +name = "bindgen" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c197aeb42872c5d4c923e7d8ad46d99a58fd0fec37f6491554ff677a6791d3c9" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "chromiumoxide_types", - "either", - "heck 0.4.1", - "once_cell", + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", - "serde", - "serde_json", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.114", ] [[package]] -name = "chromiumoxide_types" -version = "0.7.0" +name = "bit-set" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923486888790528d55ac37ec2f7483ed19eb8ccbb44701878e5856d1ceadf5d8" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "serde", - "serde_json", + "bit-vec", ] [[package]] -name = "chrono" -version = "0.4.42" +name = "bit-vec" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", -] +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] -name = "chunked_transfer" -version = "1.5.0" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "clap" -version = "4.5.50" +name = "bitflags" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ - "clap_builder", - "clap_derive", + "serde_core", ] [[package]] -name = "clap_builder" -version = "4.5.50" +name = "blake2" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim 0.11.1", - "terminal_size", + "digest", ] [[package]] -name = "clap_complete" -version = "4.5.59" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2348487adcd4631696ced64ccdb40d38ac4d31cae7f2eec8817fcea1b9d1c43c" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "clap", + "generic-array", ] [[package]] -name = "clap_derive" -version = "4.5.49" +name = "block-padding" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.108", + "generic-array", ] [[package]] -name = "clap_lex" -version = "0.7.6" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] [[package]] -name = "clipboard-win" -version = "5.4.1" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "error-code", + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "cmp_any" -version = "0.8.1" +name = "bm25" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +checksum = "1cbd8ffdfb7b4c2ff038726178a780a94f90525ed0ad264c0afaa75dd8c18a64" +dependencies = [ + "cached", + "deunicode", + "fxhash", + "rust-stemmers", + "stop-words", + "unicode-segmentation", +] [[package]] -name = "code-ansi-escape" -version = "0.0.0" +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" dependencies = [ - "ansi-to-tui", - "ratatui", - "tracing", + "cfg_aliases 0.2.1", ] [[package]] -name = "code-app-server" -version = "0.0.0" +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ - "anyhow", - "assert_cmd", - "clap", - "code-app-server-protocol", - "code-arg0", - "code-common", - "code-core", - "code-file-search", - "code-login", - "code-mcp-types", - "code-protocol", - "code-utils-json-to-toml", - "codex-utils-absolute-path", - "futures", - "owo-colors", - "reqwest", + "memchr", + "regex-automata", "serde", - "serde_json", - "sha1", - "tokio", - "tokio-tungstenite", - "toml 0.9.8", - "tracing", - "tracing-subscriber", - "uuid", ] [[package]] -name = "code-app-server-protocol" -version = "0.0.0" +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" dependencies = [ - "anyhow", - "clap", - "code-protocol", - "codex-experimental-api-macros", - "codex-utils-absolute-path", - "codex-utils-cargo-bin", - "inventory", - "pretty_assertions", - "schemars 0.8.22", - "serde", - "serde_json", - "similar", - "strum_macros 0.27.2", - "tempfile", - "thiserror 2.0.17", - "ts-rs", - "uuid", + "bytes", + "either", ] [[package]] -name = "code-apply-patch" -version = "0.0.0" +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" dependencies = [ - "anyhow", - "assert_cmd", - "pretty_assertions", - "similar", - "tempfile", - "thiserror 2.0.17", - "tree-sitter", - "tree-sitter-bash", + "bytes", ] [[package]] -name = "code-arg0" -version = "0.0.0" +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" dependencies = [ - "anyhow", - "code-apply-patch", - "code-core", - "code-linux-sandbox", - "dotenvy", - "tempfile", - "tokio", + "bzip2-sys", + "libc", ] [[package]] -name = "code-auto-drive-core" -version = "0.0.0" +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" dependencies = [ - "anyhow", - "chrono", - "code-app-server-protocol", - "code-common", - "code-core", - "code-git-tooling", - "code-protocol", - "futures", - "once_cell", - "pretty_assertions", - "rand 0.9.2", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tokio-util", - "tracing", - "uuid", + "bzip2-sys", ] [[package]] -name = "code-auto-drive-diagnostics" -version = "0.0.0" +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" dependencies = [ - "anyhow", - "code-auto-drive-core", - "code-core", - "code-protocol", - "pretty_assertions", - "serde", - "serde_json", - "thiserror 2.0.17", - "tracing", + "cc", + "pkg-config", ] [[package]] -name = "code-backend-client" -version = "0.0.0" +name = "cached" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" dependencies = [ - "anyhow", - "code-backend-openapi-models", - "pretty_assertions", - "reqwest", - "serde", - "serde_json", + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.15.5", + "once_cell", + "thiserror 2.0.18", + "web-time", ] [[package]] -name = "code-backend-openapi-models" -version = "0.0.0" +name = "cached_proc_macro" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" dependencies = [ - "serde", - "serde_json", + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "code-browser" -version = "0.0.0" +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "calendrical_calculations" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7" dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "bytes", - "chromiumoxide", - "chromiumoxide_types", - "chrono", - "fs2", - "futures", - "once_cell", - "rand 0.9.2", - "regex", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.17", - "tokio", - "tokio-test", - "tracing", - "url", - "uuid", + "core_maths", + "displaydoc", ] [[package]] -name = "code-chatgpt" -version = "0.0.0" +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ - "anyhow", - "clap", - "code-app-server-protocol", - "code-common", - "code-core", - "code-git-apply", - "code-protocol", - "serde", - "serde_json", - "tempfile", - "tokio", + "rustversion", ] [[package]] -name = "code-cli" -version = "0.0.0" +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "anyhow", - "chrono", - "clap", - "clap_complete", - "code-app-server", - "code-app-server-protocol", - "code-arg0", - "code-chatgpt", - "code-cloud-tasks", - "code-common", - "code-core", - "code-exec", - "code-login", - "code-mcp-server", - "code-process-hardening", - "code-protocol", - "code-protocol-ts", - "code-responses-api-proxy", - "code-tui", - "code-version", - "ctor 0.5.0", - "filetime", - "flate2", - "futures", - "owo-colors", - "regex", - "reqwest", - "serde", - "serde_json", - "sha2", - "supports-color 3.0.2", - "tar", - "tempfile", - "tokio", - "tokio-tungstenite", - "tracing", - "tracing-subscriber", - "uuid", - "which", - "zip", + "cipher", ] [[package]] -name = "code-cloud-tasks" -version = "0.0.0" +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "chrono", - "clap", - "code-cloud-tasks-client", - "code-common", - "code-core", - "code-login", - "code-tui", - "crossterm", - "ratatui", - "reqwest", - "serde", - "serde_json", - "throbber-widgets-tui", - "tokio", - "tokio-stream", - "tracing", - "tracing-subscriber", - "unicode-segmentation", - "unicode-width 0.1.14", + "find-msvc-tools", + "jobserver", + "libc", + "shlex", ] [[package]] -name = "code-cloud-tasks-client" -version = "0.0.0" +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "anyhow", - "async-trait", - "chrono", - "code-backend-client", - "code-git-apply", - "diffy", - "serde", - "serde_json", - "thiserror 2.0.17", + "nom 7.1.3", ] [[package]] -name = "code-common" -version = "0.0.0" +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ - "clap", - "code-app-server-protocol", - "code-core", - "code-protocol", - "once_cell", - "serde", - "toml 0.9.8", + "smallvec", + "target-lexicon", ] [[package]] -name = "code-core" -version = "0.0.0" +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ - "agent-client-protocol", - "anyhow", - "askama", - "assert_cmd", - "async-channel", - "async-trait", - "base64 0.22.1", - "bytes", - "chardetng", - "chrono", - "code-app-server-protocol", - "code-apply-patch", - "code-browser", - "code-file-search", - "code-git-tooling", - "code-mcp-types", - "code-otel", - "code-protocol", - "code-rmcp-client", - "code-version", - "codex-utils-absolute-path", - "core-foundation 0.9.4", - "crc32fast", - "dirs", - "dunce", + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", "encoding_rs", - "env-flags", - "eventsource-stream", - "filetime", - "fs2", - "futures", - "futures-util", - "htmd", - "httpdate", - "img_hash", - "indexmap 2.12.0", - "landlock", - "lazy_static", - "libc", - "maplit", - "mime_guess", - "once_cell", - "openssl-sys", - "os_info", - "path-clean", - "portable-pty", - "pretty_assertions", - "rand 0.9.2", - "regex-lite", - "reqwest", - "schemars 0.8.22", - "seccompiler", + "memchr", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", "serde", - "serde_bytes", - "serde_ignored", - "serde_json", - "serde_yaml", - "serial_test", - "sha1", - "shlex", - "similar", - "strum_macros 0.27.2", - "tempfile", - "thiserror 2.0.17", - "time", - "tokio", - "tokio-stream", - "tokio-test", - "tokio-tungstenite", - "tokio-util", - "toml 0.9.8", - "toml_edit 0.23.7", - "tracing", - "tree-sitter", - "tree-sitter-bash", - "url", - "uuid", - "walkdir", - "which", - "wildmatch", - "windows-sys 0.61.2", - "wiremock", + "wasm-bindgen", + "windows-link", ] [[package]] -name = "code-exec" -version = "0.0.0" +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "anyhow", - "chrono", - "clap", - "code-app-server-protocol", - "code-arg0", - "code-auto-drive-core", - "code-common", - "code-core", - "code-git-tooling", - "code-ollama", - "code-protocol", - "code-version", - "filetime", + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", "libc", - "once_cell", - "opentelemetry-appender-tracing", - "owo-colors", - "serde", - "serde_json", - "shlex", - "supports-color 3.0.2", - "tempfile", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", + "libloading", ] [[package]] -name = "code-execpolicy" -version = "0.0.0" +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ - "allocative", - "anyhow", - "clap", - "derive_more 2.0.1", - "env_logger", - "log", - "multimap", - "path-absolutize", - "regex-lite", - "serde", - "serde_json", - "serde_with", - "starlark", - "tempfile", + "clap_builder", + "clap_derive", ] [[package]] -name = "code-file-search" -version = "0.0.0" +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.5.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" dependencies = [ - "anyhow", "clap", - "ignore", - "nucleo-matcher", - "serde", - "serde_json", - "tokio", ] [[package]] -name = "code-git-apply" -version = "0.0.0" +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "once_cell", - "regex", - "tempfile", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "code-git-tooling" -version = "0.0.0" +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ - "pretty_assertions", - "schemars 0.8.22", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.17", - "ts-rs", - "walkdir", + "error-code", ] [[package]] -name = "code-linux-sandbox" -version = "0.0.0" +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" dependencies = [ - "clap", - "code-core", - "landlock", - "libc", - "seccompiler", - "tempfile", - "tokio", + "hashbrown 0.16.1", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmp_any" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.1", ] [[package]] -name = "code-login" +name = "codex-agent-graph-store" version = "0.0.0" dependencies = [ - "base64 0.22.1", - "chrono", - "code-app-server-protocol", - "code-browser", - "code-core", - "rand 0.9.2", - "reqwest", + "async-trait", + "codex-protocol", + "codex-state", + "pretty_assertions", "serde", "serde_json", - "sha2", "tempfile", - "tiny_http", + "thiserror 2.0.18", "tokio", - "url", - "urlencoding", - "webbrowser", ] [[package]] -name = "code-mcp-client" +name = "codex-agent-identity" version = "0.0.0" dependencies = [ "anyhow", - "code-mcp-types", + "base64 0.22.1", + "chrono", + "codex-protocol", + "crypto_box", + "ed25519-dalek", + "jsonwebtoken", + "pretty_assertions", + "rand 0.9.3", + "reqwest", "serde", "serde_json", - "tokio", - "tracing", - "tracing-subscriber", + "sha2", ] [[package]] -name = "code-mcp-server" +name = "codex-analytics" version = "0.0.0" dependencies = [ - "agent-client-protocol", - "anyhow", - "code-app-server", - "code-app-server-protocol", - "code-arg0", - "code-common", - "code-core", - "code-mcp-types", - "code-protocol", - "code-utils-json-to-toml", + "codex-app-server-protocol", + "codex-git-utils", + "codex-login", + "codex-model-provider", + "codex-plugin", + "codex-protocol", + "codex-utils-absolute-path", + "os_info", "pretty_assertions", - "schemars 0.8.22", "serde", "serde_json", - "shlex", + "sha1", "tokio", "tracing", - "tracing-subscriber", - "uuid", ] [[package]] -name = "code-mcp-types" +name = "codex-ansi-escape" version = "0.0.0" dependencies = [ - "mcp-types", - "serde_json", + "ansi-to-tui", + "ratatui", + "tracing", ] [[package]] -name = "code-ollama" +name = "codex-api" version = "0.0.0" dependencies = [ - "async-stream", + "anyhow", + "assert_matches", + "async-channel", + "async-trait", + "base64 0.22.1", "bytes", - "code-core", + "chrono", + "codex-client", + "codex-protocol", + "codex-utils-rustls-provider", + "eventsource-stream", "futures", + "http 1.4.0", + "pretty_assertions", + "regex-lite", "reqwest", + "serde", "serde_json", + "tempfile", + "thiserror 2.0.18", "tokio", + "tokio-test", + "tokio-tungstenite", + "tokio-util", "tracing", - "wiremock", + "tungstenite", + "url", + "wiremock", ] [[package]] -name = "code-otel" +name = "codex-app-server" version = "0.0.0" dependencies = [ + "anyhow", + "app_test_support", + "async-trait", + "axum", + "base64 0.22.1", "chrono", - "code-app-server-protocol", - "code-protocol", - "eventsource-stream", + "clap", + "codex-analytics", + "codex-app-server-protocol", + "codex-app-server-transport", + "codex-arg0", + "codex-backend-client", + "codex-chatgpt", + "codex-cloud-requirements", + "codex-config", + "codex-core", + "codex-core-plugins", + "codex-exec-server", + "codex-external-agent-migration", + "codex-external-agent-sessions", + "codex-features", + "codex-feedback", + "codex-file-search", + "codex-git-utils", + "codex-hooks", + "codex-login", + "codex-mcp", + "codex-memories-write", + "codex-model-provider", + "codex-model-provider-info", + "codex-models-manager", + "codex-otel", + "codex-plugin", + "codex-protocol", + "codex-rmcp-client", + "codex-rollout", + "codex-sandboxing", + "codex-shell-command", + "codex-state", + "codex-thread-store", + "codex-tools", + "codex-utils-absolute-path", + "codex-utils-cargo-bin", + "codex-utils-cli", + "codex-utils-json-to-toml", + "codex-utils-pty", + "core_test_support", + "flate2", + "futures", + "hmac", "opentelemetry", - "opentelemetry-appender-tracing", - "opentelemetry-otlp", - "opentelemetry-semantic-conventions", "opentelemetry_sdk", + "pretty_assertions", "reqwest", + "rmcp", "serde", "serde_json", - "strum_macros 0.27.2", + "serial_test", + "sha2", + "shlex", + "tar", + "tempfile", + "thiserror 2.0.18", + "time", "tokio", - "tonic", + "tokio-tungstenite", + "tokio-util", + "toml 0.9.11+spec-1.1.0", + "toml_edit 0.24.0+spec-1.1.0", "tracing", + "tracing-opentelemetry", "tracing-subscriber", + "url", + "uuid", + "wiremock", ] [[package]] -name = "code-process-hardening" +name = "codex-app-server-client" version = "0.0.0" dependencies = [ - "libc", + "codex-app-server", + "codex-app-server-protocol", + "codex-arg0", + "codex-config", + "codex-core", + "codex-exec-server", + "codex-feedback", + "codex-protocol", + "codex-utils-rustls-provider", + "futures", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-tungstenite", + "toml 0.9.11+spec-1.1.0", + "tracing", + "url", ] [[package]] -name = "code-protocol" +name = "codex-app-server-protocol" version = "0.0.0" dependencies = [ "anyhow", - "code-execpolicy", - "code-git-tooling", - "code-mcp-types", + "clap", + "codex-experimental-api-macros", + "codex-protocol", + "codex-shell-command", "codex-utils-absolute-path", - "codex-utils-image", - "icu_decimal", - "icu_locale_core", - "icu_provider", - "mime_guess", + "codex-utils-cargo-bin", + "inventory", "pretty_assertions", + "rmcp", "schemars 0.8.22", "serde", "serde_json", "serde_with", - "strum 0.27.2", - "strum_macros 0.27.2", - "sys-locale", + "similar", + "strum_macros 0.28.0", "tempfile", + "thiserror 2.0.18", "tracing", "ts-rs", "uuid", ] [[package]] -name = "code-protocol-ts" +name = "codex-app-server-test-client" version = "0.0.0" dependencies = [ "anyhow", "clap", - "code-app-server-protocol", - "code-mcp-types", - "code-protocol", - "ts-rs", + "codex-app-server-protocol", + "codex-core", + "codex-otel", + "codex-protocol", + "codex-utils-cli", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "tungstenite", + "url", + "uuid", ] [[package]] -name = "code-responses-api-proxy" +name = "codex-app-server-transport" version = "0.0.0" dependencies = [ "anyhow", + "axum", + "base64 0.22.1", + "chrono", "clap", - "code-process-hardening", - "ctor 0.5.0", - "libc", + "codex-api", + "codex-app-server-protocol", + "codex-config", + "codex-core", + "codex-login", + "codex-model-provider", + "codex-state", + "codex-uds", + "codex-utils-absolute-path", + "codex-utils-rustls-provider", + "constant_time_eq 0.3.1", + "futures", + "gethostname", + "hmac", + "jsonwebtoken", + "owo-colors", + "pretty_assertions", + "serde", + "serde_json", + "sha2", + "tempfile", + "time", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "codex-apply-patch" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "assert_matches", + "codex-exec-server", + "codex-utils-absolute-path", + "codex-utils-cargo-bin", + "pretty_assertions", + "similar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tree-sitter", + "tree-sitter-bash", +] + +[[package]] +name = "codex-arg0" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-apply-patch", + "codex-exec-server", + "codex-linux-sandbox", + "codex-sandboxing", + "codex-shell-escalation", + "codex-utils-absolute-path", + "codex-utils-home-dir", + "dotenvy", + "tempfile", + "tokio", +] + +[[package]] +name = "codex-async-utils" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "tokio", + "tokio-util", +] + +[[package]] +name = "codex-aws-auth" +version = "0.0.0" +dependencies = [ + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-types", + "bytes", + "http 1.4.0", + "pretty_assertions", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "codex-backend-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-api", + "codex-backend-openapi-models", + "codex-client", + "codex-login", + "codex-model-provider", + "codex-protocol", + "pretty_assertions", "reqwest", "serde", "serde_json", - "tiny_http", - "zeroize", ] [[package]] -name = "code-rmcp-client" +name = "codex-backend-openapi-models" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "serde_with", +] + +[[package]] +name = "codex-builtin-mcps" version = "0.0.0" dependencies = [ "anyhow", - "axum", - "code-mcp-types", + "codex-memories-mcp", + "codex-utils-absolute-path", + "pretty_assertions", + "tokio", +] + +[[package]] +name = "codex-bwrap" +version = "0.0.0" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "codex-chatgpt" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-app-server-protocol", + "codex-connectors", + "codex-core", + "codex-core-plugins", + "codex-git-utils", + "codex-login", + "codex-model-provider", + "codex-plugin", + "codex-utils-cargo-bin", + "codex-utils-cli", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "codex-cli" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "assert_matches", + "clap", + "clap_complete", + "codex-app-server", + "codex-app-server-protocol", + "codex-app-server-test-client", + "codex-arg0", + "codex-chatgpt", + "codex-cloud-tasks", + "codex-config", + "codex-core", + "codex-core-plugins", + "codex-exec", + "codex-exec-server", + "codex-execpolicy", + "codex-features", + "codex-login", + "codex-mcp", + "codex-mcp-server", + "codex-memories-write", + "codex-models-manager", + "codex-protocol", + "codex-responses-api-proxy", + "codex-rmcp-client", + "codex-rollout-trace", + "codex-sandboxing", + "codex-state", + "codex-stdio-to-uds", + "codex-terminal-detection", + "codex-tui", + "codex-utils-absolute-path", + "codex-utils-cargo-bin", + "codex-utils-cli", + "codex-utils-path", + "codex-windows-sandbox", + "libc", + "owo-colors", + "predicates", + "pretty_assertions", + "regex-lite", + "serde_json", + "sqlx", + "supports-color 3.0.2", + "tempfile", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "codex-client" +version = "0.0.0" +dependencies = [ + "async-trait", + "bytes", + "codex-utils-cargo-bin", + "codex-utils-rustls-provider", + "eventsource-stream", "futures", + "http 1.4.0", + "opentelemetry", + "opentelemetry_sdk", "pretty_assertions", + "rand 0.9.3", + "rcgen", "reqwest", - "rmcp", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "serde", "serde_json", + "tempfile", + "thiserror 2.0.18", "tokio", "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "zstd 0.13.3", ] [[package]] -name = "code-tui" +name = "codex-cloud-requirements" version = "0.0.0" dependencies = [ - "anyhow", - "arboard", + "async-trait", "base64 0.22.1", "chrono", + "codex-backend-client", + "codex-config", + "codex-core", + "codex-login", + "codex-otel", + "codex-protocol", + "hmac", + "pretty_assertions", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", +] + +[[package]] +name = "codex-cloud-tasks" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", "clap", - "code-ansi-escape", - "code-arg0", - "code-auto-drive-core", - "code-auto-drive-diagnostics", - "code-browser", - "code-cloud-tasks-client", - "code-common", - "code-core", - "code-file-search", - "code-git-tooling", - "code-login", - "code-mcp-types", - "code-ollama", - "code-protocol", - "code-tui", - "code-version", - "color-eyre", + "codex-client", + "codex-cloud-tasks-client", + "codex-cloud-tasks-mock-client", + "codex-core", + "codex-git-utils", + "codex-login", + "codex-model-provider", + "codex-tui", + "codex-utils-cli", "crossterm", - "diffy", - "filetime", - "fs2", - "futures", - "image 0.25.8", - "indoc", - "insta", - "lazy_static", - "libc", - "once_cell", - "path-clean", - "portable-pty", + "owo-colors", "pretty_assertions", - "pulldown-cmark", - "rand 0.9.2", "ratatui", - "ratatui-image", - "regex-lite", "reqwest", "serde", - "serde_bytes", "serde_json", - "sha2", - "shlex", - "signal-hook", - "strip-ansi-escapes", - "strum 0.27.2", - "strum_macros 0.27.2", "supports-color 3.0.2", - "syntect", - "tempfile", - "textwrap 0.16.2", - "thiserror 1.0.69", - "time", "tokio", - "tokio-tungstenite", - "tokio-util", - "toml 0.9.8", + "tokio-stream", "tracing", - "tracing-appender", "tracing-subscriber", - "tui-input", - "tui-markdown", - "unicode-segmentation", - "unicode-width 0.1.14", - "url", + "unicode-width 0.2.1", +] + +[[package]] +name = "codex-cloud-tasks-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codex-api", + "codex-backend-client", + "codex-git-utils", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "codex-cloud-tasks-mock-client" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "codex-cloud-tasks-client", + "diffy", +] + +[[package]] +name = "codex-code-mode" +version = "0.0.0" +dependencies = [ + "async-channel", + "async-trait", + "codex-protocol", + "deno_core_icudata", + "pretty_assertions", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "v8", +] + +[[package]] +name = "codex-collaboration-mode-templates" +version = "0.0.0" + +[[package]] +name = "codex-config" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "codex-app-server-protocol", + "codex-execpolicy", + "codex-features", + "codex-file-system", + "codex-git-utils", + "codex-model-provider-info", + "codex-network-proxy", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-path", + "core-foundation 0.9.4", + "dns-lookup", + "dunce", + "futures", + "gethostname", + "libc", + "multimap", + "pretty_assertions", + "prost 0.14.3", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "toml 0.9.11+spec-1.1.0", + "toml_edit 0.24.0+spec-1.1.0", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "wildmatch", + "winapi-util", + "windows-sys 0.52.0", +] + +[[package]] +name = "codex-connectors" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-app-server-protocol", + "pretty_assertions", + "serde", + "tokio", "urlencoding", +] + +[[package]] +name = "codex-core" +version = "0.0.0" +dependencies = [ + "anyhow", + "arc-swap", + "assert_cmd", + "assert_matches", + "async-channel", + "async-trait", + "base64 0.22.1", + "bm25", + "chrono", + "clap", + "codex-analytics", + "codex-api", + "codex-app-server-protocol", + "codex-apply-patch", + "codex-async-utils", + "codex-code-mode", + "codex-config", + "codex-connectors", + "codex-core-plugins", + "codex-core-skills", + "codex-exec-server", + "codex-execpolicy", + "codex-features", + "codex-feedback", + "codex-git-utils", + "codex-hooks", + "codex-login", + "codex-mcp", + "codex-memories-read", + "codex-model-provider", + "codex-model-provider-info", + "codex-models-manager", + "codex-network-proxy", + "codex-otel", + "codex-plugin", + "codex-protocol", + "codex-response-debug-context", + "codex-rmcp-client", + "codex-rollout", + "codex-rollout-trace", + "codex-sandboxing", + "codex-shell-command", + "codex-shell-escalation", + "codex-state", + "codex-terminal-detection", + "codex-test-binary-support", + "codex-thread-store", + "codex-tools", + "codex-utils-absolute-path", + "codex-utils-cache", + "codex-utils-cargo-bin", + "codex-utils-home-dir", + "codex-utils-image", + "codex-utils-output-truncation", + "codex-utils-path", + "codex-utils-plugins", + "codex-utils-pty", + "codex-utils-readiness", + "codex-utils-stream-parser", + "codex-utils-string", + "codex-utils-template", + "codex-windows-sandbox", + "core_test_support", + "csv", + "ctor 0.6.3", + "dirs", + "dunce", + "env-flags", + "eventsource-stream", + "futures", + "http 1.4.0", + "iana-time-zone", + "image", + "indexmap 2.13.0", + "insta", + "libc", + "maplit", + "notify", + "once_cell", + "openssl-sys", + "opentelemetry", + "opentelemetry_sdk", + "predicates", + "pretty_assertions", + "rand 0.9.3", + "regex-lite", + "reqwest", + "rmcp", + "serde", + "serde_json", + "serial_test", + "sha1", + "shlex", + "similar", + "tempfile", + "test-case", + "test-log", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tokio-util", + "toml 0.9.11+spec-1.1.0", + "toml_edit 0.24.0+spec-1.1.0", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "tracing-test", + "url", "uuid", - "vt100", - "which", + "walkdir", + "which 8.0.0", + "whoami", + "wiremock", + "zstd 0.13.3", +] + +[[package]] +name = "codex-core-api" +version = "0.0.0" +dependencies = [ + "codex-analytics", + "codex-app-server-protocol", + "codex-arg0", + "codex-config", + "codex-core", + "codex-exec-server", + "codex-features", + "codex-login", + "codex-model-provider-info", + "codex-models-manager", + "codex-protocol", + "codex-utils-absolute-path", +] + +[[package]] +name = "codex-core-plugins" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-analytics", + "codex-app-server-protocol", + "codex-config", + "codex-core-skills", + "codex-exec-server", + "codex-git-utils", + "codex-hooks", + "codex-login", + "codex-model-provider", + "codex-otel", + "codex-plugin", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-plugins", + "dirs", + "flate2", + "libc", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "tar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", + "url", + "wiremock", + "zip 2.4.2", +] + +[[package]] +name = "codex-core-skills" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-analytics", + "codex-app-server-protocol", + "codex-config", + "codex-exec-server", + "codex-login", + "codex-model-provider", + "codex-otel", + "codex-protocol", + "codex-skills", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "codex-utils-plugins", + "dirs", + "dunce", + "pretty_assertions", + "serde", + "serde_json", + "serde_yaml", + "shlex", + "tempfile", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", + "zip 2.4.2", +] + +[[package]] +name = "codex-debug-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-app-server-protocol", + "pretty_assertions", + "serde", + "serde_json", +] + +[[package]] +name = "codex-exec" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "codex-app-server-client", + "codex-app-server-protocol", + "codex-apply-patch", + "codex-arg0", + "codex-cloud-requirements", + "codex-config", + "codex-core", + "codex-feedback", + "codex-git-utils", + "codex-login", + "codex-model-provider-info", + "codex-otel", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-cargo-bin", + "codex-utils-cli", + "codex-utils-oss", + "core_test_support", + "libc", + "opentelemetry", + "opentelemetry_sdk", + "owo-colors", + "predicates", + "pretty_assertions", + "serde", + "serde_json", + "supports-color 3.0.2", + "tempfile", + "tokio", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "ts-rs", + "uuid", + "walkdir", + "wiremock", +] + +[[package]] +name = "codex-exec-server" +version = "0.0.0" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "base64 0.22.1", + "bytes", + "codex-app-server-protocol", + "codex-client", + "codex-file-system", + "codex-protocol", + "codex-sandboxing", + "codex-test-binary-support", + "codex-utils-absolute-path", + "codex-utils-pty", + "ctor 0.6.3", + "futures", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", + "test-case", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tokio-util", + "toml 0.9.11+spec-1.1.0", + "tracing", + "uuid", + "wiremock", +] + +[[package]] +name = "codex-execpolicy" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-utils-absolute-path", + "multimap", + "pretty_assertions", + "serde", + "serde_json", + "shlex", + "starlark", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "codex-execpolicy-legacy" +version = "0.0.0" +dependencies = [ + "allocative", + "anyhow", + "clap", + "derive_more 2.1.1", + "env_logger", + "log", + "multimap", + "path-absolutize", + "regex-lite", + "serde", + "serde_json", + "serde_with", + "starlark", + "tempfile", +] + +[[package]] +name = "codex-experimental-api-macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "codex-external-agent-migration" +version = "0.0.0" +dependencies = [ + "codex-hooks", + "pretty_assertions", + "serde_json", + "serde_yaml", + "tempfile", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "codex-external-agent-sessions" +version = "0.0.0" +dependencies = [ + "chrono", + "codex-app-server-protocol", + "codex-protocol", + "codex-utils-output-truncation", + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "codex-features" +version = "0.0.0" +dependencies = [ + "codex-otel", + "codex-protocol", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "toml 0.9.11+spec-1.1.0", + "tracing", +] + +[[package]] +name = "codex-feedback" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-login", + "codex-protocol", + "pretty_assertions", + "sentry", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "codex-file-search" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "crossbeam-channel", + "ignore", + "nucleo", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "codex-file-system" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-protocol", + "codex-utils-absolute-path", + "serde", +] + +[[package]] +name = "codex-git-utils" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-file-system", + "codex-protocol", + "codex-utils-absolute-path", + "futures", + "gix", + "once_cell", + "pretty_assertions", + "regex", + "schemars 0.8.22", + "serde", + "similar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "ts-rs", + "walkdir", +] + +[[package]] +name = "codex-hooks" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-config", + "codex-plugin", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "futures", + "pretty_assertions", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "codex-install-context" +version = "0.0.0" +dependencies = [ + "codex-utils-home-dir", + "pretty_assertions", + "tempfile", +] + +[[package]] +name = "codex-keyring-store" +version = "0.0.0" +dependencies = [ + "keyring", + "tracing", +] + +[[package]] +name = "codex-linux-sandbox" +version = "0.0.0" +dependencies = [ + "clap", + "codex-core", + "codex-process-hardening", + "codex-protocol", + "codex-sandboxing", + "codex-utils-absolute-path", + "globset", + "landlock", + "libc", + "pretty_assertions", + "seccompiler", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "codex-lmstudio" +version = "0.0.0" +dependencies = [ + "codex-core", + "codex-model-provider-info", + "reqwest", + "serde_json", + "tokio", + "tracing", + "which 8.0.0", + "wiremock", +] + +[[package]] +name = "codex-login" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "codex-agent-identity", + "codex-app-server-protocol", + "codex-client", + "codex-config", + "codex-keyring-store", + "codex-model-provider-info", + "codex-otel", + "codex-protocol", + "codex-terminal-detection", + "codex-utils-template", + "core_test_support", + "jsonwebtoken", + "keyring", + "once_cell", + "os_info", + "pretty_assertions", + "rand 0.9.3", + "regex-lite", + "reqwest", + "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tiny_http", + "tokio", + "tracing", + "url", + "urlencoding", + "webbrowser", + "wiremock", +] + +[[package]] +name = "codex-mcp" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-channel", + "codex-api", + "codex-async-utils", + "codex-builtin-mcps", + "codex-config", + "codex-exec-server", + "codex-login", + "codex-model-provider", + "codex-otel", + "codex-plugin", + "codex-protocol", + "codex-rmcp-client", + "codex-utils-plugins", + "futures", + "pretty_assertions", + "regex-lite", + "rmcp", + "serde", + "serde_json", + "sha1", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "codex-mcp-server" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-arg0", + "codex-config", + "codex-core", + "codex-exec-server", + "codex-login", + "codex-protocol", + "codex-shell-command", + "codex-utils-absolute-path", + "codex-utils-cli", + "codex-utils-json-to-toml", + "core_test_support", + "mcp_test_support", + "os_info", + "pretty_assertions", + "rmcp", + "schemars 0.8.22", + "serde", + "serde_json", + "shlex", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "wiremock", +] + +[[package]] +name = "codex-memories-mcp" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "pretty_assertions", + "rmcp", + "schemars 0.8.22", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "codex-memories-read" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-shell-command", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "codex-utils-template", + "pretty_assertions", + "tempfile", + "tokio", +] + +[[package]] +name = "codex-memories-write" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-backend-client", + "codex-config", + "codex-core", + "codex-features", + "codex-git-utils", + "codex-login", + "codex-models-manager", + "codex-otel", + "codex-protocol", + "codex-rollout", + "codex-rollout-trace", + "codex-secrets", + "codex-state", + "codex-terminal-detection", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "codex-utils-template", + "core_test_support", + "futures", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "uuid", + "wiremock", +] + +[[package]] +name = "codex-message-history" +version = "0.0.0" +dependencies = [ + "codex-config", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "codex-model-provider" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-agent-identity", + "codex-api", + "codex-aws-auth", + "codex-client", + "codex-feedback", + "codex-login", + "codex-model-provider-info", + "codex-models-manager", + "codex-otel", + "codex-protocol", + "codex-response-debug-context", + "http 1.4.0", + "pretty_assertions", + "serde_json", + "tokio", + "tracing", + "wiremock", +] + +[[package]] +name = "codex-model-provider-info" +version = "0.0.0" +dependencies = [ + "codex-api", + "codex-app-server-protocol", + "codex-protocol", + "codex-utils-absolute-path", + "http 1.4.0", + "maplit", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "tempfile", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "codex-models-manager" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "codex-app-server-protocol", + "codex-collaboration-mode-templates", + "codex-login", + "codex-otel", + "codex-protocol", + "codex-utils-output-truncation", + "codex-utils-template", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "codex-network-proxy" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "codex-utils-absolute-path", + "codex-utils-home-dir", + "codex-utils-rustls-provider", + "globset", + "pretty_assertions", + "rama-core", + "rama-http", + "rama-http-backend", + "rama-net", + "rama-socks5", + "rama-tcp", + "rama-tls-rustls", + "rama-unix", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "codex-ollama" +version = "0.0.0" +dependencies = [ + "assert_matches", + "async-stream", + "bytes", + "codex-core", + "codex-model-provider-info", + "futures", + "pretty_assertions", + "reqwest", + "semver", + "serde_json", + "tokio", + "tracing", + "wiremock", +] + +[[package]] +name = "codex-otel" +version = "0.0.0" +dependencies = [ + "chrono", + "codex-api", + "codex-app-server-protocol", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-string", + "eventsource-stream", + "gethostname", + "http 1.4.0", + "opentelemetry", + "opentelemetry-appender-tracing", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "os_info", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "strum_macros 0.28.0", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + +[[package]] +name = "codex-plugin" +version = "0.0.0" +dependencies = [ + "codex-config", + "codex-utils-absolute-path", + "codex-utils-plugins", + "thiserror 2.0.18", +] + +[[package]] +name = "codex-process-hardening" +version = "0.0.0" +dependencies = [ + "libc", + "pretty_assertions", +] + +[[package]] +name = "codex-protocol" +version = "0.0.0" +dependencies = [ + "anyhow", + "chardetng", + "chrono", + "codex-async-utils", + "codex-execpolicy", + "codex-network-proxy", + "codex-utils-absolute-path", + "codex-utils-image", + "codex-utils-string", + "codex-utils-template", + "encoding_rs", + "globset", + "http 1.4.0", + "icu_decimal", + "icu_locale_core", + "icu_provider", + "landlock", + "pretty_assertions", + "quick-xml", + "reqwest", + "schemars 0.8.22", + "seccompiler", + "serde", + "serde_json", + "serde_with", + "strum 0.27.2", + "strum_macros 0.28.0", + "sys-locale", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "ts-rs", + "uuid", + "wildmatch", +] + +[[package]] +name = "codex-realtime-webrtc" +version = "0.0.0" +dependencies = [ + "libwebrtc", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "codex-response-debug-context" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "codex-api", + "http 1.4.0", + "pretty_assertions", + "serde_json", +] + +[[package]] +name = "codex-responses-api-proxy" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-process-hardening", + "ctor 0.6.3", + "libc", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "tiny_http", + "zeroize", +] + +[[package]] +name = "codex-rmcp-client" +version = "0.0.0" +dependencies = [ + "anyhow", + "axum", + "bytes", + "codex-api", + "codex-client", + "codex-config", + "codex-exec-server", + "codex-keyring-store", + "codex-protocol", + "codex-utils-cargo-bin", + "codex-utils-home-dir", + "codex-utils-pty", + "futures", + "keyring", + "oauth2", + "pretty_assertions", + "reqwest", + "rmcp", + "serde", + "serde_json", + "serial_test", + "sha2", + "sse-stream", + "tempfile", + "thiserror 2.0.18", + "tiny_http", + "tokio", + "tracing", + "urlencoding", + "webbrowser", + "which 8.0.0", +] + +[[package]] +name = "codex-rollout" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codex-file-search", + "codex-git-utils", + "codex-login", + "codex-otel", + "codex-protocol", + "codex-state", + "codex-utils-path", + "codex-utils-string", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "time", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "codex-rollout-trace" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-code-mode", + "codex-protocol", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tracing", + "uuid", +] + +[[package]] +name = "codex-sandboxing" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "codex-network-proxy", + "codex-protocol", + "codex-utils-absolute-path", + "dunce", + "libc", + "pretty_assertions", + "regex-lite", + "serde_json", + "tempfile", + "tokio", + "tracing", + "url", + "which 8.0.0", +] + +[[package]] +name = "codex-secrets" +version = "0.0.0" +dependencies = [ + "age", + "anyhow", + "base64 0.22.1", + "codex-git-utils", + "codex-keyring-store", + "keyring", + "pretty_assertions", + "rand 0.9.3", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "sha2", + "tempfile", + "tracing", +] + +[[package]] +name = "codex-shell-command" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "codex-protocol", + "codex-utils-absolute-path", + "once_cell", + "pretty_assertions", + "regex", + "serde", + "serde_json", + "shlex", + "tree-sitter", + "tree-sitter-bash", + "url", + "which 8.0.0", +] + +[[package]] +name = "codex-shell-escalation" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "codex-protocol", + "codex-utils-absolute-path", + "libc", + "pretty_assertions", + "serde", + "serde_json", + "socket2 0.6.2", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "codex-skills" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "include_dir", + "thiserror 2.0.18", +] + +[[package]] +name = "codex-state" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "codex-git-utils", + "codex-protocol", + "dirs", + "log", + "owo-colors", + "pretty_assertions", + "serde", + "serde_json", + "sqlx", + "strum 0.27.2", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "codex-stdio-to-uds" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-uds", + "codex-utils-cargo-bin", + "pretty_assertions", + "tempfile", + "tokio", +] + +[[package]] +name = "codex-terminal-detection" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "tracing", +] + +[[package]] +name = "codex-test-binary-support" +version = "0.0.0" +dependencies = [ + "codex-arg0", + "tempfile", +] + +[[package]] +name = "codex-thread-manager-sample" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-core-api", + "serde_json", + "tracing", +] + +[[package]] +name = "codex-thread-store" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "codex-git-utils", + "codex-protocol", + "codex-rollout", + "codex-state", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "codex-tools" +version = "0.0.0" +dependencies = [ + "codex-app-server-protocol", + "codex-code-mode", + "codex-features", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-pty", + "pretty_assertions", + "rmcp", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "codex-tui" +version = "0.0.0" +dependencies = [ + "anyhow", + "arboard", + "assert_matches", + "base64 0.22.1", + "chrono", + "clap", + "codex-ansi-escape", + "codex-app-server-client", + "codex-app-server-protocol", + "codex-arg0", + "codex-chatgpt", + "codex-cli", + "codex-cloud-requirements", + "codex-config", + "codex-connectors", + "codex-core-plugins", + "codex-core-skills", + "codex-exec-server", + "codex-features", + "codex-feedback", + "codex-file-search", + "codex-git-utils", + "codex-install-context", + "codex-login", + "codex-mcp", + "codex-message-history", + "codex-model-provider", + "codex-model-provider-info", + "codex-models-manager", + "codex-otel", + "codex-plugin", + "codex-protocol", + "codex-realtime-webrtc", + "codex-rollout", + "codex-shell-command", + "codex-state", + "codex-terminal-detection", + "codex-utils-absolute-path", + "codex-utils-approval-presets", + "codex-utils-cargo-bin", + "codex-utils-cli", + "codex-utils-elapsed", + "codex-utils-fuzzy-match", + "codex-utils-oss", + "codex-utils-path", + "codex-utils-plugins", + "codex-utils-pty", + "codex-utils-sandbox-summary", + "codex-utils-sleep-inhibitor", + "codex-utils-string", + "codex-windows-sandbox", + "color-eyre", + "cpal", + "crossterm", + "derive_more 2.1.1", + "diffy", + "dirs", + "dunce", + "image", + "insta", + "itertools 0.14.0", + "lazy_static", + "libc", + "pathdiff", + "pretty_assertions", + "pulldown-cmark", + "rand 0.9.3", + "ratatui", + "ratatui-macros", + "regex-lite", + "reqwest", + "rmcp", + "serde", + "serde_json", + "serial_test", + "shlex", + "strum 0.27.2", + "strum_macros 0.28.0", + "supports-color 3.0.2", + "syntect", + "tempfile", + "textwrap 0.16.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "toml 0.9.11+spec-1.1.0", + "tracing", + "tracing-appender", + "tracing-subscriber", + "two-face", + "unicode-segmentation", + "unicode-width 0.2.1", + "url", + "urlencoding", + "uuid", + "vt100", + "webbrowser", + "which 8.0.0", + "windows-sys 0.52.0", + "winsplit", +] + +[[package]] +name = "codex-uds" +version = "0.0.0" +dependencies = [ + "async-io", + "pretty_assertions", + "tempfile", + "tokio", + "tokio-util", + "uds_windows", +] + +[[package]] +name = "codex-utils-absolute-path" +version = "0.0.0" +dependencies = [ + "dirs", + "dunce", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "tempfile", + "ts-rs", +] + +[[package]] +name = "codex-utils-approval-presets" +version = "0.0.0" +dependencies = [ + "codex-protocol", +] + +[[package]] +name = "codex-utils-cache" +version = "0.0.0" +dependencies = [ + "lru 0.16.3", + "sha1", + "tokio", +] + +[[package]] +name = "codex-utils-cargo-bin" +version = "0.0.0" +dependencies = [ + "assert_cmd", + "runfiles", + "thiserror 2.0.18", +] + +[[package]] +name = "codex-utils-cli" +version = "0.0.0" +dependencies = [ + "clap", + "codex-protocol", + "pretty_assertions", + "serde", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "codex-utils-elapsed" +version = "0.0.0" + +[[package]] +name = "codex-utils-fuzzy-match" +version = "0.0.0" + +[[package]] +name = "codex-utils-home-dir" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "dirs", + "pretty_assertions", + "tempfile", +] + +[[package]] +name = "codex-utils-image" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "codex-utils-cache", + "image", + "mime_guess", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "codex-utils-json-to-toml" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "serde_json", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "codex-utils-oss" +version = "0.0.0" +dependencies = [ + "codex-core", + "codex-lmstudio", + "codex-model-provider-info", + "codex-ollama", +] + +[[package]] +name = "codex-utils-output-truncation" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-utils-string", + "pretty_assertions", +] + +[[package]] +name = "codex-utils-path" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "dunce", + "pretty_assertions", + "tempfile", +] + +[[package]] +name = "codex-utils-plugins" +version = "0.0.0" +dependencies = [ + "codex-exec-server", + "codex-login", + "codex-utils-absolute-path", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "codex-utils-pty" +version = "0.0.0" +dependencies = [ + "anyhow", + "filedescriptor", + "lazy_static", + "libc", + "log", + "portable-pty", + "pretty_assertions", + "shared_library", + "tokio", + "winapi", +] + +[[package]] +name = "codex-utils-readiness" +version = "0.0.0" +dependencies = [ + "assert_matches", + "async-trait", + "thiserror 2.0.18", + "time", + "tokio", +] + +[[package]] +name = "codex-utils-rustls-provider" +version = "0.0.0" +dependencies = [ + "rustls", +] + +[[package]] +name = "codex-utils-sandbox-summary" +version = "0.0.0" +dependencies = [ + "codex-core", + "codex-model-provider-info", + "codex-protocol", + "codex-utils-absolute-path", + "pretty_assertions", +] + +[[package]] +name = "codex-utils-sleep-inhibitor" +version = "0.0.0" +dependencies = [ + "core-foundation 0.9.4", + "libc", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "codex-utils-stream-parser" +version = "0.0.0" +dependencies = [ + "pretty_assertions", +] + +[[package]] +name = "codex-utils-string" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "regex-lite", + "serde", + "serde_json", +] + +[[package]] +name = "codex-utils-template" +version = "0.0.0" +dependencies = [ + "pretty_assertions", +] + +[[package]] +name = "codex-v8-poc" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "v8", +] + +[[package]] +name = "codex-windows-sandbox" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "codex-otel", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-pty", + "codex-utils-string", + "dirs-next", + "dunce", + "glob", + "pretty_assertions", + "rand 0.8.5", + "serde", + "serde_json", + "tempfile", + "tokio", + "windows 0.58.0", + "windows-sys 0.52.0", + "winres", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "core_test_support" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "base64 0.22.1", + "codex-arg0", + "codex-config", + "codex-core", + "codex-exec-server", + "codex-features", + "codex-hooks", + "codex-login", + "codex-model-provider-info", + "codex-models-manager", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-cargo-bin", + "ctor 0.6.3", + "futures", + "notify", + "opentelemetry", + "opentelemetry_sdk", + "pretty_assertions", + "regex-lite", + "reqwest", + "serde_json", + "shlex", + "similar", + "tempfile", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "walkdir", + "wiremock", + "zstd 0.13.3", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto_box" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" +dependencies = [ + "aead", + "blake2", + "crypto_secretbox", + "curve25519-dalek", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cxx" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap 2.13.0", + "proc-macro2", + "quote", + "scratch", + "syn 2.0.114", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap 2.13.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" +dependencies = [ + "indexmap 2.13.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "debugserver-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf6834a70ed14e8e4e41882df27190bea150f1f6ecf461f1033f8739cd8af4a" +dependencies = [ + "schemafy", + "serde", + "serde_json", +] + +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + +[[package]] +name = "deno_core_icudata" +version = "0.77.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9efff8990a82c1ae664292507e1a5c6749ddd2312898cdf9cd7cb1fd4bc64c6" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "diffy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291" +dependencies = [ + "nu-ansi-term", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "diplomat" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adb46b05e2f53dcf6a7dfc242e4ce9eb60c369b6b6eb10826a01e93167f59c6" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "diplomat-runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0569bd3caaf13829da7ee4e83dbf9197a0e1ecd72772da6d08f0b4c9285c8d29" + +[[package]] +name = "diplomat_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51731530ed7f2d4495019abc7df3744f53338e69e2863a6a64ae91821c763df1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 2.0.114", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + +[[package]] +name = "display_container" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a110a75c96bedec8e65823dea00a1d710288b7a369d95fd8a0f5127639466fa" +dependencies = [ + "either", + "indenter", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dns-lookup" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" +dependencies = [ + "cfg-if", + "libc", + "socket2 0.6.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] -name = "code-utils-json-to-toml" -version = "0.0.0" -dependencies = [ - "pretty_assertions", - "serde_json", - "toml 0.9.8", -] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "code-utils-readiness" -version = "0.0.0" +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" dependencies = [ - "async-trait", - "thiserror 2.0.17", - "time", - "tokio", + "dtor-proc-macro", ] [[package]] -name = "code-version" -version = "0.0.0" +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dupe" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed2bc011db9c93fbc2b6cdb341a53737a55bafb46dbb74cf6764fc33a2fbf9c" dependencies = [ - "serde_json", + "dupe_derive", ] [[package]] -name = "codex-experimental-api-macros" -version = "0.0.0" +name = "dupe_derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] -name = "codex-utils-absolute-path" -version = "0.0.0" +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "dirs", - "path-absolutize", - "pretty_assertions", - "schemars 0.8.22", - "serde", - "serde_json", - "tempfile", - "ts-rs", + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] -name = "codex-utils-cache" -version = "0.0.0" +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "lru", - "sha1", - "tokio", + "pkcs8", + "signature", ] [[package]] -name = "codex-utils-cargo-bin" -version = "0.0.0" +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "assert_cmd", - "runfiles", - "thiserror 2.0.17", + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", ] [[package]] -name = "codex-utils-image" -version = "0.0.0" +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ - "base64 0.22.1", - "codex-utils-cache", - "image 0.25.8", - "tempfile", - "thiserror 2.0.17", - "tokio", + "serde", ] [[package]] -name = "color-eyre" -version = "0.6.5" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", ] [[package]] -name = "color-spantrace" -version = "0.3.0" +name = "ena" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", + "log", ] [[package]] -name = "color_quant" -version = "1.1.0" +name = "encode_unicode" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] -name = "colorchoice" -version = "1.0.4" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] [[package]] -name = "combine" -version = "4.6.7" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "bytes", - "memchr", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "compact_str" -version = "0.8.1" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", + "enumflags2_derive", + "serde", ] [[package]] -name = "compression-codecs" -version = "0.4.31" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ - "compression-core", - "flate2", - "memchr", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "compression-core" -version = "0.4.29" +name = "env-flags" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" +checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415" [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "env_filter" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ - "crossbeam-utils", + "log", + "regex", ] [[package]] -name = "console" -version = "0.15.11" +name = "env_home" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] -name = "convert_case" -version = "0.6.0" +name = "error-code" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ - "unicode-segmentation", + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", ] [[package]] -name = "cookie" -version = "0.18.1" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "percent-encoding", - "time", - "version_check", + "event-listener", + "pin-project-lite", ] [[package]] -name = "cookie_store" -version = "0.21.1" +name = "eventsource-stream" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", + "futures-core", + "nom 7.1.3", + "pin-project-lite", ] [[package]] -name = "core-foundation" -version = "0.9.4" +name = "eyre" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" dependencies = [ - "core-foundation-sys", - "libc", + "indenter", + "once_cell", ] [[package]] -name = "core-foundation" -version = "0.10.1" +name = "faster-hex" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" dependencies = [ - "core-foundation-sys", - "libc", + "heapless", + "serde", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.17", +] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "fax" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" dependencies = [ - "libc", + "fax_derive", ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "fax_derive" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ - "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "crossbeam-channel" -version = "0.5.15" +name = "fd-lock" +version = "4.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ - "crossbeam-utils", + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", ] [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "fdeflate" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "simd-adler32", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "crossbeam-utils", + "rand_core 0.6.4", + "subtle", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "fiat-crypto" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "crossterm" -version = "0.28.1" +name = "filedescriptor" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ - "bitflags 2.10.0", - "crossterm_winapi", - "futures-core", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", + "libc", + "thiserror 1.0.69", "winapi", ] [[package]] -name = "crossterm_winapi" -version = "0.9.1" +name = "filetime" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ - "winapi", + "cfg-if", + "libc", + "libredox", ] [[package]] -name = "crunchy" -version = "0.2.4" +name = "find-crate" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] [[package]] -name = "crypto-common" -version = "0.1.6" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "ctor" -version = "0.1.26" +name = "findshlibs" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" dependencies = [ - "quote", - "syn 1.0.109", + "cc", + "lazy_static", + "libc", + "winapi", ] [[package]] -name = "ctor" -version = "0.5.0" +name = "fixed_decimal" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +checksum = "35eabf480f94d69182677e37571d3be065822acfafd12f2f085db44fbbcc8e57" dependencies = [ - "ctor-proc-macro", - "dtor", + "displaydoc", + "smallvec", + "writeable", ] [[package]] -name = "ctor-proc-macro" -version = "0.0.6" +name = "fixedbitset" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] -name = "darling" -version = "0.20.11" +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] -name = "darling" -version = "0.21.3" +name = "flate2" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "crc32fast", + "libz-sys", + "miniz_oxide", ] [[package]] -name = "darling_core" -version = "0.20.11" +name = "float-cmp" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.108", + "num-traits", ] [[package]] -name = "darling_core" -version = "0.21.3" +name = "fluent" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.108", + "fluent-bundle", + "unic-langid", ] [[package]] -name = "darling_macro" -version = "0.20.11" +name = "fluent-bundle" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.108", + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", ] [[package]] -name = "darling_macro" -version = "0.21.3" +name = "fluent-langneg" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.108", + "unic-langid", ] [[package]] -name = "data-encoding" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" - -[[package]] -name = "deadpool" -version = "0.12.3" +name = "fluent-syntax" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" dependencies = [ - "deadpool-runtime", - "lazy_static", - "num_cpus", - "tokio", + "thiserror 1.0.69", ] [[package]] -name = "deadpool-runtime" -version = "0.1.4" +name = "flume" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] [[package]] -name = "debugserver-types" -version = "0.5.0" +name = "flume" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf6834a70ed14e8e4e41882df27190bea150f1f6ecf461f1033f8739cd8af4a" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ - "schemafy", - "serde", - "serde_json", + "fastrand", + "futures-core", + "futures-sink", + "spin", ] [[package]] -name = "deranged" -version = "0.5.4" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" -dependencies = [ - "powerfmt", - "serde_core", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "derivative" -version = "2.2.0" +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "derive_more" -version = "1.0.0" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "derive_more-impl 1.0.0", + "foreign-types-shared", ] [[package]] -name = "derive_more" -version = "2.0.1" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl 2.0.1", -] +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "derive_more-impl" -version = "1.0.0" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "syn 2.0.108", - "unicode-xid", + "percent-encoding", ] [[package]] -name = "derive_more-impl" -version = "2.0.1" +name = "fs2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", - "unicode-xid", + "libc", + "winapi", ] [[package]] -name = "diff" -version = "0.1.13" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "difflib" -version = "0.4.0" +name = "fsevent-sys" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] [[package]] -name = "diffy" -version = "0.4.2" +name = "fslock" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" dependencies = [ - "nu-ansi-term", + "libc", + "winapi", ] [[package]] -name = "digest" -version = "0.10.7" +name = "futures" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ - "block-buffer", - "crypto-common", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "dirs" -version = "6.0.0" +name = "futures-channel" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ - "dirs-sys", + "futures-core", + "futures-sink", ] [[package]] -name = "dirs-next" -version = "2.0.0" +name = "futures-core" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "futures-executor" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "futures-intrusive" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", + "futures-core", + "lock_api", + "parking_lot", ] [[package]] -name = "dispatch2" -version = "0.3.0" +name = "futures-io" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.10.0", - "objc2", -] +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] -name = "display_container" -version = "0.9.0" +name = "futures-lite" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a110a75c96bedec8e65823dea00a1d710288b7a369d95fd8a0f5127639466fa" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "either", - "indenter", + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "futures-macro" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] -name = "document-features" -version = "0.2.12" +name = "futures-sink" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] -name = "dotenvy" -version = "0.15.7" +name = "futures-task" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "futures-util" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] [[package]] -name = "dtor" -version = "0.1.0" +name = "fxhash" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" dependencies = [ - "dtor-proc-macro", + "byteorder", ] [[package]] -name = "dtor-proc-macro" -version = "0.0.6" +name = "generator" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result 0.4.1", +] [[package]] -name = "dunce" -version = "1.0.5" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] [[package]] -name = "dupe" -version = "0.9.1" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed2bc011db9c93fbc2b6cdb341a53737a55bafb46dbb74cf6764fc33a2fbf9c" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "dupe_derive", + "rustix 1.1.4", + "windows-link", ] [[package]] -name = "dupe_derive" -version = "0.9.1" +name = "getopts" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "unicode-width 0.2.1", ] [[package]] -name = "dyn-clone" -version = "1.0.20" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] [[package]] -name = "either" -version = "1.15.0" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] [[package]] -name = "ena" -version = "0.14.3" +name = "gif" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" dependencies = [ - "log", + "color_quant", + "weezl", ] [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "gimli" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "gio-sys" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" dependencies = [ - "cfg-if", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.61.2", ] [[package]] -name = "endian-type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +name = "gix" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0473c64d9ccbcfb9953a133b47c8b9a335b87ac6c52b983ee4b03d49000b0f3f" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-blame", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-index", + "gix-lock", + "gix-merge", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "nonempty", + "smallvec", + "thiserror 2.0.18", +] [[package]] -name = "enumflags2" -version = "0.7.12" +name = "gix-actor" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +checksum = "0e5e5b518339d5e6718af108fd064d4e9ba33caf728cf487352873d76411df35" dependencies = [ - "enumflags2_derive", + "bstr", + "gix-date", + "gix-error", + "winnow", ] [[package]] -name = "enumflags2_derive" -version = "0.7.12" +name = "gix-archive" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +checksum = "651c99be11aac9b303483193ae50b45eb6e094da4f5ed797019b03948f51aad6" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", ] [[package]] -name = "env-flags" -version = "0.1.1" +name = "gix-attributes" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415" +checksum = "c233d6eaa098c0ca5ce03236fd7a96e27f1abe72fad74b46003fbd11fe49563c" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] [[package]] -name = "env_filter" -version = "0.1.4" +name = "gix-bitmap" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "e7add20f40d060db8c9b1314d499bac6ed7480f33eb113ce3e1cf5d6ff85d989" dependencies = [ - "log", - "regex", + "gix-error", ] [[package]] -name = "env_logger" -version = "0.11.8" +name = "gix-blame" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", +checksum = "c77aaf9f7348f4da3ebfbfbbc35fa0d07155d98377856198dde6f695fd648705" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "equator" -version = "0.4.2" +name = "gix-chunk" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +checksum = "1096b6608fbe5d27fb4984e20f992b4e76fb8c613f6acb87d07c5831b53a6959" dependencies = [ - "equator-macro", + "gix-error", ] [[package]] -name = "equator-macro" -version = "0.4.2" +name = "gix-command" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +checksum = "b849c65a609f50d02f8a2774fe371650b3384a743c79c2a070ce0da49b7fb7da" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "gix-commitgraph" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "3196655fd1443f3c58a48c114aa480be3e4e87b393d7292daaa0d543862eb445" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] [[package]] -name = "erased-serde" -version = "0.3.31" +name = "gix-config" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +checksum = "08939b4c4ed7a663d0e64be9e1e9bdf23a1fb4fcee1febdf449f12229542e50d" dependencies = [ - "serde", + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "memchr", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", + "winnow", ] [[package]] -name = "errno" -version = "0.3.14" +name = "gix-config-value" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92" dependencies = [ + "bitflags 2.10.0", + "bstr", + "gix-path", "libc", - "windows-sys 0.61.2", + "thiserror 2.0.18", ] [[package]] -name = "error-code" -version = "3.3.2" +name = "gix-date" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "39acf819aa9fee65e4838a2eec5cb2506e47ebb89e02a5ab9918196e491571ea" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", + "smallvec", +] [[package]] -name = "event-listener" -version = "5.4.1" +name = "gix-diff" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "88f3b3475e5d3877d7c30c40827cc2441936ce890efc226e5ba4afe3a7ae33f0" dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", + "bstr", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "imara-diff 0.1.8", + "imara-diff 0.2.0", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-dir" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da4604a360988f0ba8efe6f90093ca5a844f4a7f8e1a3dcda501ec44e600ea9" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror 2.0.18", ] [[package]] -name = "event-listener-strategy" -version = "0.5.4" +name = "gix-discover" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +checksum = "c65bd3330fe0cb9d40d875bf862fd5e8ad6fa4164ddbc4842fbeb889c3f0b2c6" dependencies = [ - "event-listener", - "pin-project-lite", + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.18", ] [[package]] -name = "eventsource-stream" -version = "0.2.3" +name = "gix-error" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +checksum = "2e86d01da904d4a9265def43bd42a18c5e6dc7000a73af512946ba14579c9fbd" dependencies = [ - "futures-core", - "nom 7.1.3", - "pin-project-lite", + "bstr", ] [[package]] -name = "exr" -version = "1.73.0" +name = "gix-features" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +checksum = "752493cd4b1d5eaaa0138a7493f65c96863fefa990fc021e0e519579e389ab20" dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", + "bytes", + "crc32fast", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "prodash", + "thiserror 2.0.18", + "walkdir", + "zlib-rs", ] [[package]] -name = "eyre" -version = "0.6.12" +name = "gix-filter" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "d37598282a6566da6fb52667570c7fe0aedcb122ac886724a9e62a2180523e35" dependencies = [ - "indenter", - "once_cell", + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "fastrand" -version = "2.3.0" +name = "gix-fs" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a964b4aec683eb0bacb87533defa80805bb4768056371a47ab38b00a2d377b72" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.18", +] [[package]] -name = "fax" -version = "0.2.6" +name = "gix-glob" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee" dependencies = [ - "fax_derive", + "bitflags 2.10.0", + "bstr", + "gix-features", + "gix-path", ] [[package]] -name = "fax_derive" -version = "0.2.0" +name = "gix-hash" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +checksum = "0fb896a02d9ab96fa518475a5f30ad3952010f801a8de5840f633f4a6b985dfb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.18", ] [[package]] -name = "fd-lock" -version = "4.0.4" +name = "gix-hashtable" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +checksum = "2664216fc5e89b51e756a4a3ac676315602ce2dac07acf1da959a22038d69b33" dependencies = [ - "cfg-if", - "rustix 1.1.2", - "windows-sys 0.59.0", + "gix-hash", + "hashbrown 0.16.1", + "parking_lot", ] [[package]] -name = "fdeflate" -version = "0.3.7" +name = "gix-ignore" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +checksum = "09f915dcf6911e3027537166d34e13f0fe101ed12225178d2ae29cd1272cff26" dependencies = [ - "simd-adler32", + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", ] [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "gix-index" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "1bae54ab14e4e74d5dda60b82ea7afad7c8eb3be68283d6d5f29bd2e6d47fff7" dependencies = [ + "bitflags 2.10.0", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.16.1", + "itoa", "libc", - "thiserror 1.0.69", - "winapi", + "memmap2", + "rustix 1.1.4", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "filetime" -version = "0.2.26" +name = "gix-lock" +version = "21.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "054fbd0989700c69dc5aa80bc66944f05df1e15aa7391a9e42aca7366337905f" dependencies = [ - "cfg-if", - "libc", - "libredox", - "windows-sys 0.60.2", + "gix-tempfile", + "gix-utils", + "thiserror 2.0.18", ] [[package]] -name = "find-msvc-tools" -version = "0.1.4" +name = "gix-merge" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "f4606747466512d22c2dffc019142e1941238f543987ea51353c938cca80c500" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "imara-diff 0.1.8", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-negotiate" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c" +dependencies = [ + "bitflags 2.10.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] [[package]] -name = "fixed_decimal" -version = "0.7.0" +name = "gix-object" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35943d22b2f19c0cb198ecf915910a8158e94541c89dcc63300d7799d46c2c5e" +checksum = "cafb802bb688a7c1e69ef965612ff5ff859f046bfb616377e4a0ba4c01e43d47" dependencies = [ - "displaydoc", + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-path", + "gix-utils", + "gix-validate", + "itoa", "smallvec", - "writeable", + "thiserror 2.0.18", + "winnow", ] [[package]] -name = "fixedbitset" -version = "0.4.2" +name = "gix-odb" +version = "0.78.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "24833ae9323b4f7079575fb9f961cf9c414b0afbec428a536ab8e7dd93bc002b" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "parking_lot", + "tempfile", + "thiserror 2.0.18", +] [[package]] -name = "fixedbitset" -version = "0.5.7" +name = "gix-pack" +version = "0.68.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +checksum = "e3484119cd19859d7d7639413c27e192478fa354d3f4ff5f7e3c041e8040f0f4" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror 2.0.18", +] [[package]] -name = "flate2" -version = "1.1.4" +name = "gix-packetline" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +checksum = "be19313dcdb7dff75a3ce2f99be00878458295bcc3b6c7f0005591597573345c" dependencies = [ - "crc32fast", - "miniz_oxide", + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.18", ] [[package]] -name = "fnv" -version = "1.0.7" +name = "gix-path" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "09c31d4373bda7fab9eb01822927b55185a378d6e1bf737e0a54c743ad806658" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] [[package]] -name = "foldhash" -version = "0.1.5" +name = "gix-pathspec" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b" +dependencies = [ + "bitflags 2.10.0", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.18", +] [[package]] -name = "foreign-types" -version = "0.3.2" +name = "gix-protocol" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +checksum = "4f38666350736b5877c79f57ddae02bde07a4ce186d889adc391e831cddcbe76" dependencies = [ - "foreign-types-shared", + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror 2.0.18", + "winnow", ] [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "gix-quote" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "68533db71259c8776dd4e770d2b7b98696213ecdc1f5c9e3507119e274e0c578" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "gix-ref" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "c2159978abb99b7027c8579d15211e262ef0ef2594d5cecb3334fbcbdfe2997c" dependencies = [ - "percent-encoding", + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.18", + "winnow", ] [[package]] -name = "fs2" -version = "0.4.3" +name = "gix-refspec" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +checksum = "dc806ee13f437428f8a1ba4c72ecfaa3f20e14f5f0d4c2bc17d0b33e794aa6ac" dependencies = [ - "libc", - "winapi", + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "futf" -version = "0.1.5" +name = "gix-revision" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d" dependencies = [ - "mac", - "new_debug_unreachable", + "bitflags 2.10.0", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", ] [[package]] -name = "futures" -version = "0.3.31" +name = "gix-revwalk" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "0e4b2b87772b21ca449249e86d32febadba5cba32b0fcce804ab9cefc6f2111c" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.18", ] [[package]] -name = "futures-channel" -version = "0.3.31" +name = "gix-sec" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "bf82ae037de9c62850ce67beaa92ec8e3e17785ea307cdde7618edc215603b4f" dependencies = [ - "futures-core", - "futures-sink", + "bitflags 2.10.0", + "gix-path", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "futures-core" -version = "0.3.31" +name = "gix-shallow" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "cbf60711c9083b2364b3fac8a352444af76b17201f3682fdebe74fa66d89a772" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror 2.0.18", +] [[package]] -name = "futures-executor" -version = "0.3.31" +name = "gix-status" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "23d6c598e3fdbc352fba1c5ba7e709e69402fafbc44d9295edad2e3c4738996b" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror 2.0.18", ] [[package]] -name = "futures-io" -version = "0.3.31" +name = "gix-submodule" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "0ce5c3929c5e6821f651d35e8420f72fea3cfafe9fc1e928a61e718b462c72a5" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.18", +] [[package]] -name = "futures-macro" -version = "0.3.31" +name = "gix-tempfile" +version = "21.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "d22227f6b203f511ff451c33c89899e87e4f571fc596b06f68e6e613a6508528" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", ] [[package]] -name = "futures-sink" -version = "0.3.31" +name = "gix-trace" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "f69a13643b8437d4ca6845e08143e847a36ca82903eed13303475d0ae8b162e0" [[package]] -name = "futures-task" -version = "0.3.31" +name = "gix-transport" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "a521e39c6235ce63ed6c001e2dd79818c830b82c3b7b59247ee7b229c39ec9bb" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror 2.0.18", +] [[package]] -name = "futures-timer" -version = "3.0.3" +name = "gix-traverse" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec" +dependencies = [ + "bitflags 2.10.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.18", +] [[package]] -name = "futures-util" -version = "0.3.31" +name = "gix-url" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "d28e8af3d42581190da884f013caf254d2fd4d6ab102408f08d21bfa11de6c8d" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", + "bstr", + "gix-path", + "percent-encoding", + "thiserror 2.0.18", ] [[package]] -name = "fxhash" -version = "0.2.1" +name = "gix-utils" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5" dependencies = [ - "byteorder", + "bstr", + "fastrand", + "unicode-normalization", ] [[package]] -name = "generic-array" -version = "0.14.9" +name = "gix-validate" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "0ec1eff98d91941f47766367cba1be746bab662bad761d9891ae6f7882f7840b" dependencies = [ - "typenum", - "version_check", + "bstr", ] [[package]] -name = "gethostname" -version = "1.1.0" +name = "gix-worktree" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +checksum = "e6bd5830cbc43c9c00918b826467d2afad685b195cb82329cde2b2d116d2c578" dependencies = [ - "rustix 1.1.2", - "windows-link 0.2.1", + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", ] [[package]] -name = "getopts" -version = "0.2.24" +name = "gix-worktree-state" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +checksum = "644a1681f96e1be43c2a8384337d9d220e7624f50db54beda70997052aebf707" dependencies = [ - "unicode-width 0.2.1", + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror 2.0.18", ] [[package]] -name = "getrandom" -version = "0.2.16" +name = "gix-worktree-stream" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", +checksum = "24e3fb70a1f650a5cec7d5b8d10d6d6fe86daf3cf15bde08ba0c70988a2932c3" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "glib" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ - "cfg-if", - "js-sys", + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", "libc", - "r-efi", - "wasip2", - "wasm-bindgen", + "memchr", + "smallvec", ] [[package]] -name = "gif" -version = "0.13.3" +name = "glib-macros" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" dependencies = [ - "color_quant", - "weezl", + "heck 0.5.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "gimli" -version = "0.32.3" +name = "glib-sys" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +dependencies = [ + "libc", + "system-deps", +] [[package]] name = "glob" @@ -2891,19 +6887,50 @@ dependencies = [ "regex-syntax 0.8.8", ] +[[package]] +name = "gobject-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", +] + [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http", - "indexmap 2.12.0", + "http 1.4.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -2921,6 +6948,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2945,83 +6981,203 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http 1.4.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.3", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", ] [[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" +name = "hickory-resolver" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.3", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "hermit-abi" -version = "0.5.2" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] [[package]] -name = "hex" -version = "0.4.3" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] -name = "htmd" -version = "0.1.6" +name = "hostname" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1642def6e8e4dc182941f35454f7d2af917787f91f3f5133300030b41006d0" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ - "html5ever", - "markup5ever_rcdom", + "cfg-if", + "libc", + "windows-link", ] [[package]] -name = "html5ever" -version = "0.27.0" +name = "http" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn 2.0.108", + "bytes", + "fnv", + "itoa", ] [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -3029,7 +7185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3040,11 +7196,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3057,28 +7219,19 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humansize" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" -dependencies = [ - "libm", -] - [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -3095,7 +7248,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -3104,7 +7257,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.3", + "webpki-roots 1.0.5", ] [[package]] @@ -3138,23 +7291,22 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -3162,11 +7314,77 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -3186,11 +7404,33 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale", + "icu_locale_core", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d" + [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -3201,34 +7441,31 @@ dependencies = [ [[package]] name = "icu_decimal" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec61c43fdc4e368a9f450272833123a8ef0d7083a44597660ce94d791b8a2e2" +checksum = "a38c52231bc348f9b982c1868a2af3195199623007ba2c7650f432038f5b3e8e" dependencies = [ - "displaydoc", "fixed_decimal", "icu_decimal_data", "icu_locale", "icu_locale_core", "icu_provider", - "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_decimal_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b70963bc35f9bdf1bc66a5c1f458f4991c1dc71760e00fa06016b2c76b2738d5" +checksum = "2905b4044eab2dd848fe84199f9195567b63ab3a93094711501363f63546fef7" [[package]] name = "icu_locale" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae5921528335e91da1b6c695dbf1ec37df5ac13faa3f91e5640be93aa2fbefd" +checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_locale_data", @@ -3240,12 +7477,13 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -3253,17 +7491,16 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.0.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fdef0c124749d06a743c69e938350816554eb63ac979166590e2b4ee4252765" +checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -3274,42 +7511,40 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -3317,12 +7552,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icy_sixel" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc" - [[package]] name = "ident_case" version = "1.0.1" @@ -3352,9 +7581,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.24" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81776e6f9464432afcc28d03e52eb101c93b6f0566f52aef2427663e700f0403" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", @@ -3368,40 +7597,21 @@ dependencies = [ [[package]] name = "image" -version = "0.23.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ffcb7e7244a9bf19d35bf2883b9c080c4ced3c07a9895572178cdb8f13f6a1" -dependencies = [ - "bytemuck", - "byteorder", - "color_quant", - "num-iter", - "num-rational 0.3.2", - "num-traits", -] - -[[package]] -name = "image" -version = "0.25.8" +version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "exr", "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", ] [[package]] @@ -3415,23 +7625,48 @@ dependencies = [ ] [[package]] -name = "img_hash" -version = "3.2.0" +name = "imara-diff" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "imara-diff" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea4eac6fc4f64ed363d5c210732b747bfa5ddd8a25ac347d887f298c3a70b49" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" dependencies = [ - "base64 0.13.1", - "image 0.23.14", - "rustdct", - "serde", - "transpose 0.2.3", + "hashbrown 0.15.5", + "memchr", ] [[package]] -name = "imgref" -version = "1.12.0" +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] [[package]] name = "indenter" @@ -3452,12 +7687,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -3471,48 +7706,115 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "insta" -version = "1.43.2" +version = "1.46.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" dependencies = [ "console", "once_cell", "similar", + "tempfile", ] [[package]] name = "instability" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "indoc", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] -name = "interpolate_name" -version = "0.2.4" +name = "intl-memoizer" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", ] [[package]] -name = "inventory" -version = "0.3.21" +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + +[[package]] +name = "ipconfig" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" dependencies = [ - "rustversion", + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", ] [[package]] @@ -3523,9 +7825,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -3533,13 +7835,13 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3565,9 +7867,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.12.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ "either", ] @@ -3592,32 +7894,55 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "ixdtf" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "84de9d95a6d2547d9b77ee3f25fa0ee32e3c3a6484d47a55adebc0439c077992" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", + "windows-sys 0.61.2", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", ] [[package]] @@ -3654,14 +7979,76 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.5.1", + "windows-sys 0.60.2", + "zbus", + "zeroize", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "lalrpop" version = "0.19.12" @@ -3695,58 +8082,126 @@ dependencies = [ [[package]] name = "landlock" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affe8b77dce5b172f8e290bd801b12832a77cd1942d1ea98259916e89d5829d6" +checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.17", + "thiserror 2.0.18", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] -name = "lebe" -version = "0.5.3" +name = "libc" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] -name = "libc" -version = "0.2.177" +name = "libdbus-sys" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] [[package]] -name = "libfuzzer-sys" -version = "0.4.10" +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "arbitrary", - "cc", + "cfg-if", + "windows-link", ] [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libwebrtc" +version = "0.3.26" +source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0" +dependencies = [ + "cxx", + "glib", + "jni", + "js-sys", + "lazy_static", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "rtrb", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webrtc-sys", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", ] [[package]] @@ -3755,6 +8210,16 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-keyutils" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3763,15 +8228,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" @@ -3779,6 +8244,37 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "livekit-protocol" +version = "0.7.1" +source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0" +dependencies = [ + "futures-util", + "livekit-runtime", + "parking_lot", + "pbjson", + "pbjson-types", + "prost 0.12.6", + "serde", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "livekit-runtime" +version = "0.4.0" +source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0" +dependencies = [ + "tokio", + "tokio-stream", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.14" @@ -3790,9 +8286,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "logos" @@ -3818,12 +8314,19 @@ dependencies = [ ] [[package]] -name = "loop9" -version = "0.1.5" +name = "loom" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "imgref", + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", ] [[package]] @@ -3835,6 +8338,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3855,42 +8367,40 @@ dependencies = [ ] [[package]] -name = "mac" -version = "0.1.1" +name = "lzma-rs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] [[package]] -name = "maplit" -version = "1.0.2" +name = "lzma-sys" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] [[package]] -name = "markup5ever" -version = "0.12.1" +name = "mach2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", + "libc", ] [[package]] -name = "markup5ever_rcdom" -version = "0.3.0" +name = "maplit" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "xml5ever", -] +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "matchers" @@ -3908,32 +8418,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "maybe-rayon" -version = "0.1.1" +name = "matchit" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" + +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" dependencies = [ - "cfg-if", - "rayon", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "mcp-types" +name = "mcp_test_support" version = "0.0.0" -source = "git+https://github.com/openai/codex.git?rev=6c384eb9c610f9a83037d9cad120fb792e782c7c#6c384eb9c610f9a83037d9cad120fb792e782c7c" dependencies = [ - "schemars 0.8.22", + "anyhow", + "codex-login", + "codex-mcp-server", + "codex-terminal-detection", + "codex-utils-cargo-bin", + "core_test_support", + "os_info", + "pretty_assertions", + "rmcp", "serde", "serde_json", - "ts-rs", + "shlex", + "tokio", + "wiremock", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.6.5" @@ -3943,6 +8494,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -3977,21 +8537,38 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", ] [[package]] name = "moxcms" -version = "0.7.7" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", @@ -4015,7 +8592,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", "security-framework 2.11.1", @@ -4023,12 +8600,35 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -4056,6 +8656,19 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset 0.9.1", +] + [[package]] name = "nix" version = "0.30.1" @@ -4088,10 +8701,43 @@ dependencies = [ ] [[package]] -name = "noop_proc_macro" +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "normalize-line-endings" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.10.0", +] [[package]] name = "nu-ansi-term" @@ -4102,16 +8748,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + [[package]] name = "nucleo-matcher" version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +source = "git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee" dependencies = [ "memchr", "unicode-segmentation", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4122,21 +8791,36 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" -version = "0.2.4" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "autocfg", "num-traits", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -4146,7 +8830,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -4169,17 +8853,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-rational" version = "0.4.2" @@ -4198,6 +8871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4210,6 +8884,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -4219,6 +8915,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.21.7", + "chrono", + "getrandom 0.2.17", + "http 1.4.0", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc2" version = "0.6.3" @@ -4240,6 +8956,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -4264,6 +9001,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4277,6 +9046,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -4292,6 +9063,49 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -4301,11 +9115,47 @@ dependencies = [ "memchr", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -4335,11 +9185,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -4358,7 +9214,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -4367,20 +9223,26 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-src" -version = "300.5.4+3.5.4" +version = "300.5.5+3.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -4391,23 +9253,23 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" dependencies = [ "futures-core", "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] [[package]] name = "opentelemetry-appender-tracing" -version = "0.30.1" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16" +checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2" dependencies = [ "opentelemetry", "tracing", @@ -4417,32 +9279,32 @@ dependencies = [ [[package]] name = "opentelemetry-http" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http", + "http 1.4.0", "opentelemetry", "reqwest", ] [[package]] name = "opentelemetry-otlp" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" +checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" dependencies = [ - "http", + "http 1.4.0", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "reqwest", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tonic", "tracing", @@ -4450,39 +9312,40 @@ dependencies = [ [[package]] name = "opentelemetry-proto" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ "base64 0.22.1", - "hex", + "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "serde", + "serde_json", "tonic", + "tonic-prost", ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" [[package]] name = "opentelemetry_sdk" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" dependencies = [ "futures-channel", "futures-executor", "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.2", - "serde_json", - "thiserror 2.0.17", + "rand 0.9.3", + "thiserror 2.0.18", "tokio", "tokio-stream", ] @@ -4493,16 +9356,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_info" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" dependencies = [ + "android_system_properties", "log", - "plist", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", "serde", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4523,14 +9400,26 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" dependencies = [ "supports-color 2.1.0", "supports-color 3.0.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -4555,9 +9444,20 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", ] [[package]] @@ -4566,6 +9466,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "path-absolutize" version = "3.1.1" @@ -4575,12 +9481,6 @@ dependencies = [ "path-dedot", ] -[[package]] -name = "path-clean" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" - [[package]] name = "path-dedot" version = "3.1.1" @@ -4591,59 +9491,114 @@ dependencies = [ ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "pathdiff" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] -name = "petgraph" -version = "0.6.5" +name = "pbjson" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "1030c719b0ec2a2d25a5df729d6cff1acf3cc230bf766f4f97833591f7577b90" dependencies = [ - "fixedbitset 0.4.2", - "indexmap 2.12.0", + "base64 0.21.7", + "serde", ] [[package]] -name = "petgraph" -version = "0.8.3" +name = "pbjson-build" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735" dependencies = [ - "fixedbitset 0.5.7", - "hashbrown 0.15.5", - "indexmap 2.12.0", + "heck 0.4.1", + "itertools 0.11.0", + "prost 0.12.6", + "prost-types 0.12.6", ] [[package]] -name = "phf" -version = "0.11.3" +name = "pbjson-types" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "18f596653ba4ac51bdecbb4ef6773bc7f56042dc13927910de1684ad3d32aa12" dependencies = [ - "phf_shared", + "bytes", + "chrono", + "pbjson", + "pbjson-build", + "prost 0.12.6", + "prost-build 0.12.6", + "serde", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "pbkdf2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "phf_generator", - "phf_shared", + "digest", + "hmac", + "password-hash", + "sha2", ] [[package]] -name = "phf_generator" -version = "0.11.3" +name = "pbkdf2" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "phf_shared", - "rand 0.8.5", + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap 2.13.0", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap 2.13.0", ] [[package]] @@ -4672,7 +9627,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -4687,6 +9642,38 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -4700,8 +9687,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.12.0", - "quick-xml 0.38.3", + "indexmap 2.13.0", + "quick-xml", "serde", "time", ] @@ -4719,17 +9706,42 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -4757,11 +9769,12 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ - "serde", + "serde_core", + "writeable", "zerovec", ] @@ -4794,7 +9807,10 @@ checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" dependencies = [ "anstyle", "difflib", + "float-cmp", + "normalize-line-endings", "predicates-core", + "regex", ] [[package]] @@ -4823,78 +9839,214 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] name = "proc-macro2" -version = "1.0.102" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e0f6df8eaa422d97d72edcd152e1451618fed47fabbdbd5a8864167b1d4aff7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "process-wrap" -version = "8.2.1" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" +checksum = "fd1395947e69c07400ef4d43db0051d6f773c21f647ad8b97382fc01f0204c60" dependencies = [ "futures", - "indexmap 2.12.0", + "indexmap 2.13.0", "nix 0.30.1", "tokio", "tracing", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] -name = "profiling" -version = "1.0.17" +name = "prodash" +version = "31.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" dependencies = [ - "profiling-procmacros", + "parking_lot", ] [[package]] -name = "profiling-procmacros" -version = "1.0.17" +name = "proptest" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ - "quote", - "syn 2.0.108", + "bitflags 2.10.0", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax 0.8.8", + "unarray", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", ] [[package]] name = "prost" -version = "0.13.5" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck 0.5.0", + "itertools 0.11.0", + "log", + "multimap", + "once_cell", + "petgraph 0.6.5", + "prettyplease", + "prost 0.12.6", + "prost-types 0.12.6", + "regex", + "syn 2.0.114", + "tempfile", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "petgraph 0.8.3", + "prettyplease", + "prost 0.14.3", + "prost-types 0.14.3", + "regex", + "syn 2.0.114", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + +[[package]] +name = "prost-types" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "bytes", - "prost-derive", + "prost 0.14.3", ] [[package]] -name = "prost-derive" -version = "0.13.5" +name = "psl" +version = "2.1.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "81dc6a90669f481b41cae3005c68efa36bef275b95aa9123a7af7f1c68c6e5b2" dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.108", + "psl-types", ] [[package]] @@ -4915,9 +10067,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.0" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ "bitflags 2.10.0", "getopts", @@ -4928,28 +10080,19 @@ dependencies = [ [[package]] name = "pulldown-cmark-escape" -version = "0.11.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" [[package]] name = "pxfm" -version = "0.1.25" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "2.0.1" @@ -4958,20 +10101,12 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", + "serde", ] [[package]] @@ -4985,10 +10120,10 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", - "socket2 0.6.1", - "thiserror 2.0.17", + "socket2 0.6.2", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -4996,20 +10131,20 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -5024,16 +10159,16 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -5050,10 +10185,330 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" dependencies = [ - "endian-type", + "endian-type 0.1.2", + "nibble_vec", +] + +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type 0.2.0", "nibble_vec", ] +[[package]] +name = "rama-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" +dependencies = [ + "ahash", + "asynk-strim", + "bytes", + "futures", + "parking_lot", + "pin-project-lite", + "rama-error", + "rama-macros", + "rama-utils", + "serde", + "serde_json", + "tokio", + "tokio-graceful", + "tokio-util", + "tracing", +] + +[[package]] +name = "rama-dns" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e340fef2799277e204260b17af01bc23604712092eacd6defe40167f304baed8" +dependencies = [ + "ahash", + "hickory-resolver", + "rama-core", + "rama-net", + "rama-utils", + "serde", + "tokio", +] + +[[package]] +name = "rama-error" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" + +[[package]] +name = "rama-http" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" +dependencies = [ + "ahash", + "base64 0.22.1", + "bitflags 2.10.0", + "chrono", + "const_format", + "csv", + "http 1.4.0", + "http-range-header", + "httpdate", + "iri-string", + "matchit 0.9.1", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "radix_trie 0.3.0", + "rama-core", + "rama-error", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.3", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "rama-http-backend" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ff6a3c8ae690be8167e43777ba0bf6b0c8c2f6de165c538666affe2a32fd81" +dependencies = [ + "h2", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-core", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-tcp", + "rama-unix", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-http-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3822be6703e010afec0bcfeb5dbb6e5a3b23ca5689d9b1215b66ce6446653b77" +dependencies = [ + "ahash", + "atomic-waker", + "futures-channel", + "httparse", + "httpdate", + "indexmap 2.13.0", + "itoa", + "parking_lot", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-types", + "rama-utils", + "slab", + "tokio", + "tokio-test", + "want", +] + +[[package]] +name = "rama-http-headers" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" +dependencies = [ + "ahash", + "base64 0.22.1", + "chrono", + "const_format", + "httpdate", + "rama-core", + "rama-error", + "rama-http-types", + "rama-macros", + "rama-net", + "rama-utils", + "rand 0.9.3", + "serde", + "sha1", +] + +[[package]] +name = "rama-http-types" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" +dependencies = [ + "ahash", + "bytes", + "const_format", + "fnv", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "itoa", + "memchr", + "mime", + "mime_guess", + "nom 8.0.0", + "pin-project-lite", + "rama-core", + "rama-error", + "rama-macros", + "rama-utils", + "rand 0.9.3", + "serde", + "serde_json", + "sync_wrapper", + "tokio", +] + +[[package]] +name = "rama-macros" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "rama-net" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" +dependencies = [ + "ahash", + "const_format", + "flume 0.12.0", + "hex", + "ipnet", + "itertools 0.14.0", + "md5", + "nom 8.0.0", + "parking_lot", + "pin-project-lite", + "psl", + "radix_trie 0.3.0", + "rama-core", + "rama-http-types", + "rama-macros", + "rama-utils", + "serde", + "sha2", + "socket2 0.6.2", + "tokio", +] + +[[package]] +name = "rama-socks5" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5468b263516daaf258de32542c1974b7cbe962363ad913dcb669f5d46db0ef3e" +dependencies = [ + "byteorder", + "rama-core", + "rama-net", + "rama-tcp", + "rama-udp", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-tcp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe60cd604f91196b3659a1b28945add2e8b10bd0b4e6373c93d024fb3197704b" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-dns", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.3", + "tokio", +] + +[[package]] +name = "rama-tls-rustls" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536d47f6b269fb20dffd45e4c04aa8b340698b3509326e3c36e444b4f33ce0d6" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-http-types", + "rama-net", + "rama-utils", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "webpki-roots 1.0.5", + "x509-parser", +] + +[[package]] +name = "rama-udp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36ed05e0ecac73e084e92a3a8b1fbf16fdae8958c506f0f0eada180a2d99eef4" +dependencies = [ + "rama-core", + "rama-net", + "tokio", + "tokio-util", +] + +[[package]] +name = "rama-unix" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91acb16d571428ba4cece072dfab90d2667cdfa910a7b3cb4530c3f31542d708" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-net", + "tokio", +] + +[[package]] +name = "rama-utils" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" +dependencies = [ + "const_format", + "parking_lot", + "pin-project-lite", + "rama-macros", + "regex", + "serde", + "smallvec", + "smol_str", + "tokio", + "wildcard", +] + [[package]] name = "rand" version = "0.8.5" @@ -5067,12 +10522,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -5092,7 +10547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -5101,22 +10556,31 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" version = "0.29.0" -source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" +source = "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2" dependencies = [ "bitflags 2.10.0", "cassowary", @@ -5125,7 +10589,7 @@ dependencies = [ "indoc", "instability", "itertools 0.13.0", - "lru", + "lru 0.12.5", "paste", "strum 0.26.3", "unicode-segmentation", @@ -5134,96 +10598,62 @@ dependencies = [ ] [[package]] -name = "ratatui-image" -version = "8.0.2" +name = "ratatui-macros" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d8ad028fcbb171d83cfdeaf44df17bf0eae3585bdd7f89bc87af98fc71b0e" +checksum = "6fef540f80dbe8a0773266fa6077788ceb65ef624cdbf36e131aaf90b4a52df4" dependencies = [ - "base64-simd", - "icy_sixel", - "image 0.25.8", - "rand 0.8.5", "ratatui", - "rustix 0.38.44", - "thiserror 1.0.69", - "windows 0.58.0", ] [[package]] -name = "rav1e" -version = "0.7.1" +name = "rayon" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ - "arbitrary", - "arg_enum_proc_macro", - "arrayvec 0.7.6", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.12.1", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "once_cell", - "paste", - "profiling", - "rand 0.8.5", - "rand_chacha 0.3.1", - "simd_helpers", - "system-deps", - "thiserror 1.0.69", - "v_frame", - "wasm-bindgen", + "either", + "rayon-core", ] [[package]] -name = "ravif" -version = "0.11.20" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] -name = "rayon" -version = "1.11.0" +name = "rcgen" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" dependencies = [ - "either", - "rayon-core", + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "bitflags 2.10.0", ] [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ "bitflags 2.10.0", ] @@ -5234,7 +10664,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -5245,9 +10675,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -5267,14 +10697,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -5311,19 +10741,12 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "async-compression", "base64 0.22.1", "bytes", "cookie", @@ -5333,8 +10756,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -5366,14 +10789,34 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.3", + "webpki-roots 1.0.5", +] + +[[package]] +name = "resb" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a067ab3b5ca3b4dc307d0de9cf75f9f5e6ca9717b192b2f28a36c83e5de9e76" +dependencies = [ + "potential_utf", + "serde_core", ] [[package]] -name = "rgb" -version = "0.8.52" +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] [[package]] name = "ring" @@ -5383,97 +10826,147 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] [[package]] name = "rmcp" -version = "0.7.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534fd1cd0601e798ac30545ff2b7f4a62c6f14edd4aaed1cc5eb1e85f69f09af" +checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" dependencies = [ + "async-trait", + "axum", "base64 0.22.1", "bytes", "chrono", "futures", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "paste", + "oauth2", + "pastey", "pin-project-lite", "process-wrap", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "rmcp-macros", - "schemars 1.0.4", + "schemars 1.2.1", "serde", "serde_json", "sse-stream", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", "tower-service", "tracing", + "url", "uuid", ] [[package]] name = "rmcp-macros" -version = "0.7.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ba777eb0e5f53a757e36f0e287441da0ab766564ba7201600eeb92a4753022e" +checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "serde_json", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] -name = "rstest" -version = "0.25.0" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", - "rustc_version", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", ] [[package]] -name = "rstest_macros" -version = "0.25.0" +name = "rtrb" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7204ed6420f698836b76d4d5c2ec5dec7585fd5c3a788fd1cde855d1de598239" + +[[package]] +name = "runfiles" +version = "0.1.0" +source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81" + +[[package]] +name = "rust-embed" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", "proc-macro2", "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.108", - "unicode-ident", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", ] [[package]] -name = "runfiles" -version = "0.1.0" -source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81" +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] [[package]] name = "rustc-demangle" -version = "0.1.26" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" @@ -5491,25 +10984,12 @@ dependencies = [ ] [[package]] -name = "rustdct" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4d167674b4cf68c2114bdbcd34c95aa9071652b73b0f43b19298f1d2780b7d" -dependencies = [ - "rustfft", -] - -[[package]] -name = "rustfft" -version = "3.0.1" +name = "rusticata-macros" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77008ed59a8923c8b4ac2e5eaa6d28fbe893d3b9515098a4a5fc7767d6430fe5" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "num-complex", - "num-integer", - "num-traits", - "strength_reduce", - "transpose 0.1.0", + "nom 7.1.3", ] [[package]] @@ -5527,23 +11007,25 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -5554,11 +11036,11 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -5566,9 +11048,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -5576,13 +11058,14 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5606,7 +11089,7 @@ dependencies = [ "log", "memchr", "nix 0.28.0", - "radix_trie", + "radix_trie 0.2.1", "unicode-segmentation", "unicode-width 0.1.14", "utf8parse", @@ -5615,9 +11098,18 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "salsa20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] [[package]] name = "same-file" @@ -5714,14 +11206,14 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive 1.0.4", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -5735,33 +11227,70 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "schemars_derive" -version = "1.0.4" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.108", + "syn 2.0.114", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2", +] + [[package]] name = "sdd" version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "seccompiler" version = "0.5.0" @@ -5772,46 +11301,210 @@ dependencies = [ ] [[package]] -name = "security-framework" -version = "2.11.1" +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "sentry" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f" +dependencies = [ + "httpdate", + "native-tls", + "reqwest", + "sentry-actix", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-actix" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bac0f6b8621fa0f85e298901e51161205788322e1a995e3764329020368058" +dependencies = [ + "actix-http", + "actix-web", + "bytes", + "futures-util", + "sentry-core", +] + +[[package]] +name = "sentry-backtrace" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb1ef7534f583af20452b1b1bf610a60ed9c8dd2d8485e7bd064efc556a78fb" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd6be899d9938390b6d1ec71e2f53bd9e57b6a9d8b1d5b049e5c364e7da9078" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ab054c34b87f96c3e4701bea1888317cde30cc7e4a6136d2c48454ab96661c" +dependencies = [ + "rand 0.9.3", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-debug-images" +version = "0.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "5637ec550dc6f8c49a711537950722d3fc4baa6fd433c371912104eaff31e2a5" dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", + "findshlibs", + "sentry-core", ] [[package]] -name = "security-framework" -version = "3.5.1" +name = "sentry-panic" +version = "0.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "3f02c7162f7b69b8de872b439d4696dc1d65f80b13ddd3c3831723def4756b63" dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", + "sentry-backtrace", + "sentry-core", ] [[package]] -name = "security-framework-sys" -version = "2.15.0" +name = "sentry-tracing" +version = "0.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "e1dd47df349a80025819f3d25c3d2f751df705d49c65a4cdc0f130f700972a48" dependencies = [ - "core-foundation-sys", - "libc", + "bitflags 2.10.0", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", ] [[package]] -name = "semver" -version = "1.0.27" +name = "sentry-types" +version = "0.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "eecbd63e9d15a26a40675ed180d376fcb434635d2e33de1c24003f61e3e2230d" +dependencies = [ + "debugid", + "hex", + "rand 0.9.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid", +] [[package]] name = "serde" @@ -5823,16 +11516,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -5850,7 +11533,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -5861,58 +11544,63 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] -name = "serde_ignored" -version = "0.1.14" +name = "serde_html_form" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" dependencies = [ - "serde", + "form_urlencoded", + "indexmap 2.13.0", + "itoa", + "ryu", "serde_core", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] -name = "serde_repr" +name = "serde_path_to_error" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", + "itoa", + "serde", + "serde_core", ] [[package]] -name = "serde_spanned" -version = "0.6.9" +name = "serde_repr" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ - "serde", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -5931,17 +11619,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.0.4", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -5950,14 +11638,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -5966,7 +11654,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -5986,11 +11674,12 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" dependencies = [ - "futures", + "futures-executor", + "futures-util", "log", "once_cell", "parking_lot", @@ -6000,13 +11689,13 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -6020,6 +11709,22 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -6052,9 +11757,9 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" @@ -6074,9 +11779,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", @@ -6085,27 +11790,29 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] -name = "simd-adler32" -version = "0.3.7" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] [[package]] -name = "simd_helpers" -version = "0.1.0" +name = "simd-adler32" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simdutf8" @@ -6119,23 +11826,38 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smawk" @@ -6143,6 +11865,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "smol_str" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.5.10" @@ -6154,13 +11886,234 @@ dependencies = [ ] [[package]] -name = "socket2" -version = "0.6.1" +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.114", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.114", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ - "libc", - "windows-sys 0.60.2", + "atoi", + "chrono", + "flume 0.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", ] [[package]] @@ -6171,7 +12124,7 @@ checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -6203,7 +12156,7 @@ dependencies = [ "inventory", "itertools 0.13.0", "maplit", - "memoffset", + "memoffset 0.6.5", "num-bigint", "num-traits", "once_cell", @@ -6231,7 +12184,7 @@ dependencies = [ "dupe", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -6279,16 +12232,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "streaming-iterator" -version = "0.1.9" +name = "stop-words" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +checksum = "645a3d441ccf4bf47f2e4b7681461986681a6eeea9937d4c3bc9febd61d17c71" +dependencies = [ + "serde_json", +] [[package]] -name = "strength_reduce" -version = "0.2.4" +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "string_cache" @@ -6300,28 +12265,17 @@ dependencies = [ "parking_lot", "phf_shared", "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", ] [[package]] -name = "strip-ansi-escapes" -version = "0.1.1" +name = "stringprep" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "011cbb39cf7c1f62871aea3cc46e5817b0937b49e9447370c93cacbe93a766d8" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ - "vte 0.10.1", + "unicode-bidi", + "unicode-normalization", + "unicode-properties", ] [[package]] @@ -6350,6 +12304,9 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] [[package]] name = "strum_macros" @@ -6361,7 +12318,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -6373,7 +12330,19 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -6414,9 +12383,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.108" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -6440,7 +12409,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -6459,7 +12428,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "walkdir", "yaml-rust", ] @@ -6475,9 +12444,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -6496,56 +12465,83 @@ dependencies = [ [[package]] name = "system-deps" -version = "6.2.2" +version = "7.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.23", + "toml 0.9.11+spec-1.1.0", "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", - "xattr", ] [[package]] name = "target-lexicon" -version = "0.12.16" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] -name = "tendril" -version = "0.4.3" +name = "temporal_capi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "a151e402c2bdb6a3a2a2f3f225eddaead2e7ce7dd5d3fa2090deb11b17aa4ed8" dependencies = [ - "futf", - "mac", - "utf-8", + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale", + "num-traits", + "temporal_rs", + "timezone_provider", + "writeable", + "zoneinfo64", +] + +[[package]] +name = "temporal_rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88afde3bd75d2fc68d77a914bece426aa08aa7649ffd0cdd4a11c3d4d33474d1" +dependencies = [ + "core_maths", + "icu_calendar", + "icu_locale", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", ] [[package]] @@ -6574,7 +12570,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.60.2", ] @@ -6584,6 +12580,61 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "test-case-core", +] + +[[package]] +name = "test-log" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -6615,11 +12666,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -6630,18 +12681,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -6653,16 +12704,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "throbber-widgets-tui" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" -dependencies = [ - "rand 0.8.5", - "ratatui", -] - [[package]] name = "tiff" version = "0.10.3" @@ -6674,14 +12715,14 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -6689,27 +12730,39 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "timezone_provider" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9ba0000e9e73862f3e7ca1ff159e2ddf915c9d8bb11e38a7874760f445d993" +dependencies = [ + "tinystr", + "zerotrie", + "zerovec", + "zoneinfo64", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -6733,11 +12786,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6758,9 +12812,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -6768,11 +12822,24 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -6781,7 +12848,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -6806,23 +12873,22 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] name = "tokio-test" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ - "async-stream", - "bytes", "futures-core", "tokio", "tokio-stream", @@ -6830,56 +12896,54 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +version = "0.28.0" +source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6" dependencies = [ "futures-util", "log", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tungstenite", - "webpki-roots 0.26.11", ] [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "pin-project-lite", + "slab", "tokio", ] [[package]] name = "toml" -version = "0.8.23" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", ] [[package]] name = "toml" -version = "0.9.8" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -6887,43 +12951,33 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.12.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "indexmap 2.13.0", + "toml_datetime", + "toml_parser", "winnow", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.24.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" dependencies = [ - "indexmap 2.12.0", - "toml_datetime 0.7.3", + "indexmap 2.13.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -6931,41 +12985,43 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tonic" -version = "0.13.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" dependencies = [ "async-trait", "axum", "base64 0.22.1", "bytes", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", - "prost", - "socket2 0.5.10", + "rustls-native-certs", + "socket2 0.6.2", + "sync_wrapper", "tokio", + "tokio-rustls", "tokio-stream", "tower", "tower-layer", @@ -6973,15 +13029,54 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tonic-prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" +dependencies = [ + "bytes", + "prost 0.14.3", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build 0.14.3", + "prost-types 0.14.3", + "quote", + "syn 2.0.114", + "tempfile", + "tonic-build", +] + [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.12.0", + "indexmap 2.13.0", "pin-project-lite", "slab", "sync_wrapper", @@ -6994,15 +13089,15 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -7024,9 +13119,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -7036,32 +13131,32 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 1.0.69", + "thiserror 2.0.18", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -7088,38 +13183,72 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] -name = "transpose" -version = "0.1.0" +name = "tracing-test" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643e21580bb0627c7bb09e5cedbb42c8705b19d012de593ed6b0309270b3cd1e" +checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] [[package]] -name = "transpose" -version = "0.2.3" +name = "tracing-test-macro" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ - "num-integer", - "strength_reduce", + "quote", + "syn 2.0.114", ] [[package]] @@ -7138,9 +13267,9 @@ dependencies = [ [[package]] name = "tree-sitter-bash" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "871b0606e667e98a1237ebdc1b0d7056e0aebfdc3141d12b399865d4cb6ed8a6" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" dependencies = [ "cc", "tree-sitter-language", @@ -7148,9 +13277,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" [[package]] name = "tree_magic_mini" @@ -7176,7 +13305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "ts-rs-macros", "uuid", ] @@ -7189,54 +13318,48 @@ checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", "termcolor", ] [[package]] -name = "tui-input" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19" +name = "tungstenite" +version = "0.27.0" +source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f" dependencies = [ - "ratatui", - "unicode-width 0.2.1", + "bytes", + "data-encoding", + "flate2", + "headers", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.3", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", ] [[package]] -name = "tui-markdown" -version = "0.3.3" +name = "two-face" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf47229087fc49650d095a910a56aaf10c1c64181d042d2c2ba46fc3746ff534" +checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34" dependencies = [ - "ansi-to-tui", - "itertools 0.14.0", - "pretty_assertions", - "pulldown-cmark", - "ratatui", - "rstest", + "serde", + "serde_derive", "syntect", - "tracing", ] [[package]] -name = "tungstenite" -version = "0.23.0" +name = "type-map" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.5", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror 1.0.69", - "utf-8", + "rustc-hash 2.1.1", ] [[package]] @@ -7245,17 +13368,74 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "winapi", +] + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bom" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" [[package]] name = "unicode-ident" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-linebreak" @@ -7263,6 +13443,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -7298,28 +13493,74 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7348,25 +13589,32 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", + "sha1_smol", "wasm-bindgen", ] [[package]] -name = "v_frame" -version = "0.3.9" +name = "v8" +version = "146.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +checksum = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1" dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", + "bindgen", + "bitflags 2.10.0", + "fslock", + "gzip-header", + "home", + "miniz_oxide", + "paste", + "temporal_capi", + "which 6.0.3", ] [[package]] @@ -7383,9 +13631,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version-compare" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[package]] name = "version_check" @@ -7401,46 +13649,23 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vt100" -version = "0.15.2" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" dependencies = [ "itoa", - "log", - "unicode-width 0.1.14", - "vte 0.11.1", -] - -[[package]] -name = "vte" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbce692ab4ca2f1f3047fcf732430249c0e971bfdd2b234cf2c47ad93af5983" -dependencies = [ - "arrayvec 0.5.2", - "utf8parse", - "vte_generate_state_changes", + "unicode-width 0.2.1", + "vte", ] [[package]] name = "vte" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" -dependencies = [ - "arrayvec 0.7.6", - "utf8parse", - "vte_generate_state_changes", -] - -[[package]] -name = "vte_generate_state_changes" -version = "0.1.2" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ - "proc-macro2", - "quote", + "arrayvec", + "memchr", ] [[package]] @@ -7479,18 +13704,24 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -7499,27 +13730,14 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.108", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -7528,9 +13746,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7538,22 +13756,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.108", - "wasm-bindgen-backend", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -7573,34 +13791,34 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.13" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa6143502b9a87f759cb6a649ca801a226f77740eb54f3951cba2227790afeb" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.2", + "rustix 1.1.4", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.13" +version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ "bitflags 2.10.0", - "rustix 1.1.2", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-protocols" -version = "0.32.11" +version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -7610,9 +13828,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.11" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -7623,29 +13841,29 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.9" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.9" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81d2bd69b1dadd601d0e98ef2fc9339a1b1e00cec5ee7545a77b5a0f52a90394" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" dependencies = [ "pkg-config", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -7677,29 +13895,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.3", + "webpki-roots 1.0.5", ] [[package]] name = "webpki-roots" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc-sys" +version = "0.3.24" +source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0" +dependencies = [ + "cc", + "cxx", + "cxx-build", + "glob", + "log", + "pkg-config", + "webrtc-sys-build", +] + +[[package]] +name = "webrtc-sys-build" +version = "0.3.13" +source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0" +dependencies = [ + "anyhow", + "fs2", + "regex", + "reqwest", + "scratch", + "semver", + "zip 0.6.6", +] + [[package]] name = "weezl" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "which" @@ -7713,11 +13968,48 @@ dependencies = [ "winsafe", ] +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix 1.1.4", + "winsafe", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wildcard" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "wildmatch" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] name = "winapi" @@ -7741,7 +14033,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7750,6 +14042,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -7762,24 +14064,33 @@ dependencies = [ [[package]] name = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core 0.62.2", "windows-future", - "windows-link 0.1.3", "windows-numerics", ] [[package]] -name = "windows-collections" -version = "0.2.0" +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-core 0.61.2", + "windows-result 0.1.2", + "windows-targets 0.52.6", ] [[package]] @@ -7795,19 +14106,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -7816,19 +14114,19 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", - "windows-link 0.2.1", + "windows-link", "windows-result 0.4.1", "windows-strings 0.5.1", ] [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core 0.62.2", + "windows-link", "windows-threading", ] @@ -7840,7 +14138,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -7851,7 +14149,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -7862,7 +14160,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -7873,15 +14171,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -7890,41 +14182,41 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core 0.62.2", + "windows-link", ] [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-result" -version = "0.3.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-link 0.1.3", + "windows-targets 0.52.6", ] [[package]] @@ -7933,7 +14225,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -7946,22 +14238,13 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8015,7 +14298,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8070,7 +14353,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -8083,11 +14366,11 @@ dependencies = [ [[package]] name = "windows-threading" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -8272,9 +14555,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -8290,20 +14573,35 @@ dependencies = [ [[package]] name = "winreg" -version = "0.52.0" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ "cfg-if", "windows-sys 0.48.0", ] +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "winsafe" version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "winsplit" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab703352da6a72f35c39a533526393725640575bb211f61987a2748323ad956" + [[package]] name = "wiremock" version = "0.6.5" @@ -8314,7 +14612,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -8329,9 +14627,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "wl-clipboard-rs" @@ -8342,8 +14640,8 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.2", - "thiserror 2.0.17", + "rustix 1.1.4", + "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -8353,9 +14651,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "x11rb" @@ -8364,7 +14662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.2", + "rustix 1.1.4", "x11rb-protocol", ] @@ -8375,24 +14673,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] -name = "xattr" -version = "1.6.1" +name = "x25519-dalek" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "libc", - "rustix 1.1.2", + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", ] [[package]] -name = "xml5ever" +name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "log", - "mac", - "markup5ever", + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", ] [[package]] @@ -8410,13 +14743,21 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -8424,34 +14765,96 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -8471,7 +14874,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", "synstructure", ] @@ -8480,12 +14883,26 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -8494,10 +14911,11 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -8505,13 +14923,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -8520,32 +14938,197 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ + "aes", "byteorder", + "bzip2 0.4.4", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] -name = "zune-core" -version = "0.4.12" +name = "zip" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2 0.5.2", + "constant_time_eq 0.3.1", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.13.0", + "lzma-rs", + "memchr", + "pbkdf2 0.12.2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd 0.13.3", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + +[[package]] +name = "zoneinfo64" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2e5597efbe7c421da8a7fd396b20b571704e787c21a272eecf35dfe9d386f0" +dependencies = [ + "calendrical_calculations", + "icu_locale_core", + "potential_utf", + "resb", + "serde", +] [[package]] -name = "zune-inflate" -version = "0.2.54" +name = "zopfli" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ + "bumpalo", + "crc32fast", + "log", "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.2.4", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", ] diff --git a/code-rs/Cargo.toml b/code-rs/Cargo.toml index 0156b59fae8..6bda741c9c4 100644 --- a/code-rs/Cargo.toml +++ b/code-rs/Cargo.toml @@ -1,44 +1,113 @@ [workspace] members = [ + "aws-auth", + "analytics", + "agent-graph-store", + "agent-identity", "backend-client", + "builtin-mcps", + "bwrap", "ansi-escape", + "async-utils", "app-server", + "app-server-transport", + "app-server-client", "app-server-protocol", + "app-server-test-client", + "debug-client", "apply-patch", "arg0", - "code-auto-drive-core", - "code-auto-drive-diagnostics", - "code-backend-openapi-models", + "feedback", + "features", + "install-context", + "codex-backend-openapi-models", + "code-mode", + "cloud-requirements", "cloud-tasks", "cloud-tasks-client", + "cloud-tasks-mock-client", "cli", - "codex-experimental-api-macros", - "common", + "collaboration-mode-templates", + "connectors", + "config", + "shell-command", + "shell-escalation", + "skills", "core", + "core-api", + "core-plugins", + "core-skills", + "hooks", + "secrets", "exec", + "file-system", + "exec-server", "execpolicy", + "execpolicy-legacy", + "external-agent-migration", + "external-agent-sessions", + "keyring-store", "file-search", - "git-tooling", "linux-sandbox", + "lmstudio", "login", - "mcp-client", + "codex-mcp", "mcp-server", - "mcp-types", + "memories/mcp", + "memories/read", + "memories/write", + "model-provider-info", + "models-manager", + "network-proxy", "ollama", "process-hardening", "protocol", - "protocol-ts", + "realtime-webrtc", + "rollout", + "rollout-trace", "rmcp-client", "responses-api-proxy", + "response-debug-context", + "sandboxing", + "stdio-to-uds", "otel", "tui", - "git-apply", + "tools", + "v8-poc", "utils/absolute-path", - "utils/cache", "utils/cargo-bin", + "git-utils", + "utils/cache", "utils/image", "utils/json-to-toml", + "utils/home-dir", + "utils/pty", "utils/readiness", + "utils/rustls-provider", + "utils/string", + "utils/cli", + "utils/elapsed", + "utils/sandbox-summary", + "utils/sleep-inhibitor", + "utils/approval-presets", + "utils/oss", + "utils/output-truncation", + "utils/path-utils", + "utils/plugins", + "utils/fuzzy-match", + "utils/stream-parser", + "utils/template", + "codex-client", + "codex-api", + "state", + "terminal-detection", + "test-binary-support", + "thread-manager-sample", + "thread-store", + "uds", + "codex-experimental-api-macros", + "plugin", + "model-provider", ] resolver = "2" @@ -53,172 +122,309 @@ license = "Apache-2.0" [workspace.dependencies] # Internal -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-browser = { path = "browser" } -code-chatgpt = { path = "chatgpt" } -code-common = { path = "common" } -code-core = { path = "core" } -code-backend-client = { path = "backend-client" } -code-backend-openapi-models = { path = "code-backend-openapi-models" } -code-cloud-tasks = { path = "cloud-tasks" } -code-cloud-tasks-client = { path = "cloud-tasks-client" } -code-execpolicy = { path = "execpolicy" } -code-experimental-api-macros = { path = "codex-experimental-api-macros", package = "codex-experimental-api-macros" } -code-utils-absolute-path = { path = "utils/absolute-path", package = "codex-utils-absolute-path" } -code-utils-cache = { path = "utils/cache", package = "codex-utils-cache" } -code-utils-cargo-bin = { path = "utils/cargo-bin", package = "codex-utils-cargo-bin" } -code-utils-image = { path = "utils/image", package = "codex-utils-image" } -code-exec = { path = "exec" } -code-file-search = { path = "file-search" } -code-git-tooling = { path = "git-tooling" } -code-git-apply = { path = "git-apply" } -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" } -code-auto-drive-core = { path = "code-auto-drive-core" } -mcp-types = { path = "mcp-types", package = "code-mcp-types" } -upstream-mcp-types = { git = "https://github.com/openai/codex.git", package = "mcp-types", rev = "6c384eb9c610f9a83037d9cad120fb792e782c7c" } +app_test_support = { path = "app-server/tests/common" } +codex-analytics = { path = "analytics" } +codex-agent-graph-store = { path = "agent-graph-store" } +codex-agent-identity = { path = "agent-identity" } +codex-ansi-escape = { path = "ansi-escape" } +codex-api = { path = "codex-api" } +codex-aws-auth = { path = "aws-auth" } +codex-app-server = { path = "app-server" } +codex-app-server-transport = { path = "app-server-transport" } +codex-app-server-client = { path = "app-server-client" } +codex-app-server-protocol = { path = "app-server-protocol" } +codex-app-server-test-client = { path = "app-server-test-client" } +codex-apply-patch = { path = "apply-patch" } +codex-arg0 = { path = "arg0" } +codex-async-utils = { path = "async-utils" } +codex-backend-client = { path = "backend-client" } +codex-builtin-mcps = { path = "builtin-mcps" } +codex-chatgpt = { path = "chatgpt" } +codex-cli = { path = "cli" } +codex-client = { path = "codex-client" } +codex-collaboration-mode-templates = { path = "collaboration-mode-templates" } +codex-cloud-requirements = { path = "cloud-requirements" } +codex-cloud-tasks-client = { path = "cloud-tasks-client" } +codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } +codex-code-mode = { path = "code-mode" } +codex-config = { path = "config" } +codex-connectors = { path = "connectors" } +codex-core = { path = "core" } +codex-core-api = { path = "core-api" } +codex-core-plugins = { path = "core-plugins" } +codex-core-skills = { path = "core-skills" } +codex-exec = { path = "exec" } +codex-file-system = { path = "file-system" } +codex-exec-server = { path = "exec-server" } +codex-execpolicy = { path = "execpolicy" } +codex-external-agent-migration = { path = "external-agent-migration" } +codex-external-agent-sessions = { path = "external-agent-sessions" } +codex-experimental-api-macros = { path = "codex-experimental-api-macros" } +codex-features = { path = "features" } +codex-feedback = { path = "feedback" } +codex-install-context = { path = "install-context" } +codex-file-search = { path = "file-search" } +codex-git-utils = { path = "git-utils" } +codex-hooks = { path = "hooks" } +codex-keyring-store = { path = "keyring-store" } +codex-linux-sandbox = { path = "linux-sandbox" } +codex-lmstudio = { path = "lmstudio" } +codex-login = { path = "login" } +codex-message-history = { path = "message-history" } +codex-memories-mcp = { path = "memories/mcp" } +codex-memories-read = { path = "memories/read" } +codex-memories-write = { path = "memories/write" } +codex-mcp = { path = "codex-mcp" } +codex-mcp-server = { path = "mcp-server" } +codex-model-provider-info = { path = "model-provider-info" } +codex-models-manager = { path = "models-manager" } +codex-network-proxy = { path = "network-proxy" } +codex-ollama = { path = "ollama" } +codex-otel = { path = "otel" } +codex-plugin = { path = "plugin" } +codex-model-provider = { path = "model-provider" } +codex-process-hardening = { path = "process-hardening" } +codex-protocol = { path = "protocol" } +codex-realtime-webrtc = { path = "realtime-webrtc" } +codex-responses-api-proxy = { path = "responses-api-proxy" } +codex-response-debug-context = { path = "response-debug-context" } +codex-rmcp-client = { path = "rmcp-client" } +codex-rollout = { path = "rollout" } +codex-rollout-trace = { path = "rollout-trace" } +codex-sandboxing = { path = "sandboxing" } +codex-secrets = { path = "secrets" } +codex-shell-command = { path = "shell-command" } +codex-shell-escalation = { path = "shell-escalation" } +codex-skills = { path = "skills" } +codex-state = { path = "state" } +codex-stdio-to-uds = { path = "stdio-to-uds" } +codex-terminal-detection = { path = "terminal-detection" } +codex-test-binary-support = { path = "test-binary-support" } +codex-thread-store = { path = "thread-store" } +codex-tools = { path = "tools" } +codex-tui = { path = "tui" } +codex-uds = { path = "uds" } +codex-utils-absolute-path = { path = "utils/absolute-path" } +codex-utils-approval-presets = { path = "utils/approval-presets" } +codex-utils-cache = { path = "utils/cache" } +codex-utils-cargo-bin = { path = "utils/cargo-bin" } +codex-utils-cli = { path = "utils/cli" } +codex-utils-elapsed = { path = "utils/elapsed" } +codex-utils-fuzzy-match = { path = "utils/fuzzy-match" } +codex-utils-home-dir = { path = "utils/home-dir" } +codex-utils-image = { path = "utils/image" } +codex-utils-json-to-toml = { path = "utils/json-to-toml" } +codex-utils-oss = { path = "utils/oss" } +codex-utils-output-truncation = { path = "utils/output-truncation" } +codex-utils-path = { path = "utils/path-utils" } +codex-utils-plugins = { path = "utils/plugins" } +codex-utils-pty = { path = "utils/pty" } +codex-utils-readiness = { path = "utils/readiness" } +codex-utils-rustls-provider = { path = "utils/rustls-provider" } +codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } +codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } +codex-utils-stream-parser = { path = "utils/stream-parser" } +codex-utils-string = { path = "utils/string" } +codex-utils-template = { path = "utils/template" } +codex-v8-poc = { path = "v8-poc" } +codex-windows-sandbox = { path = "windows-sandbox-rs" } +core_test_support = { path = "core/tests/common" } +mcp_test_support = { path = "mcp-server/tests/common" } # External +age = "0.11.1" allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = { version = "3", features = ["wayland-data-control"] } -askama = "0.12" +arc-swap = "1.9.0" assert_cmd = "2" +assert_matches = "1.5.0" async-channel = "2.3.1" +async-io = "2.6.0" async-stream = "0.3.6" async-trait = "0.1.89" +aws-config = "1" +aws-credential-types = "1" +aws-sigv4 = "1" +aws-types = "1" +axum = { version = "0.8", default-features = false } base64 = "0.22.1" +bm25 = "2.3.2" bytes = "1.10.1" chardetng = "0.1.17" -chrono = "0.4.42" +chrono = "0.4.43" clap = "4" clap_complete = "4" color-eyre = "0.6.3" +constant_time_eq = "0.3.1" +crossbeam-channel = "0.5.15" +crypto_box = { version = "0.9.1", features = ["seal"] } crossterm = "0.28.1" -crc32fast = "1.5" -ctor = "0.5.0" +csv = "1.3.1" +ctor = "0.6.3" +deno_core_icudata = "0.77.0" derive_more = "2" diffy = "0.4.2" dirs = "6" +dns-lookup = "3.0.1" dotenvy = "0.15.7" -dunce = "1.0.5" -env-flags = "0.1.1" -env_logger = "0.11.5" -escargot = "0.5" +dunce = "1.0.4" +ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] } encoding_rs = "0.8.35" +env-flags = "0.1.1" +env_logger = "0.11.9" eventsource-stream = "0.2.3" -filetime = "0.2" -futures = "0.3" -icu_provider = "2.0.0" -icu_decimal = "2.0.0" -icu_locale_core = "2.0.0" +flate2 = "1.1.8" +futures = { version = "0.3", default-features = false } +gethostname = "1.1.0" +gix = { version = "0.81.0", default-features = false, features = ["sha1"] } +glob = "0.3" +globset = "0.4" +hmac = "0.12.1" +http = "1.3.1" +iana-time-zone = "0.1.64" +icu_decimal = "2.1" +icu_locale_core = "2.1" +icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" -image = { version = "^0.25.8", default-features = false } -indexmap = "2.6.0" -insta = "1.43.2" -inventory = "0.3" +image = { version = "^0.25.9", default-features = false } +include_dir = "0.7.4" +indexmap = "2.12.0" +insta = "1.46.3" +inventory = "0.3.19" itertools = "0.14.0" -landlock = "0.4.1" +jsonwebtoken = "9.3.1" +keyring = { version = "3.6", default-features = false } +landlock = "0.4.4" lazy_static = "1" -libc = "0.2.175" +libc = "0.2.182" log = "0.4" -lru = "0.12" +lru = "0.16.3" maplit = "1.0.2" mime_guess = "2.0.5" multimap = "0.10.0" -once_cell = "1" -nucleo-matcher = "0.3.1" +notify = "8.2.0" +nucleo = { git = "https://github.com/helix-editor/nucleo.git", rev = "4253de9faabb4e5c6d81d946a5e35a90f87347ee" } +once_cell = "1.20.2" 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" +opentelemetry = "0.31.0" +opentelemetry-appender-tracing = "0.31.0" +opentelemetry-otlp = "0.31.0" +opentelemetry-semantic-conventions = "0.31.0" +opentelemetry_sdk = "0.31.0" os_info = "3.12.0" -owo-colors = "4.2.0" +owo-colors = "4.3.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" -proc-macro2 = "1" pulldown-cmark = "0.10" -quote = "1" +quick-xml = "0.38.4" rand = "0.9" ratatui = "0.29.0" -regex = "1" -regex-lite = "0.1.7" -reqwest = "0.12" +ratatui-macros = "0.6.0" +rcgen = { version = "0.14.7", default-features = false, features = [ + "aws_lc_rs", + "pem", +] } +regex = "1.12.3" +regex-lite = "0.1.8" +reqwest = { version = "0.12", features = ["cookies"] } +rmcp = { version = "0.15.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", +] } +rustls-native-certs = "0.8.3" +rustls-pki-types = "1.14.0" schemars = "0.8.22" seccompiler = "0.5.0" +semver = "1.0" +sentry = "0.46.0" serde = "1" serde_json = "1" -serde_with = "3.14" +serde_path_to_error = "0.1.20" +serde_with = "3.17" +serde_yaml = "0.9" +serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10" shlex = "1.3.0" similar = "2.7.0" +socket2 = "0.6.1" +sqlx = { version = "0.8.6", default-features = false, features = [ + "chrono", + "json", + "macros", + "migrate", + "runtime-tokio-rustls", + "sqlite", + "time", + "uuid", +] } starlark = "0.13.0" strum = "0.27.2" -strum_macros = "0.27.2" +strum_macros = "0.28.0" supports-color = "3.0.2" -syn = { version = "2", features = ["full", "extra-traits"] } +syntect = "5" sys-locale = "0.3.2" +tar = { version = "=0.4.45", default-features = false } tempfile = "3.23.0" +test-log = "0.2.19" textwrap = "0.16.2" -thiserror = "2.0.16" -time = "0.3" +thiserror = "2.0.17" +time = "0.3.47" tiny_http = "0.12" tokio = "1" -tokio-stream = "0.1.17" +tokio-stream = "0.1.18" tokio-test = "0.4" -tokio-util = "0.7.16" +tokio-tungstenite = { version = "0.28.0", features = [ + "proxy", + "rustls-tls-native-roots", +] } +tokio-util = "0.7.18" toml = "0.9.5" -toml_edit = "0.23.4" -tonic = "0.13.1" -tracing = "0.1.41" +toml_edit = "0.24.0" +tracing = "0.1.44" tracing-appender = "0.2.3" -tracing-subscriber = "0.3.20" +tracing-opentelemetry = "0.32.0" +tracing-subscriber = "0.3.22" tracing-test = "0.2.5" -tree-sitter = "0.25.9" -tree-sitter-bash = "0.25.0" +tonic = { version = "0.14.3", default-features = false, features = ["channel", "codegen"] } +tonic-prost = "0.14.3" +tree-sitter = "0.25.10" +tree-sitter-bash = "0.25" ts-rs = "11" +tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } +uds_windows = "1.1.0" unicode-segmentation = "1.12.0" unicode-width = "0.2" url = "2" urlencoding = "2.1" uuid = "1" +v8 = "=146.4.0" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" -which = "6" -wildmatch = "2.5.0" +which = "8" +whoami = "1.6.1" +wildmatch = "2.6.1" +winapi-util = "0.1.11" +zip = "2.4.2" +zstd = "0.13" + wiremock = "0.6" -zeroize = "1.8.1" +zeroize = "1.8.2" [workspace.lints] rust = {} [workspace.lints.clippy] +await_holding_invalid_type = "deny" +await_holding_lock = "deny" expect_used = "deny" identity_op = "deny" manual_clamp = "deny" @@ -256,10 +462,30 @@ 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"] +ignored = [ + "codex-agent-graph-store", + "codex-memories-mcp", + "icu_provider", + "openssl-sys", + "codex-utils-readiness", + "codex-utils-template", + "codex-v8-poc", +] + +[profile.dev] +# Keep line tables/backtraces while avoiding expensive full variable debug info +# across local dev builds. +debug = "limited" + +[profile.dev-small] +inherits = "dev" +opt-level = 0 +debug = "none" +strip = "symbols" [profile.release] lto = "fat" +split-debuginfo = "off" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. strip = "symbols" @@ -267,30 +493,28 @@ strip = "symbols" # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 +[profile.profiling] +inherits = "release" +debug = "full" +lto = false +strip = false + +[profile.ci-test] +# Reduce binary size to reduce disk pressure. +debug = "limited" +inherits = "test" +opt-level = 0 + [patch.crates-io] +# Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } -ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" } +ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" } +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } -# 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" +# Uncomment to debug local changes. +# rmcp = { path = "../../rust-sdk/crates/rmcp" } -[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 +[patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"] +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } diff --git a/code-rs/README.md b/code-rs/README.md index faccb6ab498..d219061a350 100644 --- a/code-rs/README.md +++ b/code-rs/README.md @@ -1,148 +1,105 @@ -# Every Code CLI (Rust Implementation) +# Codex CLI (Rust Implementation) -Every Code provides a standalone native `code` executable. This directory is the -Rust workspace that builds the product CLI used for local dogfooding and GitHub -Release updates. +We provide Codex CLI as a standalone executable to ensure a zero-dependency install. -## Installing Every Code +## Installing Codex -The canonical internal install and update source is the repository's GitHub -Releases, including the generated `update-manifest.json` consumed by the CLI -updater. npm and Homebrew publishing are deferred unless package-manager -distribution becomes intentional again. +Today, the easiest way to install Codex is via `npm`: ```shell -code update-check -code update --yes +npm i -g @openai/codex +codex ``` -For local development, build from the repository root with: +You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). -```shell -./build-fast.sh -``` +## Documentation quickstart + +- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management). +- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md). ## What's new in the Rust CLI -The Rust implementation is the maintained Every Code CLI and serves as the -default experience. It includes a number of features that the legacy TypeScript -CLI never supported. +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 -Code 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. +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 -Code 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. +#### MCP client -It is still experimental, but you can also launch Code as an MCP _server_ by running `code mcp-server`. Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: - -```shell -npx @modelcontextprotocol/inspector code mcp-server -``` - -Use `code mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `code mcp-server` to run the MCP server directly. - -### Notifications +Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details. -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. +#### MCP server (experimental) -### `code exec` to run Code programmatically/non-interactively +Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent. -To run Code non-interactively, run `code exec PROMPT` (you can also pass the prompt via `stdin`) and Code 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 the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: -### 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. +```shell +npx @modelcontextprotocol/inspector codex mcp-server +``` -### Esc–Esc to edit a previous message +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. -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 Code 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. +### Notifications -In the transcript preview, the footer shows an `Esc edit prev` hint while editing is active. +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. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. -### `--cd`/`-C` flag +### `codex exec` to run Codex programmatically/non-interactively -Sometimes it is not convenient to `cd` to the directory you want Code to use as the "working root" before running Code. Fortunately, `code` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Code is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. +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. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. +Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk. -### Shell completions +### Experimenting with the Codex Sandbox -Generate shell completion scripts via: +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: -```shell -code completion bash -code completion zsh -code completion fish ``` +# macOS +codex sandbox macos [--log-denials] [COMMAND]... -### Experimenting with the Code Sandbox +# Linux +codex sandbox linux [COMMAND]... -To test to see what happens when a command is run under the sandbox provided by Code, we provide the following subcommands in Code CLI: +# Windows +codex sandbox windows [COMMAND]... +# Legacy aliases +codex debug seatbelt [--log-denials] [COMMAND]... +codex debug landlock [COMMAND]... ``` -# macOS -code debug seatbelt [--full-auto] [COMMAND]... -# Linux -code debug landlock [--full-auto] [COMMAND]... -``` +To try a writable legacy sandbox mode with these commands, pass an explicit config override such +as `-c 'sandbox_mode="workspace-write"'`. ### 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 Code with the default, read-only sandbox -code --sandbox read-only +# 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 -code --sandbox workspace-write +codex --sandbox workspace-write # Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env) -code --sandbox danger-full-access +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 -``` - -### TUI anti-truncation fallback - -If the transcript's last line intermittently clips, Code keeps a guarded -bottom spacer enabled. The TUI adds a 1–2 row overscan pad when the computed -history height looks like it might land flush with the viewport, reducing the -chance the final row disappears mid-stream. Enable `RUST_LOG=debug` to log when -the fallback fires while you iterate on layouts. - -### 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)` +The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`. +In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval. ## 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 Code. Ultimately, we hope this to be a library crate that is generally useful for building other Rust/native applications that use Code. +- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes 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. + +If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned. diff --git a/code-rs/agent-graph-store/BUILD.bazel b/code-rs/agent-graph-store/BUILD.bazel new file mode 100644 index 00000000000..96c077e263b --- /dev/null +++ b/code-rs/agent-graph-store/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "agent-graph-store", + crate_name = "codex_agent_graph_store", +) diff --git a/code-rs/agent-graph-store/Cargo.toml b/code-rs/agent-graph-store/Cargo.toml new file mode 100644 index 00000000000..9ecd827194b --- /dev/null +++ b/code-rs/agent-graph-store/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-agent-graph-store" +version.workspace = true + +[lib] +name = "codex_agent_graph_store" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-protocol = { workspace = true } +codex-state = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/code-rs/agent-graph-store/src/error.rs b/code-rs/agent-graph-store/src/error.rs new file mode 100644 index 00000000000..ddd8eeef380 --- /dev/null +++ b/code-rs/agent-graph-store/src/error.rs @@ -0,0 +1,20 @@ +/// Result type returned by agent graph store operations. +pub type AgentGraphStoreResult = Result; + +/// Error type shared by agent graph store implementations. +#[derive(Debug, thiserror::Error)] +pub enum AgentGraphStoreError { + /// The caller supplied invalid request data. + #[error("invalid agent graph store request: {message}")] + InvalidRequest { + /// User-facing explanation of the invalid request. + message: String, + }, + + /// Catch-all for implementation failures that do not fit a more specific category. + #[error("agent graph store internal error: {message}")] + Internal { + /// User-facing explanation of the implementation failure. + message: String, + }, +} diff --git a/code-rs/agent-graph-store/src/lib.rs b/code-rs/agent-graph-store/src/lib.rs new file mode 100644 index 00000000000..72e8b45e846 --- /dev/null +++ b/code-rs/agent-graph-store/src/lib.rs @@ -0,0 +1,12 @@ +//! Storage-neutral parent/child topology for thread-spawned agents. + +mod error; +mod local; +mod store; +mod types; + +pub use error::AgentGraphStoreError; +pub use error::AgentGraphStoreResult; +pub use local::LocalAgentGraphStore; +pub use store::AgentGraphStore; +pub use types::ThreadSpawnEdgeStatus; diff --git a/code-rs/agent-graph-store/src/local.rs b/code-rs/agent-graph-store/src/local.rs new file mode 100644 index 00000000000..f45874855c6 --- /dev/null +++ b/code-rs/agent-graph-store/src/local.rs @@ -0,0 +1,325 @@ +use async_trait::async_trait; +use codex_protocol::ThreadId; +use codex_state::StateRuntime; +use std::sync::Arc; + +use crate::AgentGraphStore; +use crate::AgentGraphStoreError; +use crate::AgentGraphStoreResult; +use crate::ThreadSpawnEdgeStatus; + +/// SQLite-backed implementation of [`AgentGraphStore`] using an existing state runtime. +#[derive(Clone)] +pub struct LocalAgentGraphStore { + state_db: Arc, +} + +impl std::fmt::Debug for LocalAgentGraphStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalAgentGraphStore") + .field("codex_home", &self.state_db.codex_home()) + .finish_non_exhaustive() + } +} + +impl LocalAgentGraphStore { + /// Create a local graph store from an already-initialized state runtime. + pub fn new(state_db: Arc) -> Self { + Self { state_db } + } +} + +#[async_trait] +impl AgentGraphStore for LocalAgentGraphStore { + async fn upsert_thread_spawn_edge( + &self, + parent_thread_id: ThreadId, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()> { + self.state_db + .upsert_thread_spawn_edge(parent_thread_id, child_thread_id, to_state_status(status)) + .await + .map_err(internal_error) + } + + async fn set_thread_spawn_edge_status( + &self, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()> { + self.state_db + .set_thread_spawn_edge_status(child_thread_id, to_state_status(status)) + .await + .map_err(internal_error) + } + + async fn list_thread_spawn_children( + &self, + parent_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult> { + if let Some(status) = status_filter { + return self + .state_db + .list_thread_spawn_children_with_status(parent_thread_id, to_state_status(status)) + .await + .map_err(internal_error); + } + + self.state_db + .list_thread_spawn_children(parent_thread_id) + .await + .map_err(internal_error) + } + + async fn list_thread_spawn_descendants( + &self, + root_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult> { + match status_filter { + Some(status) => self + .state_db + .list_thread_spawn_descendants_with_status(root_thread_id, to_state_status(status)) + .await + .map_err(internal_error), + None => self + .state_db + .list_thread_spawn_descendants(root_thread_id) + .await + .map_err(internal_error), + } + } +} + +fn to_state_status(status: ThreadSpawnEdgeStatus) -> codex_state::DirectionalThreadSpawnEdgeStatus { + match status { + ThreadSpawnEdgeStatus::Open => codex_state::DirectionalThreadSpawnEdgeStatus::Open, + ThreadSpawnEdgeStatus::Closed => codex_state::DirectionalThreadSpawnEdgeStatus::Closed, + } +} + +fn internal_error(err: impl std::fmt::Display) -> AgentGraphStoreError { + AgentGraphStoreError::Internal { + message: err.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_state::DirectionalThreadSpawnEdgeStatus; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + struct TestRuntime { + state_db: Arc, + _codex_home: TempDir, + } + + fn thread_id(suffix: u128) -> ThreadId { + ThreadId::from_string(&format!("00000000-0000-0000-0000-{suffix:012}")) + .expect("valid thread id") + } + + async fn state_runtime() -> TestRuntime { + let codex_home = TempDir::new().expect("tempdir should be created"); + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + TestRuntime { + state_db, + _codex_home: codex_home, + } + } + + #[tokio::test] + async fn local_store_upserts_and_lists_direct_children_with_status_filters() { + let fixture = state_runtime().await; + let state_db = fixture.state_db; + let store = LocalAgentGraphStore::new(state_db.clone()); + let parent_thread_id = thread_id(/*suffix*/ 1); + let first_child_thread_id = thread_id(/*suffix*/ 2); + let second_child_thread_id = thread_id(/*suffix*/ 3); + + store + .upsert_thread_spawn_edge( + parent_thread_id, + second_child_thread_id, + ThreadSpawnEdgeStatus::Closed, + ) + .await + .expect("closed child edge should insert"); + store + .upsert_thread_spawn_edge( + parent_thread_id, + first_child_thread_id, + ThreadSpawnEdgeStatus::Open, + ) + .await + .expect("open child edge should insert"); + + let all_children = store + .list_thread_spawn_children(parent_thread_id, /*status_filter*/ None) + .await + .expect("all children should load"); + assert_eq!( + all_children, + vec![first_child_thread_id, second_child_thread_id] + ); + + let open_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open)) + .await + .expect("open children should load"); + let state_open_children = state_db + .list_thread_spawn_children_with_status( + parent_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + .expect("state open children should load"); + assert_eq!(open_children, state_open_children); + assert_eq!(open_children, vec![first_child_thread_id]); + + let closed_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed)) + .await + .expect("closed children should load"); + assert_eq!(closed_children, vec![second_child_thread_id]); + } + + #[tokio::test] + async fn local_store_updates_edge_status() { + let fixture = state_runtime().await; + let state_db = fixture.state_db; + let store = LocalAgentGraphStore::new(state_db); + let parent_thread_id = thread_id(/*suffix*/ 10); + let child_thread_id = thread_id(/*suffix*/ 11); + + store + .upsert_thread_spawn_edge( + parent_thread_id, + child_thread_id, + ThreadSpawnEdgeStatus::Open, + ) + .await + .expect("child edge should insert"); + store + .set_thread_spawn_edge_status(child_thread_id, ThreadSpawnEdgeStatus::Closed) + .await + .expect("child edge should close"); + + let open_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open)) + .await + .expect("open children should load"); + assert_eq!(open_children, Vec::::new()); + + let closed_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed)) + .await + .expect("closed children should load"); + assert_eq!(closed_children, vec![child_thread_id]); + } + + #[tokio::test] + async fn local_store_lists_descendants_breadth_first_with_status_filters() { + let fixture = state_runtime().await; + let state_db = fixture.state_db; + let store = LocalAgentGraphStore::new(state_db.clone()); + let root_thread_id = thread_id(/*suffix*/ 20); + let later_child_thread_id = thread_id(/*suffix*/ 22); + let earlier_child_thread_id = thread_id(/*suffix*/ 21); + let closed_grandchild_thread_id = thread_id(/*suffix*/ 23); + let open_grandchild_thread_id = thread_id(/*suffix*/ 24); + let closed_child_thread_id = thread_id(/*suffix*/ 25); + let closed_great_grandchild_thread_id = thread_id(/*suffix*/ 26); + + for (parent_thread_id, child_thread_id, status) in [ + ( + root_thread_id, + later_child_thread_id, + ThreadSpawnEdgeStatus::Open, + ), + ( + root_thread_id, + earlier_child_thread_id, + ThreadSpawnEdgeStatus::Open, + ), + ( + earlier_child_thread_id, + open_grandchild_thread_id, + ThreadSpawnEdgeStatus::Open, + ), + ( + later_child_thread_id, + closed_grandchild_thread_id, + ThreadSpawnEdgeStatus::Closed, + ), + ( + root_thread_id, + closed_child_thread_id, + ThreadSpawnEdgeStatus::Closed, + ), + ( + closed_child_thread_id, + closed_great_grandchild_thread_id, + ThreadSpawnEdgeStatus::Closed, + ), + ] { + store + .upsert_thread_spawn_edge(parent_thread_id, child_thread_id, status) + .await + .expect("edge should insert"); + } + + let all_descendants = store + .list_thread_spawn_descendants(root_thread_id, /*status_filter*/ None) + .await + .expect("all descendants should load"); + assert_eq!( + all_descendants, + vec![ + earlier_child_thread_id, + later_child_thread_id, + closed_child_thread_id, + closed_grandchild_thread_id, + open_grandchild_thread_id, + closed_great_grandchild_thread_id, + ] + ); + + let open_descendants = store + .list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Open)) + .await + .expect("open descendants should load"); + let state_open_descendants = state_db + .list_thread_spawn_descendants_with_status( + root_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + .expect("state open descendants should load"); + assert_eq!(open_descendants, state_open_descendants); + assert_eq!( + open_descendants, + vec![ + earlier_child_thread_id, + later_child_thread_id, + open_grandchild_thread_id, + ] + ); + + let closed_descendants = store + .list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Closed)) + .await + .expect("closed descendants should load"); + assert_eq!( + closed_descendants, + vec![closed_child_thread_id, closed_great_grandchild_thread_id] + ); + } +} diff --git a/code-rs/agent-graph-store/src/store.rs b/code-rs/agent-graph-store/src/store.rs new file mode 100644 index 00000000000..c421182110f --- /dev/null +++ b/code-rs/agent-graph-store/src/store.rs @@ -0,0 +1,55 @@ +use async_trait::async_trait; +use codex_protocol::ThreadId; + +use crate::AgentGraphStoreResult; +use crate::ThreadSpawnEdgeStatus; + +/// Storage-neutral boundary for persisted thread-spawn parent/child topology. +/// +/// Implementations are expected to return stable ordering for list methods so callers can merge +/// persisted graph state with live in-memory state without introducing nondeterministic output. +#[async_trait] +pub trait AgentGraphStore: Send + Sync { + /// Insert or replace the directional parent/child edge for a spawned thread. + /// + /// `child_thread_id` has at most one persisted parent. Re-inserting the same child should + /// update both the parent and status to match the supplied values. + async fn upsert_thread_spawn_edge( + &self, + parent_thread_id: ThreadId, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()>; + + /// Update the persisted lifecycle status of a spawned thread's incoming edge. + /// + /// Implementations should treat missing children as a successful no-op. + async fn set_thread_spawn_edge_status( + &self, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()>; + + /// List direct spawned children of a parent thread. + /// + /// When `status_filter` is `Some`, only child edges with that exact status are returned. When + /// it is `None`, all direct child edges are returned regardless of status, including statuses + /// that may be added by a future store implementation. + async fn list_thread_spawn_children( + &self, + parent_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult>; + + /// List spawned descendants breadth-first by depth, then by thread id. + /// + /// `status_filter` is applied to every traversed edge, not just to the returned descendants. + /// For example, `Some(Open)` walks only open edges, so descendants under a closed edge are not + /// included even if their own incoming edge is open. `None` walks and returns every persisted + /// edge regardless of status. + async fn list_thread_spawn_descendants( + &self, + root_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult>; +} diff --git a/code-rs/agent-graph-store/src/types.rs b/code-rs/agent-graph-store/src/types.rs new file mode 100644 index 00000000000..2a9f6caedb6 --- /dev/null +++ b/code-rs/agent-graph-store/src/types.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; +use serde::Serialize; + +/// Lifecycle status attached to a directional thread-spawn edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadSpawnEdgeStatus { + /// The child thread is still live or resumable as an open spawned agent. + Open, + /// The child thread has been closed from the parent/child graph's perspective. + Closed, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn thread_spawn_edge_status_serializes_as_snake_case() { + assert_eq!( + serde_json::to_string(&ThreadSpawnEdgeStatus::Open) + .expect("open status should serialize"), + "\"open\"" + ); + assert_eq!( + serde_json::to_string(&ThreadSpawnEdgeStatus::Closed) + .expect("closed status should serialize"), + "\"closed\"" + ); + assert_eq!( + serde_json::from_str::("\"open\"") + .expect("open status should deserialize"), + ThreadSpawnEdgeStatus::Open + ); + assert_eq!( + serde_json::from_str::("\"closed\"") + .expect("closed status should deserialize"), + ThreadSpawnEdgeStatus::Closed + ); + } +} diff --git a/code-rs/agent-identity/BUILD.bazel b/code-rs/agent-identity/BUILD.bazel new file mode 100644 index 00000000000..d1363c468f1 --- /dev/null +++ b/code-rs/agent-identity/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "agent-identity", + crate_name = "codex_agent_identity", +) diff --git a/code-rs/agent-identity/Cargo.toml b/code-rs/agent-identity/Cargo.toml new file mode 100644 index 00000000000..4610d6ec9b3 --- /dev/null +++ b/code-rs/agent-identity/Cargo.toml @@ -0,0 +1,30 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-agent-identity" +version.workspace = true + +[lib] +doctest = false +name = "codex_agent_identity" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +codex-protocol = { workspace = true } +crypto_box = { workspace = true } +ed25519-dalek = { workspace = true } +jsonwebtoken = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/code-rs/agent-identity/src/lib.rs b/code-rs/agent-identity/src/lib.rs new file mode 100644 index 00000000000..7aad81a34f1 --- /dev/null +++ b/code-rs/agent-identity/src/lib.rs @@ -0,0 +1,737 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::SecondsFormat; +use chrono::Utc; +use codex_protocol::auth::PlanType as AuthPlanType; +use codex_protocol::protocol::SessionSource; +use crypto_box::SecretKey as Curve25519SecretKey; +use ed25519_dalek::Signer as _; +use ed25519_dalek::SigningKey; +use ed25519_dalek::VerifyingKey; +use ed25519_dalek::pkcs8::DecodePrivateKey; +use ed25519_dalek::pkcs8::EncodePrivateKey; +use jsonwebtoken::Algorithm; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::Validation; +use jsonwebtoken::decode; +use jsonwebtoken::decode_header; +use jsonwebtoken::jwk::JwkSet; +use rand::TryRngCore; +use rand::rngs::OsRng; +use serde::Deserialize; +use serde::Serialize; +use serde::de::DeserializeOwned; +use sha2::Digest as _; +use sha2::Sha512; + +const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30); +const AGENT_IDENTITY_JWKS_TIMEOUT: Duration = Duration::from_secs(10); +const AGENT_IDENTITY_JWT_AUDIENCE: &str = "codex-app-server"; +const AGENT_IDENTITY_JWT_ISSUER: &str = "https://chatgpt.com/codex-backend/agent-identity"; + +/// Stored key material for a registered agent identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AgentIdentityKey<'a> { + pub agent_runtime_id: &'a str, + pub private_key_pkcs8_base64: &'a str, +} + +/// Task binding to use when constructing a task-scoped AgentAssertion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AgentTaskAuthorizationTarget<'a> { + pub agent_runtime_id: &'a str, + pub task_id: &'a str, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentBillOfMaterials { + pub agent_version: String, + pub agent_harness_id: String, + pub running_location: String, +} + +pub struct GeneratedAgentKeyMaterial { + pub private_key_pkcs8_base64: String, + pub public_key_ssh: String, +} + +/// Claims carried by an Agent Identity JWT. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct AgentIdentityJwtClaims { + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, + pub agent_runtime_id: String, + pub agent_private_key: String, + pub account_id: String, + pub chatgpt_user_id: String, + pub email: String, + pub plan_type: AuthPlanType, + pub chatgpt_account_is_fedramp: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct AgentAssertionEnvelope { + agent_runtime_id: String, + task_id: String, + timestamp: String, + signature: String, +} + +#[derive(Serialize)] +struct RegisterTaskRequest { + timestamp: String, + signature: String, +} + +#[derive(Deserialize)] +struct RegisterTaskResponse { + #[serde(default)] + task_id: Option, + #[serde(default, rename = "taskId")] + task_id_camel: Option, + #[serde(default)] + encrypted_task_id: Option, + #[serde(default, rename = "encryptedTaskId")] + encrypted_task_id_camel: Option, +} + +pub fn authorization_header_for_agent_task( + key: AgentIdentityKey<'_>, + target: AgentTaskAuthorizationTarget<'_>, +) -> Result { + anyhow::ensure!( + key.agent_runtime_id == target.agent_runtime_id, + "agent task runtime {} does not match stored agent identity {}", + target.agent_runtime_id, + key.agent_runtime_id + ); + + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let envelope = AgentAssertionEnvelope { + agent_runtime_id: target.agent_runtime_id.to_string(), + task_id: target.task_id.to_string(), + timestamp: timestamp.clone(), + signature: sign_agent_assertion_payload(key, target.task_id, ×tamp)?, + }; + let serialized_assertion = serialize_agent_assertion(&envelope)?; + Ok(format!("AgentAssertion {serialized_assertion}")) +} + +pub async fn fetch_agent_identity_jwks( + client: &reqwest::Client, + chatgpt_base_url: &str, +) -> Result { + let response = client + .get(agent_identity_jwks_url(chatgpt_base_url)) + .timeout(AGENT_IDENTITY_JWKS_TIMEOUT) + .send() + .await + .context("failed to request agent identity JWKS")? + .error_for_status() + .context("agent identity JWKS endpoint returned an error")?; + + response + .json() + .await + .context("failed to decode agent identity JWKS") +} + +pub fn decode_agent_identity_jwt( + jwt: &str, + jwks: Option<&JwkSet>, +) -> Result { + let Some(jwks) = jwks else { + return decode_agent_identity_jwt_payload(jwt); + }; + + let header = decode_header(jwt).context("failed to decode agent identity JWT header")?; + let kid = header + .kid + .context("agent identity JWT header does not include a kid")?; + let jwk = jwks + .find(&kid) + .with_context(|| format!("agent identity JWT kid {kid} is not trusted"))?; + let decoding_key = DecodingKey::from_jwk(jwk).context("failed to build JWT decoding key")?; + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[AGENT_IDENTITY_JWT_AUDIENCE]); + validation.set_issuer(&[AGENT_IDENTITY_JWT_ISSUER]); + validation.required_spec_claims.insert("iss".to_string()); + validation.required_spec_claims.insert("aud".to_string()); + decode::(jwt, &decoding_key, &validation) + .map(|data| data.claims) + .context("failed to verify agent identity JWT") +} + +fn decode_agent_identity_jwt_payload(jwt: &str) -> Result { + let mut parts = jwt.split('.'); + let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => anyhow::bail!("invalid agent identity JWT format"), + }; + anyhow::ensure!(parts.next().is_none(), "invalid agent identity JWT format"); + + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .context("agent identity JWT payload is not valid base64url")?; + serde_json::from_slice(&payload_bytes).context("agent identity JWT payload is not valid JSON") +} + +pub fn sign_task_registration_payload( + key: AgentIdentityKey<'_>, + timestamp: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(key.private_key_pkcs8_base64)?; + let payload = format!("{}:{timestamp}", key.agent_runtime_id); + Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes())) +} + +pub async fn register_agent_task( + client: &reqwest::Client, + chatgpt_base_url: &str, + key: AgentIdentityKey<'_>, +) -> Result { + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let request = RegisterTaskRequest { + signature: sign_task_registration_payload(key, ×tamp)?, + timestamp, + }; + let url = agent_task_registration_url(chatgpt_base_url, key.agent_runtime_id); + + let response = client + .post(url) + .timeout(AGENT_TASK_REGISTRATION_TIMEOUT) + .json(&request) + .send() + .await + .context("failed to register agent task")?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let body = if body.len() > 512 { + format!("{}...", body.chars().take(512).collect::()) + } else { + body + }; + anyhow::bail!("failed to register agent task with status {status}: {body}"); + } + + let response = response + .json() + .await + .context("failed to decode agent task registration response")?; + + task_id_from_register_task_response(key, response) +} + +fn task_id_from_register_task_response( + key: AgentIdentityKey<'_>, + response: RegisterTaskResponse, +) -> Result { + if let Some(task_id) = response.task_id.or(response.task_id_camel) { + return Ok(task_id); + } + let encrypted_task_id = response + .encrypted_task_id + .or(response.encrypted_task_id_camel) + .context("agent task registration response omitted task id")?; + decrypt_task_id_response(key, &encrypted_task_id) +} + +pub fn decrypt_task_id_response( + key: AgentIdentityKey<'_>, + encrypted_task_id: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(key.private_key_pkcs8_base64)?; + let ciphertext = BASE64_STANDARD + .decode(encrypted_task_id) + .context("encrypted task id is not valid base64")?; + let plaintext = curve25519_secret_key_from_signing_key(&signing_key) + .unseal(&ciphertext) + .map_err(|_| anyhow::anyhow!("failed to decrypt encrypted task id"))?; + String::from_utf8(plaintext).context("decrypted task id is not valid UTF-8") +} + +pub fn generate_agent_key_material() -> Result { + let mut secret_key_bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut secret_key_bytes) + .context("failed to generate agent identity private key bytes")?; + let signing_key = SigningKey::from_bytes(&secret_key_bytes); + let private_key_pkcs8 = signing_key + .to_pkcs8_der() + .context("failed to encode agent identity private key as PKCS#8")?; + + Ok(GeneratedAgentKeyMaterial { + private_key_pkcs8_base64: BASE64_STANDARD.encode(private_key_pkcs8.as_bytes()), + public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()), + }) +} + +pub fn public_key_ssh_from_private_key_pkcs8_base64( + private_key_pkcs8_base64: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64)?; + Ok(encode_ssh_ed25519_public_key(&signing_key.verifying_key())) +} + +pub fn verifying_key_from_private_key_pkcs8_base64( + private_key_pkcs8_base64: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64)?; + Ok(signing_key.verifying_key()) +} + +pub fn curve25519_secret_key_from_private_key_pkcs8_base64( + private_key_pkcs8_base64: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64)?; + Ok(curve25519_secret_key_from_signing_key(&signing_key)) +} + +pub fn agent_registration_url(chatgpt_base_url: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/v1/agent/register") +} + +pub fn agent_task_registration_url(chatgpt_base_url: &str, agent_runtime_id: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/v1/agent/{agent_runtime_id}/task/register") +} + +pub fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/authenticate_app_v2") +} + +pub fn agent_identity_jwks_url(chatgpt_base_url: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + if trimmed.contains("/backend-api") { + format!("{trimmed}/wham/agent-identities/jwks") + } else { + format!("{trimmed}/agent-identities/jwks") + } +} + +pub fn agent_identity_request_id() -> Result { + let mut request_id_bytes = [0u8; 16]; + OsRng + .try_fill_bytes(&mut request_id_bytes) + .context("failed to generate agent identity request id")?; + Ok(format!( + "codex-agent-identity-{}", + URL_SAFE_NO_PAD.encode(request_id_bytes) + )) +} + +pub fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials { + AgentBillOfMaterials { + agent_version: env!("CARGO_PKG_VERSION").to_string(), + agent_harness_id: match &session_source { + SessionSource::VSCode => "codex-app".to_string(), + SessionSource::Cli + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Internal(_) + | SessionSource::SubAgent(_) + | SessionSource::Unknown => "codex-cli".to_string(), + }, + running_location: format!("{}-{}", session_source, std::env::consts::OS), + } +} + +pub fn encode_ssh_ed25519_public_key(verifying_key: &VerifyingKey) -> String { + let mut blob = Vec::with_capacity(4 + 11 + 4 + 32); + append_ssh_string(&mut blob, b"ssh-ed25519"); + append_ssh_string(&mut blob, verifying_key.as_bytes()); + format!("ssh-ed25519 {}", BASE64_STANDARD.encode(blob)) +} + +fn sign_agent_assertion_payload( + key: AgentIdentityKey<'_>, + task_id: &str, + timestamp: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(key.private_key_pkcs8_base64)?; + let payload = format!("{}:{task_id}:{timestamp}", key.agent_runtime_id); + Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes())) +} + +fn serialize_agent_assertion(envelope: &AgentAssertionEnvelope) -> Result { + let payload = serde_json::to_vec(&BTreeMap::from([ + ("agent_runtime_id", envelope.agent_runtime_id.as_str()), + ("signature", envelope.signature.as_str()), + ("task_id", envelope.task_id.as_str()), + ("timestamp", envelope.timestamp.as_str()), + ])) + .context("failed to serialize agent assertion envelope")?; + Ok(URL_SAFE_NO_PAD.encode(payload)) +} + +fn curve25519_secret_key_from_signing_key(signing_key: &SigningKey) -> Curve25519SecretKey { + let digest = Sha512::digest(signing_key.to_bytes()); + let mut secret_key = [0u8; 32]; + secret_key.copy_from_slice(&digest[..32]); + secret_key[0] &= 248; + secret_key[31] &= 127; + secret_key[31] |= 64; + Curve25519SecretKey::from(secret_key) +} + +fn append_ssh_string(buf: &mut Vec, value: &[u8]) { + buf.extend_from_slice(&(value.len() as u32).to_be_bytes()); + buf.extend_from_slice(value); +} + +fn signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64: &str) -> Result { + let private_key = BASE64_STANDARD + .decode(private_key_pkcs8_base64) + .context("stored agent identity private key is not valid base64")?; + SigningKey::from_pkcs8_der(&private_key) + .context("stored agent identity private key is not valid PKCS#8") +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use ed25519_dalek::Signature; + use ed25519_dalek::Verifier as _; + use jsonwebtoken::EncodingKey; + use jsonwebtoken::Header; + use pretty_assertions::assert_eq; + + use codex_protocol::auth::KnownPlan; + + use super::*; + + #[test] + fn authorization_header_for_agent_task_serializes_signed_agent_assertion() { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let private_key = signing_key + .to_pkcs8_der() + .expect("encode test key material"); + let key = AgentIdentityKey { + agent_runtime_id: "agent-123", + private_key_pkcs8_base64: &BASE64_STANDARD.encode(private_key.as_bytes()), + }; + let target = AgentTaskAuthorizationTarget { + agent_runtime_id: "agent-123", + task_id: "task-123", + }; + + let header = + authorization_header_for_agent_task(key, target).expect("build agent assertion header"); + let token = header + .strip_prefix("AgentAssertion ") + .expect("agent assertion scheme"); + let payload = URL_SAFE_NO_PAD + .decode(token) + .expect("valid base64url payload"); + let envelope: AgentAssertionEnvelope = + serde_json::from_slice(&payload).expect("valid assertion envelope"); + + assert_eq!( + envelope, + AgentAssertionEnvelope { + agent_runtime_id: "agent-123".to_string(), + task_id: "task-123".to_string(), + timestamp: envelope.timestamp.clone(), + signature: envelope.signature.clone(), + } + ); + let signature_bytes = BASE64_STANDARD + .decode(&envelope.signature) + .expect("valid base64 signature"); + let signature = Signature::from_slice(&signature_bytes).expect("valid signature bytes"); + signing_key + .verifying_key() + .verify( + format!( + "{}:{}:{}", + envelope.agent_runtime_id, envelope.task_id, envelope.timestamp + ) + .as_bytes(), + &signature, + ) + .expect("signature should verify"); + } + + #[test] + fn authorization_header_for_agent_task_rejects_mismatched_runtime() { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let private_key = signing_key + .to_pkcs8_der() + .expect("encode test key material"); + let private_key_pkcs8_base64 = BASE64_STANDARD.encode(private_key.as_bytes()); + let key = AgentIdentityKey { + agent_runtime_id: "agent-123", + private_key_pkcs8_base64: &private_key_pkcs8_base64, + }; + let target = AgentTaskAuthorizationTarget { + agent_runtime_id: "agent-456", + task_id: "task-123", + }; + + let error = authorization_header_for_agent_task(key, target) + .expect_err("runtime mismatch should fail"); + + assert_eq!( + error.to_string(), + "agent task runtime agent-456 does not match stored agent identity agent-123" + ); + } + + #[test] + fn decode_agent_identity_jwt_reads_claims() { + let jwt = jwt_with_payload(serde_json::json!({ + "iss": AGENT_IDENTITY_JWT_ISSUER, + "aud": AGENT_IDENTITY_JWT_AUDIENCE, + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + + let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode"); + + assert_eq!( + claims, + AgentIdentityJwtClaims { + iss: AGENT_IDENTITY_JWT_ISSUER.to_string(), + aud: AGENT_IDENTITY_JWT_AUDIENCE.to_string(), + iat: 1_700_000_000, + exp: 4_000_000_000, + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AuthPlanType::Known(KnownPlan::Pro), + chatgpt_account_is_fedramp: false, + } + ); + } + + #[test] + fn decode_agent_identity_jwt_maps_raw_plan_aliases() { + let jwt = jwt_with_payload(serde_json::json!({ + "iss": AGENT_IDENTITY_JWT_ISSUER, + "aud": AGENT_IDENTITY_JWT_AUDIENCE, + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "hc", + "chatgpt_account_is_fedramp": false, + })); + + let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode"); + + assert_eq!(claims.plan_type, AuthPlanType::Known(KnownPlan::Enterprise)); + } + + #[test] + fn decode_agent_identity_jwt_verifies_when_jwks_is_present() { + let jwks = test_jwks("test-key"); + let claims = AgentIdentityJwtClaims { + iss: AGENT_IDENTITY_JWT_ISSUER.to_string(), + aud: AGENT_IDENTITY_JWT_AUDIENCE.to_string(), + iat: 1_700_000_000, + exp: 4_000_000_000, + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AuthPlanType::Known(KnownPlan::Pro), + chatgpt_account_is_fedramp: false, + }; + let jwt = jsonwebtoken::encode( + &test_jwt_header("test-key"), + &serde_json::json!({ + "iss": claims.iss, + "aud": claims.aud, + "iat": claims.iat, + "exp": claims.exp, + "agent_runtime_id": claims.agent_runtime_id, + "agent_private_key": claims.agent_private_key, + "account_id": claims.account_id, + "chatgpt_user_id": claims.chatgpt_user_id, + "email": claims.email, + "plan_type": "pro", + "chatgpt_account_is_fedramp": claims.chatgpt_account_is_fedramp, + }), + &test_rsa_encoding_key(), + ) + .expect("JWT should encode"); + + let expected_claims = AgentIdentityJwtClaims { + iss: AGENT_IDENTITY_JWT_ISSUER.to_string(), + aud: AGENT_IDENTITY_JWT_AUDIENCE.to_string(), + iat: 1_700_000_000, + exp: 4_000_000_000, + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AuthPlanType::Known(KnownPlan::Pro), + chatgpt_account_is_fedramp: false, + }; + assert_eq!( + decode_agent_identity_jwt(&jwt, Some(&jwks)).expect("JWT should verify"), + expected_claims + ); + } + + #[test] + fn decode_agent_identity_jwt_rejects_untrusted_kid() { + let jwks = test_jwks("other-key"); + + let jwt = jsonwebtoken::encode( + &test_jwt_header("test-key"), + &serde_json::json!({ + "iss": AGENT_IDENTITY_JWT_ISSUER, + "aud": AGENT_IDENTITY_JWT_AUDIENCE, + "iat": 1_700_000_000, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }), + &test_rsa_encoding_key(), + ) + .expect("JWT should encode"); + + decode_agent_identity_jwt(&jwt, Some(&jwks)).expect_err("JWT should not verify"); + } + + #[test] + fn decode_agent_identity_jwt_requires_issuer_and_audience() { + let jwks = test_jwks("test-key"); + let jwt = jsonwebtoken::encode( + &test_jwt_header("test-key"), + &serde_json::json!({ + "iat": 1_700_000_000, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }), + &test_rsa_encoding_key(), + ) + .expect("JWT should encode"); + + decode_agent_identity_jwt(&jwt, Some(&jwks)).expect_err("JWT should not verify"); + } + + fn test_jwt_header(kid: &str) -> Header { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + header + } + + fn test_rsa_encoding_key() -> EncodingKey { + EncodingKey::from_rsa_pem( + br#"-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO +bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 +FPbBlmIZSL3FQTbyt/hYutPFKfCou5PLmScw/TzILS3/RhT8UY9kxxZvXiEbTki9 +mvxRuZFpVqDFJHwfitIjKZGhXDCYVKurPTrxetYZJg0h8sQBLKjkZ0BqqaTUkAsg +0eBgZAlXEzG3By8PGhUqYLt6W1Q3KYw0FmGy/gTyzH1g0ukGgSJvOd8SkNT8MbOs +zl5kKxDNqpuEE6UZ3jbuJ+5382d31w+rOAJRzbf7QVdI9+luCSwJcDACYPQ4WNBa +uCpV0ovpAgMBAAECggEAVu84LwZdqYN9XpswX8VoPYrjMm9IODapWQBRpQFoNyK2 +1ksF3bjEPvA2Azk8U/l7k+vLKw22l6lY3EyRZPcz5GnB8xLm3ogE3mtNOp4yCyVu +RxhQ91aaN7mU17/a4BdorLi2LYVCg3zBmYociD1Q2AluNGsCmwPu+K7tfR2J0Sg8 +NjqiTbDG1XDpR/icwgC9t6vh8lZpCHDhF4tbQfLLVLeA/OdcuzXDyMCXbmdVIdBQ +rm4aIFmr2e1/2ctTbCg85S6AGFTH+pSLjrwTzyvf+F6NW5uNjLQAQLFj+EznBDxj +Xdx90cySrjsKK6PVWQF4RiTvkSW8eWL7R6B2FZbGwQKBgQDuVQRj72hWloR7mbEL +aUEEv3pIXTMXWEsoMBNczos/1L1RnAN1AI44TurznasPZAWvQj+kVbLDR+TAeZrL +iA8HIWswQUI18hFmgKzSkwIXGtubcKVrgsKeS4lMDKCM/Ef6WAYdeq6ronoY5lCN +YrJFmGp81W5zcV7lyiycgbSiGwKBgQDmjWYf6pZjrK7Z+OJ3X1AZfi2vss15SCvL +3fPgzIDbViztpGyQhc3DQZIsBNIu0xZp/veGce9TEeTds2ro9NfdJFeou8+fC7Pq +sOsM3amGFFi+ZW/9BWyjZEM88bgWWAjqLHbpfHDxjAf5CSxddqxgHlbP0Ytyb1Vg +gmPDn9YKSwKBgQDbTi3hC35WFuDHn0/zcSHcDZmnFuOZeqyFyV83yfMGhGrEuqvP +sPgtRikajJ3IZsB4WZyYSidZXEFY/0z6NjOl2xF38MTNQPbT/FmK1q1Yt2UWrlv5 +BvSwlk87RG9D7C0LZo4R+D7cPoDdgqjiwMvMEIkEX5zn641oI1ZTmWKuuwKBgQCD +KF+3unnRvHRAVoFnTZbA2fJdqMeRvogD04GhGlYX8V9f1hFY6nXTJaNlXVzA/J8c +r8ra9kgjJuPfZ+ljG58OFFW2DRohLcQtuHYPfK6rMzoFHqnl9EcIcMp7ijuionR3 +29HOJFgQYgxLFXfit9d6WugiE+BTupiEbckZif13HwKBgE/lAlkVHP6YahOO2Ljc +J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN +5da0D4h2rYOXnbYIg0BVu4spQbaM6ewsp66b8+MzLOBvj8SzWdt1Oyw0q/MRyQAR +8U4M2TSWCKUY/A6sT4W8+mT9 +-----END PRIVATE KEY-----"#, + ) + .expect("test RSA key should parse") + } + + fn test_jwks(kid: &str) -> jsonwebtoken::jwk::JwkSet { + serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "RSA", + "kid": kid, + "use": "sig", + "alg": "RS256", + "n": "1qQF2MqTrGAMDm7wXbjJP5sWqGA83tAGUs2ksy7iJXLJdhCg4AtwGm4SFl4f6kxhCSzlN1QdXuZjvRT2wZZiGUi9xUE28rf4WLrTxSnwqLuTy5knMP08yC0t_0YU_FGPZMcWb14hG05IvZr8UbmRaVagxSR8H4rSIymRoVwwmFSrqz068XrWGSYNIfLEASyo5GdAaqmk1JALINHgYGQJVxMxtwcvDxoVKmC7eltUNymMNBZhsv4E8sx9YNLpBoEibznfEpDU_DGzrM5eZCsQzaqbhBOlGd427ifud_Nnd9cPqzgCUc23-0FXSPfpbgksCXAwAmD0OFjQWrgqVdKL6Q", + "e": "AQAB", + }] + })) + .expect("test JWKS should parse") + } + + #[test] + fn agent_identity_jwks_url_uses_backend_api_base_url() { + assert_eq!( + agent_identity_jwks_url("https://chatgpt.com/backend-api"), + "https://chatgpt.com/backend-api/wham/agent-identities/jwks" + ); + assert_eq!( + agent_identity_jwks_url("https://chatgpt.com/backend-api/"), + "https://chatgpt.com/backend-api/wham/agent-identities/jwks" + ); + } + + #[test] + fn agent_identity_jwks_url_uses_codex_api_base_url() { + assert_eq!( + agent_identity_jwks_url("http://localhost:8080/api/codex"), + "http://localhost:8080/api/codex/agent-identities/jwks" + ); + assert_eq!( + agent_identity_jwks_url("http://localhost:8080/api/codex/"), + "http://localhost:8080/api/codex/agent-identities/jwks" + ); + } + + fn jwt_with_payload(payload: serde_json::Value) -> String { + let encode = |bytes: &[u8]| URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("payload should serialize")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } +} diff --git a/code-rs/analytics/BUILD.bazel b/code-rs/analytics/BUILD.bazel new file mode 100644 index 00000000000..aec07c87469 --- /dev/null +++ b/code-rs/analytics/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "analytics", + crate_name = "codex_analytics", +) diff --git a/code-rs/analytics/Cargo.toml b/code-rs/analytics/Cargo.toml new file mode 100644 index 00000000000..918e7edc720 --- /dev/null +++ b/code-rs/analytics/Cargo.toml @@ -0,0 +1,34 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-analytics" +version.workspace = true + +[lib] +doctest = false +name = "codex_analytics" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-app-server-protocol = { workspace = true } +codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-plugin = { workspace = true } +codex-protocol = { workspace = true } +os_info = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = { workspace = true } +tokio = { workspace = true, features = [ + "macros", + "rt-multi-thread", +] } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +codex-utils-absolute-path = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/code-rs/analytics/src/analytics_client_tests.rs b/code-rs/analytics/src/analytics_client_tests.rs new file mode 100644 index 00000000000..880adfc254f --- /dev/null +++ b/code-rs/analytics/src/analytics_client_tests.rs @@ -0,0 +1,3078 @@ +use crate::client::AnalyticsEventsQueue; +use crate::events::AppServerRpcTransport; +use crate::events::CodexAppMentionedEventRequest; +use crate::events::CodexAppServerClientMetadata; +use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; +use crate::events::CodexCompactionEventRequest; +use crate::events::CodexHookRunEventRequest; +use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; +use crate::events::CodexTurnEventRequest; +use crate::events::GuardianApprovalRequestSource; +use crate::events::GuardianReviewDecision; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewFailureReason; +use crate::events::GuardianReviewTerminalStatus; +use crate::events::GuardianReviewedAction; +use crate::events::ThreadInitializedEvent; +use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemFinalApprovalOutcome; +use crate::events::ToolItemTerminalStatus; +use crate::events::TrackEventRequest; +use crate::events::codex_app_metadata; +use crate::events::codex_hook_run_metadata; +use crate::events::codex_plugin_metadata; +use crate::events::codex_plugin_used_metadata; +use crate::events::subagent_thread_started_event_request; +use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; +use crate::facts::AppInvocation; +use crate::facts::AppMentionedInput; +use crate::facts::AppUsedInput; +use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionImplementation; +use crate::facts::CompactionPhase; +use crate::facts::CompactionReason; +use crate::facts::CompactionStatus; +use crate::facts::CompactionStrategy; +use crate::facts::CompactionTrigger; +use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunFact; +use crate::facts::HookRunInput; +use crate::facts::InputError; +use crate::facts::InvocationType; +use crate::facts::PluginState; +use crate::facts::PluginStateChangedInput; +use crate::facts::PluginUsedInput; +use crate::facts::SkillInvocation; +use crate::facts::SkillInvokedInput; +use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnTokenUsageFact; +use crate::reducer::AnalyticsReducer; +use crate::reducer::normalize_path_for_skill_id; +use crate::reducer::skill_id_for_local_skill; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SessionSource as AppServerSessionSource; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource as AppServerThreadSource; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; +use codex_app_server_protocol::UserInput; +use codex_login::default_client::DEFAULT_ORIGINATOR; +use codex_login::default_client::originator; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookSource; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TokenUsage; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use tokio::sync::mpsc; + +fn sample_thread_with_metadata( + thread_id: &str, + ephemeral: bool, + source: AppServerSessionSource, + thread_source: Option, +) -> Thread { + Thread { + id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), + forked_from_id: None, + preview: "first prompt".to_string(), + ephemeral, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: AppServerThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: "0.0.0".to_string(), + source, + thread_source, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + } +} + +fn sample_thread_start_response( + thread_id: &str, + ephemeral: bool, + model: &str, +) -> ClientResponsePayload { + ClientResponsePayload::ThreadStart(ThreadStartResponse { + thread: sample_thread_with_metadata( + thread_id, + ephemeral, + AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), + ), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: None, + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: DEFAULT_ORIGINATOR.to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: Some(true), + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_thread_resume_response( + thread_id: &str, + ephemeral: bool, + model: &str, +) -> ClientResponsePayload { + sample_thread_resume_response_with_source( + thread_id, + ephemeral, + model, + AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), + ) +} + +fn sample_thread_resume_response_with_source( + thread_id: &str, + ephemeral: bool, + model: &str, + source: AppServerSessionSource, + thread_source: Option, +) -> ClientResponsePayload { + ClientResponsePayload::ThreadResume(ThreadResumeResponse { + thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: None, + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str) -> ClientResponsePayload { + ClientResponsePayload::TurnStart(codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: thread_id.to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + permission_profile: CorePermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), + permission_profile_cwd: PathBuf::from("/tmp"), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::AutoReview, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str) -> ClientResponsePayload { + ClientResponsePayload::TurnSteer(TurnSteerResponse { + turn_id: turn_id.to_string(), + }) +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 8, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-web".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-web".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 8, + request_id: RequestId::Integer(6), + response: Box::new(sample_thread_resume_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, + include_token_usage: bool, +) { + if include_initialize { + ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); + } + + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + response: Box::new(sample_turn_start_response("turn-2")), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("thread-2", "turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } +} + +async fn ingest_tool_review_prerequisites( + reducer: &mut AnalyticsReducer, + events: &mut Vec, +) { + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), events) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-1", /*ephemeral*/ false, "gpt-5", + )), + }, + events, + ) + .await; + events.clear(); +} + +fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { + AnalyticsFact::Initialize { + connection_id, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "linux".to_string(), + runtime_os_version: "24.04".to_string(), + runtime_arch: "x86_64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Websocket, + } +} + +fn sample_command_execution_item( + status: CommandExecutionStatus, + exit_code: Option, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "item-1".to_string(), + command: "echo hi".to_string(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-1".to_string()), + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + +fn sample_command_execution_item_with_actions( + status: CommandExecutionStatus, + exit_code: Option, + duration_ms: Option, + command_actions: Vec, +) -> ThreadItem { + let mut item = sample_command_execution_item(status, exit_code, duration_ms); + let ThreadItem::CommandExecution { + command_actions: item_command_actions, + .. + } = &mut item + else { + unreachable!("sample command execution item should be CommandExecution"); + }; + *item_command_actions = command_actions; + item +} + +fn expected_absolute_path(path: &PathBuf) -> String { + std::fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .replace('\\', "/") +} + +#[test] +fn normalize_path_for_skill_id_repo_scoped_uses_relative_path() { + let repo_root = PathBuf::from("/repo/root"); + let skill_path = PathBuf::from("/repo/root/.codex/skills/doc/SKILL.md"); + + let path = normalize_path_for_skill_id( + Some("https://example.com/repo.git"), + Some(repo_root.as_path()), + skill_path.as_path(), + ); + + assert_eq!(path, ".codex/skills/doc/SKILL.md"); +} + +#[test] +fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() { + let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md"); + + let path = normalize_path_for_skill_id( + /*repo_url*/ None, + /*repo_root*/ None, + skill_path.as_path(), + ); + let expected = expected_absolute_path(&skill_path); + + assert_eq!(path, expected); +} + +#[test] +fn normalize_path_for_skill_id_admin_scoped_uses_absolute_path() { + let skill_path = PathBuf::from("/etc/codex/skills/doc/SKILL.md"); + + let path = normalize_path_for_skill_id( + /*repo_url*/ None, + /*repo_root*/ None, + skill_path.as_path(), + ); + let expected = expected_absolute_path(&skill_path); + + assert_eq!(path, expected); +} + +#[test] +fn normalize_path_for_skill_id_repo_root_not_in_skill_path_uses_absolute_path() { + let repo_root = PathBuf::from("/repo/root"); + let skill_path = PathBuf::from("/other/path/.codex/skills/doc/SKILL.md"); + + let path = normalize_path_for_skill_id( + Some("https://example.com/repo.git"), + Some(repo_root.as_path()), + skill_path.as_path(), + ); + let expected = expected_absolute_path(&skill_path); + + assert_eq!(path, expected); +} + +#[test] +fn app_mentioned_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let event = TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest { + event_type: "codex_app_mentioned", + event_params: codex_app_metadata( + &tracking, + AppInvocation { + connector_id: Some("calendar".to_string()), + app_name: Some("Calendar".to_string()), + invocation_type: Some(InvocationType::Explicit), + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize app mentioned event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_app_mentioned", + "event_params": { + "connector_id": "calendar", + "thread_id": "thread-1", + "turn_id": "turn-1", + "app_name": "Calendar", + "product_client_id": originator().value, + "invoke_type": "explicit", + "model_slug": "gpt-5" + } + }) + ); +} + +#[test] +fn app_used_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest { + event_type: "codex_app_used", + event_params: codex_app_metadata( + &tracking, + AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Google Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize app used event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_app_used", + "event_params": { + "connector_id": "drive", + "thread_id": "thread-2", + "turn_id": "turn-2", + "app_name": "Google Drive", + "product_client_id": originator().value, + "invoke_type": "implicit", + "model_slug": "gpt-5" + } + }) + ); +} + +#[test] +fn compaction_event_serializes_expected_shape() { + let event = TrackEventRequest::Compaction(Box::new(CodexCompactionEventRequest { + event_type: "codex_compaction_event", + event_params: crate::events::codex_compaction_event_params( + CodexCompactionEvent { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + trigger: CompactionTrigger::Auto, + reason: CompactionReason::ContextLimit, + implementation: CompactionImplementation::ResponsesCompact, + phase: CompactionPhase::MidTurn, + strategy: CompactionStrategy::Memento, + status: CompactionStatus::Completed, + error: None, + active_context_tokens_before: 120_000, + active_context_tokens_after: 18_000, + started_at: 100, + completed_at: 106, + duration_ms: Some(6543), + }, + sample_app_server_client_metadata(), + sample_runtime_metadata(), + Some(ThreadSource::User), + /*subagent_source*/ None, + /*parent_thread_id*/ None, + ), + })); + + let payload = serde_json::to_value(&event).expect("serialize compaction event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_compaction_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "app_server_client": { + "product_client_id": DEFAULT_ORIGINATOR, + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "thread_source": "user", + "subagent_source": null, + "parent_thread_id": null, + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compact", + "phase": "mid_turn", + "strategy": "memento", + "status": "completed", + "error": null, + "active_context_tokens_before": 120000, + "active_context_tokens_after": 18000, + "started_at": 100, + "completed_at": 106, + "duration_ms": 6543 + } + }) + ); +} + +#[test] +fn app_used_dedupe_is_keyed_by_turn_and_connector() { + let (sender, _receiver) = mpsc::channel(1); + let queue = AnalyticsEventsQueue { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + }; + let app = AppInvocation { + connector_id: Some("calendar".to_string()), + app_name: Some("Calendar".to_string()), + invocation_type: Some(InvocationType::Implicit), + }; + + let turn_1 = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let turn_2 = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-2".to_string(), + }; + + assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), true); + assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false); + assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true); +} + +#[test] +fn thread_initialized_event_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(ThreadInitializedEvent { + event_type: "codex_thread_initialized", + event_params: ThreadInitializedEventParams { + thread_id: "thread-0".to_string(), + app_server_client: CodexAppServerClientMetadata { + product_client_id: DEFAULT_ORIGINATOR.to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: Some(true), + }, + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + model: "gpt-5".to_string(), + ephemeral: true, + thread_source: Some(ThreadSource::User), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + created_at: 1, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize thread initialized event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_thread_initialized", + "event_params": { + "thread_id": "thread-0", + "app_server_client": { + "product_client_id": DEFAULT_ORIGINATOR, + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "model": "gpt-5", + "ephemeral": true, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "created_at": 1 + } + }) + ); +} + +#[test] +fn command_execution_event_serializes_expected_shape() { + let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base: CodexToolItemEventBase { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + app_server_client: CodexAppServerClientMetadata { + product_client_id: "codex_tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.2.3".to_string()), + rpc_transport: AppServerRpcTransport::Websocket, + experimental_api_enabled: Some(true), + }, + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + thread_source: Some(ThreadSource::User), + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at_ms: 123_000, + completed_at_ms: 125_000, + duration_ms: Some(2000), + execution_duration_ms: Some(1900), + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + requested_additional_permissions: false, + requested_network_access: false, + }, + command_execution_source: CommandExecutionSource::Agent, + exit_code: Some(0), + command_total_action_count: 4, + command_read_action_count: 1, + command_list_files_action_count: 1, + command_search_action_count: 1, + command_unknown_action_count: 1, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_command_execution_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "item_id": "item-1", + "app_server_client": { + "product_client_id": "codex_tui", + "client_name": "codex-tui", + "client_version": "1.2.3", + "rpc_transport": "websocket", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.99.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "thread_source": "user", + "subagent_source": null, + "parent_thread_id": null, + "tool_name": "shell", + "started_at_ms": 123000, + "completed_at_ms": 125000, + "duration_ms": 2000, + "execution_duration_ms": 1900, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_approval_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "requested_additional_permissions": false, + "requested_network_access": false, + "command_execution_source": "agent", + "exit_code": 0, + "command_total_action_count": 4, + "command_read_action_count": 1, + "command_list_files_action_count": 1, + "command_search_action_count": 1, + "command_unknown_action_count": 1 + } + }) + ); +} + +#[tokio::test] +async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-no-client", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "thread events should require initialize"); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "linux".to_string(), + runtime_os_version: "24.04".to_string(), + runtime_arch: "x86_64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "initialize should not publish by itself"); + + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(2), + response: Box::new(sample_thread_resume_response( + "thread-1", /*ephemeral*/ true, "gpt-5", + )), + }, + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_version"], + "1.0.0" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["rpc_transport"], + "websocket" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["experimental_api_enabled"], + false + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.99.0" + ); + assert_eq!(payload[0]["event_params"]["runtime"]["runtime_os"], "linux"); + assert_eq!( + payload[0]["event_params"]["runtime"]["runtime_os_version"], + "24.04" + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["runtime_arch"], + "x86_64" + ); +} + +#[tokio::test] +async fn unrelated_client_requests_are_ignored_by_reducer() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(ClientRequest::ThreadArchive { + request_id: RequestId::Integer(3), + params: ThreadArchiveParams { + thread_id: "thread-2".to_string(), + }, + }), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + response: Box::new(sample_turn_start_response("turn-2")), + }, + &mut events, + ) + .await; + + assert!( + events.is_empty(), + "unrelated requests must not create pending turn state" + ); +} + +#[tokio::test] +async fn unrelated_client_responses_are_ignored_by_reducer() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_initialize(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(9), + response: Box::new(ClientResponsePayload::ThreadArchive( + ThreadArchiveResponse {}, + )), + }, + &mut events, + ) + .await; + + assert!(events.is_empty()); +} + +#[tokio::test] +async fn compaction_event_ingests_custom_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let parent_thread_id = + codex_protocol::ThreadId::from_string("22222222-2222-2222-2222-222222222222") + .expect("valid parent thread id"); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(2), + response: Box::new(sample_thread_resume_response_with_source( + "thread-1", + /*ephemeral*/ false, + "gpt-5", + AppServerSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + Some(AppServerThreadSource::Subagent), + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(Box::new( + CodexCompactionEvent { + thread_id: "thread-1".to_string(), + turn_id: "turn-compact".to_string(), + trigger: CompactionTrigger::Manual, + reason: CompactionReason::UserRequested, + implementation: CompactionImplementation::Responses, + phase: CompactionPhase::StandaloneTurn, + strategy: CompactionStrategy::Memento, + status: CompactionStatus::Failed, + error: Some("context limit exceeded".to_string()), + active_context_tokens_before: 131_000, + active_context_tokens_after: 131_000, + started_at: 100, + completed_at: 101, + duration_ms: Some(1200), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_compaction_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-compact"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["rpc_transport"], + "websocket" + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!(payload[0]["event_params"]["thread_source"], "subagent"); + assert_eq!( + payload[0]["event_params"]["subagent_source"], + "thread_spawn" + ); + assert_eq!( + payload[0]["event_params"]["parent_thread_id"], + "22222222-2222-2222-2222-222222222222" + ); + assert_eq!(payload[0]["event_params"]["trigger"], "manual"); + assert_eq!(payload[0]["event_params"]["reason"], "user_requested"); + assert_eq!(payload[0]["event_params"]["implementation"], "responses"); + assert_eq!(payload[0]["event_params"]["phase"], "standalone_turn"); + assert_eq!(payload[0]["event_params"]["strategy"], "memento"); + assert_eq!(payload[0]["event_params"]["status"], "failed"); +} + +#[tokio::test] +async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-guardian", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(Box::new( + GuardianReviewEventParams { + thread_id: "thread-guardian".to_string(), + turn_id: "turn-guardian".to_string(), + review_id: "review-guardian".to_string(), + target_item_id: None, + approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent, + reviewed_action: GuardianReviewedAction::NetworkAccess { + protocol: NetworkApprovalProtocol::Https, + port: 443, + }, + reviewed_action_truncated: false, + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + review_timeout_ms: 90_000, + tool_call_count: None, + time_to_first_token_ms: None, + completion_latency_ms: Some(90_000), + started_at: 100, + completed_at: Some(190), + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_guardian_review"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); + assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); + assert_eq!(payload[0]["event_params"]["target_item_id"], json!(null)); + assert_eq!( + payload[0]["event_params"]["approval_request_source"], + "delegated_subagent" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["type"], + "network_access" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["protocol"], + "https" + ); + assert_eq!(payload[0]["event_params"]["reviewed_action"]["port"], 443); + assert!(payload[0]["event_params"].get("retry_reason").is_none()); + assert!(payload[0]["event_params"].get("rationale").is_none()); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("target") + .is_none() + ); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("host") + .is_none() + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out"); + assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout"); + assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); +} + +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: 1_000, + item: sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 1_045, + item: sample_command_execution_item_with_actions( + CommandExecutionStatus::Completed, + Some(0), + Some(42), + vec![ + CommandAction::Read { + command: "cat README.md".to_string(), + name: "README.md".to_string(), + path: test_path_buf("/tmp/README.md").abs(), + }, + CommandAction::ListFiles { + command: "ls".to_string(), + path: None, + }, + CommandAction::Search { + command: "rg TODO".to_string(), + query: Some("TODO".to_string()), + path: None, + }, + CommandAction::Unknown { + command: "cargo test".to_string(), + }, + ], + ), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); + assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!( + payload[0]["event_params"]["command_execution_source"], + "agent" + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + assert_eq!( + payload[0]["event_params"]["final_approval_outcome"], + "unknown" + ); + assert_eq!( + payload[0]["event_params"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + assert_eq!(payload[0]["event_params"]["command_total_action_count"], 4); + assert_eq!(payload[0]["event_params"]["command_read_action_count"], 1); + assert_eq!( + payload[0]["event_params"]["command_list_files_action_count"], + 1 + ); + assert_eq!(payload[0]["event_params"]["command_search_action_count"], 1); + assert_eq!( + payload[0]["event_params"]["command_unknown_action_count"], + 1 + ); + assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); + assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_045); + assert_eq!(payload[0]["event_params"]["duration_ms"], 45); + assert_eq!(payload[0]["event_params"]["execution_duration_ms"], 42); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); +} + +#[test] +fn subagent_thread_started_review_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + parent_thread_id: None, + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 123, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize review subagent event"); + assert_eq!(payload["event_params"]["thread_source"], "subagent"); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + "codex-tui" + ); + assert_eq!( + payload["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!( + payload["event_params"]["app_server_client"]["client_version"], + "1.0.0" + ); + assert_eq!( + payload["event_params"]["app_server_client"]["rpc_transport"], + "in_process" + ); + assert_eq!(payload["event_params"]["created_at"], 123); + assert_eq!(payload["event_params"]["initialization_mode"], "new"); + assert_eq!(payload["event_params"]["subagent_source"], "review"); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); +} + +#[test] +fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-spawn".to_string(), + parent_thread_id: None, + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: true, + subagent_source: SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + created_at: 124, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize thread spawn subagent event"); + assert_eq!(payload["event_params"]["thread_source"], "subagent"); + assert_eq!(payload["event_params"]["subagent_source"], "thread_spawn"); + assert_eq!( + payload["event_params"]["parent_thread_id"], + "11111111-1111-1111-1111-111111111111" + ); +} + +#[test] +fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-memory".to_string(), + parent_thread_id: None, + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::MemoryConsolidation, + created_at: 125, + }, + )); + + let payload = + serde_json::to_value(&event).expect("serialize memory consolidation subagent event"); + assert_eq!( + payload["event_params"]["subagent_source"], + "memory_consolidation" + ); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); +} + +#[test] +fn subagent_thread_started_other_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-guardian".to_string(), + parent_thread_id: None, + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Other("guardian".to_string()), + created_at: 126, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize other subagent event"); + assert_eq!(payload["event_params"]["subagent_source"], "guardian"); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); +} + +#[test] +fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-guardian".to_string(), + parent_thread_id: Some("parent-thread-guardian".to_string()), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Other("guardian".to_string()), + created_at: 126, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize auto-review subagent event"); + assert_eq!(payload["event_params"]["subagent_source"], "guardian"); + assert_eq!( + payload["event_params"]["parent_thread_id"], + "parent-thread-guardian" + ); +} + +#[tokio::test] +async fn subagent_thread_started_publishes_without_initialize() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + parent_thread_id: None, + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 127, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + "codex-tui" + ); + assert_eq!(payload[0]["event_params"]["thread_source"], "subagent"); + assert_eq!(payload[0]["event_params"]["subagent_source"], "review"); +} + +#[tokio::test] +async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let parent_thread_id = + codex_protocol::ThreadId::from_string("44444444-4444-4444-4444-444444444444") + .expect("valid parent thread id"); + let parent_thread_id_string = parent_thread_id.to_string(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "parent-client".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "parent-client".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + &parent_thread_id_string, + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + parent_thread_id: None, + product_client_id: "parent-client".to_string(), + client_name: "parent-client".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + created_at: 130, + }, + )), + &mut events, + ) + .await; + + events.clear(); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(Box::new( + CodexCompactionEvent { + thread_id: "thread-review".to_string(), + turn_id: "turn-compact".to_string(), + trigger: CompactionTrigger::Manual, + reason: CompactionReason::UserRequested, + implementation: CompactionImplementation::Responses, + phase: CompactionPhase::StandaloneTurn, + strategy: CompactionStrategy::Memento, + status: CompactionStatus::Completed, + error: None, + active_context_tokens_before: 131_000, + active_context_tokens_after: 64_000, + started_at: 100, + completed_at: 101, + duration_ms: Some(1200), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + "parent-client" + ); + assert_eq!( + payload[0]["event_params"]["parent_thread_id"], + "44444444-4444-4444-4444-444444444444" + ); +} + +#[tokio::test] +async fn subagent_tool_items_inherit_parent_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-subagent".to_string(), + parent_thread_id: Some("thread-1".to_string()), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 128, + }, + )), + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-subagent".to_string(), + turn_id: "turn-subagent".to_string(), + started_at_ms: 1_000, + item: sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-subagent".to_string(), + turn_id: "turn-subagent".to_string(), + completed_at_ms: 1_042, + item: sample_command_execution_item( + CommandExecutionStatus::Completed, + Some(0), + Some(42), + ), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_source"], "subagent"); + assert_eq!(payload[0]["event_params"]["subagent_source"], "review"); + assert_eq!(payload[0]["event_params"]["parent_thread_id"], "thread-1"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); +} + +#[test] +fn plugin_used_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { + event_type: "codex_plugin_used", + event_params: codex_plugin_used_metadata(&tracking, sample_plugin_metadata()), + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin used event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_used", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "thread_id": "thread-3", + "turn_id": "turn-3", + "model_slug": "gpt-5" + } + }) + ); +} + +#[test] +fn plugin_management_event_serializes_expected_shape() { + let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest { + event_type: "codex_plugin_installed", + event_params: codex_plugin_metadata(sample_plugin_metadata()), + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin installed event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_installed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value + } + }) + ); +} + +#[test] +fn plugin_management_event_can_use_remote_plugin_id_override() { + let mut plugin = sample_plugin_metadata(); + plugin.remote_plugin_id = Some("plugins~Plugin_remote".to_string()); + let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest { + event_type: "codex_plugin_installed", + event_params: codex_plugin_metadata(plugin), + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin installed event"); + + assert_eq!( + payload["event_params"]["plugin_id"], + "plugins~Plugin_remote" + ); + assert_eq!(payload["event_params"]["plugin_name"], "sample"); + assert_eq!(payload["event_params"]["marketplace_name"], "test"); +} + +#[test] +fn hook_run_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::HookRun(CodexHookRunEventRequest { + event_type: "codex_hook_run", + event_params: codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::PreToolUse, + hook_source: HookSource::User, + status: HookRunStatus::Completed, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize hook run event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_hook_run", + "event_params": { + "thread_id": "thread-3", + "turn_id": "turn-3", + "model_slug": "gpt-5", + "hook_name": "PreToolUse", + "hook_source": "user", + "status": "completed" + } + }) + ); +} + +#[test] +fn hook_run_metadata_maps_sources_and_statuses() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + let system = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::SessionStart, + hook_source: HookSource::System, + status: HookRunStatus::Completed, + }, + )) + .expect("serialize system hook"); + let project = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + hook_source: HookSource::Project, + status: HookRunStatus::Blocked, + }, + )) + .expect("serialize project hook"); + let cloud_requirements = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + hook_source: HookSource::CloudRequirements, + status: HookRunStatus::Blocked, + }, + )) + .expect("serialize cloud requirements hook"); + let unknown = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::UserPromptSubmit, + hook_source: HookSource::Unknown, + status: HookRunStatus::Failed, + }, + )) + .expect("serialize unknown hook"); + + assert_eq!(system["hook_source"], "system"); + assert_eq!(system["status"], "completed"); + assert_eq!(project["hook_source"], "project"); + assert_eq!(project["status"], "blocked"); + assert_eq!(cloud_requirements["hook_source"], "cloud_requirements"); + assert_eq!(cloud_requirements["status"], "blocked"); + assert_eq!(unknown["hook_source"], "unknown"); + assert_eq!(unknown["status"], "failed"); +} + +#[test] +fn hook_run_metadata_maps_stopped_status() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + let stopped = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + hook_source: HookSource::User, + status: HookRunStatus::Stopped, + }, + )) + .expect("serialize stopped hook"); + + assert_eq!(stopped["hook_source"], "user"); + assert_eq!(stopped["status"], "stopped"); +} + +#[test] +fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() { + let (sender, _receiver) = mpsc::channel(1); + let queue = AnalyticsEventsQueue { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + }; + let plugin = sample_plugin_metadata(); + + let turn_1 = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let turn_2 = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-2".to_string(), + }; + + assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), true); + assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), false); + assert_eq!(queue.should_enqueue_plugin_used(&turn_2, &plugin), true); +} + +#[tokio::test] +async fn reducer_ingests_skill_invoked_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md"); + let expected_skill_id = skill_id_for_local_skill( + /*repo_url*/ None, + /*repo_root*/ None, + skill_path.as_path(), + "doc", + ); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(SkillInvokedInput { + tracking, + invocations: vec![SkillInvocation { + skill_name: "doc".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path, + plugin_id: None, + invocation_type: InvocationType::Explicit, + }], + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "skill_invocation", + "skill_id": expected_skill_id, + "skill_name": "doc", + "event_params": { + "product_client_id": originator().value, + "skill_scope": "user", + "plugin_id": null, + "repo_url": null, + "thread_id": "thread-1", + "turn_id": "turn-1", + "invoke_type": "explicit", + "model_slug": "gpt-5" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_includes_plugin_id_for_plugin_skill_invocations() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let skill_path = + PathBuf::from("/Users/abc/.codex/plugins/cache/test/sample/skills/doc/SKILL.md"); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(SkillInvokedInput { + tracking, + invocations: vec![SkillInvocation { + skill_name: "sample:doc".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path, + plugin_id: Some("sample@test".to_string()), + invocation_type: InvocationType::Explicit, + }], + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload[0]["event_params"]["plugin_id"], + json!("sample@test") + ); +} + +#[tokio::test] +async fn reducer_ingests_hook_run_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(HookRunInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + hook: HookRunFact { + event_name: HookEventName::PostToolUse, + hook_source: HookSource::Unknown, + status: HookRunStatus::Failed, + }, + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_hook_run"); + assert_eq!(payload[0]["event_params"]["hook_name"], "PostToolUse"); + assert_eq!(payload[0]["event_params"]["hook_source"], "unknown"); + assert_eq!(payload[0]["event_params"]["status"], "failed"); +} + +#[tokio::test] +async fn reducer_ingests_app_and_plugin_facts() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppMentioned(AppMentionedInput { + tracking: tracking.clone(), + mentions: vec![AppInvocation { + connector_id: Some("calendar".to_string()), + app_name: Some("Calendar".to_string()), + invocation_type: Some(InvocationType::Explicit), + }], + })), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(AppUsedInput { + tracking: tracking.clone(), + app: AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + })), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed(PluginUsedInput { + tracking, + plugin: sample_plugin_metadata(), + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 3); + assert_eq!(payload[0]["event_type"], "codex_app_mentioned"); + assert_eq!(payload[1]["event_type"], "codex_app_used"); + assert_eq!(payload[2]["event_type"], "codex_plugin_used"); +} + +#[tokio::test] +async fn reducer_ingests_plugin_state_changed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginStateChanged( + PluginStateChangedInput { + plugin: sample_plugin_metadata(), + state: PluginState::Disabled, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_disabled", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value + } + }]) + ); +} + +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some(ThreadSource::User), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "auto_review".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: Some(0), + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "auto_review", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": 0, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + response: Box::new(sample_turn_steer_response("turn-2")), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + response: Box::new(sample_turn_start_response("turn-2")), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("thread-2", "turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); +} + +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + response: Box::new(sample_turn_steer_response("turn-2")), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(6), + response: Box::new(sample_turn_steer_response("turn-2")), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); +} + +fn sample_plugin_metadata() -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), + remote_plugin_id: None, + capability_summary: Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["mcp-1".to_string(), "mcp-2".to_string()], + app_connector_ids: vec![ + AppConnectorId("calendar".to_string()), + AppConnectorId("drive".to_string()), + ], + }), + } +} diff --git a/code-rs/analytics/src/client.rs b/code-rs/analytics/src/client.rs new file mode 100644 index 00000000000..6d46b2ce570 --- /dev/null +++ b/code-rs/analytics/src/client.rs @@ -0,0 +1,409 @@ +use crate::events::AppServerRpcTransport; +use crate::events::GuardianReviewAnalyticsResult; +use crate::events::GuardianReviewTrackContext; +use crate::events::TrackEventRequest; +use crate::events::TrackEventsRequest; +use crate::events::current_runtime_metadata; +use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; +use crate::facts::AppInvocation; +use crate::facts::AppMentionedInput; +use crate::facts::AppUsedInput; +use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunFact; +use crate::facts::HookRunInput; +use crate::facts::PluginState; +use crate::facts::PluginStateChangedInput; +use crate::facts::SkillInvocation; +use crate::facts::SkillInvokedInput; +use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; +use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; +use codex_login::AuthManager; +use codex_login::default_client::create_client; +use codex_plugin::PluginTelemetryMetadata; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; +use tokio::sync::mpsc; + +const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256; +const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10); +const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096; + +#[derive(Clone)] +pub(crate) struct AnalyticsEventsQueue { + pub(crate) sender: mpsc::Sender, + pub(crate) app_used_emitted_keys: Arc>>, + pub(crate) plugin_used_emitted_keys: Arc>>, +} + +#[derive(Clone)] +pub struct AnalyticsEventsClient { + queue: Option, +} + +impl AnalyticsEventsQueue { + pub(crate) fn new(auth_manager: Arc, base_url: String) -> Self { + let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE); + tokio::spawn(async move { + let mut reducer = AnalyticsReducer::default(); + while let Some(input) = receiver.recv().await { + let mut events = Vec::new(); + reducer.ingest(input, &mut events).await; + send_track_events(&auth_manager, &base_url, events).await; + } + }); + Self { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + } + } + + fn try_send(&self, input: AnalyticsFact) { + if self.sender.try_send(input).is_err() { + //TODO: add a metric for this + tracing::warn!("dropping analytics events: queue is full"); + } + } + + pub(crate) fn should_enqueue_app_used( + &self, + tracking: &TrackEventsContext, + app: &AppInvocation, + ) -> bool { + let Some(connector_id) = app.connector_id.as_ref() else { + return true; + }; + let mut emitted = self + .app_used_emitted_keys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { + emitted.clear(); + } + emitted.insert((tracking.turn_id.clone(), connector_id.clone())) + } + + pub(crate) fn should_enqueue_plugin_used( + &self, + tracking: &TrackEventsContext, + plugin: &PluginTelemetryMetadata, + ) -> bool { + let mut emitted = self + .plugin_used_emitted_keys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { + emitted.clear(); + } + emitted.insert((tracking.turn_id.clone(), plugin.plugin_id.as_key())) + } +} + +impl AnalyticsEventsClient { + pub fn new( + auth_manager: Arc, + base_url: String, + analytics_enabled: Option, + ) -> Self { + Self { + queue: (analytics_enabled != Some(false)) + .then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url)), + } + } + + pub fn disabled() -> Self { + Self { queue: None } + } + + pub fn track_skill_invocations( + &self, + tracking: TrackEventsContext, + invocations: Vec, + ) { + if invocations.is_empty() { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked( + SkillInvokedInput { + tracking, + invocations, + }, + ))); + } + + pub fn track_initialize( + &self, + connection_id: u64, + params: InitializeParams, + product_client_id: String, + rpc_transport: AppServerRpcTransport, + ) { + self.record_fact(AnalyticsFact::Initialize { + connection_id, + params, + product_client_id, + runtime: current_runtime_metadata(), + rpc_transport, + }); + } + + pub fn track_subagent_thread_started(&self, input: SubAgentThreadStartedInput) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::SubAgentThreadStarted(input), + )); + } + + pub fn track_guardian_review( + &self, + tracking: &GuardianReviewTrackContext, + result: GuardianReviewAnalyticsResult, + ) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview( + Box::new(tracking.event_params(result)), + ))); + } + + pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec) { + if mentions.is_empty() { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppMentioned( + AppMentionedInput { tracking, mentions }, + ))); + } + + pub fn track_request( + &self, + connection_id: u64, + request_id: RequestId, + request: &ClientRequest, + ) { + if !matches!( + request, + ClientRequest::TurnStart { .. } | ClientRequest::TurnSteer { .. } + ) { + return; + } + self.record_fact(AnalyticsFact::ClientRequest { + connection_id, + request_id, + request: Box::new(request.clone()), + }); + } + + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_app_used(&tracking, &app) { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed( + AppUsedInput { tracking, app }, + ))); + } + + pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun( + HookRunInput { tracking, hook }, + ))); + } + + pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_plugin_used(&tracking, &plugin) { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed( + crate::facts::PluginUsedInput { tracking, plugin }, + ))); + } + + pub fn track_compaction(&self, event: crate::facts::CodexCompactionEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Compaction( + Box::new(event), + ))); + } + + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Installed, + }), + )); + } + + pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Uninstalled, + }), + )); + } + + pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Enabled, + }), + )); + } + + pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Disabled, + }), + )); + } + + pub(crate) fn record_fact(&self, input: AnalyticsFact) { + if let Some(queue) = self.queue.as_ref() { + queue.try_send(input); + } + } + + pub fn track_response( + &self, + connection_id: u64, + request_id: RequestId, + response: ClientResponsePayload, + ) { + if !matches!( + response, + ClientResponsePayload::ThreadStart(_) + | ClientResponsePayload::ThreadResume(_) + | ClientResponsePayload::ThreadFork(_) + | ClientResponsePayload::TurnStart(_) + | ClientResponsePayload::TurnSteer(_) + ) { + return; + } + self.record_fact(AnalyticsFact::ClientResponse { + connection_id, + request_id, + response: Box::new(response), + }); + } + + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, completed_at_ms: u64, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + completed_at_ms, + response: Box::new(response), + }); + } + + pub fn track_notification(&self, notification: ServerNotification) { + if !matches!( + notification, + ServerNotification::TurnStarted(_) + | ServerNotification::TurnCompleted(_) + | ServerNotification::ItemStarted(_) + | ServerNotification::ItemCompleted(_) + | ServerNotification::ItemGuardianApprovalReviewStarted(_) + | ServerNotification::ItemGuardianApprovalReviewCompleted(_) + ) { + return; + } + self.record_fact(AnalyticsFact::Notification(Box::new(notification))); + } +} + +async fn send_track_events( + auth_manager: &AuthManager, + base_url: &str, + events: Vec, +) { + if events.is_empty() { + return; + } + let Some(auth) = auth_manager.auth().await else { + return; + }; + if !auth.uses_codex_backend() { + return; + } + + let base_url = base_url.trim_end_matches('/'); + let url = format!("{base_url}/codex/analytics-events/events"); + let payload = TrackEventsRequest { events }; + + let response = create_client() + .post(&url) + .timeout(ANALYTICS_EVENTS_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await; + + match response { + Ok(response) if response.status().is_success() => {} + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!("events failed with status {status}: {body}"); + } + Err(err) => { + tracing::warn!("failed to send events request: {err}"); + } + } +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/code-rs/analytics/src/client_tests.rs b/code-rs/analytics/src/client_tests.rs new file mode 100644 index 00000000000..3021d558d68 --- /dev/null +++ b/code-rs/analytics/src/client_tests.rs @@ -0,0 +1,224 @@ +use super::AnalyticsEventsClient; +use super::AnalyticsEventsQueue; +use crate::facts::AnalyticsFact; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::SessionSource as AppServerSessionSource; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TryRecvError; + +fn client_with_receiver() -> (AnalyticsEventsClient, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(8); + let queue = AnalyticsEventsQueue { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + }; + (AnalyticsEventsClient { queue: Some(queue) }, receiver) +} + +fn sample_turn_start_request() -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(1), + params: TurnStartParams { + thread_id: "thread-1".to_string(), + input: Vec::new(), + ..Default::default() + }, + } +} + +fn sample_turn_steer_request() -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(2), + params: TurnSteerParams { + thread_id: "thread-1".to_string(), + expected_turn_id: "turn-1".to_string(), + input: Vec::new(), + responsesapi_client_metadata: None, + }, + } +} + +fn sample_thread_archive_request() -> ClientRequest { + ClientRequest::ThreadArchive { + request_id: RequestId::Integer(3), + params: ThreadArchiveParams { + thread_id: "thread-1".to_string(), + }, + } +} + +fn sample_thread(thread_id: &str) -> Thread { + Thread { + id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), + forked_from_id: None, + preview: "first prompt".to_string(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: AppServerThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: "0.0.0".to_string(), + source: AppServerSessionSource::Exec, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + } +} + +fn sample_permission_profile() -> AppServerPermissionProfile { + CorePermissionProfile::Disabled.into() +} + +fn sample_thread_start_response() -> ClientResponsePayload { + ClientResponsePayload::ThreadStart(ThreadStartResponse { + thread: sample_thread("thread-1"), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_thread_resume_response() -> ClientResponsePayload { + ClientResponsePayload::ThreadResume(ThreadResumeResponse { + thread: sample_thread("thread-2"), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_thread_fork_response() -> ClientResponsePayload { + ClientResponsePayload::ThreadFork(ThreadForkResponse { + thread: sample_thread("thread-3"), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_turn_start_response() -> ClientResponsePayload { + ClientResponsePayload::TurnStart(TurnStartResponse { + turn: Turn { + id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_steer_response() -> ClientResponsePayload { + ClientResponsePayload::TurnSteer(TurnSteerResponse { + turn_id: "turn-2".to_string(), + }) +} + +#[test] +fn track_request_only_enqueues_analytics_relevant_requests() { + let (client, mut receiver) = client_with_receiver(); + + for (request_id, request) in [ + (RequestId::Integer(1), sample_turn_start_request()), + (RequestId::Integer(2), sample_turn_steer_request()), + ] { + client.track_request(/*connection_id*/ 7, request_id, &request); + assert!(matches!( + receiver.try_recv(), + Ok(AnalyticsFact::ClientRequest { .. }) + )); + } + + let ignored_request = sample_thread_archive_request(); + client.track_request( + /*connection_id*/ 7, + RequestId::Integer(3), + &ignored_request, + ); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); +} + +#[test] +fn track_response_only_enqueues_analytics_relevant_responses() { + let (client, mut receiver) = client_with_receiver(); + + for (request_id, response) in [ + (RequestId::Integer(1), sample_thread_start_response()), + (RequestId::Integer(2), sample_thread_resume_response()), + (RequestId::Integer(3), sample_thread_fork_response()), + (RequestId::Integer(4), sample_turn_start_response()), + (RequestId::Integer(5), sample_turn_steer_response()), + ] { + client.track_response(/*connection_id*/ 7, request_id, response); + assert!(matches!( + receiver.try_recv(), + Ok(AnalyticsFact::ClientResponse { .. }) + )); + } + + client.track_response( + /*connection_id*/ 7, + RequestId::Integer(6), + ClientResponsePayload::ThreadArchive(ThreadArchiveResponse {}), + ); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); +} diff --git a/code-rs/analytics/src/events.rs b/code-rs/analytics/src/events.rs new file mode 100644 index 00000000000..eaa7daf8f86 --- /dev/null +++ b/code-rs/analytics/src/events.rs @@ -0,0 +1,1050 @@ +use std::time::Instant; + +use crate::facts::AppInvocation; +use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionImplementation; +use crate::facts::CompactionPhase; +use crate::facts::CompactionReason; +use crate::facts::CompactionStatus; +use crate::facts::CompactionStrategy; +use crate::facts::CompactionTrigger; +use crate::facts::HookRunFact; +use crate::facts::InvocationType; +use crate::facts::PluginState; +use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; +use crate::facts::TurnSubmissionType; +use crate::now_unix_millis; +use crate::now_unix_seconds; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandExecutionSource; +use codex_login::default_client::originator; +use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianCommandSource; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TokenUsage; +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AppServerRpcTransport { + Stdio, + Websocket, + InProcess, +} + +#[derive(Serialize)] +pub(crate) struct TrackEventsRequest { + pub(crate) events: Vec, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub(crate) enum TrackEventRequest { + SkillInvocation(SkillInvocationEventRequest), + ThreadInitialized(ThreadInitializedEvent), + GuardianReview(Box), + AppMentioned(CodexAppMentionedEventRequest), + AppUsed(CodexAppUsedEventRequest), + HookRun(CodexHookRunEventRequest), + Compaction(Box), + TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), + CommandExecution(CodexCommandExecutionEventRequest), + FileChange(CodexFileChangeEventRequest), + McpToolCall(CodexMcpToolCallEventRequest), + DynamicToolCall(CodexDynamicToolCallEventRequest), + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + WebSearch(CodexWebSearchEventRequest), + ImageGeneration(CodexImageGenerationEventRequest), + #[allow(dead_code)] + ReviewEvent(CodexReviewEventRequest), + PluginUsed(CodexPluginUsedEventRequest), + PluginInstalled(CodexPluginEventRequest), + PluginUninstalled(CodexPluginEventRequest), + PluginEnabled(CodexPluginEventRequest), + PluginDisabled(CodexPluginEventRequest), +} + +#[derive(Serialize)] +pub(crate) struct SkillInvocationEventRequest { + pub(crate) event_type: &'static str, + pub(crate) skill_id: String, + pub(crate) skill_name: String, + pub(crate) event_params: SkillInvocationEventParams, +} + +#[derive(Serialize)] +pub(crate) struct SkillInvocationEventParams { + pub(crate) product_client_id: Option, + pub(crate) skill_scope: Option, + pub(crate) plugin_id: Option, + pub(crate) repo_url: Option, + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) invoke_type: Option, + pub(crate) model_slug: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct CodexAppServerClientMetadata { + pub(crate) product_client_id: String, + pub(crate) client_name: Option, + pub(crate) client_version: Option, + pub(crate) rpc_transport: AppServerRpcTransport, + pub(crate) experimental_api_enabled: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct CodexRuntimeMetadata { + pub(crate) codex_rs_version: String, + pub(crate) runtime_os: String, + pub(crate) runtime_os_version: String, + pub(crate) runtime_arch: String, +} + +#[derive(Serialize)] +pub(crate) struct ThreadInitializedEventParams { + pub(crate) thread_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) model: String, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct ThreadInitializedEvent { + pub(crate) event_type: &'static str, + pub(crate) event_params: ThreadInitializedEventParams, +} + +#[derive(Serialize)] +pub(crate) struct GuardianReviewEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: GuardianReviewEventPayload, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewDecision { + Approved, + Denied, + Aborted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewTerminalStatus { + Approved, + Denied, + Aborted, + TimedOut, + FailedClosed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewFailureReason { + Timeout, + Cancelled, + PromptBuildError, + SessionError, + ParseError, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewSessionKind { + TrunkNew, + TrunkReused, + EphemeralForked, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianApprovalRequestSource { + /// Approval requested directly by the main Codex turn. + MainTurn, + /// Approval requested by a delegated subagent and routed through the parent + /// session for guardian review. + DelegatedSubagent, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GuardianReviewedAction { + Shell { + sandbox_permissions: SandboxPermissions, + additional_permissions: Option, + }, + UnifiedExec { + sandbox_permissions: SandboxPermissions, + additional_permissions: Option, + tty: bool, + }, + Execve { + source: GuardianCommandSource, + program: String, + additional_permissions: Option, + }, + ApplyPatch {}, + NetworkAccess { + protocol: NetworkApprovalProtocol, + port: u16, + }, + McpToolCall { + server: String, + tool_name: String, + connector_id: Option, + connector_name: Option, + tool_title: Option, + }, + RequestPermissions {}, +} + +#[derive(Clone, Serialize)] +pub struct GuardianReviewEventParams { + pub thread_id: String, + pub turn_id: String, + pub review_id: String, + pub target_item_id: Option, + pub approval_request_source: GuardianApprovalRequestSource, + pub reviewed_action: GuardianReviewedAction, + pub reviewed_action_truncated: bool, + pub decision: GuardianReviewDecision, + pub terminal_status: GuardianReviewTerminalStatus, + pub failure_reason: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, + pub guardian_thread_id: Option, + pub guardian_session_kind: Option, + pub guardian_model: Option, + pub guardian_reasoning_effort: Option, + pub had_prior_review_context: Option, + pub review_timeout_ms: u64, + pub tool_call_count: Option, + pub time_to_first_token_ms: Option, + pub completion_latency_ms: Option, + pub started_at: u64, + pub completed_at: Option, + pub input_tokens: Option, + pub cached_input_tokens: Option, + pub output_tokens: Option, + pub reasoning_output_tokens: Option, + pub total_tokens: Option, +} + +pub struct GuardianReviewTrackContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + review_timeout_ms: u64, + pub started_at_ms: u64, + started_instant: Instant, +} + +impl GuardianReviewTrackContext { + pub fn new( + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + review_timeout_ms: u64, + ) -> Self { + Self { + thread_id, + turn_id, + review_id, + target_item_id, + approval_request_source, + reviewed_action, + review_timeout_ms, + started_at_ms: now_unix_millis(), + started_instant: Instant::now(), + } + } + + pub(crate) fn event_params( + &self, + result: GuardianReviewAnalyticsResult, + ) -> GuardianReviewEventParams { + GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: result.reviewed_action_truncated, + decision: result.decision, + terminal_status: result.terminal_status, + failure_reason: result.failure_reason, + risk_level: result.risk_level, + user_authorization: result.user_authorization, + outcome: result.outcome, + guardian_thread_id: result.guardian_thread_id, + guardian_session_kind: result.guardian_session_kind, + guardian_model: result.guardian_model, + guardian_reasoning_effort: result.guardian_reasoning_effort, + had_prior_review_context: result.had_prior_review_context, + review_timeout_ms: self.review_timeout_ms, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: result.time_to_first_token_ms, + completion_latency_ms: Some(self.started_instant.elapsed().as_millis() as u64), + started_at: self.started_at_ms / 1_000, + completed_at: Some(now_unix_seconds()), + input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens), + cached_input_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: result.token_usage.as_ref().map(|usage| usage.output_tokens), + reasoning_output_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: result.token_usage.as_ref().map(|usage| usage.total_tokens), + } + } +} + +#[derive(Debug)] +pub struct GuardianReviewAnalyticsResult { + pub decision: GuardianReviewDecision, + pub terminal_status: GuardianReviewTerminalStatus, + pub failure_reason: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, + pub guardian_thread_id: Option, + pub guardian_session_kind: Option, + pub guardian_model: Option, + pub guardian_reasoning_effort: Option, + pub had_prior_review_context: Option, + pub reviewed_action_truncated: bool, + pub token_usage: Option, + pub time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsResult { + pub fn without_session() -> Self { + Self { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: None, + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + } + } + + pub fn from_session( + guardian_thread_id: String, + guardian_session_kind: GuardianReviewSessionKind, + guardian_model: String, + guardian_reasoning_effort: Option, + had_prior_review_context: bool, + ) -> Self { + Self { + guardian_thread_id: Some(guardian_thread_id), + guardian_session_kind: Some(guardian_session_kind), + guardian_model: Some(guardian_model), + guardian_reasoning_effort, + had_prior_review_context: Some(had_prior_review_context), + ..Self::without_session() + } + } +} + +#[derive(Serialize)] +pub(crate) struct GuardianReviewEventPayload { + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + #[serde(flatten)] + pub(crate) guardian_review: GuardianReviewEventParams, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemFinalApprovalOutcome { + Unknown, + NotNeeded, + ConfigAllowed, + PolicyForbidden, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolItemEventBase { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + /// App-server ThreadItem.id. For tool-originated items this generally + /// corresponds to the originating core call_id. + pub(crate) item_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_name: String, + pub(crate) started_at_ms: u64, + pub(crate) completed_at_ms: u64, + // Observed item lifecycle duration. This may undercount end-to-end execution + // for tools where app-server only sees part of the upstream flow. + pub(crate) duration_ms: Option, + pub(crate) execution_duration_ms: Option, + pub(crate) review_count: u64, + pub(crate) guardian_review_count: u64, + pub(crate) user_review_count: u64, + pub(crate) final_approval_outcome: ToolItemFinalApprovalOutcome, + pub(crate) terminal_status: ToolItemTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewSubjectKind { + CommandExecution, + FileChange, + McpToolCall, + Permissions, + NetworkAccess, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Reviewer { + Guardian, + User, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewTrigger { + Initial, + SandboxDenial, + NetworkPolicyDenial, + ExecveIntercept, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewStatus { + Approved, + Denied, + Aborted, + TimedOut, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewResolution { + None, + SessionApproval, + ExecPolicyAmendment, + NetworkPolicyAmendment, +} + +#[derive(Serialize)] +pub(crate) struct CodexReviewEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) item_id: Option, + pub(crate) review_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_kind: ReviewSubjectKind, + pub(crate) tool_name: String, + pub(crate) reviewer: Reviewer, + pub(crate) trigger: ReviewTrigger, + pub(crate) status: ReviewStatus, + pub(crate) resolution: ReviewResolution, + pub(crate) started_at_ms: u64, + pub(crate) completed_at_ms: u64, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexReviewEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexReviewEventParams, +} +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WebSearchActionKind { + Search, + OpenPage, + FindInPage, + Other, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) command_execution_source: CommandExecutionSource, + pub(crate) exit_code: Option, + pub(crate) command_total_action_count: u64, + pub(crate) command_read_action_count: u64, + pub(crate) command_list_files_action_count: u64, + pub(crate) command_search_action_count: u64, + pub(crate) command_unknown_action_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCommandExecutionEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) file_change_count: u64, + pub(crate) file_add_count: u64, + pub(crate) file_update_count: u64, + pub(crate) file_delete_count: u64, + pub(crate) file_move_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexFileChangeEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) mcp_server_name: String, + pub(crate) mcp_tool_name: String, + pub(crate) mcp_error_present: bool, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexMcpToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) dynamic_tool_name: String, + pub(crate) success: Option, + pub(crate) output_content_item_count: Option, + pub(crate) output_text_item_count: Option, + pub(crate) output_image_item_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexDynamicToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) sender_thread_id: String, + pub(crate) receiver_thread_count: u64, + pub(crate) receiver_thread_ids: Option>, + pub(crate) requested_model: Option, + pub(crate) requested_reasoning_effort: Option, + pub(crate) agent_state_count: Option, + pub(crate) completed_agent_count: Option, + pub(crate) failed_agent_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCollabAgentToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) web_search_action: Option, + pub(crate) query_present: bool, + pub(crate) query_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexWebSearchEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) revised_prompt_present: bool, + pub(crate) saved_path_present: bool, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexImageGenerationEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexAppMetadata { + pub(crate) connector_id: Option, + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) app_name: Option, + pub(crate) product_client_id: Option, + pub(crate) invoke_type: Option, + pub(crate) model_slug: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexAppMentionedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexAppMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexAppUsedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexAppMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexHookRunMetadata { + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) model_slug: Option, + pub(crate) hook_name: Option, + pub(crate) hook_source: Option<&'static str>, + pub(crate) status: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexHookRunEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexHookRunMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexCompactionEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) trigger: CompactionTrigger, + pub(crate) reason: CompactionReason, + pub(crate) implementation: CompactionImplementation, + pub(crate) phase: CompactionPhase, + pub(crate) strategy: CompactionStrategy, + pub(crate) status: CompactionStatus, + pub(crate) error: Option, + pub(crate) active_context_tokens_before: i64, + pub(crate) active_context_tokens_after: i64, + pub(crate) started_at: u64, + pub(crate) completed_at: u64, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCompactionEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCompactionEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + // TODO(rhan-oai): Populate once queued/default submission type is plumbed from + // the turn/start callsites instead of always being reported as None. + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + // TODO(rhan-oai): Populate these once tool-call accounting is emitted from + // core; the schema is reserved but these fields are currently always None. + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginMetadata { + pub(crate) plugin_id: Option, + pub(crate) plugin_name: Option, + pub(crate) marketplace_name: Option, + pub(crate) has_skills: Option, + pub(crate) mcp_server_count: Option, + pub(crate) connector_ids: Option>, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginUsedMetadata { + #[serde(flatten)] + pub(crate) plugin: CodexPluginMetadata, + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) model_slug: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginUsedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginUsedMetadata, +} + +pub(crate) fn plugin_state_event_type(state: PluginState) -> &'static str { + match state { + PluginState::Installed => "codex_plugin_installed", + PluginState::Uninstalled => "codex_plugin_uninstalled", + PluginState::Enabled => "codex_plugin_enabled", + PluginState::Disabled => "codex_plugin_disabled", + } +} + +pub(crate) fn codex_app_metadata( + tracking: &TrackEventsContext, + app: AppInvocation, +) -> CodexAppMetadata { + CodexAppMetadata { + connector_id: app.connector_id, + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + app_name: app.app_name, + product_client_id: Some(originator().value), + invoke_type: app.invocation_type, + model_slug: Some(tracking.model_slug.clone()), + } +} + +pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata { + let PluginTelemetryMetadata { + plugin_id, + remote_plugin_id, + capability_summary, + } = plugin; + let event_plugin_id = remote_plugin_id.unwrap_or_else(|| plugin_id.as_key()); + CodexPluginMetadata { + plugin_id: Some(event_plugin_id), + plugin_name: Some(plugin_id.plugin_name), + marketplace_name: Some(plugin_id.marketplace_name), + has_skills: capability_summary + .as_ref() + .map(|summary| summary.has_skills), + mcp_server_count: capability_summary + .as_ref() + .map(|summary| summary.mcp_server_names.len()), + connector_ids: capability_summary.map(|summary| { + summary + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect() + }), + product_client_id: Some(originator().value), + } +} + +pub(crate) fn codex_compaction_event_params( + input: CodexCompactionEvent, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + thread_source: Option, + subagent_source: Option, + parent_thread_id: Option, +) -> CodexCompactionEventParams { + CodexCompactionEventParams { + thread_id: input.thread_id, + turn_id: input.turn_id, + app_server_client, + runtime, + thread_source, + subagent_source, + parent_thread_id, + trigger: input.trigger, + reason: input.reason, + implementation: input.implementation, + phase: input.phase, + strategy: input.strategy, + status: input.status, + error: input.error, + active_context_tokens_before: input.active_context_tokens_before, + active_context_tokens_after: input.active_context_tokens_after, + started_at: input.started_at, + completed_at: input.completed_at, + duration_ms: input.duration_ms, + } +} + +pub(crate) fn codex_plugin_used_metadata( + tracking: &TrackEventsContext, + plugin: PluginTelemetryMetadata, +) -> CodexPluginUsedMetadata { + CodexPluginUsedMetadata { + plugin: codex_plugin_metadata(plugin), + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + model_slug: Some(tracking.model_slug.clone()), + } +} + +pub(crate) fn codex_hook_run_metadata( + tracking: &TrackEventsContext, + hook: HookRunFact, +) -> CodexHookRunMetadata { + CodexHookRunMetadata { + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + model_slug: Some(tracking.model_slug.clone()), + hook_name: Some(analytics_hook_event_name(hook.event_name).to_owned()), + hook_source: Some(analytics_hook_source(hook.hook_source)), + status: Some(analytics_hook_status(hook.status)), + } +} + +fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::Stop => "Stop", + } +} + +fn analytics_hook_source(source: HookSource) -> &'static str { + match source { + HookSource::System => "system", + HookSource::User => "user", + HookSource::Project => "project", + HookSource::Mdm => "mdm", + HookSource::SessionFlags => "session_flags", + HookSource::Plugin => "plugin", + HookSource::CloudRequirements => "cloud_requirements", + HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", + HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", + HookSource::Unknown => "unknown", + } +} + +pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata { + let os_info = os_info::get(); + CodexRuntimeMetadata { + codex_rs_version: env!("CARGO_PKG_VERSION").to_string(), + runtime_os: std::env::consts::OS.to_string(), + runtime_os_version: os_info.version().to_string(), + runtime_arch: std::env::consts::ARCH.to_string(), + } +} + +pub(crate) fn subagent_thread_started_event_request( + input: SubAgentThreadStartedInput, +) -> ThreadInitializedEvent { + let event_params = ThreadInitializedEventParams { + thread_id: input.thread_id, + app_server_client: CodexAppServerClientMetadata { + product_client_id: input.product_client_id, + client_name: Some(input.client_name), + client_version: Some(input.client_version), + rpc_transport: AppServerRpcTransport::InProcess, + experimental_api_enabled: None, + }, + runtime: current_runtime_metadata(), + model: input.model, + ephemeral: input.ephemeral, + thread_source: Some(ThreadSource::Subagent), + initialization_mode: ThreadInitializationMode::New, + subagent_source: Some(subagent_source_name(&input.subagent_source)), + parent_thread_id: input + .parent_thread_id + .or_else(|| subagent_parent_thread_id(&input.subagent_source)), + created_at: input.created_at, + }; + ThreadInitializedEvent { + event_type: "codex_thread_initialized", + event_params, + } +} + +pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String { + match subagent_source { + SubAgentSource::Review => "review".to_string(), + SubAgentSource::Compact => "compact".to_string(), + SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(), + SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(), + SubAgentSource::Other(other) => other.clone(), + } +} + +pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option { + match subagent_source { + SubAgentSource::ThreadSpawn { + parent_thread_id, .. + } => Some(parent_thread_id.to_string()), + _ => None, + } +} + +fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus { + match status { + // Running is unexpected here and normalized defensively. + HookRunStatus::Running => HookRunStatus::Failed, + other => other, + } +} diff --git a/code-rs/analytics/src/facts.rs b/code-rs/analytics/src/facts.rs new file mode 100644 index 00000000000..d0446e8c0ca --- /dev/null +++ b/code-rs/analytics/src/facts.rs @@ -0,0 +1,364 @@ +use crate::events::AppServerRpcTransport; +use crate::events::CodexRuntimeMetadata; +use crate::events::GuardianReviewEventParams; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; +use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookSource; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Clone)] +pub struct TrackEventsContext { + pub model_slug: String, + pub thread_id: String, + pub turn_id: String, +} + +pub fn build_track_events_context( + model_slug: String, + thread_id: String, + turn_id: String, +) -> TrackEventsContext { + TrackEventsContext { + model_slug, + thread_id, + turn_id, + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub model: String, + pub model_provider: String, + pub permission_profile: PermissionProfile, + pub permission_profile_cwd: PathBuf, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + +#[derive(Clone, Debug)] +pub struct SkillInvocation { + pub skill_name: String, + pub skill_scope: SkillScope, + pub skill_path: PathBuf, + pub plugin_id: Option, + pub invocation_type: InvocationType, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum InvocationType { + Explicit, + Implicit, +} + +pub struct AppInvocation { + pub connector_id: Option, + pub app_name: Option, + pub invocation_type: Option, +} + +#[derive(Clone)] +pub struct SubAgentThreadStartedInput { + pub thread_id: String, + pub parent_thread_id: Option, + pub product_client_id: String, + pub client_name: String, + pub client_version: String, + pub model: String, + pub ephemeral: bool, + pub subagent_source: SubAgentSource, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionTrigger { + Manual, + Auto, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionReason { + UserRequested, + ContextLimit, + ModelDownshift, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionImplementation { + Responses, + ResponsesCompact, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionPhase { + StandaloneTurn, + PreTurn, + MidTurn, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionStrategy { + Memento, + PrefixCompaction, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionStatus { + Completed, + Failed, + Interrupted, +} + +#[derive(Clone)] +pub struct CodexCompactionEvent { + pub thread_id: String, + pub turn_id: String, + pub trigger: CompactionTrigger, + pub reason: CompactionReason, + pub implementation: CompactionImplementation, + pub phase: CompactionPhase, + pub strategy: CompactionStrategy, + pub status: CompactionStatus, + pub error: Option, + pub active_context_tokens_before: i64, + pub active_context_tokens_after: i64, + pub started_at: u64, + pub completed_at: u64, + pub duration_ms: Option, +} + +#[allow(dead_code)] +pub(crate) enum AnalyticsFact { + Initialize { + connection_id: u64, + params: InitializeParams, + product_client_id: String, + runtime: CodexRuntimeMetadata, + rpc_transport: AppServerRpcTransport, + }, + ClientRequest { + connection_id: u64, + request_id: RequestId, + request: Box, + }, + ClientResponse { + connection_id: u64, + request_id: RequestId, + response: Box, + }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + completed_at_ms: u64, + response: Box, + }, + Notification(Box), + // Facts that do not naturally exist on the app-server protocol surface, or + // would require non-trivial protocol reshaping on this branch. + Custom(CustomAnalyticsFact), +} + +pub(crate) enum CustomAnalyticsFact { + SubAgentThreadStarted(SubAgentThreadStartedInput), + Compaction(Box), + GuardianReview(Box), + TurnResolvedConfig(Box), + TurnTokenUsage(Box), + SkillInvoked(SkillInvokedInput), + AppMentioned(AppMentionedInput), + AppUsed(AppUsedInput), + HookRun(HookRunInput), + PluginUsed(PluginUsedInput), + PluginStateChanged(PluginStateChangedInput), +} + +pub(crate) struct SkillInvokedInput { + pub tracking: TrackEventsContext, + pub invocations: Vec, +} + +pub(crate) struct AppMentionedInput { + pub tracking: TrackEventsContext, + pub mentions: Vec, +} + +pub(crate) struct AppUsedInput { + pub tracking: TrackEventsContext, + pub app: AppInvocation, +} + +pub(crate) struct HookRunInput { + pub tracking: TrackEventsContext, + pub hook: HookRunFact, +} + +pub struct HookRunFact { + pub event_name: HookEventName, + pub hook_source: HookSource, + pub status: HookRunStatus, +} + +pub(crate) struct PluginUsedInput { + pub tracking: TrackEventsContext, + pub plugin: PluginTelemetryMetadata, +} + +pub(crate) struct PluginStateChangedInput { + pub plugin: PluginTelemetryMetadata, + pub state: PluginState, +} + +#[derive(Clone, Copy)] +pub(crate) enum PluginState { + Installed, + Uninstalled, + Enabled, + Disabled, +} diff --git a/code-rs/analytics/src/lib.rs b/code-rs/analytics/src/lib.rs new file mode 100644 index 00000000000..2fb23199cb6 --- /dev/null +++ b/code-rs/analytics/src/lib.rs @@ -0,0 +1,77 @@ +mod client; +mod events; +mod facts; +mod reducer; + +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +pub use client::AnalyticsEventsClient; +pub use events::AppServerRpcTransport; +pub use events::GuardianApprovalRequestSource; +pub use events::GuardianReviewAnalyticsResult; +pub use events::GuardianReviewDecision; +pub use events::GuardianReviewEventParams; +pub use events::GuardianReviewFailureReason; +pub use events::GuardianReviewSessionKind; +pub use events::GuardianReviewTerminalStatus; +pub use events::GuardianReviewTrackContext; +pub use events::GuardianReviewedAction; +pub use facts::AnalyticsJsonRpcError; +pub use facts::AppInvocation; +pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; +pub use facts::CompactionImplementation; +pub use facts::CompactionPhase; +pub use facts::CompactionReason; +pub use facts::CompactionStatus; +pub use facts::CompactionStrategy; +pub use facts::CompactionTrigger; +pub use facts::HookRunFact; +pub use facts::InputError; +pub use facts::InvocationType; +pub use facts::SkillInvocation; +pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; +pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; +pub use facts::TurnTokenUsageFact; +pub use facts::build_track_events_context; + +#[cfg(test)] +mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub fn now_unix_millis() -> u64 { + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +pub(crate) fn serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +pub(crate) fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +pub(crate) fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) +} diff --git a/code-rs/analytics/src/reducer.rs b/code-rs/analytics/src/reducer.rs new file mode 100644 index 00000000000..2ddb59c0cfe --- /dev/null +++ b/code-rs/analytics/src/reducer.rs @@ -0,0 +1,1876 @@ +use crate::events::AppServerRpcTransport; +use crate::events::CodexAppMentionedEventRequest; +use crate::events::CodexAppServerClientMetadata; +use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCollabAgentToolCallEventParams; +use crate::events::CodexCollabAgentToolCallEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; +use crate::events::CodexCompactionEventRequest; +use crate::events::CodexDynamicToolCallEventParams; +use crate::events::CodexDynamicToolCallEventRequest; +use crate::events::CodexFileChangeEventParams; +use crate::events::CodexFileChangeEventRequest; +use crate::events::CodexHookRunEventRequest; +use crate::events::CodexImageGenerationEventParams; +use crate::events::CodexImageGenerationEventRequest; +use crate::events::CodexMcpToolCallEventParams; +use crate::events::CodexMcpToolCallEventRequest; +use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventParams; +use crate::events::CodexTurnSteerEventRequest; +use crate::events::CodexWebSearchEventParams; +use crate::events::CodexWebSearchEventRequest; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewEventPayload; +use crate::events::GuardianReviewEventRequest; +use crate::events::SkillInvocationEventParams; +use crate::events::SkillInvocationEventRequest; +use crate::events::ThreadInitializedEvent; +use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemFailureKind; +use crate::events::ToolItemFinalApprovalOutcome; +use crate::events::ToolItemTerminalStatus; +use crate::events::TrackEventRequest; +use crate::events::WebSearchActionKind; +use crate::events::codex_app_metadata; +use crate::events::codex_compaction_event_params; +use crate::events::codex_hook_run_metadata; +use crate::events::codex_plugin_metadata; +use crate::events::codex_plugin_used_metadata; +use crate::events::plugin_state_event_type; +use crate::events::subagent_parent_thread_id; +use crate::events::subagent_source_name; +use crate::events::subagent_thread_started_event_request; +use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; +use crate::facts::AppMentionedInput; +use crate::facts::AppUsedInput; +use crate::facts::CodexCompactionEvent; +use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunInput; +use crate::facts::PluginState; +use crate::facts::PluginStateChangedInput; +use crate::facts::PluginUsedInput; +use crate::facts::SkillInvokedInput; +use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; +use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; +use crate::option_i64_to_u64; +use crate::serialize_enum_as_string; +use crate::usize_to_u64; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallStatus; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::TurnSteerResponse; +use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WebSearchAction; +use codex_git_utils::collect_git_info; +use codex_git_utils::get_git_repo_root; +use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TokenUsage; +use sha1::Digest; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Default)] +pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, + connections: HashMap, + threads: HashMap, + tool_items_started_at_ms: HashMap, +} + +struct ConnectionState { + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, +} + +#[derive(Default)] +struct ThreadAnalyticsState { + connection_id: Option, + metadata: Option, +} + +#[derive(Clone, Copy)] +struct AnalyticsDropSite<'a> { + event_name: &'static str, + thread_id: &'a str, + turn_id: Option<&'a str>, + review_id: Option<&'a str>, + item_id: Option<&'a str>, +} + +impl<'a> AnalyticsDropSite<'a> { + fn guardian(input: &'a GuardianReviewEventParams) -> Self { + Self { + event_name: "guardian", + thread_id: &input.thread_id, + turn_id: Some(&input.turn_id), + review_id: Some(&input.review_id), + item_id: None, + } + } + + fn compaction(input: &'a CodexCompactionEvent) -> Self { + Self { + event_name: "compaction", + thread_id: &input.thread_id, + turn_id: Some(&input.turn_id), + review_id: None, + item_id: None, + } + } + + fn tool_item( + notification: &'a codex_app_server_protocol::ItemCompletedNotification, + item_id: &'a str, + ) -> Self { + Self { + event_name: "tool item", + thread_id: ¬ification.thread_id, + turn_id: Some(¬ification.turn_id), + review_id: None, + item_id: Some(item_id), + } + } + + fn turn_steer(thread_id: &'a str) -> Self { + Self { + event_name: "turn steer", + thread_id, + turn_id: None, + review_id: None, + item_id: None, + } + } + + fn turn(thread_id: &'a str, turn_id: &'a str) -> Self { + Self { + event_name: "turn", + thread_id, + turn_id: Some(turn_id), + review_id: None, + item_id: None, + } + } +} + +enum MissingAnalyticsContext { + ThreadConnection, + Connection { connection_id: u64 }, + ThreadMetadata, +} + +#[derive(Clone)] +struct ThreadMetadataState { + thread_source: Option, + initialization_mode: ThreadInitializationMode, + subagent_source: Option, + parent_thread_id: Option, +} + +impl ThreadMetadataState { + fn from_thread_metadata( + session_source: &SessionSource, + thread_source: Option, + initialization_mode: ThreadInitializationMode, + ) -> Self { + let (subagent_source, parent_thread_id) = match session_source { + SessionSource::SubAgent(subagent_source) => ( + Some(subagent_source_name(subagent_source)), + subagent_parent_thread_id(subagent_source), + ), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Internal(_) + | SessionSource::Unknown => (None, None), + }; + Self { + thread_source, + initialization_mode, + subagent_source, + parent_thread_id, + } + } +} + +enum RequestState { + TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + token_usage: Option, + completed: Option, + steer_count: usize, +} + +#[derive(Hash, Eq, PartialEq)] +struct ToolItemKey { + thread_id: String, + turn_id: String, + item_id: String, +} + +impl AnalyticsReducer { + pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { + match input { + AnalyticsFact::Initialize { + connection_id, + params, + product_client_id, + runtime, + rpc_transport, + } => { + self.ingest_initialize( + connection_id, + params, + product_client_id, + runtime, + rpc_transport, + ); + } + AnalyticsFact::ClientRequest { + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } + AnalyticsFact::ClientResponse { + connection_id, + request_id, + response, + } => { + if let Some(response) = response.into_client_response(request_id) { + self.ingest_response(connection_id, response, out); + } + } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + .. + } => {} + AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::SubAgentThreadStarted(input) => { + self.ingest_subagent_thread_started(input, out); + } + CustomAnalyticsFact::Compaction(input) => { + self.ingest_compaction(*input, out); + } + CustomAnalyticsFact::GuardianReview(input) => { + self.ingest_guardian_review(*input, out); + } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } + CustomAnalyticsFact::SkillInvoked(input) => { + self.ingest_skill_invoked(input, out).await; + } + CustomAnalyticsFact::AppMentioned(input) => { + self.ingest_app_mentioned(input, out); + } + CustomAnalyticsFact::AppUsed(input) => { + self.ingest_app_used(input, out); + } + CustomAnalyticsFact::HookRun(input) => { + self.ingest_hook_run(input, out); + } + CustomAnalyticsFact::PluginUsed(input) => { + self.ingest_plugin_used(input, out); + } + CustomAnalyticsFact::PluginStateChanged(input) => { + self.ingest_plugin_state_changed(input, out); + } + }, + } + } + + fn ingest_initialize( + &mut self, + connection_id: u64, + params: InitializeParams, + product_client_id: String, + runtime: CodexRuntimeMetadata, + rpc_transport: AppServerRpcTransport, + ) { + self.connections.insert( + connection_id, + ConnectionState { + app_server_client: CodexAppServerClientMetadata { + product_client_id, + client_name: Some(params.client_info.name), + client_version: Some(params.client_info.version), + rpc_transport, + experimental_api_enabled: params + .capabilities + .map(|capabilities| capabilities.experimental_api), + }, + runtime, + }, + ); + } + + fn ingest_subagent_thread_started( + &mut self, + input: SubAgentThreadStartedInput, + out: &mut Vec, + ) { + let parent_thread_id = input + .parent_thread_id + .clone() + .or_else(|| subagent_parent_thread_id(&input.subagent_source)); + let parent_connection_id = parent_thread_id + .as_ref() + .and_then(|parent_thread_id| self.threads.get(parent_thread_id)) + .and_then(|thread| thread.connection_id); + let thread_state = self.threads.entry(input.thread_id.clone()).or_default(); + thread_state + .metadata + .get_or_insert_with(|| ThreadMetadataState { + thread_source: Some(ThreadSource::Subagent), + initialization_mode: ThreadInitializationMode::New, + subagent_source: Some(subagent_source_name(&input.subagent_source)), + parent_thread_id, + }); + if thread_state.connection_id.is_none() { + thread_state.connection_id = parent_connection_id; + } + out.push(TrackEventRequest::ThreadInitialized( + subagent_thread_started_event_request(input), + )); + } + + fn ingest_guardian_review( + &mut self, + input: GuardianReviewEventParams, + out: &mut Vec, + ) { + let Some(connection_state) = + self.thread_connection_or_warn(AnalyticsDropSite::guardian(&input)) + else { + return; + }; + out.push(TrackEventRequest::GuardianReview(Box::new( + GuardianReviewEventRequest { + event_type: "codex_guardian_review", + event_params: GuardianReviewEventPayload { + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + guardian_review: input, + }, + }, + ))); + } + + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + let thread_id = input.thread_id.clone(); + let num_input_images = input.num_input_images; + let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + steer_count: 0, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + steer_count: 0, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + + async fn ingest_skill_invoked( + &mut self, + input: SkillInvokedInput, + out: &mut Vec, + ) { + let SkillInvokedInput { + tracking, + invocations, + } = input; + for invocation in invocations { + let skill_scope = match invocation.skill_scope { + SkillScope::User => "user", + SkillScope::Repo => "repo", + SkillScope::System => "system", + SkillScope::Admin => "admin", + }; + let repo_root = get_git_repo_root(invocation.skill_path.as_path()); + let repo_url = if let Some(root) = repo_root.as_ref() { + collect_git_info(root) + .await + .and_then(|info| info.repository_url) + } else { + None + }; + let skill_id = skill_id_for_local_skill( + repo_url.as_deref(), + repo_root.as_deref(), + invocation.skill_path.as_path(), + invocation.skill_name.as_str(), + ); + out.push(TrackEventRequest::SkillInvocation( + SkillInvocationEventRequest { + event_type: "skill_invocation", + skill_id, + skill_name: invocation.skill_name.clone(), + event_params: SkillInvocationEventParams { + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + invoke_type: Some(invocation.invocation_type), + model_slug: Some(tracking.model_slug.clone()), + product_client_id: Some(originator().value), + repo_url, + skill_scope: Some(skill_scope.to_string()), + plugin_id: invocation.plugin_id, + }, + }, + )); + } + } + + fn ingest_app_mentioned(&mut self, input: AppMentionedInput, out: &mut Vec) { + let AppMentionedInput { tracking, mentions } = input; + out.extend(mentions.into_iter().map(|mention| { + let event_params = codex_app_metadata(&tracking, mention); + TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest { + event_type: "codex_app_mentioned", + event_params, + }) + })); + } + + fn ingest_app_used(&mut self, input: AppUsedInput, out: &mut Vec) { + let AppUsedInput { tracking, app } = input; + let event_params = codex_app_metadata(&tracking, app); + out.push(TrackEventRequest::AppUsed(CodexAppUsedEventRequest { + event_type: "codex_app_used", + event_params, + })); + } + + fn ingest_hook_run(&mut self, input: HookRunInput, out: &mut Vec) { + let HookRunInput { tracking, hook } = input; + out.push(TrackEventRequest::HookRun(CodexHookRunEventRequest { + event_type: "codex_hook_run", + event_params: codex_hook_run_metadata(&tracking, hook), + })); + } + + fn ingest_plugin_used(&mut self, input: PluginUsedInput, out: &mut Vec) { + let PluginUsedInput { tracking, plugin } = input; + out.push(TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { + event_type: "codex_plugin_used", + event_params: codex_plugin_used_metadata(&tracking, plugin), + })); + } + + fn ingest_plugin_state_changed( + &mut self, + input: PluginStateChangedInput, + out: &mut Vec, + ) { + let PluginStateChangedInput { plugin, state } = input; + let event = CodexPluginEventRequest { + event_type: plugin_state_event_type(state), + event_params: codex_plugin_metadata(plugin), + }; + out.push(match state { + PluginState::Installed => TrackEventRequest::PluginInstalled(event), + PluginState::Uninstalled => TrackEventRequest::PluginUninstalled(event), + PluginState::Enabled => TrackEventRequest::PluginEnabled(event), + PluginState::Disabled => TrackEventRequest::PluginDisabled(event), + }); + } + + fn ingest_response( + &mut self, + connection_id: u64, + response: ClientResponse, + out: &mut Vec, + ) { + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + steer_count: 0, + }); + turn_state.connection_id = Some(connection_id); + turn_state.thread_id = Some(pending_request.thread_id); + turn_state.num_input_images = Some(pending_request.num_input_images); + self.maybe_emit_turn_event(&turn_id, out); + } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } + _ => {} + } + } + + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + connection_id: u64, + pending_request: PendingTurnSteerState, + error_type: Option, + out: &mut Vec, + ) { + self.emit_turn_steer_event( + connection_id, + pending_request, + /*accepted_turn_id*/ None, + TurnSteerResult::Rejected, + rejection_reason_from_error_type(error_type), + out, + ); + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + return; + }; + let Some(started_at_ms) = option_i64_to_u64(Some(notification.started_at_ms)) + else { + return; + }; + self.tool_items_started_at_ms.insert( + ToolItemKey { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + item_id: item_id.to_string(), + }, + started_at_ms, + ); + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + return; + }; + let key = ToolItemKey { + thread_id: notification.thread_id.clone(), + turn_id: notification.turn_id.clone(), + item_id: item_id.to_string(), + }; + let Some(started_at_ms) = self.tool_items_started_at_ms.remove(&key) else { + tracing::warn!( + thread_id = %notification.thread_id, + turn_id = %notification.turn_id, + item_id, + "dropping tool item analytics event: missing item started notification" + ); + return; + }; + let Some(completed_at_ms) = option_i64_to_u64(Some(notification.completed_at_ms)) + else { + return; + }; + let Some((connection_state, thread_metadata)) = self + .thread_context_or_warn(AnalyticsDropSite::tool_item(¬ification, item_id)) + else { + return; + }; + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + ) { + out.push(event); + } + } + ServerNotification::TurnStarted(notification) => { + let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + steer_count: 0, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + steer_count: 0, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { + let session_source: SessionSource = thread.source.into(); + let thread_id = thread.id; + let Some(connection_state) = self.connections.get(&connection_id) else { + return; + }; + let thread_metadata = ThreadMetadataState::from_thread_metadata( + &session_source, + thread.thread_source.map(Into::into), + initialization_mode, + ); + self.threads.insert( + thread_id.clone(), + ThreadAnalyticsState { + connection_id: Some(connection_id), + metadata: Some(thread_metadata.clone()), + }, + ); + out.push(TrackEventRequest::ThreadInitialized( + ThreadInitializedEvent { + event_type: "codex_thread_initialized", + event_params: ThreadInitializedEventParams { + thread_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + model, + ephemeral: thread.ephemeral, + thread_source: thread_metadata.thread_source, + initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id, + created_at: u64::try_from(thread.created_at).unwrap_or_default(), + }, + }, + )); + } + + fn ingest_compaction(&mut self, input: CodexCompactionEvent, out: &mut Vec) { + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::compaction(&input)) + else { + return; + }; + out.push(TrackEventRequest::Compaction(Box::new( + CodexCompactionEventRequest { + event_type: "codex_compaction_event", + event_params: codex_compaction_event_params( + input, + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + thread_metadata.thread_source, + thread_metadata.subagent_source.clone(), + thread_metadata.parent_thread_id.clone(), + ), + }, + ))); + } + + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + fn emit_turn_steer_event( + &mut self, + connection_id: u64, + pending_request: PendingTurnSteerState, + accepted_turn_id: Option, + result: TurnSteerResult, + rejection_reason: Option, + out: &mut Vec, + ) { + let Some(connection_state) = self.connections.get(&connection_id) else { + return; + }; + let drop_site = AnalyticsDropSite::turn_steer(&pending_request.thread_id); + let Some(thread_metadata) = self + .threads + .get(drop_site.thread_id) + .and_then(|thread| thread.metadata.as_ref()) + else { + warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata); + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: CodexTurnSteerEventParams { + thread_id: pending_request.thread_id, + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }, + })); + } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let Some(thread_id) = turn_state.thread_id.as_ref() else { + return; + }; + let Some(connection_id) = turn_state.connection_id else { + return; + }; + let Some(connection_state) = self.connections.get(&connection_id) else { + warn_missing_analytics_context( + &AnalyticsDropSite::turn(thread_id, turn_id), + MissingAnalyticsContext::Connection { connection_id }, + ); + return; + }; + let drop_site = AnalyticsDropSite::turn(thread_id, turn_id); + let Some(thread_metadata) = self + .threads + .get(drop_site.thread_id) + .and_then(|thread| thread.metadata.as_ref()) + else { + warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata); + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + turn_id.to_string(), + turn_state, + thread_metadata, + ), + }, + ))); + self.turns.remove(turn_id); + } + + fn thread_connection_or_warn( + &self, + drop_site: AnalyticsDropSite<'_>, + ) -> Option<&ConnectionState> { + let Some(thread_state) = self.threads.get(drop_site.thread_id) else { + warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection); + return None; + }; + let Some(connection_id) = thread_state.connection_id else { + warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection); + return None; + }; + let Some(connection_state) = self.connections.get(&connection_id) else { + warn_missing_analytics_context( + &drop_site, + MissingAnalyticsContext::Connection { connection_id }, + ); + return None; + }; + Some(connection_state) + } + + fn thread_context_or_warn( + &self, + drop_site: AnalyticsDropSite<'_>, + ) -> Option<(&ConnectionState, &ThreadMetadataState)> { + let connection_state = self.thread_connection_or_warn(drop_site)?; + let Some(thread_metadata) = self + .threads + .get(drop_site.thread_id) + .and_then(|thread| thread.metadata.as_ref()) + else { + warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata); + return None; + }; + Some((connection_state, thread_metadata)) + } +} + +fn warn_missing_analytics_context( + drop_site: &AnalyticsDropSite<'_>, + missing: MissingAnalyticsContext, +) { + let (missing_context, connection_id) = match missing { + MissingAnalyticsContext::ThreadConnection => ("thread_connection", None), + MissingAnalyticsContext::Connection { connection_id } => { + ("connection", Some(connection_id)) + } + MissingAnalyticsContext::ThreadMetadata => ("thread_metadata", None), + }; + tracing::warn!( + thread_id = %drop_site.thread_id, + turn_id = ?drop_site.turn_id, + review_id = ?drop_site.review_id, + item_id = ?drop_site.item_id, + missing_context, + connection_id, + "dropping {} analytics event: missing analytics context", + drop_site.event_name + ); +} + +fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { + match item { + ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageGeneration { id, .. } => Some(id), + ThreadItem::UserMessage { .. } + | ThreadItem::HookPrompt { .. } + | ThreadItem::AgentMessage { .. } + | ThreadItem::Plan { .. } + | ThreadItem::Reasoning { .. } + | ThreadItem::ImageView { .. } + | ThreadItem::EnteredReviewMode { .. } + | ThreadItem::ExitedReviewMode { .. } + | ThreadItem::ContextCompaction { .. } => None, + } +} + +fn tool_item_event( + thread_id: &str, + turn_id: &str, + item: &ThreadItem, + started_at_ms: u64, + completed_at_ms: u64, + connection_state: &ConnectionState, + thread_metadata: &ThreadMetadataState, +) -> Option { + let context = ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }; + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let action_counts = command_action_counts(command_actions); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + command_execution_tool_name(*source).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + context, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: *source, + exit_code: *exit_code, + command_total_action_count: action_counts.total, + command_read_action_count: action_counts.read, + command_list_files_action_count: action_counts.list_files, + command_search_action_count: action_counts.search, + command_unknown_action_count: action_counts.unknown, + }, + }, + )) + } + ThreadItem::FileChange { + id, + changes, + status, + } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + let counts = file_change_counts(changes); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "apply_patch".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::FileChange(CodexFileChangeEventRequest { + event_type: "codex_file_change_event", + event_params: CodexFileChangeEventParams { + base, + file_change_count: usize_to_u64(changes.len()), + file_add_count: counts.add, + file_update_count: counts.update, + file_delete_count: counts.delete, + file_move_count: counts.move_, + }, + })) + } + ThreadItem::McpToolCall { + id, + server, + tool, + status, + error, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + context, + ); + Some(TrackEventRequest::McpToolCall( + CodexMcpToolCallEventRequest { + event_type: "codex_mcp_tool_call_event", + event_params: CodexMcpToolCallEventParams { + base, + mcp_server_name: server.clone(), + mcp_tool_name: tool.clone(), + mcp_error_present: error.is_some(), + }, + }, + )) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + content_items, + success, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + let counts = content_items + .as_ref() + .map(|items| dynamic_content_counts(items)); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + context, + ); + Some(TrackEventRequest::DynamicToolCall( + CodexDynamicToolCallEventRequest { + event_type: "codex_dynamic_tool_call_event", + event_params: CodexDynamicToolCallEventParams { + base, + dynamic_tool_name: tool.clone(), + success: *success, + output_content_item_count: counts.map(|counts| counts.total), + output_text_item_count: counts.map(|counts| counts.text), + output_image_item_count: counts.map(|counts| counts.image), + }, + }, + )) + } + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + model, + reasoning_effort, + agents_states, + .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + collab_agent_tool_name(tool).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + sender_thread_id: sender_thread_id.clone(), + receiver_thread_count: usize_to_u64(receiver_thread_ids.len()), + receiver_thread_ids: Some(receiver_thread_ids.clone()), + requested_model: model.clone(), + requested_reasoning_effort: reasoning_effort + .as_ref() + .and_then(serialize_enum_as_string), + agent_state_count: Some(usize_to_u64(agents_states.len())), + completed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| state.status == CollabAgentStatus::Completed) + .count(), + )), + failed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| { + matches!( + state.status, + CollabAgentStatus::Errored + | CollabAgentStatus::Shutdown + | CollabAgentStatus::NotFound + ) + }) + .count(), + )), + }, + }, + )) + } + ThreadItem::WebSearch { id, query, action } => { + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "web_search".to_string(), + ToolItemOutcome { + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::WebSearch(CodexWebSearchEventRequest { + event_type: "codex_web_search_event", + event_params: CodexWebSearchEventParams { + base, + web_search_action: action.as_ref().map(web_search_action_kind), + query_present: !query.trim().is_empty(), + query_count: web_search_query_count(query, action.as_ref()), + }, + })) + } + ThreadItem::ImageGeneration { + id, + status, + revised_prompt, + saved_path, + .. + } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "image_generation".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::ImageGeneration( + CodexImageGenerationEventRequest { + event_type: "codex_image_generation_event", + event_params: CodexImageGenerationEventParams { + base, + revised_prompt_present: revised_prompt.is_some(), + saved_path_present: saved_path.is_some(), + }, + }, + )) + } + _ => None, + } +} + +struct ToolItemOutcome { + terminal_status: ToolItemTerminalStatus, + failure_kind: Option, + execution_duration_ms: Option, +} + +#[derive(Default)] +struct CommandActionCounts { + total: u64, + read: u64, + list_files: u64, + search: u64, + unknown: u64, +} + +fn command_action_counts(command_actions: &[CommandAction]) -> CommandActionCounts { + let mut counts = CommandActionCounts { + total: usize_to_u64(command_actions.len()), + ..Default::default() + }; + for action in command_actions { + match action { + CommandAction::Read { .. } => counts.read += 1, + CommandAction::ListFiles { .. } => counts.list_files += 1, + CommandAction::Search { .. } => counts.search += 1, + CommandAction::Unknown { .. } => counts.unknown += 1, + } + } + counts +} + +#[derive(Clone, Copy)] +struct ToolItemContext<'a> { + started_at_ms: u64, + completed_at_ms: u64, + connection_state: &'a ConnectionState, + thread_metadata: &'a ThreadMetadataState, +} + +fn tool_item_base( + thread_id: &str, + turn_id: &str, + item_id: String, + tool_name: String, + outcome: ToolItemOutcome, + context: ToolItemContext<'_>, +) -> CodexToolItemEventBase { + let thread_metadata = context.thread_metadata; + CodexToolItemEventBase { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id, + app_server_client: context.connection_state.app_server_client.clone(), + runtime: context.connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + tool_name, + started_at_ms: context.started_at_ms, + completed_at_ms: context.completed_at_ms, + // duration_ms reflects item lifecycle observed by app-server. For web + // search and image generation in particular, that can be narrower than + // full upstream execution time. + duration_ms: observed_duration_ms(context.started_at_ms, context.completed_at_ms), + execution_duration_ms: outcome.execution_duration_ms, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::Unknown, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + } +} + +fn observed_duration_ms(started_at_ms: u64, completed_at_ms: u64) -> Option { + completed_at_ms.checked_sub(started_at_ms) +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolItemTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + ), + _ => (ToolItemTerminalStatus::Completed, None), + } +} + +fn collab_agent_tool_name(tool: &CollabAgentTool) -> &'static str { + match tool { + CollabAgentTool::SpawnAgent => "spawn_agent", + CollabAgentTool::SendInput => "send_input", + CollabAgentTool::ResumeAgent => "resume_agent", + CollabAgentTool::Wait => "wait_agent", + CollabAgentTool::CloseAgent => "close_agent", + } +} + +#[derive(Default)] +struct FileChangeCounts { + add: u64, + update: u64, + delete: u64, + move_: u64, +} + +fn file_change_counts(changes: &[codex_app_server_protocol::FileUpdateChange]) -> FileChangeCounts { + let mut counts = FileChangeCounts::default(); + for change in changes { + match &change.kind { + PatchChangeKind::Add => counts.add += 1, + PatchChangeKind::Delete => counts.delete += 1, + PatchChangeKind::Update { move_path: Some(_) } => counts.move_ += 1, + PatchChangeKind::Update { move_path: None } => counts.update += 1, + } + } + counts +} + +#[derive(Clone, Copy)] +struct DynamicContentCounts { + total: u64, + text: u64, + image: u64, +} + +fn dynamic_content_counts(items: &[DynamicToolCallOutputContentItem]) -> DynamicContentCounts { + let mut text = 0; + let mut image = 0; + for item in items { + match item { + DynamicToolCallOutputContentItem::InputText { .. } => text += 1, + DynamicToolCallOutputContentItem::InputImage { .. } => image += 1, + } + } + DynamicContentCounts { + total: usize_to_u64(items.len()), + text, + image, + } +} + +fn web_search_action_kind(action: &WebSearchAction) -> WebSearchActionKind { + match action { + WebSearchAction::Search { .. } => WebSearchActionKind::Search, + WebSearchAction::OpenPage { .. } => WebSearchActionKind::OpenPage, + WebSearchAction::FindInPage { .. } => WebSearchActionKind::FindInPage, + WebSearchAction::Other => WebSearchActionKind::Other, + } +} + +fn web_search_query_count(query: &str, action: Option<&WebSearchAction>) -> Option { + match action { + Some(WebSearchAction::Search { query, queries }) => queries + .as_ref() + .map(|queries| usize_to_u64(queries.len())) + .or_else(|| query.as_ref().map(|_| 1)), + Some(WebSearchAction::OpenPage { .. }) + | Some(WebSearchAction::FindInPage { .. }) + | Some(WebSearchAction::Other) => None, + None => (!query.trim().is_empty()).then_some(1), + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source: _session_source, + model, + model_provider, + permission_profile, + permission_profile_cwd, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + let token_usage = turn_state.token_usage.clone(); + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_metadata.thread_source, + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode( + &permission_profile, + permission_profile_cwd.as_path(), + )), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: Some(turn_state.steer_count), + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(permission_profile: &PermissionProfile, cwd: &Path) -> &'static str { + match permission_profile { + PermissionProfile::Disabled => "full_access", + PermissionProfile::External { .. } => "external_sandbox", + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + if permission_profile.network_sandbox_policy().is_enabled() { + "full_access" + } else { + "external_sandbox" + } + } else if file_system_policy + .get_writable_roots_with_cwd(cwd) + .is_empty() + { + "read_only" + } else { + "workspace_write" + } + } + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } +} + +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + +pub(crate) fn skill_id_for_local_skill( + repo_url: Option<&str>, + repo_root: Option<&Path>, + skill_path: &Path, + skill_name: &str, +) -> String { + let path = normalize_path_for_skill_id(repo_url, repo_root, skill_path); + let prefix = if let Some(url) = repo_url { + format!("repo_{url}") + } else { + "personal".to_string() + }; + let raw_id = format!("{prefix}_{path}_{skill_name}"); + let mut hasher = sha1::Sha1::new(); + sha1::Digest::update(&mut hasher, raw_id.as_bytes()); + format!("{:x}", sha1::Digest::finalize(hasher)) +} + +/// Returns a normalized path for skill ID construction. +/// +/// - Repo-scoped skills use a path relative to the repo root. +/// - User/admin/system skills use an absolute path. +pub(crate) fn normalize_path_for_skill_id( + repo_url: Option<&str>, + repo_root: Option<&Path>, + skill_path: &Path, +) -> String { + let resolved_path = + std::fs::canonicalize(skill_path).unwrap_or_else(|_| skill_path.to_path_buf()); + match (repo_url, repo_root) { + (Some(_), Some(root)) => { + let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + resolved_path + .strip_prefix(&resolved_root) + .unwrap_or(resolved_path.as_path()) + .to_string_lossy() + .replace('\\', "/") + } + _ => resolved_path.to_string_lossy().replace('\\', "/"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::SandboxEnforcement; + use codex_protocol::permissions::FileSystemSandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; + + #[test] + fn managed_full_disk_with_restricted_network_reports_external_sandbox() { + let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::Managed, + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Restricted, + ); + + assert_eq!( + sandbox_policy_mode(&permission_profile, Path::new("/")), + "external_sandbox" + ); + } +} diff --git a/code-rs/ansi-escape/BUILD.bazel b/code-rs/ansi-escape/BUILD.bazel new file mode 100644 index 00000000000..27622583be3 --- /dev/null +++ b/code-rs/ansi-escape/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "ansi-escape", + crate_name = "codex_ansi_escape", +) diff --git a/code-rs/ansi-escape/Cargo.toml b/code-rs/ansi-escape/Cargo.toml index 2c3c28b0b05..9e0f8a81234 100644 --- a/code-rs/ansi-escape/Cargo.toml +++ b/code-rs/ansi-escape/Cargo.toml @@ -1,11 +1,14 @@ [package] -edition = "2024" -name = "code-ansi-escape" -version = { workspace = true } +name = "codex-ansi-escape" +version.workspace = true +edition.workspace = true +license.workspace = true [lib] -name = "code_ansi_escape" +name = "codex_ansi_escape" path = "src/lib.rs" +test = false +doctest = false [lints] workspace = true diff --git a/code-rs/ansi-escape/src/lib.rs b/code-rs/ansi-escape/src/lib.rs index 68ea5e9aa9c..b47cf14f8ea 100644 --- a/code-rs/ansi-escape/src/lib.rs +++ b/code-rs/ansi-escape/src/lib.rs @@ -3,11 +3,30 @@ use ansi_to_tui::IntoText; use ratatui::text::Line; use ratatui::text::Text; +// Expand tabs in a best-effort way for transcript rendering. +// Tabs can interact poorly with left-gutter prefixes in our TUI and CLI +// transcript views (e.g., `nl` separates line numbers from content with a tab). +// Replacing tabs with spaces avoids odd visual artifacts without changing +// semantics for our use cases. +fn expand_tabs(s: &str) -> std::borrow::Cow<'_, str> { + if s.contains('\t') { + // Keep it simple: replace each tab with 4 spaces. + // We do not try to align to tab stops since most usages (like `nl`) + // look acceptable with a fixed substitution and this avoids stateful math + // across spans. + std::borrow::Cow::Owned(s.replace('\t', " ")) + } else { + std::borrow::Cow::Borrowed(s) + } +} + /// 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); + // Normalize tabs to spaces to avoid odd gutter collisions in transcript mode. + let s = expand_tabs(s); + let text = ansi_escape(&s); match text.lines.as_slice() { [] => "".into(), [only] => only.clone(), diff --git a/code-rs/app-server-client/BUILD.bazel b/code-rs/app-server-client/BUILD.bazel new file mode 100644 index 00000000000..953de7421ef --- /dev/null +++ b/code-rs/app-server-client/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "app-server-client", + crate_name = "codex_app_server_client", +) diff --git a/code-rs/app-server-client/Cargo.toml b/code-rs/app-server-client/Cargo.toml new file mode 100644 index 00000000000..ac284cbdfe6 --- /dev/null +++ b/code-rs/app-server-client/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "codex-app-server-client" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_app_server_client" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-app-server = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-arg0 = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-exec-server = { workspace = true } +codex-feedback = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-rustls-provider = { workspace = true } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["sync", "time", "rt"] } +tokio-tungstenite = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/code-rs/app-server-client/README.md b/code-rs/app-server-client/README.md new file mode 100644 index 00000000000..8944ab4ba15 --- /dev/null +++ b/code-rs/app-server-client/README.md @@ -0,0 +1,67 @@ +# codex-app-server-client + +Shared in-process app-server client used by conversational CLI surfaces: + +- `codex-exec` +- `codex-tui` + +## Purpose + +This crate centralizes startup and lifecycle management for an in-process +`codex-app-server` runtime, so CLI clients do not need to duplicate: + +- app-server bootstrap and initialize handshake +- in-memory request/event transport wiring +- lifecycle orchestration around caller-provided startup identity +- graceful shutdown behavior + +## Startup identity + +Callers pass both the app-server `SessionSource` and the initialize +`client_info.name` explicitly when starting the facade. + +That keeps thread metadata (for example in `thread/list` and `thread/read`) +aligned with the originating runtime without baking TUI/exec-specific policy +into the shared client layer. + +## Transport model + +The in-process path uses typed channels: + +- client -> server: `ClientRequest` / `ClientNotification` +- server -> client: `InProcessServerEvent` + - `ServerRequest` + - `ServerNotification` + - `LegacyNotification` + +JSON serialization is still used at external transport boundaries +(stdio/websocket), but the in-process hot path is typed. + +Typed requests still receive app-server responses through the JSON-RPC +result envelope internally. That is intentional: the in-process path is +meant to preserve app-server semantics while removing the process +boundary, not to introduce a second response contract. + +## Bootstrap behavior + +The client facade starts an already-initialized in-process runtime, but +thread bootstrap still follows normal app-server flow: + +- caller sends `thread/start` or `thread/resume` +- app-server returns the immediate typed response +- richer session metadata may arrive later as a `SessionConfigured` + legacy event + +Surfaces such as TUI and exec may therefore need a short bootstrap +phase where they reconcile startup response data with later events. + +## Backpressure and shutdown + +- Queues are bounded and use `DEFAULT_IN_PROCESS_CHANNEL_CAPACITY` by default. +- Full queues return explicit overload behavior instead of unbounded growth. +- `shutdown()` performs a bounded graceful shutdown and then aborts if timeout + is exceeded. + +If the client falls behind on event consumption, the worker emits +`InProcessServerEvent::Lagged` and may reject pending server requests so +approval flows do not hang indefinitely behind a saturated queue. diff --git a/code-rs/app-server-client/src/lib.rs b/code-rs/app-server-client/src/lib.rs new file mode 100644 index 00000000000..ebafe351af2 --- /dev/null +++ b/code-rs/app-server-client/src/lib.rs @@ -0,0 +1,2188 @@ +//! Shared in-process app-server client facade for CLI surfaces. +//! +//! This crate wraps [`codex_app_server::in_process`] behind a single async API +//! used by surfaces like TUI and exec. It centralizes: +//! +//! - Runtime startup and initialize-capabilities handshake. +//! - Typed caller-provided startup identity (`SessionSource` + client name). +//! - Typed and raw request/notification dispatch. +//! - Server request resolution and rejection. +//! - Event consumption with backpressure signaling ([`InProcessServerEvent::Lagged`]). +//! - Bounded graceful shutdown with abort fallback. +//! +//! The facade interposes a worker task between the caller and the underlying +//! [`InProcessClientHandle`](codex_app_server::in_process::InProcessClientHandle), +//! bridging async `mpsc` channels on both sides. Queues are bounded so overload +//! surfaces as channel-full errors rather than unbounded memory growth. + +mod remote; + +use std::error::Error; +use std::fmt; +use std::io::Error as IoError; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::sync::Arc; +use std::time::Duration; + +pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +pub use codex_app_server::in_process::InProcessServerEvent; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server::in_process::LogDbLayer; +pub use codex_app_server::in_process::StateDbHandle; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result as JsonRpcResult; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_config::RemoteThreadConfigLoader; +use codex_config::ThreadConfigLoader; +use codex_core::config::Config; +pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentManagerArgs; +pub use codex_exec_server::ExecServerRuntimePaths; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use serde::de::DeserializeOwned; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::timeout; +use toml::Value as TomlValue; +use tracing::warn; + +pub use crate::remote::RemoteAppServerClient; +pub use crate::remote::RemoteAppServerConnectArgs; + +/// Transitional access to core-only embedded app-server types. +/// +/// New TUI behavior should prefer the app-server protocol methods. This +/// module exists so clients can remove a direct `codex-core` dependency +/// while legacy startup/config paths are migrated to RPCs. +pub mod legacy_core { + pub use codex_core::DEFAULT_AGENTS_MD_FILENAME; + pub use codex_core::LOCAL_AGENTS_MD_FILENAME; + pub use codex_core::McpManager; + pub use codex_core::check_execpolicy_for_warnings; + pub use codex_core::format_exec_policy_error_with_source; + pub use codex_core::grant_read_root_non_elevated; + pub use codex_core::web_search_detail; + + pub mod config { + pub use codex_core::config::*; + + pub mod edit { + pub use codex_core::config::edit::*; + } + } + + pub mod connectors { + pub use codex_core::connectors::*; + } + + pub mod otel_init { + pub use codex_core::otel_init::*; + } + + pub mod personality_migration { + pub use codex_core::personality_migration::*; + } + + pub mod review_format { + pub use codex_core::review_format::*; + } + + pub mod review_prompts { + pub use codex_core::review_prompts::*; + } + + pub mod test_support { + pub use codex_core::test_support::*; + } + + pub mod util { + pub use codex_core::util::*; + } + + pub mod windows_sandbox { + pub use codex_core::windows_sandbox::*; + } +} + +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + +/// Raw app-server request result for typed in-process requests. +/// +/// Even on the in-process path, successful responses still travel back through +/// the same JSON-RPC result envelope used by socket/stdio transports because +/// `MessageProcessor` continues to produce that shape internally. +pub type RequestResult = std::result::Result; + +#[derive(Debug, Clone)] +pub enum AppServerEvent { + Lagged { skipped: usize }, + ServerNotification(ServerNotification), + ServerRequest(ServerRequest), + Disconnected { message: String }, +} + +impl From for AppServerEvent { + fn from(value: InProcessServerEvent) -> Self { + match value { + InProcessServerEvent::Lagged { skipped } => Self::Lagged { skipped }, + InProcessServerEvent::ServerNotification(notification) => { + Self::ServerNotification(notification) + } + InProcessServerEvent::ServerRequest(request) => Self::ServerRequest(request), + } + } +} + +fn event_requires_delivery(event: &InProcessServerEvent) -> bool { + // These transcript and terminal events must remain lossless. Dropping + // streamed assistant text or the authoritative completed item can leave + // the TUI with permanently corrupted markdown, while dropping completion + // notifications can leave surfaces waiting forever. + match event { + InProcessServerEvent::ServerNotification(notification) => { + server_notification_requires_delivery(notification) + } + _ => false, + } +} + +/// Returns `true` for notifications that must survive backpressure. +/// +/// Transcript events (`AgentMessageDelta`, `PlanDelta`, reasoning deltas) and +/// the authoritative `ItemCompleted` / `TurnCompleted` form the lossless tier +/// of the event stream. Dropping any of these corrupts the visible assistant +/// output or leaves surfaces waiting for a completion signal that already +/// fired. Everything else (`CommandExecutionOutputDelta`, progress, etc.) is +/// best-effort and may be dropped with only cosmetic impact. +/// +/// Both the in-process and remote transports delegate to this function so the +/// classification stays in sync. +pub(crate) fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { + matches!( + notification, + ServerNotification::TurnCompleted(_) + | ServerNotification::ItemCompleted(_) + | ServerNotification::AgentMessageDelta(_) + | ServerNotification::PlanDelta(_) + | ServerNotification::ReasoningSummaryTextDelta(_) + | ServerNotification::ReasoningTextDelta(_) + ) +} + +/// Outcome of attempting to forward a single event to the consumer channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ForwardEventResult { + /// The event was delivered (or intentionally dropped); the stream is healthy. + Continue, + /// The consumer channel is closed; the caller should stop producing events. + DisableStream, +} + +/// Forwards a single in-process event to the consumer, respecting the +/// lossless/best-effort split. +/// +/// Lossless events (transcript deltas, item/turn completions) block until the +/// consumer drains capacity. Best-effort events use `try_send` and increment +/// `skipped_events` on failure. When a lag marker needs to be flushed before a +/// lossless event, the flush itself blocks so the marker is never lost. +/// +/// If a dropped event is a `ServerRequest`, `reject_server_request` is called +/// so the server does not wait for a response that will never come. +async fn forward_in_process_event( + event_tx: &mpsc::Sender, + skipped_events: &mut usize, + event: InProcessServerEvent, + mut reject_server_request: F, +) -> ForwardEventResult +where + F: FnMut(ServerRequest), +{ + if *skipped_events > 0 { + if event_requires_delivery(&event) { + // Surface lag before the lossless event, but do not let the lag marker itself cause + // us to drop the transcript/completion notification the caller is blocked on. + if event_tx + .send(InProcessServerEvent::Lagged { + skipped: *skipped_events, + }) + .await + .is_err() + { + return ForwardEventResult::DisableStream; + } + *skipped_events = 0; + } else { + match event_tx.try_send(InProcessServerEvent::Lagged { + skipped: *skipped_events, + }) { + Ok(()) => { + *skipped_events = 0; + } + Err(mpsc::error::TrySendError::Full(_)) => { + *skipped_events = skipped_events.saturating_add(1); + warn!("dropping in-process app-server event because consumer queue is full"); + if let InProcessServerEvent::ServerRequest(request) = event { + reject_server_request(request); + } + return ForwardEventResult::Continue; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + return ForwardEventResult::DisableStream; + } + } + } + } + + if event_requires_delivery(&event) { + // Block until the consumer catches up for transcript/completion notifications; this + // preserves the visible assistant output even when the queue is otherwise saturated. + if event_tx.send(event).await.is_err() { + return ForwardEventResult::DisableStream; + } + return ForwardEventResult::Continue; + } + + match event_tx.try_send(event) { + Ok(()) => ForwardEventResult::Continue, + Err(mpsc::error::TrySendError::Full(event)) => { + *skipped_events = skipped_events.saturating_add(1); + warn!("dropping in-process app-server event because consumer queue is full"); + if let InProcessServerEvent::ServerRequest(request) = event { + reject_server_request(request); + } + ForwardEventResult::Continue + } + Err(mpsc::error::TrySendError::Closed(_)) => ForwardEventResult::DisableStream, + } +} + +/// Layered error for [`InProcessAppServerClient::request_typed`]. +/// +/// This keeps transport failures, server-side JSON-RPC failures, and response +/// decode failures distinct so callers can decide whether to retry, surface a +/// server error, or treat the response as an internal request/response mismatch. +#[derive(Debug)] +pub enum TypedRequestError { + Transport { + method: String, + source: IoError, + }, + Server { + method: String, + source: JSONRPCErrorError, + }, + Deserialize { + method: String, + source: serde_json::Error, + }, +} + +impl fmt::Display for TypedRequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport { method, source } => { + write!(f, "{method} transport error: {source}") + } + Self::Server { method, source } => { + write!( + f, + "{method} failed: {} (code {})", + source.message, source.code + )?; + if let Some(data) = source.data.as_ref() { + write!(f, ", data: {data}")?; + } + Ok(()) + } + Self::Deserialize { method, source } => { + write!(f, "{method} response decode error: {source}") + } + } + } +} + +impl Error for TypedRequestError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Transport { source, .. } => Some(source), + Self::Server { .. } => None, + Self::Deserialize { source, .. } => Some(source), + } + } +} + +#[derive(Clone)] +pub struct InProcessClientStartArgs { + /// Resolved argv0 dispatch paths used by command execution internals. + pub arg0_paths: Arg0DispatchPaths, + /// Shared config used to initialize app-server runtime. + pub config: Arc, + /// CLI config overrides that are already parsed into TOML values. + pub cli_overrides: Vec<(String, TomlValue)>, + /// Loader override knobs used by config API paths. + pub loader_overrides: LoaderOverrides, + /// Preloaded cloud requirements provider. + pub cloud_requirements: CloudRequirementsLoader, + /// Feedback sink used by app-server/core telemetry and logs. + pub feedback: CodexFeedback, + /// SQLite tracing layer used to flush recently emitted logs before feedback upload. + pub log_db: Option, + /// Process-wide SQLite state handle shared with the embedded app-server. + pub state_db: Option, + /// Environment manager used by core execution and filesystem operations. + pub environment_manager: Arc, + /// Startup warnings emitted after initialize succeeds. + pub config_warnings: Vec, + /// Session source recorded in app-server thread metadata. + pub session_source: SessionSource, + /// Whether auth loading should honor the `CODEX_API_KEY` environment variable. + pub enable_codex_api_key_env: bool, + /// Client name reported during initialize. + pub client_name: String, + /// Client version reported during initialize. + pub client_version: String, + /// Whether experimental APIs are requested at initialize time. + pub experimental_api: bool, + /// Notification methods this client opts out of receiving. + pub opt_out_notification_methods: Vec, + /// Queue capacity for command/event channels (clamped to at least 1). + pub channel_capacity: usize, +} + +fn configured_thread_config_loader(config: &Config) -> Arc { + match config.experimental_thread_config_endpoint.as_deref() { + Some(endpoint) => Arc::new(RemoteThreadConfigLoader::new(endpoint)), + None => Arc::new(NoopThreadConfigLoader), + } +} + +impl InProcessClientStartArgs { + /// Builds initialize params from caller-provided metadata. + pub fn initialize_params(&self) -> InitializeParams { + let capabilities = InitializeCapabilities { + experimental_api: self.experimental_api, + opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { + None + } else { + Some(self.opt_out_notification_methods.clone()) + }, + }; + + InitializeParams { + client_info: ClientInfo { + name: self.client_name.clone(), + title: None, + version: self.client_version.clone(), + }, + capabilities: Some(capabilities), + } + } + + fn into_runtime_start_args(self) -> InProcessStartArgs { + let initialize = self.initialize_params(); + let thread_config_loader = configured_thread_config_loader(&self.config); + InProcessStartArgs { + arg0_paths: self.arg0_paths, + config: self.config, + cli_overrides: self.cli_overrides, + loader_overrides: self.loader_overrides, + cloud_requirements: self.cloud_requirements, + thread_config_loader, + feedback: self.feedback, + log_db: self.log_db, + state_db: self.state_db, + environment_manager: self.environment_manager, + config_warnings: self.config_warnings, + session_source: self.session_source, + enable_codex_api_key_env: self.enable_codex_api_key_env, + initialize, + channel_capacity: self.channel_capacity, + } + } +} + +/// Internal command sent from public facade methods to the worker task. +/// +/// Each variant carries a oneshot sender so the caller can `await` the +/// result without holding a mutable reference to the client. +enum ClientCommand { + Request { + request: Box, + response_tx: oneshot::Sender>, + }, + Notify { + notification: ClientNotification, + response_tx: oneshot::Sender>, + }, + ResolveServerRequest { + request_id: RequestId, + result: JsonRpcResult, + response_tx: oneshot::Sender>, + }, + RejectServerRequest { + request_id: RequestId, + error: JSONRPCErrorError, + response_tx: oneshot::Sender>, + }, + Shutdown { + response_tx: oneshot::Sender>, + }, +} + +/// Async facade over the in-process app-server runtime. +/// +/// This type owns a worker task that bridges between: +/// - caller-facing async `mpsc` channels used by TUI/exec +/// - [`codex_app_server::in_process::InProcessClientHandle`], which speaks to +/// the embedded `MessageProcessor` +/// +/// The facade intentionally preserves the server's request/notification/event +/// model instead of exposing direct core runtime handles. That keeps in-process +/// callers aligned with app-server behavior while still avoiding a process +/// boundary. +pub struct InProcessAppServerClient { + command_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + worker_handle: tokio::task::JoinHandle<()>, +} + +#[derive(Clone)] +pub struct InProcessAppServerRequestHandle { + command_tx: mpsc::Sender, +} + +#[derive(Clone)] +pub enum AppServerRequestHandle { + InProcess(InProcessAppServerRequestHandle), + Remote(crate::remote::RemoteAppServerRequestHandle), +} + +pub enum AppServerClient { + InProcess(InProcessAppServerClient), + Remote(RemoteAppServerClient), +} + +impl InProcessAppServerClient { + /// Starts the in-process runtime and facade worker task. + /// + /// The returned client is ready for requests and event consumption. If the + /// internal event queue is saturated later, server requests are rejected + /// with overload error instead of being silently dropped. + pub async fn start(args: InProcessClientStartArgs) -> IoResult { + let channel_capacity = args.channel_capacity.max(1); + let mut handle = + codex_app_server::in_process::start(args.into_runtime_start_args()).await?; + let request_sender = handle.sender(); + let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); + let (event_tx, event_rx) = mpsc::channel::(channel_capacity); + + let worker_handle = tokio::spawn(async move { + let mut event_stream_enabled = true; + let mut skipped_events = 0usize; + loop { + tokio::select! { + command = command_rx.recv() => { + match command { + Some(ClientCommand::Request { request, response_tx }) => { + let request_sender = request_sender.clone(); + // Request waits happen on a detached task so + // this loop can keep draining runtime events + // while the request is blocked on client input. + tokio::spawn(async move { + let result = request_sender.request(*request).await; + let _ = response_tx.send(result); + }); + } + Some(ClientCommand::Notify { + notification, + response_tx, + }) => { + let result = request_sender.notify(notification); + let _ = response_tx.send(result); + } + Some(ClientCommand::ResolveServerRequest { + request_id, + result, + response_tx, + }) => { + let send_result = + request_sender.respond_to_server_request(request_id, result); + let _ = response_tx.send(send_result); + } + Some(ClientCommand::RejectServerRequest { + request_id, + error, + response_tx, + }) => { + let send_result = request_sender.fail_server_request(request_id, error); + let _ = response_tx.send(send_result); + } + Some(ClientCommand::Shutdown { response_tx }) => { + let shutdown_result = handle.shutdown().await; + let _ = response_tx.send(shutdown_result); + break; + } + None => { + let _ = handle.shutdown().await; + break; + } + } + } + event = handle.next_event(), if event_stream_enabled => { + let Some(event) = event else { + break; + }; + if let InProcessServerEvent::ServerRequest( + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } + ) = &event + { + let send_result = request_sender.fail_server_request( + request_id.clone(), + JSONRPCErrorError { + code: -32000, + message: "chatgpt auth token refresh is not supported for in-process app-server clients".to_string(), + data: None, + }, + ); + if let Err(err) = send_result { + warn!( + "failed to reject unsupported chatgpt auth token refresh request: {err}" + ); + } + continue; + } + + match forward_in_process_event( + &event_tx, + &mut skipped_events, + event, + |request| { + let _ = request_sender.fail_server_request( + request.id().clone(), + JSONRPCErrorError { + code: -32001, + message: "in-process app-server event queue is full" + .to_string(), + data: None, + }, + ); + }, + ) + .await + { + ForwardEventResult::Continue => {} + ForwardEventResult::DisableStream => { + event_stream_enabled = false; + } + } + } + } + } + }); + + Ok(Self { + command_tx, + event_rx, + worker_handle, + }) + } + + pub fn request_handle(&self) -> InProcessAppServerRequestHandle { + InProcessAppServerRequestHandle { + command_tx: self.command_tx.clone(), + } + } + + /// Sends a typed client request and returns raw JSON-RPC result. + /// + /// Callers that expect a concrete response type should usually prefer + /// [`request_typed`](Self::request_typed). + pub async fn request(&self, request: ClientRequest) -> IoResult { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::Request { + request: Box::new(request), + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server request channel is closed", + ) + })? + } + + /// Sends a typed client request and decodes the successful response body. + /// + /// This still deserializes from a JSON value produced by app-server's + /// JSON-RPC result envelope. Because the caller chooses `T`, `Deserialize` + /// failures indicate an internal request/response mismatch at the call site + /// (or an in-process bug), not transport skew from an external client. + pub async fn request_typed(&self, request: ClientRequest) -> Result + where + T: DeserializeOwned, + { + let method = request_method_name(&request); + let response = + self.request(request) + .await + .map_err(|source| TypedRequestError::Transport { + method: method.clone(), + source, + })?; + let result = response.map_err(|source| TypedRequestError::Server { + method: method.clone(), + source, + })?; + serde_json::from_value(result) + .map_err(|source| TypedRequestError::Deserialize { method, source }) + } + + /// Sends a typed client notification. + pub async fn notify(&self, notification: ClientNotification) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::Notify { + notification, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server notify channel is closed", + ) + })? + } + + /// Resolves a pending server request. + /// + /// This should only be called with request IDs obtained from the current + /// client's event stream. + pub async fn resolve_server_request( + &self, + request_id: RequestId, + result: JsonRpcResult, + ) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::ResolveServerRequest { + request_id, + result, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server resolve channel is closed", + ) + })? + } + + /// Rejects a pending server request with JSON-RPC error payload. + pub async fn reject_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::RejectServerRequest { + request_id, + error, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server reject channel is closed", + ) + })? + } + + /// Returns the next in-process event, or `None` when worker exits. + /// + /// Callers are expected to drain this stream promptly. If they fall behind, + /// the worker emits [`InProcessServerEvent::Lagged`] markers and may reject + /// pending server requests rather than letting approval flows hang. + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } + + /// Shuts down worker and in-process runtime with bounded wait. + /// + /// If graceful shutdown exceeds timeout, the worker task is aborted to + /// avoid leaking background tasks in embedding callers. + pub async fn shutdown(self) -> IoResult<()> { + let Self { + command_tx, + event_rx, + worker_handle, + } = self; + let mut worker_handle = worker_handle; + // Drop the caller-facing receiver before asking the worker to shut + // down. That unblocks any pending must-deliver `event_tx.send(..)` + // so the worker can reach `handle.shutdown()` instead of timing out + // and getting aborted with the runtime still attached. + drop(event_rx); + let (response_tx, response_rx) = oneshot::channel(); + if command_tx + .send(ClientCommand::Shutdown { response_tx }) + .await + .is_ok() + && let Ok(command_result) = timeout(SHUTDOWN_TIMEOUT, response_rx).await + { + command_result.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server shutdown channel is closed", + ) + })??; + } + + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut worker_handle).await { + worker_handle.abort(); + let _ = worker_handle.await; + } + Ok(()) + } +} + +impl InProcessAppServerRequestHandle { + pub async fn request(&self, request: ClientRequest) -> IoResult { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::Request { + request: Box::new(request), + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server request channel is closed", + ) + })? + } + + pub async fn request_typed(&self, request: ClientRequest) -> Result + where + T: DeserializeOwned, + { + let method = request_method_name(&request); + let response = + self.request(request) + .await + .map_err(|source| TypedRequestError::Transport { + method: method.clone(), + source, + })?; + let result = response.map_err(|source| TypedRequestError::Server { + method: method.clone(), + source, + })?; + serde_json::from_value(result) + .map_err(|source| TypedRequestError::Deserialize { method, source }) + } +} + +impl AppServerRequestHandle { + pub async fn request(&self, request: ClientRequest) -> IoResult { + match self { + Self::InProcess(handle) => handle.request(request).await, + Self::Remote(handle) => handle.request(request).await, + } + } + + pub async fn request_typed(&self, request: ClientRequest) -> Result + where + T: DeserializeOwned, + { + match self { + Self::InProcess(handle) => handle.request_typed(request).await, + Self::Remote(handle) => handle.request_typed(request).await, + } + } +} + +impl AppServerClient { + pub async fn request(&self, request: ClientRequest) -> IoResult { + match self { + Self::InProcess(client) => client.request(request).await, + Self::Remote(client) => client.request(request).await, + } + } + + pub async fn request_typed(&self, request: ClientRequest) -> Result + where + T: DeserializeOwned, + { + match self { + Self::InProcess(client) => client.request_typed(request).await, + Self::Remote(client) => client.request_typed(request).await, + } + } + + pub async fn notify(&self, notification: ClientNotification) -> IoResult<()> { + match self { + Self::InProcess(client) => client.notify(notification).await, + Self::Remote(client) => client.notify(notification).await, + } + } + + pub async fn resolve_server_request( + &self, + request_id: RequestId, + result: JsonRpcResult, + ) -> IoResult<()> { + match self { + Self::InProcess(client) => client.resolve_server_request(request_id, result).await, + Self::Remote(client) => client.resolve_server_request(request_id, result).await, + } + } + + pub async fn reject_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + match self { + Self::InProcess(client) => client.reject_server_request(request_id, error).await, + Self::Remote(client) => client.reject_server_request(request_id, error).await, + } + } + + pub async fn next_event(&mut self) -> Option { + match self { + Self::InProcess(client) => client.next_event().await.map(Into::into), + Self::Remote(client) => client.next_event().await, + } + } + + pub async fn shutdown(self) -> IoResult<()> { + match self { + Self::InProcess(client) => client.shutdown().await, + Self::Remote(client) => client.shutdown().await, + } + } + + pub fn request_handle(&self) -> AppServerRequestHandle { + match self { + Self::InProcess(client) => AppServerRequestHandle::InProcess(client.request_handle()), + Self::Remote(client) => AppServerRequestHandle::Remote(client.request_handle()), + } + } +} + +/// Extracts the JSON-RPC method name for diagnostics without extending the +/// protocol crate with in-process-only helpers. +pub(crate) fn request_method_name(request: &ClientRequest) -> String { + serde_json::to_value(request) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::AccountUpdatedNotification; + use codex_app_server_protocol::ConfigRequirementsReadResponse; + use codex_app_server_protocol::GetAccountResponse; + use codex_app_server_protocol::JSONRPCMessage; + use codex_app_server_protocol::JSONRPCRequest; + use codex_app_server_protocol::JSONRPCResponse; + use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::SessionSource as ApiSessionSource; + use codex_app_server_protocol::ThreadStartParams; + use codex_app_server_protocol::ThreadStartResponse; + use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_app_server_protocol::ToolRequestUserInputQuestion; + use codex_core::config::ConfigBuilder; + use codex_core::init_state_db; + use futures::SinkExt; + use futures::StreamExt; + use pretty_assertions::assert_eq; + use std::ops::Deref; + use std::path::Path; + use tempfile::TempDir; + use tokio::net::TcpListener; + use tokio::time::Duration; + use tokio::time::timeout; + use tokio_tungstenite::accept_hdr_async; + use tokio_tungstenite::tungstenite::Message; + use tokio_tungstenite::tungstenite::handshake::server::Request as WebSocketRequest; + use tokio_tungstenite::tungstenite::handshake::server::Response as WebSocketResponse; + use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; + + async fn build_test_config() -> Config { + match ConfigBuilder::default().build().await { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides(Vec::new()) + .await + .expect("default config should load"), + } + } + + async fn build_test_config_for_codex_home(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), + } + } + + struct TestClient { + _codex_home: TempDir, + client: InProcessAppServerClient, + } + + impl Deref for TestClient { + type Target = InProcessAppServerClient; + + fn deref(&self) -> &Self::Target { + &self.client + } + } + + impl TestClient { + async fn shutdown(self) -> IoResult<()> { + self.client.shutdown().await + } + } + + async fn start_test_client_with_capacity( + session_source: SessionSource, + channel_capacity: usize, + ) -> TestClient { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config_for_codex_home(codex_home.path()).await); + let state_db = init_state_db(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); + let client = InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config, + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + log_db: None, + state_db: Some(state_db), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity, + }) + .await + .expect("in-process app-server client should start"); + + TestClient { + _codex_home: codex_home, + client, + } + } + + async fn start_test_client(session_source: SessionSource) -> TestClient { + start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await + } + + async fn start_test_remote_server(handler: F) -> String + where + F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + + Send + + 'static, + Fut: std::future::Future + Send + 'static, + { + start_test_remote_server_with_auth(/*expected_auth_token*/ None, handler).await + } + + async fn start_test_remote_server_with_auth( + expected_auth_token: Option, + handler: F, + ) -> String + where + F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + + Send + + 'static, + Fut: std::future::Future + Send + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let addr = listener.local_addr().expect("listener address"); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept should succeed"); + let websocket = accept_hdr_async( + stream, + move |request: &WebSocketRequest, response: WebSocketResponse| { + let provided_auth_token = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let expected_auth_token = expected_auth_token + .as_ref() + .map(|token| format!("Bearer {token}")); + assert_eq!(provided_auth_token, expected_auth_token); + Ok(response) + }, + ) + .await + .expect("websocket upgrade should succeed"); + handler(websocket).await; + }); + format!("ws://{addr}") + } + + async fn expect_remote_initialize( + websocket: &mut tokio_tungstenite::WebSocketStream, + ) { + let JSONRPCMessage::Request(request) = read_websocket_message(websocket).await else { + panic!("expected initialize request"); + }; + assert_eq!(request.method, "initialize"); + write_websocket_message( + websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::json!({}), + }), + ) + .await; + + let JSONRPCMessage::Notification(notification) = read_websocket_message(websocket).await + else { + panic!("expected initialized notification"); + }; + assert_eq!(notification.method, "initialized"); + } + + async fn read_websocket_message( + websocket: &mut tokio_tungstenite::WebSocketStream, + ) -> JSONRPCMessage { + loop { + let frame = websocket + .next() + .await + .expect("frame should be available") + .expect("frame should decode"); + match frame { + Message::Text(text) => { + return serde_json::from_str::(&text) + .expect("text frame should be valid JSON-RPC"); + } + Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => { + continue; + } + Message::Close(_) => panic!("unexpected close frame"), + } + } + } + + async fn write_websocket_message( + websocket: &mut tokio_tungstenite::WebSocketStream, + message: JSONRPCMessage, + ) { + websocket + .send(Message::Text( + serde_json::to_string(&message) + .expect("message should serialize") + .into(), + )) + .await + .expect("message should send"); + } + + fn command_execution_output_delta_notification(delta: &str) -> ServerNotification { + ServerNotification::CommandExecutionOutputDelta( + codex_app_server_protocol::CommandExecutionOutputDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: delta.to_string(), + }, + ) + } + + fn agent_message_delta_notification(delta: &str) -> ServerNotification { + ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: delta.to_string(), + }, + ) + } + + fn item_completed_notification(text: &str) -> ServerNotification { + ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + completed_at_ms: 0, + item: codex_app_server_protocol::ThreadItem::AgentMessage { + id: "item".to_string(), + text: text.to_string(), + phase: None, + memory_citation: None, + }, + }) + } + + fn turn_completed_notification() -> ServerNotification { + ServerNotification::TurnCompleted(codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), + }, + }) + } + + fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs { + RemoteAppServerConnectArgs { + websocket_url, + auth_token: None, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, + } + } + + #[tokio::test] + async fn typed_request_roundtrip_works() { + let client = start_test_client(SessionSource::Exec).await; + let _response: ConfigRequirementsReadResponse = client + .request_typed(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(1), + params: None, + }) + .await + .expect("typed request should succeed"); + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn typed_request_reports_json_rpc_errors() { + let client = start_test_client(SessionSource::Exec).await; + let err = client + .request_typed::(ClientRequest::ThreadRead { + request_id: RequestId::Integer(99), + params: codex_app_server_protocol::ThreadReadParams { + thread_id: "missing-thread".to_string(), + include_turns: false, + }, + }) + .await + .expect_err("missing thread should return a JSON-RPC error"); + assert!( + err.to_string().starts_with("thread/read failed:"), + "expected method-qualified JSON-RPC failure message" + ); + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn caller_provided_session_source_is_applied() { + for (session_source, expected_source) in [ + (SessionSource::Exec, ApiSessionSource::Exec), + (SessionSource::Cli, ApiSessionSource::Cli), + ] { + let client = start_test_client(session_source).await; + let parsed: ThreadStartResponse = client + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::Integer(2), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }) + .await + .expect("thread/start should succeed"); + assert_eq!(parsed.thread.source, expected_source); + client.shutdown().await.expect("shutdown should complete"); + } + } + + #[tokio::test] + async fn threads_started_via_app_server_are_visible_through_typed_requests() { + let client = start_test_client(SessionSource::Cli).await; + + let response: ThreadStartResponse = client + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::Integer(3), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }) + .await + .expect("thread/start should succeed"); + let read = client + .request_typed::( + ClientRequest::ThreadRead { + request_id: RequestId::Integer(4), + params: codex_app_server_protocol::ThreadReadParams { + thread_id: response.thread.id.clone(), + include_turns: false, + }, + }, + ) + .await + .expect("thread/read should return the newly started thread"); + assert_eq!(read.thread.id, response.thread.id); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn tiny_channel_capacity_still_supports_request_roundtrip() { + let client = + start_test_client_with_capacity(SessionSource::Exec, /*channel_capacity*/ 1).await; + let _response: ConfigRequirementsReadResponse = client + .request_typed(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(1), + params: None, + }) + .await + .expect("typed request should succeed"); + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn forward_in_process_event_preserves_transcript_notifications_under_backpressure() { + let (event_tx, mut event_rx) = mpsc::channel(1); + event_tx + .send(InProcessServerEvent::ServerNotification( + command_execution_output_delta_notification("stdout-1"), + )) + .await + .expect("initial event should enqueue"); + + let mut skipped_events = 0usize; + let result = forward_in_process_event( + &event_tx, + &mut skipped_events, + InProcessServerEvent::ServerNotification(command_execution_output_delta_notification( + "stdout-2", + )), + |_| {}, + ) + .await; + assert_eq!(result, ForwardEventResult::Continue); + assert_eq!(skipped_events, 1); + + let receive_task = tokio::spawn(async move { + let mut events = Vec::new(); + for _ in 0..5 { + events.push( + timeout(Duration::from_secs(2), event_rx.recv()) + .await + .expect("event should arrive before timeout") + .expect("event stream should stay open"), + ); + } + events + }); + + for notification in [ + agent_message_delta_notification("hello"), + item_completed_notification("hello"), + turn_completed_notification(), + ] { + let result = forward_in_process_event( + &event_tx, + &mut skipped_events, + InProcessServerEvent::ServerNotification(notification), + |_| {}, + ) + .await; + assert_eq!(result, ForwardEventResult::Continue); + } + assert_eq!(skipped_events, 0); + + let events = receive_task + .await + .expect("receiver task should join successfully"); + assert!(matches!( + &events[0], + InProcessServerEvent::ServerNotification( + ServerNotification::CommandExecutionOutputDelta(notification) + ) if notification.delta == "stdout-1" + )); + assert!(matches!( + &events[1], + InProcessServerEvent::Lagged { skipped: 1 } + )); + assert!(matches!( + &events[2], + InProcessServerEvent::ServerNotification(ServerNotification::AgentMessageDelta( + notification + )) if notification.delta == "hello" + )); + assert!(matches!( + &events[3], + InProcessServerEvent::ServerNotification(ServerNotification::ItemCompleted( + notification + )) if matches!( + ¬ification.item, + codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } if text == "hello" + ) + )); + assert!(matches!( + &events[4], + InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted( + notification + )) if notification.turn.status == codex_app_server_protocol::TurnStatus::Completed + )); + } + + #[tokio::test] + async fn remote_typed_request_roundtrip_works() { + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await + else { + panic!("expected account/read request"); + }; + assert_eq!(request.method, "account/read"); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::to_value(GetAccountResponse { + account: None, + requires_openai_auth: false, + }) + .expect("response should serialize"), + }), + ) + .await; + websocket.close(None).await.expect("close should succeed"); + }) + .await; + let client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + + let response: GetAccountResponse = client + .request_typed(ClientRequest::GetAccount { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::GetAccountParams { + refresh_token: false, + }, + }) + .await + .expect("typed request should succeed"); + assert_eq!(response.account, None); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_typed_request_accepts_large_single_frame_response() { + let padding = "x".repeat((17 << 20) + 1024); + let websocket_url = start_test_remote_server(move |mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await + else { + panic!("expected account/read request"); + }; + assert_eq!(request.method, "account/read"); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::json!({ + "account": null, + "requiresOpenaiAuth": false, + "padding": padding, + }), + }), + ) + .await; + websocket.close(None).await.expect("close should succeed"); + }) + .await; + let client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + + let response: GetAccountResponse = client + .request_typed(ClientRequest::GetAccount { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::GetAccountParams { + refresh_token: false, + }, + }) + .await + .expect("large typed request should succeed"); + assert_eq!( + response, + GetAccountResponse { + account: None, + requires_openai_auth: false, + } + ); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_connect_includes_auth_header_when_configured() { + let auth_token = "remote-bearer-token".to_string(); + let websocket_url = start_test_remote_server_with_auth( + Some(auth_token.clone()), + |mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + websocket.close(None).await.expect("close should succeed"); + }, + ) + .await; + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + auth_token: Some(auth_token), + ..test_remote_connect_args(websocket_url) + }) + .await + .expect("remote client should connect"); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() { + let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url: "ws://example.com:4500".to_string(), + auth_token: Some("remote-bearer-token".to_string()), + ..test_remote_connect_args("ws://127.0.0.1:1".to_string()) + }) + .await; + let err = match result { + Ok(_) => panic!("non-loopback ws should be rejected before connect"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("remote auth tokens require `wss://` or loopback `ws://` URLs") + ); + } + + #[test] + fn remote_auth_token_transport_policy_allows_wss_and_loopback_ws() { + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("wss://example.com:443").expect("wss URL should parse") + )); + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://127.0.0.1:4500").expect("loopback ws URL should parse") + )); + assert!(!crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://example.com:4500").expect("non-loopback ws URL should parse") + )); + } + + #[tokio::test] + async fn remote_duplicate_request_id_keeps_original_waiter() { + let (first_request_seen_tx, first_request_seen_rx) = tokio::sync::oneshot::channel(); + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await + else { + panic!("expected account/read request"); + }; + assert_eq!(request.method, "account/read"); + first_request_seen_tx + .send(request.id.clone()) + .expect("request id should send"); + assert!( + timeout( + Duration::from_millis(100), + read_websocket_message(&mut websocket) + ) + .await + .is_err(), + "duplicate request should not be forwarded to the server" + ); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::to_value(GetAccountResponse { + account: None, + requires_openai_auth: false, + }) + .expect("response should serialize"), + }), + ) + .await; + let _ = websocket.next().await; + }) + .await; + let client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + let first_request_handle = client.request_handle(); + let second_request_handle = first_request_handle.clone(); + + let first_request = tokio::spawn(async move { + first_request_handle + .request_typed::(ClientRequest::GetAccount { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::GetAccountParams { + refresh_token: false, + }, + }) + .await + }); + + let first_request_id = first_request_seen_rx + .await + .expect("server should observe the first request"); + assert_eq!(first_request_id, RequestId::Integer(1)); + + let second_err = second_request_handle + .request_typed::(ClientRequest::GetAccount { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::GetAccountParams { + refresh_token: false, + }, + }) + .await + .expect_err("duplicate request id should be rejected"); + assert_eq!( + second_err.to_string(), + "account/read transport error: duplicate remote app-server request id `1`" + ); + + let first_response = first_request + .await + .expect("first request task should join") + .expect("first request should succeed"); + assert_eq!( + first_response, + GetAccountResponse { + account: None, + requires_openai_auth: false, + } + ); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_notifications_arrive_over_websocket() { + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + write_websocket_message( + &mut websocket, + JSONRPCMessage::Notification( + serde_json::from_value( + serde_json::to_value(ServerNotification::AccountUpdated( + AccountUpdatedNotification { + auth_mode: None, + plan_type: None, + }, + )) + .expect("notification should serialize"), + ) + .expect("notification should convert to JSON-RPC"), + ), + ) + .await; + }) + .await; + let mut client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + + let event = client.next_event().await.expect("event should arrive"); + assert!(matches!( + event, + AppServerEvent::ServerNotification(ServerNotification::AccountUpdated(_)) + )); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_backpressure_preserves_transcript_notifications() { + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + for notification in [ + command_execution_output_delta_notification("stdout-1"), + command_execution_output_delta_notification("stdout-2"), + agent_message_delta_notification("hello"), + item_completed_notification("hello"), + turn_completed_notification(), + ] { + write_websocket_message( + &mut websocket, + JSONRPCMessage::Notification( + serde_json::from_value( + serde_json::to_value(notification) + .expect("notification should serialize"), + ) + .expect("notification should convert to JSON-RPC"), + ), + ) + .await; + } + let _ = done_rx.await; + }) + .await; + let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url, + auth_token: None, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 1, + }) + .await + .expect("remote client should connect"); + + let first_event = timeout(Duration::from_secs(2), client.next_event()) + .await + .expect("first event should arrive before timeout") + .expect("event stream should stay open"); + assert!(matches!( + first_event, + AppServerEvent::ServerNotification(ServerNotification::CommandExecutionOutputDelta( + notification + )) if notification.delta == "stdout-1" + )); + + let mut remaining_events = Vec::new(); + for _ in 0..4 { + remaining_events.push( + timeout(Duration::from_secs(2), client.next_event()) + .await + .expect("event should arrive before timeout") + .expect("event stream should stay open"), + ); + } + + let mut transcript_event_names = Vec::new(); + for event in &remaining_events { + match event { + AppServerEvent::Lagged { skipped: 1 } => {} + AppServerEvent::ServerNotification( + ServerNotification::CommandExecutionOutputDelta(notification), + ) if notification.delta == "stdout-2" => {} + AppServerEvent::ServerNotification(ServerNotification::AgentMessageDelta( + notification, + )) if notification.delta == "hello" => { + transcript_event_names.push("agent_message_delta"); + } + AppServerEvent::ServerNotification(ServerNotification::ItemCompleted( + notification, + )) if matches!( + ¬ification.item, + codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } if text == "hello" + ) => + { + transcript_event_names.push("item_completed"); + } + AppServerEvent::ServerNotification(ServerNotification::TurnCompleted( + notification, + )) if notification.turn.status + == codex_app_server_protocol::TurnStatus::Completed => + { + transcript_event_names.push("turn_completed"); + } + _ => panic!("unexpected remaining event: {event:?}"), + } + } + assert_eq!( + transcript_event_names, + vec!["agent_message_delta", "item_completed", "turn_completed"] + ); + + done_tx + .send(()) + .expect("server completion signal should send"); + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_server_request_resolution_roundtrip_works() { + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + let request_id = RequestId::String("srv-1".to_string()); + let server_request = JSONRPCRequest { + id: request_id.clone(), + method: "item/tool/requestUserInput".to_string(), + params: Some( + serde_json::to_value(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "question-1".to_string(), + header: "Mode".to_string(), + question: "Pick one".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![]), + }], + }) + .expect("params should serialize"), + ), + trace: None, + }; + write_websocket_message(&mut websocket, JSONRPCMessage::Request(server_request)).await; + + let JSONRPCMessage::Response(response) = read_websocket_message(&mut websocket).await + else { + panic!("expected server request response"); + }; + assert_eq!(response.id, request_id); + }) + .await; + let mut client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + + let AppServerEvent::ServerRequest(request) = client + .next_event() + .await + .expect("request event should arrive") + else { + panic!("expected server request event"); + }; + client + .resolve_server_request(request.id().clone(), serde_json::json!({})) + .await + .expect("server request should resolve"); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_server_request_received_during_initialize_is_delivered() { + let websocket_url = start_test_remote_server(|mut websocket| async move { + let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await + else { + panic!("expected initialize request"); + }; + assert_eq!(request.method, "initialize"); + + let request_id = RequestId::String("srv-init".to_string()); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Request(JSONRPCRequest { + id: request_id.clone(), + method: "item/tool/requestUserInput".to_string(), + params: Some( + serde_json::to_value(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "question-1".to_string(), + header: "Mode".to_string(), + question: "Pick one".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![]), + }], + }) + .expect("params should serialize"), + ), + trace: None, + }), + ) + .await; + write_websocket_message( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::json!({}), + }), + ) + .await; + + let JSONRPCMessage::Notification(notification) = + read_websocket_message(&mut websocket).await + else { + panic!("expected initialized notification"); + }; + assert_eq!(notification.method, "initialized"); + + let JSONRPCMessage::Response(response) = read_websocket_message(&mut websocket).await + else { + panic!("expected server request response"); + }; + assert_eq!(response.id, request_id); + }) + .await; + let mut client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + + let AppServerEvent::ServerRequest(request) = client + .next_event() + .await + .expect("request event should arrive") + else { + panic!("expected server request event"); + }; + client + .resolve_server_request(request.id().clone(), serde_json::json!({})) + .await + .expect("server request should resolve"); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_unknown_server_request_is_rejected() { + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + let request_id = RequestId::String("srv-unknown".to_string()); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Request(JSONRPCRequest { + id: request_id.clone(), + method: "thread/unknown".to_string(), + params: None, + trace: None, + }), + ) + .await; + + let JSONRPCMessage::Error(response) = read_websocket_message(&mut websocket).await + else { + panic!("expected JSON-RPC error response"); + }; + assert_eq!(response.id, request_id); + assert_eq!(response.error.code, -32601); + assert_eq!( + response.error.message, + "unsupported remote app-server request `thread/unknown`" + ); + }) + .await; + let client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_disconnect_surfaces_as_event() { + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + websocket.close(None).await.expect("close should succeed"); + }) + .await; + let mut client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"); + + let event = client + .next_event() + .await + .expect("disconnect event should arrive"); + assert!(matches!(event, AppServerEvent::Disconnected { .. })); + } + + #[test] + fn typed_request_error_exposes_sources() { + let transport = TypedRequestError::Transport { + method: "config/read".to_string(), + source: IoError::new(ErrorKind::BrokenPipe, "closed"), + }; + assert_eq!(std::error::Error::source(&transport).is_some(), true); + + let server = TypedRequestError::Server { + method: "thread/read".to_string(), + source: JSONRPCErrorError { + code: -32603, + data: Some(serde_json::json!({"detail": "config lock mismatch"})), + message: "internal".to_string(), + }, + }; + assert_eq!(std::error::Error::source(&server).is_some(), false); + assert_eq!( + server.to_string(), + "thread/read failed: internal (code -32603), data: {\"detail\":\"config lock mismatch\"}" + ); + + let deserialize = TypedRequestError::Deserialize { + method: "thread/start".to_string(), + source: serde_json::from_str::("\"nope\"") + .expect_err("invalid integer should return deserialize error"), + }; + assert_eq!(std::error::Error::source(&deserialize).is_some(), true); + } + + #[tokio::test] + async fn next_event_surfaces_lagged_markers() { + let (command_tx, _command_rx) = mpsc::channel(1); + let (event_tx, event_rx) = mpsc::channel(1); + let worker_handle = tokio::spawn(async {}); + event_tx + .send(InProcessServerEvent::Lagged { skipped: 3 }) + .await + .expect("lagged marker should enqueue"); + drop(event_tx); + + let mut client = InProcessAppServerClient { + command_tx, + event_rx, + worker_handle, + }; + + let event = timeout(Duration::from_secs(2), client.next_event()) + .await + .expect("lagged marker should arrive before timeout"); + assert!(matches!( + event, + Some(InProcessServerEvent::Lagged { skipped: 3 }) + )); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[test] + fn event_requires_delivery_marks_transcript_and_terminal_events() { + assert!(event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, + }, + } + ) + ) + )); + assert!(event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: "hello".to_string(), + } + ) + ) + )); + assert!(event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + completed_at_ms: 0, + item: codex_app_server_protocol::ThreadItem::AgentMessage { + id: "item".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + } + ) + ) + )); + assert!(!event_requires_delivery(&InProcessServerEvent::Lagged { + skipped: 1 + })); + assert!(!event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::CommandExecutionOutputDelta( + codex_app_server_protocol::CommandExecutionOutputDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: "stdout".to_string(), + } + ) + ) + )); + } + + #[tokio::test] + async fn runtime_start_args_forward_environment_manager() { + let config = Arc::new(build_test_config().await); + let environment_manager = Arc::new( + EnvironmentManager::create_for_tests( + Some("ws://127.0.0.1:8765".to_string()), + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), + ) + .await, + ); + + let runtime_args = InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: config.clone(), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: environment_manager.clone(), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + } + .into_runtime_start_args(); + + assert_eq!(runtime_args.config, config); + assert!(Arc::ptr_eq( + &runtime_args.environment_manager, + &environment_manager + )); + assert!( + runtime_args + .environment_manager + .default_environment() + .expect("default environment") + .is_remote() + ); + } + + #[tokio::test] + async fn runtime_start_args_use_remote_thread_config_loader_when_configured() { + let mut config = build_test_config().await; + config.experimental_thread_config_endpoint = Some("not-a-valid-endpoint".to_string()); + + let runtime_args = InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + } + .into_runtime_start_args(); + + let err = runtime_args + .thread_config_loader + .load(Default::default()) + .await + .expect_err("configured remote loader should try to connect"); + assert_eq!( + err.code(), + codex_config::ThreadConfigLoadErrorCode::RequestFailed + ); + } + + #[tokio::test] + async fn shutdown_completes_promptly_without_retained_managers() { + let client = start_test_client(SessionSource::Cli).await; + + timeout(Duration::from_secs(1), client.shutdown()) + .await + .expect("shutdown should not wait for the 5s fallback timeout") + .expect("shutdown should complete"); + } +} diff --git a/code-rs/app-server-client/src/remote.rs b/code-rs/app-server-client/src/remote.rs new file mode 100644 index 00000000000..d75534c1604 --- /dev/null +++ b/code-rs/app-server-client/src/remote.rs @@ -0,0 +1,890 @@ +/* +This module implements the websocket-backed app-server client transport. + +It owns the remote connection lifecycle, including the initialize/initialized +handshake, JSON-RPC request/response routing, server-request resolution, and +notification streaming. The rest of the crate uses the same `AppServerEvent` +surface for both in-process and remote transports, so callers such as the TUI +can switch between them without changing their higher-level session logic. +*/ + +use std::collections::HashMap; +use std::collections::VecDeque; +use std::io::Error as IoError; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::time::Duration; + +use crate::AppServerEvent; +use crate::RequestResult; +use crate::SHUTDOWN_TIMEOUT; +use crate::TypedRequestError; +use crate::request_method_name; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result as JsonRpcResult; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use futures::SinkExt; +use futures::StreamExt; +use serde::de::DeserializeOwned; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::timeout; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::connect_async_with_config; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use tracing::warn; +use url::Url; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE: usize = 128 << 20; + +#[derive(Debug, Clone)] +pub struct RemoteAppServerConnectArgs { + pub websocket_url: String, + pub auth_token: Option, + pub client_name: String, + pub client_version: String, + pub experimental_api: bool, + pub opt_out_notification_methods: Vec, + pub channel_capacity: usize, +} + +impl RemoteAppServerConnectArgs { + fn initialize_params(&self) -> InitializeParams { + let capabilities = InitializeCapabilities { + experimental_api: self.experimental_api, + opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { + None + } else { + Some(self.opt_out_notification_methods.clone()) + }, + }; + + InitializeParams { + client_info: ClientInfo { + name: self.client_name.clone(), + title: None, + version: self.client_version.clone(), + }, + capabilities: Some(capabilities), + } + } +} + +pub(crate) fn websocket_url_supports_auth_token(url: &Url) -> bool { + match (url.scheme(), url.host()) { + ("wss", Some(_)) => true, + ("ws", Some(url::Host::Domain(domain))) => domain.eq_ignore_ascii_case("localhost"), + ("ws", Some(url::Host::Ipv4(addr))) => addr.is_loopback(), + ("ws", Some(url::Host::Ipv6(addr))) => addr.is_loopback(), + _ => false, + } +} + +enum RemoteClientCommand { + Request { + request: Box, + response_tx: oneshot::Sender>, + }, + Notify { + notification: ClientNotification, + response_tx: oneshot::Sender>, + }, + ResolveServerRequest { + request_id: RequestId, + result: JsonRpcResult, + response_tx: oneshot::Sender>, + }, + RejectServerRequest { + request_id: RequestId, + error: JSONRPCErrorError, + response_tx: oneshot::Sender>, + }, + Shutdown { + response_tx: oneshot::Sender>, + }, +} + +pub struct RemoteAppServerClient { + command_tx: mpsc::Sender, + event_rx: mpsc::UnboundedReceiver, + pending_events: VecDeque, + worker_handle: tokio::task::JoinHandle<()>, +} + +#[derive(Clone)] +pub struct RemoteAppServerRequestHandle { + command_tx: mpsc::Sender, +} + +impl RemoteAppServerClient { + pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult { + let channel_capacity = args.channel_capacity.max(1); + let websocket_url = args.websocket_url.clone(); + let url = Url::parse(&websocket_url).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if args.auth_token.is_some() && !websocket_url_supports_auth_token(&url) { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!( + "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" + ), + )); + } + let mut request = url.as_str().into_client_request().map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if let Some(auth_token) = args.auth_token.as_deref() { + let header_value = + HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid remote authorization header value: {err}"), + ) + })?; + request.headers_mut().insert(AUTHORIZATION, header_value); + } + ensure_rustls_crypto_provider(); + // Remote resume responses can legitimately carry large thread histories. + // Keep a bounded cap, but raise it above tungstenite's 16 MiB frame default. + let websocket_config = WebSocketConfig::default() + .max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) + .max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)); + let stream = timeout( + CONNECT_TIMEOUT, + connect_async_with_config( + request, + Some(websocket_config), + /*disable_nagle*/ false, + ), + ) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out connecting to remote app server at `{websocket_url}`"), + ) + })? + .map(|(stream, _response)| stream) + .map_err(|err| { + IoError::other(format!( + "failed to connect to remote app server at `{websocket_url}`: {err}" + )) + })?; + let mut stream = stream; + let pending_events = initialize_remote_connection( + &mut stream, + &websocket_url, + args.initialize_params(), + INITIALIZE_TIMEOUT, + ) + .await?; + + let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + let worker_handle = tokio::spawn(async move { + let mut pending_requests = + HashMap::>>::new(); + let mut worker_exit_error: Option<(ErrorKind, String)> = None; + loop { + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { + let _ = stream.close(None).await; + break; + }; + match command { + RemoteClientCommand::Request { request, response_tx } => { + let request_id = request_id_from_client_request(&request); + if pending_requests.contains_key(&request_id) { + let _ = response_tx.send(Err(IoError::new( + ErrorKind::InvalidInput, + format!("duplicate remote app-server request id `{request_id}`"), + ))); + continue; + } + pending_requests.insert(request_id.clone(), response_tx); + if let Err(err) = write_jsonrpc_message( + &mut stream, + JSONRPCMessage::Request(jsonrpc_request_from_client_request(*request)), + &websocket_url, + ) + .await + { + let err_message = err.to_string(); + let message = format!( + "remote app server at `{websocket_url}` write failed: {err_message}" + ); + if let Some(response_tx) = pending_requests.remove(&request_id) { + let _ = response_tx.send(Err(err)); + } + let _ = deliver_event( + &event_tx, + AppServerEvent::Disconnected { + message: message.clone(), + }, + ); + worker_exit_error = Some((ErrorKind::BrokenPipe, message)); + break; + } + } + RemoteClientCommand::Notify { notification, response_tx } => { + let result = write_jsonrpc_message( + &mut stream, + JSONRPCMessage::Notification( + jsonrpc_notification_from_client_notification(notification), + ), + &websocket_url, + ) + .await; + let _ = response_tx.send(result); + } + RemoteClientCommand::ResolveServerRequest { + request_id, + result, + response_tx, + } => { + let result = write_jsonrpc_message( + &mut stream, + JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result, + }), + &websocket_url, + ) + .await; + let _ = response_tx.send(result); + } + RemoteClientCommand::RejectServerRequest { + request_id, + error, + response_tx, + } => { + let result = write_jsonrpc_message( + &mut stream, + JSONRPCMessage::Error(JSONRPCError { + error, + id: request_id, + }), + &websocket_url, + ) + .await; + let _ = response_tx.send(result); + } + RemoteClientCommand::Shutdown { response_tx } => { + let close_result = stream.close(None).await.map_err(|err| { + IoError::other(format!( + "failed to close websocket app server `{websocket_url}`: {err}" + )) + }); + let _ = response_tx.send(close_result); + break; + } + } + } + message = stream.next() => { + match message { + Some(Ok(Message::Text(text))) => { + match serde_json::from_str::(&text) { + Ok(JSONRPCMessage::Response(response)) => { + if let Some(response_tx) = pending_requests.remove(&response.id) { + let _ = response_tx.send(Ok(Ok(response.result))); + } + } + Ok(JSONRPCMessage::Error(error)) => { + if let Some(response_tx) = pending_requests.remove(&error.id) { + let _ = response_tx.send(Ok(Err(error.error))); + } + } + Ok(JSONRPCMessage::Notification(notification)) => { + if let Some(event) = + app_server_event_from_notification(notification) + && let Err(err) = deliver_event( + &event_tx, + event, + ) + { + warn!(%err, "failed to deliver remote app-server event"); + break; + } + } + Ok(JSONRPCMessage::Request(request)) => { + let request_id = request.id.clone(); + let method = request.method.clone(); + match ServerRequest::try_from(request) { + Ok(request) => { + if let Err(err) = deliver_event( + &event_tx, + AppServerEvent::ServerRequest(request), + ) + { + warn!(%err, "failed to deliver remote app-server server request"); + break; + } + } + Err(err) => { + warn!(%err, method, "rejecting unknown remote app-server request"); + if let Err(reject_err) = write_jsonrpc_message( + &mut stream, + JSONRPCMessage::Error(JSONRPCError { + error: JSONRPCErrorError { + code: -32601, + message: format!( + "unsupported remote app-server request `{method}`" + ), + data: None, + }, + id: request_id, + }), + &websocket_url, + ) + .await + { + let err_message = reject_err.to_string(); + let message = format!( + "remote app server at `{websocket_url}` write failed: {err_message}" + ); + let _ = deliver_event( + &event_tx, + AppServerEvent::Disconnected { + message: message.clone(), + }, + ); + worker_exit_error = + Some((ErrorKind::BrokenPipe, message)); + break; + } + } + } + } + Err(err) => { + let message = format!( + "remote app server at `{websocket_url}` sent invalid JSON-RPC: {err}" + ); + let _ = deliver_event( + &event_tx, + AppServerEvent::Disconnected { + message: message.clone(), + }, + ); + worker_exit_error = + Some((ErrorKind::InvalidData, message)); + break; + } + } + } + Some(Ok(Message::Close(frame))) => { + let reason = frame + .as_ref() + .map(|frame| frame.reason.to_string()) + .filter(|reason| !reason.is_empty()) + .unwrap_or_else(|| "connection closed".to_string()); + let message = format!( + "remote app server at `{websocket_url}` disconnected: {reason}" + ); + let _ = deliver_event( + &event_tx, + AppServerEvent::Disconnected { + message: message.clone(), + }, + ); + worker_exit_error = Some(( + ErrorKind::ConnectionAborted, + message, + )); + break; + } + Some(Ok(Message::Binary(_))) + | Some(Ok(Message::Ping(_))) + | Some(Ok(Message::Pong(_))) + | Some(Ok(Message::Frame(_))) => {} + Some(Err(err)) => { + let message = format!( + "remote app server at `{websocket_url}` transport failed: {err}" + ); + let _ = deliver_event( + &event_tx, + AppServerEvent::Disconnected { + message: message.clone(), + }, + ); + worker_exit_error = Some((ErrorKind::InvalidData, message)); + break; + } + None => { + let message = format!( + "remote app server at `{websocket_url}` closed the connection" + ); + let _ = deliver_event( + &event_tx, + AppServerEvent::Disconnected { + message: message.clone(), + }, + ); + worker_exit_error = Some((ErrorKind::UnexpectedEof, message)); + break; + } + } + } + } + } + + let (err_kind, err_message) = worker_exit_error.unwrap_or_else(|| { + ( + ErrorKind::BrokenPipe, + "remote app-server worker channel is closed".to_string(), + ) + }); + for (_, response_tx) in pending_requests { + let _ = response_tx.send(Err(IoError::new(err_kind, err_message.clone()))); + } + }); + + Ok(Self { + command_tx, + event_rx, + pending_events: pending_events.into(), + worker_handle, + }) + } + + pub fn request_handle(&self) -> RemoteAppServerRequestHandle { + RemoteAppServerRequestHandle { + command_tx: self.command_tx.clone(), + } + } + + pub async fn request(&self, request: ClientRequest) -> IoResult { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(RemoteClientCommand::Request { + request: Box::new(request), + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server request channel is closed", + ) + })? + } + + pub async fn request_typed(&self, request: ClientRequest) -> Result + where + T: DeserializeOwned, + { + let method = request_method_name(&request); + let response = + self.request(request) + .await + .map_err(|source| TypedRequestError::Transport { + method: method.clone(), + source, + })?; + let result = response.map_err(|source| TypedRequestError::Server { + method: method.clone(), + source, + })?; + serde_json::from_value(result) + .map_err(|source| TypedRequestError::Deserialize { method, source }) + } + + pub async fn notify(&self, notification: ClientNotification) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(RemoteClientCommand::Notify { + notification, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server notify channel is closed", + ) + })? + } + + pub async fn resolve_server_request( + &self, + request_id: RequestId, + result: JsonRpcResult, + ) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(RemoteClientCommand::ResolveServerRequest { + request_id, + result, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server resolve channel is closed", + ) + })? + } + + pub async fn reject_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(RemoteClientCommand::RejectServerRequest { + request_id, + error, + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server reject channel is closed", + ) + })? + } + + pub async fn next_event(&mut self) -> Option { + if let Some(event) = self.pending_events.pop_front() { + return Some(event); + } + self.event_rx.recv().await + } + + pub async fn shutdown(self) -> IoResult<()> { + let Self { + command_tx, + event_rx, + pending_events: _pending_events, + worker_handle, + } = self; + let mut worker_handle = worker_handle; + drop(event_rx); + let (response_tx, response_rx) = oneshot::channel(); + if command_tx + .send(RemoteClientCommand::Shutdown { response_tx }) + .await + .is_ok() + && let Ok(Ok(close_result)) = timeout(SHUTDOWN_TIMEOUT, response_rx).await + { + close_result?; + } + + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut worker_handle).await { + worker_handle.abort(); + let _ = worker_handle.await; + } + Ok(()) + } +} + +impl RemoteAppServerRequestHandle { + pub async fn request(&self, request: ClientRequest) -> IoResult { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(RemoteClientCommand::Request { + request: Box::new(request), + response_tx, + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server request channel is closed", + ) + })? + } + + pub async fn request_typed(&self, request: ClientRequest) -> Result + where + T: DeserializeOwned, + { + let method = request_method_name(&request); + let response = + self.request(request) + .await + .map_err(|source| TypedRequestError::Transport { + method: method.clone(), + source, + })?; + let result = response.map_err(|source| TypedRequestError::Server { + method: method.clone(), + source, + })?; + serde_json::from_value(result) + .map_err(|source| TypedRequestError::Deserialize { method, source }) + } +} + +async fn initialize_remote_connection( + stream: &mut WebSocketStream>, + websocket_url: &str, + params: InitializeParams, + initialize_timeout: Duration, +) -> IoResult> { + let initialize_request_id = RequestId::String("initialize".to_string()); + let mut pending_events = Vec::new(); + write_jsonrpc_message( + stream, + JSONRPCMessage::Request(jsonrpc_request_from_client_request( + ClientRequest::Initialize { + request_id: initialize_request_id.clone(), + params, + }, + )), + websocket_url, + ) + .await?; + + timeout(initialize_timeout, async { + loop { + match stream.next().await { + Some(Ok(Message::Text(text))) => { + let message = serde_json::from_str::(&text).map_err(|err| { + IoError::other(format!( + "remote app server at `{websocket_url}` sent invalid initialize response: {err}" + )) + })?; + match message { + JSONRPCMessage::Response(response) if response.id == initialize_request_id => { + break Ok(()); + } + JSONRPCMessage::Error(error) if error.id == initialize_request_id => { + break Err(IoError::other(format!( + "remote app server at `{websocket_url}` rejected initialize: {}", + error.error.message + ))); + } + JSONRPCMessage::Notification(notification) => { + if let Some(event) = app_server_event_from_notification(notification) { + pending_events.push(event); + } + } + JSONRPCMessage::Request(request) => { + let request_id = request.id.clone(); + let method = request.method.clone(); + match ServerRequest::try_from(request) { + Ok(request) => { + pending_events.push(AppServerEvent::ServerRequest(request)); + } + Err(err) => { + warn!(%err, method, "rejecting unknown remote app-server request during initialize"); + write_jsonrpc_message( + stream, + JSONRPCMessage::Error(JSONRPCError { + error: JSONRPCErrorError { + code: -32601, + message: format!( + "unsupported remote app-server request `{method}`" + ), + data: None, + }, + id: request_id, + }), + websocket_url, + ) + .await?; + } + } + } + JSONRPCMessage::Response(_) | JSONRPCMessage::Error(_) => {} + } + } + Some(Ok(Message::Binary(_))) + | Some(Ok(Message::Ping(_))) + | Some(Ok(Message::Pong(_))) + | Some(Ok(Message::Frame(_))) => {} + Some(Ok(Message::Close(frame))) => { + let reason = frame + .as_ref() + .map(|frame| frame.reason.to_string()) + .filter(|reason| !reason.is_empty()) + .unwrap_or_else(|| "connection closed during initialize".to_string()); + break Err(IoError::new( + ErrorKind::ConnectionAborted, + format!( + "remote app server at `{websocket_url}` closed during initialize: {reason}" + ), + )); + } + Some(Err(err)) => { + break Err(IoError::other(format!( + "remote app server at `{websocket_url}` transport failed during initialize: {err}" + ))); + } + None => { + break Err(IoError::new( + ErrorKind::UnexpectedEof, + format!("remote app server at `{websocket_url}` closed during initialize"), + )); + } + } + } + }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out waiting for initialize response from `{websocket_url}`"), + ) + })??; + + write_jsonrpc_message( + stream, + JSONRPCMessage::Notification(jsonrpc_notification_from_client_notification( + ClientNotification::Initialized, + )), + websocket_url, + ) + .await?; + + Ok(pending_events) +} + +fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option { + match ServerNotification::try_from(notification) { + Ok(notification) => Some(AppServerEvent::ServerNotification(notification)), + Err(_) => None, + } +} + +fn deliver_event( + event_tx: &mpsc::UnboundedSender, + event: AppServerEvent, +) -> IoResult<()> { + event_tx.send(event).map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "remote app-server event consumer channel is closed", + ) + }) +} + +fn request_id_from_client_request(request: &ClientRequest) -> RequestId { + jsonrpc_request_from_client_request(request.clone()).id +} + +fn jsonrpc_request_from_client_request(request: ClientRequest) -> JSONRPCRequest { + let value = match serde_json::to_value(request) { + Ok(value) => value, + Err(err) => panic!("client request should serialize: {err}"), + }; + match serde_json::from_value(value) { + Ok(request) => request, + Err(err) => panic!("client request should encode as JSON-RPC request: {err}"), + } +} + +fn jsonrpc_notification_from_client_notification( + notification: ClientNotification, +) -> JSONRPCNotification { + let value = match serde_json::to_value(notification) { + Ok(value) => value, + Err(err) => panic!("client notification should serialize: {err}"), + }; + match serde_json::from_value(value) { + Ok(notification) => notification, + Err(err) => panic!("client notification should encode as JSON-RPC notification: {err}"), + } +} + +async fn write_jsonrpc_message( + stream: &mut WebSocketStream>, + message: JSONRPCMessage, + websocket_url: &str, +) -> IoResult<()> { + let payload = serde_json::to_string(&message).map_err(IoError::other)?; + stream + .send(Message::Text(payload.into())) + .await + .map_err(|err| { + IoError::other(format!( + "failed to write websocket message to `{websocket_url}`: {err}" + )) + }) +} +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn shutdown_tolerates_worker_exit_after_command_is_queued() { + let (command_tx, mut command_rx) = mpsc::channel(1); + let (_event_tx, event_rx) = mpsc::unbounded_channel::(); + let worker_handle = tokio::spawn(async move { + let _ = command_rx.recv().await; + }); + let client = RemoteAppServerClient { + command_tx, + event_rx, + pending_events: VecDeque::new(), + worker_handle, + }; + + client + .shutdown() + .await + .expect("shutdown should complete when worker exits first"); + } +} diff --git a/code-rs/app-server-protocol/BUILD.bazel b/code-rs/app-server-protocol/BUILD.bazel index 967b8fe2da9..b95356e7428 100644 --- a/code-rs/app-server-protocol/BUILD.bazel +++ b/code-rs/app-server-protocol/BUILD.bazel @@ -2,6 +2,6 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "app-server-protocol", - crate_name = "code_app_server_protocol", + crate_name = "codex_app_server_protocol", test_data_extra = glob(["schema/**"], allow_empty = True), ) diff --git a/code-rs/app-server-protocol/Cargo.toml b/code-rs/app-server-protocol/Cargo.toml index 8e66a335e63..0749b07e083 100644 --- a/code-rs/app-server-protocol/Cargo.toml +++ b/code-rs/app-server-protocol/Cargo.toml @@ -1,12 +1,13 @@ [package] -name = "code-app-server-protocol" +name = "codex-app-server-protocol" version.workspace = true edition.workspace = true license.workspace = true [lib] -name = "code_app_server_protocol" +name = "codex_app_server_protocol" path = "src/lib.rs" +doctest = false [lints] workspace = true @@ -14,21 +15,30 @@ workspace = true [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } -code-protocol = { workspace = true } -code-experimental-api-macros = { workspace = true } -code-utils-absolute-path = { workspace = true } +codex-experimental-api-macros = { workspace = true } +codex-protocol = { workspace = true } +codex-shell-command = { workspace = true } +codex-utils-absolute-path = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_with = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } +rmcp = { workspace = true, default-features = false, features = [ + "base64", + "macros", + "schemars", + "server", +] } ts-rs = { workspace = true } inventory = { workspace = true } +tracing = { workspace = true } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] anyhow = { workspace = true } -code-utils-cargo-bin = { workspace = true } +codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } similar = { workspace = true } tempfile = { workspace = true } diff --git a/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json b/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json index 12f12b43748..d1174a05b0d 100644 --- a/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json +++ b/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalParams.json @@ -77,7 +77,7 @@ }, "properties": { "callId": { - "description": "Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] and [codex_core::protocol::PatchApplyEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] and [codex_protocol::protocol::PatchApplyEndEvent].", "type": "string" }, "conversationId": { @@ -111,4 +111,4 @@ ], "title": "ApplyPatchApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json b/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json index 906ab889e70..84c36edf10b 100644 --- a/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json +++ b/code-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json @@ -1,6 +1,28 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "ReviewDecision": { "description": "User's decision in response to an ExecApprovalRequest.", "oneOf": [ @@ -37,12 +59,34 @@ "type": "object" }, { - "description": "User has approved this command and wants to automatically approve any future identical instances (`command` and `cwd` match exactly) for the remainder of the session.", + "description": "User has approved this request and wants future prompts in the same session-scoped approval cache to be automatically approved for the remainder of the session.", "enum": [ "approved_for_session" ], "type": "string" }, + { + "additionalProperties": false, + "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", + "properties": { + "network_policy_amendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "network_policy_amendment" + ], + "title": "NetworkPolicyAmendmentReviewDecision", + "type": "object" + }, { "description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.", "enum": [ @@ -50,6 +94,13 @@ ], "type": "string" }, + { + "description": "Automatic approval review timed out before reaching a decision.", + "enum": [ + "timed_out" + ], + "type": "string" + }, { "description": "User has denied this command and the agent should not do anything until the user's next command.", "enum": [ @@ -70,4 +121,4 @@ ], "title": "ApplyPatchApprovalResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshParams.json b/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshParams.json index d320eb93d3b..8b320fd673d 100644 --- a/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshParams.json +++ b/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshParams.json @@ -30,4 +30,4 @@ ], "title": "ChatgptAuthTokensRefreshParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshResponse.json b/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshResponse.json index 0a6cc999b54..6d88e784c53 100644 --- a/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshResponse.json +++ b/code-rs/app-server-protocol/schema/json/ChatgptAuthTokensRefreshResponse.json @@ -20,4 +20,4 @@ ], "title": "ChatgptAuthTokensRefreshResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ClientNotification.json b/code-rs/app-server-protocol/schema/json/ClientNotification.json index 397561babdd..dde0b31fbd6 100644 --- a/code-rs/app-server-protocol/schema/json/ClientNotification.json +++ b/code-rs/app-server-protocol/schema/json/ClientNotification.json @@ -19,4 +19,4 @@ } ], "title": "ClientNotification" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ClientRequest.json b/code-rs/app-server-protocol/schema/json/ClientRequest.json index 8cf07f13741..fe3738c8873 100644 --- a/code-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/code-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,20 +5,21 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "AddConversationListenerParams": { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "experimentalRawEvents": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "conversationId" + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" ], - "type": "object" + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" }, "AppsListParams": { "description": "EXPERIMENTAL - list available apps/connectors.", @@ -53,21 +54,6 @@ }, "type": "object" }, - "ArchiveConversationParams": { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "rolloutPath" - ], - "type": "object" - }, "AskForApproval": { "oneOf": [ { @@ -82,7 +68,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -111,57 +97,10 @@ } }, "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - } - ] - }, - "AskForApproval2": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval2", + "title": "GranularAskForApproval", "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" } ] }, @@ -195,17 +134,6 @@ ], "type": "object" }, - "CancelLoginChatGptParams": { - "properties": { - "loginId": { - "type": "string" - } - }, - "required": [ - "loginId" - ], - "type": "object" - }, "ClientInfo": { "properties": { "name": { @@ -244,14 +172,54 @@ "type": "object" }, "CommandExecParams": { + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -265,14 +233,39 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" + }, + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" } }, "required": [ @@ -280,6 +273,98 @@ ], "type": "object" }, + "CommandExecResizeParams": { + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "size": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } + }, + "required": [ + "processId", + "size" + ], + "type": "object" + }, + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "type": "object" + }, + "CommandExecWriteParams": { + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "type": "object" + }, + "CommandMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "ConfigBatchWriteParams": { "properties": { "edits": { @@ -300,6 +385,10 @@ "string", "null" ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" } }, "required": [ @@ -394,6 +483,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -460,41 +559,18 @@ ], "type": "object" }, - "ExecOneOffCommandParams": { + "ExperimentalFeatureEnablementSetParams": { "properties": { - "command": { - "items": { - "type": "string" + "enablement": { + "additionalProperties": { + "type": "boolean" }, - "type": "array" - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "sandboxPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxPolicy2" - }, - { - "type": "null" - } - ] - }, - "timeoutMs": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" } }, "required": [ - "command" + "enablement" ], "type": "object" }, @@ -564,6 +640,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" } @@ -579,7 +665,12 @@ "AGENTS_MD", "CONFIG", "SKILLS", - "MCP_SERVER_CONFIG" + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS" ], "type": "string" }, @@ -588,6 +679,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, @@ -597,6 +697,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", @@ -610,35 +719,393 @@ ], "type": "object" }, - "ForkConversationParams": { - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" }, - { - "type": "null" + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" } - ] + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" + { + "properties": { + "pattern": { + "type": "string" }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "FsCopyParams": { + "description": "Copy a file or directory tree on the host filesystem.", + "properties": { + "destinationPath": { + "allOf": [ { - "type": "null" + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute destination path." + }, + "recursive": { + "description": "Required for directory copies; ignored for file copies.", + "type": "boolean" + }, + "sourcePath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute source path." + } + }, + "required": [ + "destinationPath", + "sourcePath" + ], + "type": "object" + }, + "FsCreateDirectoryParams": { + "description": "Create a directory on the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to create." + }, + "recursive": { + "description": "Whether parent directories should also be created. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "FsGetMetadataParams": { + "description": "Request metadata for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to inspect." + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "FsReadDirectoryParams": { + "description": "List direct child names for a directory.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to read." + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "FsReadFileParams": { + "description": "Read a file from the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" } + ], + "description": "Absolute path to read." + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "FsRemoveParams": { + "description": "Remove a file or directory tree from the host filesystem.", + "properties": { + "force": { + "description": "Whether missing paths should be ignored. Defaults to `true`.", + "type": [ + "boolean", + "null" ] }, "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to remove." + }, + "recursive": { + "description": "Whether directory removal should recurse. Defaults to `true`.", "type": [ - "string", + "boolean", "null" ] } }, + "required": [ + "path" + ], + "type": "object" + }, + "FsUnwatchParams": { + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "type": "object" + }, + "FsWatchParams": { + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "type": "object" + }, + "FsWriteFileParams": { + "description": "Write a file on the host filesystem.", + "properties": { + "dataBase64": { + "description": "File contents encoded as base64.", + "type": "string" + }, + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to write." + } + }, + "required": [ + "dataBase64", + "path" + ], "type": "object" }, "FunctionCallOutputBody": { @@ -709,24 +1176,6 @@ } ] }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, "FuzzyFileSearchParams": { "properties": { "cancellationToken": { @@ -761,78 +1210,27 @@ }, "type": "object" }, - "GetAuthStatusParams": { + "HookMigration": { "properties": { - "includeToken": { - "type": [ - "boolean", - "null" - ] - }, - "refreshToken": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "GetConversationSummaryParams": { - "anyOf": [ - { - "properties": { - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "rolloutPath" - ], - "title": "RolloutPathGetConversationSummaryParams", - "type": "object" - }, - { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "ConversationIdGetConversationSummaryParams", - "type": "object" - } - ] - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { + "name": { "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] } }, "required": [ - "id" + "name" ], "type": "object" }, - "GitDiffToRemoteParams": { + "HooksListParams": { "properties": { - "cwd": { - "type": "string" + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" } }, - "required": [ - "cwd" - ], "type": "object" }, "ImageDetail": { @@ -851,6 +1249,16 @@ "default": false, "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" + }, + "optOutNotificationMethods": { + "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "type": "object" @@ -876,141 +1284,6 @@ ], "type": "object" }, - "InputItem": { - "oneOf": [ - { - "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "TextInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" - ], - "type": "object" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "ImageInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "LocalImageInputItem", - "type": "object" - } - ] - }, - "InterruptConversationParams": { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "type": "object" - }, - "ListConversationsParams": { - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "modelProviders": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "pageSize": { - "format": "uint", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - }, "ListMcpServerStatusParams": { "properties": { "cursor": { @@ -1020,6 +1293,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -1120,6 +1404,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" @@ -1134,6 +1421,22 @@ "title": "ChatgptLoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodeLoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodeLoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { @@ -1170,14 +1473,83 @@ } ] }, - "LoginApiKeyParams": { + "MarketplaceAddParams": { + "properties": { + "refName": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source" + ], + "type": "object" + }, + "MarketplaceRemoveParams": { + "properties": { + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "marketplaceName" + ], + "type": "object" + }, + "MarketplaceUpgradeParams": { + "properties": { + "marketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "McpResourceReadParams": { "properties": { - "apiKey": { + "server": { + "type": "string" + }, + "threadId": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "uri" + ], + "type": "object" + }, + "McpServerMigration": { + "properties": { + "name": { "type": "string" } }, "required": [ - "apiKey" + "name" ], "type": "object" }, @@ -1208,25 +1580,53 @@ ], "type": "object" }, - "MergeStrategy": { + "McpServerStatusDetail": { "enum": [ - "replace", - "upsert" + "full", + "toolsAndAuthOnly" ], "type": "string" }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], + "McpServerToolCallParams": { + "properties": { + "_meta": true, + "arguments": true, + "server": { "type": "string" }, - { - "description": "The assistant's terminal answer text for the current turn.", + "threadId": { + "type": "string" + }, + "tool": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "tool" + ], + "type": "object" + }, + "MergeStrategy": { + "enum": [ + "replace", + "upsert" + ], + "type": "string" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", "enum": [ "final_answer" ], @@ -1234,6 +1634,53 @@ } ] }, + "MigrationDetails": { + "properties": { + "commands": { + "default": [], + "items": { + "$ref": "#/definitions/CommandMigration" + }, + "type": "array" + }, + "hooks": { + "default": [], + "items": { + "$ref": "#/definitions/HookMigration" + }, + "type": "array" + }, + "mcpServers": { + "default": [], + "items": { + "$ref": "#/definitions/McpServerMigration" + }, + "type": "array" + }, + "plugins": { + "default": [], + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + }, + "sessions": { + "default": [], + "items": { + "$ref": "#/definitions/SessionMigration" + }, + "type": "array" + }, + "subagents": { + "default": [], + "items": { + "$ref": "#/definitions/SubagentMigration" + }, + "type": "array" + } + }, + "type": "object" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -1270,106 +1717,507 @@ }, "type": "object" }, - "NetworkAccess": { + "ModelProviderCapabilitiesReadParams": { + "type": "object" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "PluginInstallParams": { + "properties": { + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginName" + ], + "type": "object" + }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, + "PluginListParams": { + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "PluginReadParams": { + "properties": { + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginName" + ], + "type": "object" + }, + "PluginShareDeleteParams": { + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDiscoverability": { "enum": [ - "restricted", - "enabled" + "LISTED", + "UNLISTED", + "PRIVATE" ], "type": "string" }, - "NetworkAccess2": { - "description": "Represents whether outbound network access is available to the agent.", + "PluginShareListParams": { + "type": "object" + }, + "PluginSharePrincipalType": { "enum": [ - "restricted", - "enabled" + "user", + "group", + "workspace" ], "type": "string" }, - "NewConversationParams": { + "PluginShareSaveParams": { "properties": { - "approvalPolicy": { + "discoverability": { "anyOf": [ { - "$ref": "#/definitions/AskForApproval2" + "$ref": "#/definitions/PluginShareDiscoverability" }, { "type": "null" } ] }, - "baseInstructions": { - "type": [ - "string", - "null" - ] + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" }, - "compactPrompt": { + "remotePluginId": { "type": [ "string", "null" ] }, - "config": { - "additionalProperties": true, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, "type": [ - "object", + "array", "null" ] + } + }, + "required": [ + "pluginPath" + ], + "type": "object" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" }, - "cwd": { - "type": [ - "string", - "null" - ] + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareUpdateTargetsParams": { + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareUpdateDiscoverability" }, - "developerInstructions": { - "type": [ - "string", - "null" - ] + "remotePluginId": { + "type": "string" }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "type": "object" + }, + "PluginSkillReadParams": { + "properties": { + "remoteMarketplaceName": { + "type": "string" }, - "model": { - "type": [ - "string", - "null" - ] + "remotePluginId": { + "type": "string" }, - "modelProvider": { - "type": [ - "string", - "null" - ] + "skillName": { + "type": "string" + } + }, + "required": [ + "remoteMarketplaceName", + "remotePluginId", + "skillName" + ], + "type": "object" + }, + "PluginUninstallParams": { + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "type": "object" + }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" }, - "profile": { - "type": [ - "string", - "null" - ] + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, + "ProcessTerminalSize": { + "description": "PTY size in character cells for `process/spawn` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode2" - }, - { - "type": "null" - } - ] + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" } }, + "required": [ + "cols", + "rows" + ], "type": "object" }, - "Personality": { + "RealtimeOutputModality": { "enum": [ - "none", - "friendly", - "pragmatic" + "text", + "audio" + ], + "type": "string" + }, + "RealtimeVoice": { + "enum": [ + "alloy", + "arbor", + "ash", + "ballad", + "breeze", + "cedar", + "coral", + "cove", + "echo", + "ember", + "juniper", + "maple", + "marin", + "sage", + "shimmer", + "sol", + "spruce", + "vale", + "verse" ], "type": "string" }, @@ -1473,49 +2321,6 @@ } ] }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoveConversationListenerParams": { - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "type": "object" - }, "RequestId": { "anyOf": [ { @@ -1537,12 +2342,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -1597,10 +2396,6 @@ "null" ] }, - "id": { - "type": "string", - "writeOnly": true - }, "summary": { "items": { "$ref": "#/definitions/ReasoningItemReasoningSummary" @@ -1616,7 +2411,6 @@ } }, "required": [ - "id", "summary", "type" ], @@ -1750,7 +2544,7 @@ "type": "string" }, "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" + "$ref": "#/definitions/FunctionCallOutputBody" }, "type": { "enum": [ @@ -1821,7 +2615,7 @@ ] }, "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" + "$ref": "#/definitions/FunctionCallOutputBody" }, "type": { "enum": [ @@ -1879,7 +2673,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -1949,64 +2743,145 @@ }, { "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" + "encrypted_content": { + "type": "string" }, "type": { "enum": [ - "ghost_snapshot" + "compaction" ], - "title": "GhostSnapshotResponseItemType", + "title": "CompactionResponseItemType", "type": "string" } }, "required": [ - "ghost_commit", + "encrypted_content", "type" ], - "title": "GhostSnapshotResponseItem", + "title": "CompactionResponseItem", "type": "object" }, { "properties": { "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponseItemType", "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponseItem", + "type": "object" + } + ] + }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] }, "type": { "enum": [ - "compaction_summary" + "search" ], - "title": "CompactionSummaryResponseItemType", + "title": "SearchResponsesApiWebSearchActionType", "type": "string" } }, "required": [ - "encrypted_content", "type" ], - "title": "CompactionSummaryResponseItem", + "title": "SearchResponsesApiWebSearchAction", "type": "object" }, { "properties": { - "encrypted_content": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { "type": [ "string", "null" ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" } }, "required": [ "type" ], - "title": "ContextCompactionResponseItem", + "title": "FindInPageResponsesApiWebSearchAction", "type": "object" }, { @@ -2015,58 +2890,18 @@ "enum": [ "other" ], - "title": "OtherResponseItemType", + "title": "OtherResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "OtherResponseItem", + "title": "OtherResponsesApiWebSearchAction", "type": "object" } ] }, - "ResumeConversationParams": { - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history": { - "items": { - "$ref": "#/definitions/ResponseItem" - }, - "type": [ - "array", - "null" - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ReviewDelivery": { "enum": [ "inline", @@ -2200,14 +3035,6 @@ ], "type": "string" }, - "SandboxMode2": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, "SandboxPolicy": { "oneOf": [ { @@ -2228,6 +3055,10 @@ }, { "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" @@ -2303,203 +3134,36 @@ } ] }, - "SandboxPolicy2": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicy2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy2", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicy2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy2", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess2" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicy2Type", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy2", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicy2Type", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy2", - "type": "object" - } - ] - }, - "SendUserMessageParams": { + "SendAddCreditsNudgeEmailParams": { "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" } }, "required": [ - "conversationId", - "items" + "creditType" ], "type": "object" }, - "SendUserTurnParams": { + "SessionMigration": { "properties": { - "approvalPolicy": { - "$ref": "#/definitions/AskForApproval2" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, "cwd": { "type": "string" }, - "effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - }, - "model": { + "path": { "type": "string" }, - "outputSchema": { - "description": "Optional JSON Schema used to constrain the final assistant message for this turn." - }, - "sandboxPolicy": { - "$ref": "#/definitions/SandboxPolicy2" - }, - "summary": { - "$ref": "#/definitions/ReasoningSummary" - } - }, - "required": [ - "approvalPolicy", - "conversationId", - "cwd", - "items", - "model", - "sandboxPolicy", - "summary" - ], - "type": "object" - }, - "SetDefaultModelParams": { - "properties": { - "model": { + "title": { "type": [ "string", "null" ] - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] } }, + "required": [ + "cwd", + "path" + ], "type": "object" }, "Settings": { @@ -2535,31 +3199,27 @@ "enabled": { "type": "boolean" }, - "path": { - "type": "string" - } - }, - "required": [ - "enabled", - "path" - ], - "type": "object" - }, - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "cwd", - "extraUserRoots" + "enabled" ], "type": "object" }, @@ -2575,36 +3235,25 @@ "forceReload": { "description": "When true, bypass the skills cache and re-scan skills from disk.", "type": "boolean" - }, - "perCwdExtraUserRoots": { - "default": null, - "description": "Optional per-cwd extra roots to scan as user-scoped skills.", - "items": { - "$ref": "#/definitions/SkillsListExtraRootsForCwd" - }, - "type": [ - "array", - "null" - ] } }, "type": "object" }, - "SkillsRemoteReadParams": { - "type": "object" + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" }, - "SkillsRemoteWriteParams": { + "SubagentMigration": { "properties": { - "hazelnutId": { + "name": { "type": "string" - }, - "isPreload": { - "type": "boolean" } }, "required": [ - "hazelnutId", - "isPreload" + "name" ], "type": "object" }, @@ -2631,6 +3280,21 @@ ], "type": "object" }, + "ThreadApproveGuardianDeniedActionParams": { + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "type": "object" + }, "ThreadArchiveParams": { "properties": { "threadId": { @@ -2666,6 +3330,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -2691,6 +3366,9 @@ "null" ] }, + "ephemeral": { + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ @@ -2714,8 +3392,25 @@ } ] }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -2723,9 +3418,45 @@ ], "type": "object" }, - "ThreadId": { + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], "type": "string" }, + "ThreadInjectItemsParams": { + "properties": { + "items": { + "description": "Raw Responses API items to append to the thread's model-visible history.", + "items": true, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "items", + "threadId" + ], + "type": "object" + }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "properties": { "archived": { @@ -2743,11 +3474,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -2768,6 +3503,24 @@ "null" ] }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, "sortKey": { "anyOf": [ { @@ -2788,6 +3541,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "type": "object" @@ -2813,6 +3570,61 @@ }, "type": "object" }, + "ThreadMemoryMode": { + "enum": [ + "enabled", + "disabled" + ], + "type": "string" + }, + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ThreadMetadataUpdateParams": { + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadReadParams": { "properties": { "includeTurns": { @@ -2829,6 +3641,86 @@ ], "type": "object" }, + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": [ + "string", + "null" + ] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + }, + "ThreadRealtimeStartTransport": { + "description": "EXPERIMENTAL - transport used by thread realtime.", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "websocket" + ], + "title": "WebsocketThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "WebsocketThreadRealtimeStartTransport", + "type": "object" + }, + { + "properties": { + "sdp": { + "description": "SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the realtime events data channel.", + "type": "string" + }, + "type": { + "enum": [ + "webrtc" + ], + "title": "WebrtcThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "sdp", + "type" + ], + "title": "WebrtcThreadRealtimeStartTransport", + "type": "object" + } + ] + }, "ThreadResumeParams": { "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", "properties": { @@ -2842,6 +3734,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -2900,6 +3803,12 @@ } ] }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" } @@ -2942,6 +3851,22 @@ ], "type": "object" }, + "ThreadShellCommandParams": { + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "type": "object" + }, "ThreadSortKey": { "enum": [ "created_at", @@ -2949,6 +3874,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -2976,6 +3909,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -3038,10 +3982,50 @@ "type": "null" } ] + }, + "serviceName": { + "type": [ + "string", + "null" + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadStartSource" + }, + { + "type": "null" + } + ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "type": "object" }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" + }, "ThreadUnarchiveParams": { "properties": { "threadId": { @@ -3049,7 +4033,33 @@ } }, "required": [ - "threadId" + "threadId" + ], + "type": "object" + }, + "ThreadUnsubscribeParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" ], "type": "object" }, @@ -3068,6 +4078,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStartParams": { "properties": { "approvalPolicy": { @@ -3081,6 +4116,17 @@ ], "description": "Override the approval policy for this turn and subsequent turns." }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this turn and subsequent turns." + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -3137,6 +4183,13 @@ ], "description": "Override the sandbox policy for this turn and subsequent turns." }, + "serviceTier": { + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, "summary": { "anyOf": [ { @@ -3299,156 +4352,376 @@ "title": "MentionUserInput", "type": "object" } - ] + ] + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsSandboxSetupStartParams": { + "properties": { + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + }, + "description": "Request from the client to the server.", + "oneOf": [ + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "initialize" + ], + "title": "InitializeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/InitializeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "InitializeRequest", + "type": "object" + }, + { + "description": "NEW APIs", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/start" + ], + "title": "Thread/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/resume" + ], + "title": "Thread/resumeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadResumeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/resumeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/fork" + ], + "title": "Thread/forkRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadForkParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/forkRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/archive" + ], + "title": "Thread/archiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/archiveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unsubscribe" + ], + "title": "Thread/unsubscribeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnsubscribeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unsubscribeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/name/set" + ], + "title": "Thread/name/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSetNameParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/name/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/metadata/update" + ], + "title": "Thread/metadata/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMetadataUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/metadata/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unarchive" + ], + "title": "Thread/unarchiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unarchiveRequest", + "type": "object" }, - "V1ByteRange": { + { "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" + "id": { + "$ref": "#/definitions/RequestId" }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" + "method": { + "enum": [ + "thread/compact/start" + ], + "title": "Thread/compact/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadCompactStartParams" } }, "required": [ - "end", - "start" + "id", + "method", + "params" ], + "title": "Thread/compact/startRequest", "type": "object" }, - "V1TextElement": { + { "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/shellCommand" ], - "description": "Byte range in the parent `text` buffer that this element occupies." + "title": "Thread/shellCommandRequestMethod", + "type": "string" }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] + "params": { + "$ref": "#/definitions/ThreadShellCommandParams" } }, "required": [ - "byteRange" + "id", + "method", + "params" ], + "title": "Thread/shellCommandRequest", "type": "object" }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" + "method": { + "enum": [ + "thread/approveGuardianDeniedAction" ], - "title": "OpenPageWebSearchAction", - "type": "object" + "title": "Thread/approveGuardianDeniedActionRequestMethod", + "type": "string" }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" + "params": { + "$ref": "#/definitions/ThreadApproveGuardianDeniedActionParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/approveGuardianDeniedActionRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/rollback" ], - "title": "FindInPageWebSearchAction", - "type": "object" + "title": "Thread/rollbackRequestMethod", + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" + "params": { + "$ref": "#/definitions/ThreadRollbackParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/rollbackRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/list" ], - "title": "OtherWebSearchAction", - "type": "object" + "title": "Thread/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadListParams" } - ] - } - }, - "description": "Request from the client to the server.", - "oneOf": [ + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -3456,13 +4729,13 @@ }, "method": { "enum": [ - "initialize" + "thread/loaded/list" ], - "title": "InitializeRequestMethod", + "title": "Thread/loaded/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/InitializeParams" + "$ref": "#/definitions/ThreadLoadedListParams" } }, "required": [ @@ -3470,24 +4743,23 @@ "method", "params" ], - "title": "InitializeRequest", + "title": "Thread/loaded/listRequest", "type": "object" }, { - "description": "NEW APIs", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "thread/start" + "thread/read" ], - "title": "Thread/startRequestMethod", + "title": "Thread/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadStartParams" + "$ref": "#/definitions/ThreadReadParams" } }, "required": [ @@ -3495,23 +4767,24 @@ "method", "params" ], - "title": "Thread/startRequest", + "title": "Thread/readRequest", "type": "object" }, { + "description": "Append raw Responses API items to the thread history without starting a user turn.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "thread/resume" + "thread/inject_items" ], - "title": "Thread/resumeRequestMethod", + "title": "Thread/injectItemsRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadResumeParams" + "$ref": "#/definitions/ThreadInjectItemsParams" } }, "required": [ @@ -3519,7 +4792,7 @@ "method", "params" ], - "title": "Thread/resumeRequest", + "title": "Thread/injectItemsRequest", "type": "object" }, { @@ -3529,13 +4802,13 @@ }, "method": { "enum": [ - "thread/fork" + "skills/list" ], - "title": "Thread/forkRequestMethod", + "title": "Skills/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadForkParams" + "$ref": "#/definitions/SkillsListParams" } }, "required": [ @@ -3543,7 +4816,7 @@ "method", "params" ], - "title": "Thread/forkRequest", + "title": "Skills/listRequest", "type": "object" }, { @@ -3553,13 +4826,13 @@ }, "method": { "enum": [ - "thread/archive" + "hooks/list" ], - "title": "Thread/archiveRequestMethod", + "title": "Hooks/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadArchiveParams" + "$ref": "#/definitions/HooksListParams" } }, "required": [ @@ -3567,7 +4840,7 @@ "method", "params" ], - "title": "Thread/archiveRequest", + "title": "Hooks/listRequest", "type": "object" }, { @@ -3577,13 +4850,13 @@ }, "method": { "enum": [ - "thread/name/set" + "marketplace/add" ], - "title": "Thread/name/setRequestMethod", + "title": "Marketplace/addRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadSetNameParams" + "$ref": "#/definitions/MarketplaceAddParams" } }, "required": [ @@ -3591,7 +4864,7 @@ "method", "params" ], - "title": "Thread/name/setRequest", + "title": "Marketplace/addRequest", "type": "object" }, { @@ -3601,13 +4874,13 @@ }, "method": { "enum": [ - "thread/unarchive" + "marketplace/remove" ], - "title": "Thread/unarchiveRequestMethod", + "title": "Marketplace/removeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadUnarchiveParams" + "$ref": "#/definitions/MarketplaceRemoveParams" } }, "required": [ @@ -3615,7 +4888,7 @@ "method", "params" ], - "title": "Thread/unarchiveRequest", + "title": "Marketplace/removeRequest", "type": "object" }, { @@ -3625,13 +4898,13 @@ }, "method": { "enum": [ - "thread/compact/start" + "marketplace/upgrade" ], - "title": "Thread/compact/startRequestMethod", + "title": "Marketplace/upgradeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadCompactStartParams" + "$ref": "#/definitions/MarketplaceUpgradeParams" } }, "required": [ @@ -3639,7 +4912,7 @@ "method", "params" ], - "title": "Thread/compact/startRequest", + "title": "Marketplace/upgradeRequest", "type": "object" }, { @@ -3649,13 +4922,13 @@ }, "method": { "enum": [ - "thread/rollback" + "plugin/list" ], - "title": "Thread/rollbackRequestMethod", + "title": "Plugin/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadRollbackParams" + "$ref": "#/definitions/PluginListParams" } }, "required": [ @@ -3663,7 +4936,7 @@ "method", "params" ], - "title": "Thread/rollbackRequest", + "title": "Plugin/listRequest", "type": "object" }, { @@ -3673,13 +4946,13 @@ }, "method": { "enum": [ - "thread/list" + "plugin/read" ], - "title": "Thread/listRequestMethod", + "title": "Plugin/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadListParams" + "$ref": "#/definitions/PluginReadParams" } }, "required": [ @@ -3687,7 +4960,7 @@ "method", "params" ], - "title": "Thread/listRequest", + "title": "Plugin/readRequest", "type": "object" }, { @@ -3697,13 +4970,13 @@ }, "method": { "enum": [ - "thread/loaded/list" + "plugin/skill/read" ], - "title": "Thread/loaded/listRequestMethod", + "title": "Plugin/skill/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadLoadedListParams" + "$ref": "#/definitions/PluginSkillReadParams" } }, "required": [ @@ -3711,7 +4984,7 @@ "method", "params" ], - "title": "Thread/loaded/listRequest", + "title": "Plugin/skill/readRequest", "type": "object" }, { @@ -3721,13 +4994,13 @@ }, "method": { "enum": [ - "thread/read" + "plugin/share/save" ], - "title": "Thread/readRequestMethod", + "title": "Plugin/share/saveRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadReadParams" + "$ref": "#/definitions/PluginShareSaveParams" } }, "required": [ @@ -3735,7 +5008,7 @@ "method", "params" ], - "title": "Thread/readRequest", + "title": "Plugin/share/saveRequest", "type": "object" }, { @@ -3745,13 +5018,13 @@ }, "method": { "enum": [ - "skills/list" + "plugin/share/updateTargets" ], - "title": "Skills/listRequestMethod", + "title": "Plugin/share/updateTargetsRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SkillsListParams" + "$ref": "#/definitions/PluginShareUpdateTargetsParams" } }, "required": [ @@ -3759,7 +5032,7 @@ "method", "params" ], - "title": "Skills/listRequest", + "title": "Plugin/share/updateTargetsRequest", "type": "object" }, { @@ -3769,13 +5042,13 @@ }, "method": { "enum": [ - "skills/remote/read" + "plugin/share/list" ], - "title": "Skills/remote/readRequestMethod", + "title": "Plugin/share/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SkillsRemoteReadParams" + "$ref": "#/definitions/PluginShareListParams" } }, "required": [ @@ -3783,7 +5056,7 @@ "method", "params" ], - "title": "Skills/remote/readRequest", + "title": "Plugin/share/listRequest", "type": "object" }, { @@ -3793,13 +5066,13 @@ }, "method": { "enum": [ - "skills/remote/write" + "plugin/share/delete" ], - "title": "Skills/remote/writeRequestMethod", + "title": "Plugin/share/deleteRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SkillsRemoteWriteParams" + "$ref": "#/definitions/PluginShareDeleteParams" } }, "required": [ @@ -3807,7 +5080,7 @@ "method", "params" ], - "title": "Skills/remote/writeRequest", + "title": "Plugin/share/deleteRequest", "type": "object" }, { @@ -3841,13 +5114,13 @@ }, "method": { "enum": [ - "skills/config/write" + "fs/readFile" ], - "title": "Skills/config/writeRequestMethod", + "title": "Fs/readFileRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SkillsConfigWriteParams" + "$ref": "#/definitions/FsReadFileParams" } }, "required": [ @@ -3855,7 +5128,7 @@ "method", "params" ], - "title": "Skills/config/writeRequest", + "title": "Fs/readFileRequest", "type": "object" }, { @@ -3865,13 +5138,13 @@ }, "method": { "enum": [ - "turn/start" + "fs/writeFile" ], - "title": "Turn/startRequestMethod", + "title": "Fs/writeFileRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TurnStartParams" + "$ref": "#/definitions/FsWriteFileParams" } }, "required": [ @@ -3879,7 +5152,7 @@ "method", "params" ], - "title": "Turn/startRequest", + "title": "Fs/writeFileRequest", "type": "object" }, { @@ -3889,13 +5162,13 @@ }, "method": { "enum": [ - "turn/steer" + "fs/createDirectory" ], - "title": "Turn/steerRequestMethod", + "title": "Fs/createDirectoryRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TurnSteerParams" + "$ref": "#/definitions/FsCreateDirectoryParams" } }, "required": [ @@ -3903,7 +5176,7 @@ "method", "params" ], - "title": "Turn/steerRequest", + "title": "Fs/createDirectoryRequest", "type": "object" }, { @@ -3913,13 +5186,13 @@ }, "method": { "enum": [ - "turn/interrupt" + "fs/getMetadata" ], - "title": "Turn/interruptRequestMethod", + "title": "Fs/getMetadataRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TurnInterruptParams" + "$ref": "#/definitions/FsGetMetadataParams" } }, "required": [ @@ -3927,7 +5200,7 @@ "method", "params" ], - "title": "Turn/interruptRequest", + "title": "Fs/getMetadataRequest", "type": "object" }, { @@ -3937,13 +5210,13 @@ }, "method": { "enum": [ - "review/start" + "fs/readDirectory" ], - "title": "Review/startRequestMethod", + "title": "Fs/readDirectoryRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ReviewStartParams" + "$ref": "#/definitions/FsReadDirectoryParams" } }, "required": [ @@ -3951,7 +5224,7 @@ "method", "params" ], - "title": "Review/startRequest", + "title": "Fs/readDirectoryRequest", "type": "object" }, { @@ -3961,13 +5234,13 @@ }, "method": { "enum": [ - "model/list" + "fs/remove" ], - "title": "Model/listRequestMethod", + "title": "Fs/removeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ModelListParams" + "$ref": "#/definitions/FsRemoveParams" } }, "required": [ @@ -3975,7 +5248,7 @@ "method", "params" ], - "title": "Model/listRequest", + "title": "Fs/removeRequest", "type": "object" }, { @@ -3985,13 +5258,13 @@ }, "method": { "enum": [ - "experimentalFeature/list" + "fs/copy" ], - "title": "ExperimentalFeature/listRequestMethod", + "title": "Fs/copyRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ExperimentalFeatureListParams" + "$ref": "#/definitions/FsCopyParams" } }, "required": [ @@ -3999,7 +5272,7 @@ "method", "params" ], - "title": "ExperimentalFeature/listRequest", + "title": "Fs/copyRequest", "type": "object" }, { @@ -4009,13 +5282,13 @@ }, "method": { "enum": [ - "mcpServer/oauth/login" + "fs/watch" ], - "title": "McpServer/oauth/loginRequestMethod", + "title": "Fs/watchRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/McpServerOauthLoginParams" + "$ref": "#/definitions/FsWatchParams" } }, "required": [ @@ -4023,7 +5296,7 @@ "method", "params" ], - "title": "McpServer/oauth/loginRequest", + "title": "Fs/watchRequest", "type": "object" }, { @@ -4033,20 +5306,21 @@ }, "method": { "enum": [ - "config/mcpServer/reload" + "fs/unwatch" ], - "title": "Config/mcpServer/reloadRequestMethod", + "title": "Fs/unwatchRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/FsUnwatchParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "Config/mcpServer/reloadRequest", + "title": "Fs/unwatchRequest", "type": "object" }, { @@ -4056,13 +5330,13 @@ }, "method": { "enum": [ - "mcpServerStatus/list" + "skills/config/write" ], - "title": "McpServerStatus/listRequestMethod", + "title": "Skills/config/writeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ListMcpServerStatusParams" + "$ref": "#/definitions/SkillsConfigWriteParams" } }, "required": [ @@ -4070,7 +5344,7 @@ "method", "params" ], - "title": "McpServerStatus/listRequest", + "title": "Skills/config/writeRequest", "type": "object" }, { @@ -4080,13 +5354,13 @@ }, "method": { "enum": [ - "account/login/start" + "plugin/install" ], - "title": "Account/login/startRequestMethod", + "title": "Plugin/installRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/LoginAccountParams" + "$ref": "#/definitions/PluginInstallParams" } }, "required": [ @@ -4094,7 +5368,7 @@ "method", "params" ], - "title": "Account/login/startRequest", + "title": "Plugin/installRequest", "type": "object" }, { @@ -4104,13 +5378,13 @@ }, "method": { "enum": [ - "account/login/cancel" + "plugin/uninstall" ], - "title": "Account/login/cancelRequestMethod", + "title": "Plugin/uninstallRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/CancelLoginAccountParams" + "$ref": "#/definitions/PluginUninstallParams" } }, "required": [ @@ -4118,7 +5392,7 @@ "method", "params" ], - "title": "Account/login/cancelRequest", + "title": "Plugin/uninstallRequest", "type": "object" }, { @@ -4128,20 +5402,21 @@ }, "method": { "enum": [ - "account/logout" + "turn/start" ], - "title": "Account/logoutRequestMethod", + "title": "Turn/startRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/TurnStartParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "Account/logoutRequest", + "title": "Turn/startRequest", "type": "object" }, { @@ -4151,20 +5426,21 @@ }, "method": { "enum": [ - "account/rateLimits/read" + "turn/steer" ], - "title": "Account/rateLimits/readRequestMethod", + "title": "Turn/steerRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/TurnSteerParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "Account/rateLimits/readRequest", + "title": "Turn/steerRequest", "type": "object" }, { @@ -4174,13 +5450,13 @@ }, "method": { "enum": [ - "feedback/upload" + "turn/interrupt" ], - "title": "Feedback/uploadRequestMethod", + "title": "Turn/interruptRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/FeedbackUploadParams" + "$ref": "#/definitions/TurnInterruptParams" } }, "required": [ @@ -4188,24 +5464,23 @@ "method", "params" ], - "title": "Feedback/uploadRequest", + "title": "Turn/interruptRequest", "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "command/exec" + "review/start" ], - "title": "Command/execRequestMethod", + "title": "Review/startRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/CommandExecParams" + "$ref": "#/definitions/ReviewStartParams" } }, "required": [ @@ -4213,7 +5488,7 @@ "method", "params" ], - "title": "Command/execRequest", + "title": "Review/startRequest", "type": "object" }, { @@ -4223,13 +5498,13 @@ }, "method": { "enum": [ - "config/read" + "model/list" ], - "title": "Config/readRequestMethod", + "title": "Model/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ConfigReadParams" + "$ref": "#/definitions/ModelListParams" } }, "required": [ @@ -4237,7 +5512,7 @@ "method", "params" ], - "title": "Config/readRequest", + "title": "Model/listRequest", "type": "object" }, { @@ -4247,13 +5522,13 @@ }, "method": { "enum": [ - "externalAgentConfig/detect" + "modelProvider/capabilities/read" ], - "title": "ExternalAgentConfig/detectRequestMethod", + "title": "ModelProvider/capabilities/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ExternalAgentConfigDetectParams" + "$ref": "#/definitions/ModelProviderCapabilitiesReadParams" } }, "required": [ @@ -4261,7 +5536,7 @@ "method", "params" ], - "title": "ExternalAgentConfig/detectRequest", + "title": "ModelProvider/capabilities/readRequest", "type": "object" }, { @@ -4271,13 +5546,13 @@ }, "method": { "enum": [ - "externalAgentConfig/import" + "experimentalFeature/list" ], - "title": "ExternalAgentConfig/importRequestMethod", + "title": "ExperimentalFeature/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ExternalAgentConfigImportParams" + "$ref": "#/definitions/ExperimentalFeatureListParams" } }, "required": [ @@ -4285,7 +5560,7 @@ "method", "params" ], - "title": "ExternalAgentConfig/importRequest", + "title": "ExperimentalFeature/listRequest", "type": "object" }, { @@ -4295,13 +5570,13 @@ }, "method": { "enum": [ - "config/value/write" + "experimentalFeature/enablement/set" ], - "title": "Config/value/writeRequestMethod", + "title": "ExperimentalFeature/enablement/setRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ConfigValueWriteParams" + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" } }, "required": [ @@ -4309,7 +5584,7 @@ "method", "params" ], - "title": "Config/value/writeRequest", + "title": "ExperimentalFeature/enablement/setRequest", "type": "object" }, { @@ -4319,13 +5594,13 @@ }, "method": { "enum": [ - "config/batchWrite" + "mcpServer/oauth/login" ], - "title": "Config/batchWriteRequestMethod", + "title": "McpServer/oauth/loginRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ConfigBatchWriteParams" + "$ref": "#/definitions/McpServerOauthLoginParams" } }, "required": [ @@ -4333,7 +5608,7 @@ "method", "params" ], - "title": "Config/batchWriteRequest", + "title": "McpServer/oauth/loginRequest", "type": "object" }, { @@ -4343,9 +5618,9 @@ }, "method": { "enum": [ - "configRequirements/read" + "config/mcpServer/reload" ], - "title": "ConfigRequirements/readRequestMethod", + "title": "Config/mcpServer/reloadRequestMethod", "type": "string" }, "params": { @@ -4356,7 +5631,7 @@ "id", "method" ], - "title": "ConfigRequirements/readRequest", + "title": "Config/mcpServer/reloadRequest", "type": "object" }, { @@ -4366,13 +5641,13 @@ }, "method": { "enum": [ - "account/read" + "mcpServerStatus/list" ], - "title": "Account/readRequestMethod", + "title": "McpServerStatus/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/GetAccountParams" + "$ref": "#/definitions/ListMcpServerStatusParams" } }, "required": [ @@ -4380,24 +5655,23 @@ "method", "params" ], - "title": "Account/readRequest", + "title": "McpServerStatus/listRequest", "type": "object" }, { - "description": "DEPRECATED APIs below", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "newConversation" + "mcpServer/resource/read" ], - "title": "NewConversationRequestMethod", + "title": "McpServer/resource/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/NewConversationParams" + "$ref": "#/definitions/McpResourceReadParams" } }, "required": [ @@ -4405,7 +5679,7 @@ "method", "params" ], - "title": "NewConversationRequest", + "title": "McpServer/resource/readRequest", "type": "object" }, { @@ -4415,13 +5689,13 @@ }, "method": { "enum": [ - "getConversationSummary" + "mcpServer/tool/call" ], - "title": "GetConversationSummaryRequestMethod", + "title": "McpServer/tool/callRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/GetConversationSummaryParams" + "$ref": "#/definitions/McpServerToolCallParams" } }, "required": [ @@ -4429,24 +5703,23 @@ "method", "params" ], - "title": "GetConversationSummaryRequest", + "title": "McpServer/tool/callRequest", "type": "object" }, { - "description": "List recorded Codex conversations (rollouts) with optional pagination and search.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "listConversations" + "windowsSandbox/setupStart" ], - "title": "ListConversationsRequestMethod", + "title": "WindowsSandbox/setupStartRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ListConversationsParams" + "$ref": "#/definitions/WindowsSandboxSetupStartParams" } }, "required": [ @@ -4454,49 +5727,46 @@ "method", "params" ], - "title": "ListConversationsRequest", + "title": "WindowsSandbox/setupStartRequest", "type": "object" }, { - "description": "Resume a recorded Codex conversation from a rollout file.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "resumeConversation" + "windowsSandbox/readiness" ], - "title": "ResumeConversationRequestMethod", + "title": "WindowsSandbox/readinessRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ResumeConversationParams" + "type": "null" } }, "required": [ "id", - "method", - "params" + "method" ], - "title": "ResumeConversationRequest", + "title": "WindowsSandbox/readinessRequest", "type": "object" }, { - "description": "Fork a recorded Codex conversation into a new session.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "forkConversation" + "account/login/start" ], - "title": "ForkConversationRequestMethod", + "title": "Account/login/startRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ForkConversationParams" + "$ref": "#/definitions/LoginAccountParams" } }, "required": [ @@ -4504,7 +5774,7 @@ "method", "params" ], - "title": "ForkConversationRequest", + "title": "Account/login/startRequest", "type": "object" }, { @@ -4514,13 +5784,13 @@ }, "method": { "enum": [ - "archiveConversation" + "account/login/cancel" ], - "title": "ArchiveConversationRequestMethod", + "title": "Account/login/cancelRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ArchiveConversationParams" + "$ref": "#/definitions/CancelLoginAccountParams" } }, "required": [ @@ -4528,7 +5798,7 @@ "method", "params" ], - "title": "ArchiveConversationRequest", + "title": "Account/login/cancelRequest", "type": "object" }, { @@ -4538,21 +5808,20 @@ }, "method": { "enum": [ - "sendUserMessage" + "account/logout" ], - "title": "SendUserMessageRequestMethod", + "title": "Account/logoutRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SendUserMessageParams" + "type": "null" } }, "required": [ "id", - "method", - "params" + "method" ], - "title": "SendUserMessageRequest", + "title": "Account/logoutRequest", "type": "object" }, { @@ -4562,21 +5831,20 @@ }, "method": { "enum": [ - "sendUserTurn" + "account/rateLimits/read" ], - "title": "SendUserTurnRequestMethod", + "title": "Account/rateLimits/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SendUserTurnParams" + "type": "null" } }, "required": [ "id", - "method", - "params" + "method" ], - "title": "SendUserTurnRequest", + "title": "Account/rateLimits/readRequest", "type": "object" }, { @@ -4586,13 +5854,13 @@ }, "method": { "enum": [ - "interruptConversation" + "account/sendAddCreditsNudgeEmail" ], - "title": "InterruptConversationRequestMethod", + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/InterruptConversationParams" + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" } }, "required": [ @@ -4600,7 +5868,7 @@ "method", "params" ], - "title": "InterruptConversationRequest", + "title": "Account/sendAddCreditsNudgeEmailRequest", "type": "object" }, { @@ -4610,13 +5878,13 @@ }, "method": { "enum": [ - "addConversationListener" + "feedback/upload" ], - "title": "AddConversationListenerRequestMethod", + "title": "Feedback/uploadRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AddConversationListenerParams" + "$ref": "#/definitions/FeedbackUploadParams" } }, "required": [ @@ -4624,23 +5892,24 @@ "method", "params" ], - "title": "AddConversationListenerRequest", + "title": "Feedback/uploadRequest", "type": "object" }, { + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "removeConversationListener" + "command/exec" ], - "title": "RemoveConversationListenerRequestMethod", + "title": "Command/execRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/RemoveConversationListenerParams" + "$ref": "#/definitions/CommandExecParams" } }, "required": [ @@ -4648,23 +5917,24 @@ "method", "params" ], - "title": "RemoveConversationListenerRequest", + "title": "Command/execRequest", "type": "object" }, { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "gitDiffToRemote" + "command/exec/write" ], - "title": "GitDiffToRemoteRequestMethod", + "title": "Command/exec/writeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/GitDiffToRemoteParams" + "$ref": "#/definitions/CommandExecWriteParams" } }, "required": [ @@ -4672,23 +5942,24 @@ "method", "params" ], - "title": "GitDiffToRemoteRequest", + "title": "Command/exec/writeRequest", "type": "object" }, { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "loginApiKey" + "command/exec/terminate" ], - "title": "LoginApiKeyRequestMethod", + "title": "Command/exec/terminateRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/LoginApiKeyParams" + "$ref": "#/definitions/CommandExecTerminateParams" } }, "required": [ @@ -4696,30 +5967,32 @@ "method", "params" ], - "title": "LoginApiKeyRequest", + "title": "Command/exec/terminateRequest", "type": "object" }, { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "loginChatGpt" + "command/exec/resize" ], - "title": "LoginChatGptRequestMethod", + "title": "Command/exec/resizeRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/CommandExecResizeParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "LoginChatGptRequest", + "title": "Command/exec/resizeRequest", "type": "object" }, { @@ -4729,13 +6002,13 @@ }, "method": { "enum": [ - "cancelLoginChatGpt" + "config/read" ], - "title": "CancelLoginChatGptRequestMethod", + "title": "Config/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/CancelLoginChatGptParams" + "$ref": "#/definitions/ConfigReadParams" } }, "required": [ @@ -4743,7 +6016,7 @@ "method", "params" ], - "title": "CancelLoginChatGptRequest", + "title": "Config/readRequest", "type": "object" }, { @@ -4753,37 +6026,37 @@ }, "method": { "enum": [ - "logoutChatGpt" + "externalAgentConfig/detect" ], - "title": "LogoutChatGptRequestMethod", + "title": "ExternalAgentConfig/detectRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/ExternalAgentConfigDetectParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "LogoutChatGptRequest", + "title": "ExternalAgentConfig/detectRequest", "type": "object" }, { - "description": "DEPRECATED in favor of GetAccount", "properties": { "id": { "$ref": "#/definitions/RequestId" }, "method": { "enum": [ - "getAuthStatus" + "externalAgentConfig/import" ], - "title": "GetAuthStatusRequestMethod", + "title": "ExternalAgentConfig/importRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/GetAuthStatusParams" + "$ref": "#/definitions/ExternalAgentConfigImportParams" } }, "required": [ @@ -4791,7 +6064,7 @@ "method", "params" ], - "title": "GetAuthStatusRequest", + "title": "ExternalAgentConfig/importRequest", "type": "object" }, { @@ -4801,20 +6074,21 @@ }, "method": { "enum": [ - "getUserSavedConfig" + "config/value/write" ], - "title": "GetUserSavedConfigRequestMethod", + "title": "Config/value/writeRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/ConfigValueWriteParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "GetUserSavedConfigRequest", + "title": "Config/value/writeRequest", "type": "object" }, { @@ -4824,13 +6098,13 @@ }, "method": { "enum": [ - "setDefaultModel" + "config/batchWrite" ], - "title": "SetDefaultModelRequestMethod", + "title": "Config/batchWriteRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SetDefaultModelParams" + "$ref": "#/definitions/ConfigBatchWriteParams" } }, "required": [ @@ -4838,7 +6112,7 @@ "method", "params" ], - "title": "SetDefaultModelRequest", + "title": "Config/batchWriteRequest", "type": "object" }, { @@ -4848,9 +6122,9 @@ }, "method": { "enum": [ - "getUserAgent" + "configRequirements/read" ], - "title": "GetUserAgentRequestMethod", + "title": "ConfigRequirements/readRequestMethod", "type": "string" }, "params": { @@ -4861,7 +6135,7 @@ "id", "method" ], - "title": "GetUserAgentRequest", + "title": "ConfigRequirements/readRequest", "type": "object" }, { @@ -4871,20 +6145,21 @@ }, "method": { "enum": [ - "userInfo" + "account/read" ], - "title": "UserInfoRequestMethod", + "title": "Account/readRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/GetAccountParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "UserInfoRequest", + "title": "Account/readRequest", "type": "object" }, { @@ -4910,32 +6185,7 @@ ], "title": "FuzzyFileSearchRequest", "type": "object" - }, - { - "description": "Execute a command (argv vector) under the server's sandbox.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "execOneOffCommand" - ], - "title": "ExecOneOffCommandRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ExecOneOffCommandParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "ExecOneOffCommandRequest", - "type": "object" } ], "title": "ClientRequest" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 1cc6c5ec39c..5b6c4cd1853 100644 --- a/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -1,11 +1,33 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": [ "array", @@ -13,8 +35,9 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": [ "array", @@ -24,39 +47,13 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { + "AdditionalNetworkPermissions": { "properties": { - "accessibility": { + "enabled": { "type": [ "boolean", "null" ] - }, - "automations": { - "anyOf": [ - { - "$ref": "#/definitions/MacOsAutomationValue" - }, - { - "type": "null" - } - ] - }, - "calendar": { - "type": [ - "boolean", - "null" - ] - }, - "preferences": { - "anyOf": [ - { - "$ref": "#/definitions/MacOsPreferencesValue" - }, - { - "type": "null" - } - ] } }, "type": "object" @@ -73,47 +70,22 @@ } ] }, - "macos": { + "network": { "anyOf": [ { - "$ref": "#/definitions/AdditionalMacOsPermissions" + "$ref": "#/definitions/AdditionalNetworkPermissions" }, { "type": "null" } - ] - }, - "network": { - "type": [ - "boolean", - "null" - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -123,7 +95,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -222,26 +194,278 @@ } ] }, - "MacOsAutomationValue": { - "anyOf": [ + "CommandExecutionApprovalDecision": { + "oneOf": [ { - "type": "boolean" + "description": "User approved the command.", + "enum": [ + "accept" + ], + "type": "string" }, { - "items": { - "type": "string" + "description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.", + "enum": [ + "acceptForSession" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", + "properties": { + "acceptWithExecpolicyAmendment": { + "properties": { + "execpolicy_amendment": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "execpolicy_amendment" + ], + "type": "object" + } }, - "type": "array" + "required": [ + "acceptWithExecpolicyAmendment" + ], + "title": "AcceptWithExecpolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, + { + "additionalProperties": false, + "description": "User chose a persistent network policy rule (allow/deny) for this host.", + "properties": { + "applyNetworkPolicyAmendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "applyNetworkPolicyAmendment" + ], + "title": "ApplyNetworkPolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, + { + "description": "User denied the command. The agent will continue the turn.", + "enum": [ + "decline" + ], + "type": "string" + }, + { + "description": "User denied the command. The turn will also be immediately interrupted.", + "enum": [ + "cancel" + ], + "type": "string" } ] }, - "MacOsPreferencesValue": { - "anyOf": [ + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ { - "type": "boolean" + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" }, { - "type": "string" + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" } ] }, @@ -268,11 +492,33 @@ "socks5Udp" ], "type": "string" + }, + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" } }, "properties": { "approvalId": { - "description": "Identifier for this specific approval callback.", + "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.", "type": [ "string", "null" @@ -296,11 +542,15 @@ ] }, "cwd": { - "description": "The command's working directory.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "The command's working directory." }, "itemId": { "type": "string" @@ -326,6 +576,16 @@ "null" ] }, + "proposedNetworkPolicyAmendments": { + "description": "Optional proposed network policy amendments (allow/deny host) for future requests.", + "items": { + "$ref": "#/definitions/NetworkPolicyAmendment" + }, + "type": [ + "array", + "null" + ] + }, "reason": { "description": "Optional explanatory reason (e.g. request for network access).", "type": [ @@ -333,6 +593,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -342,9 +607,10 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], "title": "CommandExecutionRequestApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json b/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json index 699dc0fea2a..0b7986fba92 100644 --- a/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json +++ b/code-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json @@ -11,7 +11,7 @@ "type": "string" }, { - "description": "User approved the command and future identical commands should run without prompting.", + "description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.", "enum": [ "acceptForSession" ], @@ -42,6 +42,28 @@ "title": "AcceptWithExecpolicyAmendmentCommandExecutionApprovalDecision", "type": "object" }, + { + "additionalProperties": false, + "description": "User chose a persistent network policy rule (allow/deny) for this host.", + "properties": { + "applyNetworkPolicyAmendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "applyNetworkPolicyAmendment" + ], + "title": "ApplyNetworkPolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, { "description": "User denied the command. The agent will continue the turn.", "enum": [ @@ -57,6 +79,28 @@ "type": "string" } ] + }, + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" } }, "properties": { @@ -69,4 +113,4 @@ ], "title": "CommandExecutionRequestApprovalResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/DynamicToolCallParams.json b/code-rs/app-server-protocol/schema/json/DynamicToolCallParams.json index 0aa8b9c571f..991733da668 100644 --- a/code-rs/app-server-protocol/schema/json/DynamicToolCallParams.json +++ b/code-rs/app-server-protocol/schema/json/DynamicToolCallParams.json @@ -30,4 +30,4 @@ ], "title": "DynamicToolCallParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/DynamicToolCallResponse.json b/code-rs/app-server-protocol/schema/json/DynamicToolCallResponse.json index 1409ee84c12..e0e29641d26 100644 --- a/code-rs/app-server-protocol/schema/json/DynamicToolCallResponse.json +++ b/code-rs/app-server-protocol/schema/json/DynamicToolCallResponse.json @@ -63,4 +63,4 @@ ], "title": "DynamicToolCallResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/EventMsg.json b/code-rs/app-server-protocol/schema/json/EventMsg.json deleted file mode 100644 index 2df96cc8e52..00000000000 --- a/code-rs/app-server-protocol/schema/json/EventMsg.json +++ /dev/null @@ -1,8408 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "AutoContextPhase": { - "enum": [ - "checking", - "compacting" - ], - "type": "string" - }, - "AutomationOrigin": { - "properties": { - "actor": { - "description": "Actor reported by the source system as applying the trigger.", - "type": [ - "string", - "null" - ] - }, - "event_id": { - "description": "GitHub event delivery id, webhook id, or local request id.", - "type": [ - "string", - "null" - ] - }, - "issue_number": { - "description": "GitHub issue or pull request number associated with the trigger.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "kind": { - "$ref": "#/definitions/AutomationTriggerKind" - }, - "label": { - "description": "Label that triggered automation, such as `every-code`.", - "type": [ - "string", - "null" - ] - }, - "repository": { - "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", - "type": [ - "string", - "null" - ] - }, - "source": { - "description": "Tool, worker, or integration that launched this automated session.", - "type": [ - "string", - "null" - ] - }, - "url": { - "description": "Direct URL to the triggering issue, PR, event, or worker record.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - "AutomationTriggerKind": { - "enum": [ - "github_label", - "other" - ], - "type": "string" - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "cyber_policy", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "model_cap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "model_cap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Auto Context is evaluating whether to compact before the next turn.", - "properties": { - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/AutoContextPhase" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "auto_context_check" - ], - "title": "AutoContextCheckEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AutoContextCheckEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "automation_origin": { - "anyOf": [ - { - "$ref": "#/definitions/AutomationOrigin" - }, - { - "type": "null" - } - ], - "description": "Structured metadata for automated sessions, if the launcher provided it." - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_begin" - ], - "title": "ImageGenerationBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "ImageGenerationBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_end" - ], - "title": "ImageGenerationEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "result", - "status", - "type" - ], - "title": "ImageGenerationEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "default": {}, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "default": {}, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "default": {}, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "server_failures": { - "additionalProperties": { - "$ref": "#/definitions/McpServerFailure" - }, - "description": "Legacy server failure map keyed by server name.", - "type": [ - "object", - "null" - ] - }, - "server_tools": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "Legacy server -> tool names map used by existing UI surfaces.", - "type": [ - "object", - "null" - ] - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Optional note used by newer clients; when provided it supersedes `name`.", - "type": [ - "string", - "null" - ] - }, - "name": { - "default": null, - "description": "Legacy field name used by existing clients.", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "prompt": { - "default": "", - "description": "Legacy plain-text prompt retained for compatibility with older review flows.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "snapshot": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewSnapshotInfo" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "ImageDetail": { - "enum": [ - "auto", - "low", - "high", - "original" - ], - "type": "string" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpServerFailure": { - "properties": { - "message": { - "type": "string" - }, - "phase": { - "$ref": "#/definitions/McpServerFailurePhase" - } - }, - "required": [ - "message", - "phase" - ], - "type": "object" - }, - "McpServerFailurePhase": { - "enum": [ - "start", - "list_tools" - ], - "type": "string" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "read_command" - ], - "title": "ReadCommandParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ReadCommandParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitReachedType": { - "enum": [ - "rate_limit_reached", - "workspace_owner_credits_depleted", - "workspace_member_credits_depleted", - "workspace_owner_usage_limit_reached", - "workspace_member_usage_limit_reached" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "rate_limit_reached_type": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitReachedType" - }, - { - "type": "null" - } - ], - "default": null - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "resets_in_seconds": { - "description": "Legacy relative reset in seconds.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": true, - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "tool_search_call" - ], - "title": "ToolSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "execution", - "type" - ], - "title": "ToolSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tools": { - "items": true, - "type": "array" - }, - "type": { - "enum": [ - "tool_search_output" - ], - "title": "ToolSearchOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "execution", - "status", - "tools", - "type" - ], - "title": "ToolSearchOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_call" - ], - "title": "ImageGenerationCallResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction_summary" - ], - "title": "CompactionSummaryResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionSummaryResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewSnapshotInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "repo_root": { - "type": [ - "string", - "null" - ] - }, - "snapshot_commit": { - "type": [ - "string", - "null" - ] - }, - "worktree_path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "allow_implicit_invocation": { - "default": true, - "type": "boolean" - }, - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "cached_input_tokens_reported": { - "default": false, - "type": "boolean" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "latest_response_model": { - "type": [ - "string", - "null" - ] - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "requested_model": { - "type": [ - "string", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "ImageGeneration" - ], - "title": "ImageGenerationTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Auto Context is evaluating whether to compact before the next turn.", - "properties": { - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/AutoContextPhase" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "auto_context_check" - ], - "title": "AutoContextCheckEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AutoContextCheckEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "automation_origin": { - "anyOf": [ - { - "$ref": "#/definitions/AutomationOrigin" - }, - { - "type": "null" - } - ], - "description": "Structured metadata for automated sessions, if the launcher provided it." - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_begin" - ], - "title": "ImageGenerationBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "ImageGenerationBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_end" - ], - "title": "ImageGenerationEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "result", - "status", - "type" - ], - "title": "ImageGenerationEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "default": {}, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "default": {}, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "default": {}, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "server_failures": { - "additionalProperties": { - "$ref": "#/definitions/McpServerFailure" - }, - "description": "Legacy server failure map keyed by server name.", - "type": [ - "object", - "null" - ] - }, - "server_tools": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "Legacy server -> tool names map used by existing UI surfaces.", - "type": [ - "object", - "null" - ] - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Optional note used by newer clients; when provided it supersedes `name`.", - "type": [ - "string", - "null" - ] - }, - "name": { - "default": null, - "description": "Legacy field name used by existing clients.", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "prompt": { - "default": "", - "description": "Legacy plain-text prompt retained for compatibility with older review flows.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "snapshot": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewSnapshotInfo" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ], - "title": "EventMsg" -} diff --git a/code-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json b/code-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json index d855116d7a8..43f85d21ae8 100644 --- a/code-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json +++ b/code-rs/app-server-protocol/schema/json/ExecCommandApprovalParams.json @@ -3,26 +3,6 @@ "definitions": { "ParsedCommand": { "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "read_command" - ], - "title": "ReadCommandParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ReadCommandParsedCommand", - "type": "object" - }, { "properties": { "cmd": { @@ -145,7 +125,7 @@ ] }, "callId": { - "description": "Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] and [codex_core::protocol::ExecCommandEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] and [codex_protocol::protocol::ExecCommandEndEvent].", "type": "string" }, "command": { @@ -182,4 +162,4 @@ ], "title": "ExecCommandApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json b/code-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json index 6f7c0b3b987..477109e2b05 100644 --- a/code-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json +++ b/code-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json @@ -1,6 +1,28 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "ReviewDecision": { "description": "User's decision in response to an ExecApprovalRequest.", "oneOf": [ @@ -37,12 +59,34 @@ "type": "object" }, { - "description": "User has approved this command and wants to automatically approve any future identical instances (`command` and `cwd` match exactly) for the remainder of the session.", + "description": "User has approved this request and wants future prompts in the same session-scoped approval cache to be automatically approved for the remainder of the session.", "enum": [ "approved_for_session" ], "type": "string" }, + { + "additionalProperties": false, + "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", + "properties": { + "network_policy_amendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "network_policy_amendment" + ], + "title": "NetworkPolicyAmendmentReviewDecision", + "type": "object" + }, { "description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.", "enum": [ @@ -50,6 +94,13 @@ ], "type": "string" }, + { + "description": "Automatic approval review timed out before reaching a decision.", + "enum": [ + "timed_out" + ], + "type": "string" + }, { "description": "User has denied this command and the agent should not do anything until the user's next command.", "enum": [ @@ -70,4 +121,4 @@ ], "title": "ExecCommandApprovalResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json b/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json index b1fc648dbc0..f17388aa53a 100644 --- a/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json +++ b/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json @@ -18,6 +18,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -27,9 +32,10 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], "title": "FileChangeRequestApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalResponse.json b/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalResponse.json index ab3a86509ba..f20035e3d7a 100644 --- a/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalResponse.json +++ b/code-rs/app-server-protocol/schema/json/FileChangeRequestApprovalResponse.json @@ -44,4 +44,4 @@ ], "title": "FileChangeRequestApprovalResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json index 5dc2c431c1d..3a72939de43 100644 --- a/code-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json +++ b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json @@ -23,4 +23,4 @@ ], "title": "FuzzyFileSearchParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json index 29268b8ff42..3c91a79c697 100644 --- a/code-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json +++ b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json @@ -1,6 +1,13 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "FuzzyFileSearchMatchType": { + "enum": [ + "file", + "directory" + ], + "type": "string" + }, "FuzzyFileSearchResult": { "description": "Superset of [`codex_file_search::FileMatch`]", "properties": { @@ -18,6 +25,9 @@ "null" ] }, + "match_type": { + "$ref": "#/definitions/FuzzyFileSearchMatchType" + }, "path": { "type": "string" }, @@ -32,6 +42,7 @@ }, "required": [ "file_name", + "match_type", "path", "root", "score" @@ -52,4 +63,4 @@ ], "title": "FuzzyFileSearchResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json new file mode 100644 index 00000000000..c8924e77caf --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionCompletedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionUpdatedNotification.json new file mode 100644 index 00000000000..b69ad9b288f --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/FuzzyFileSearchSessionUpdatedNotification.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "FuzzyFileSearchMatchType": { + "enum": [ + "file", + "directory" + ], + "type": "string" + }, + "FuzzyFileSearchResult": { + "description": "Superset of [`codex_file_search::FileMatch`]", + "properties": { + "file_name": { + "type": "string" + }, + "indices": { + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "match_type": { + "$ref": "#/definitions/FuzzyFileSearchMatchType" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "score": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "file_name", + "match_type", + "path", + "root", + "score" + ], + "type": "object" + } + }, + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" + }, + "type": "array" + }, + "query": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "files", + "query", + "sessionId" + ], + "title": "FuzzyFileSearchSessionUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/JSONRPCError.json b/code-rs/app-server-protocol/schema/json/JSONRPCError.json index 02fa4dd7b7f..6db5d1a7fa5 100644 --- a/code-rs/app-server-protocol/schema/json/JSONRPCError.json +++ b/code-rs/app-server-protocol/schema/json/JSONRPCError.json @@ -45,4 +45,4 @@ ], "title": "JSONRPCError", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/JSONRPCErrorError.json b/code-rs/app-server-protocol/schema/json/JSONRPCErrorError.json index e4b90987aae..932ef33c9a7 100644 --- a/code-rs/app-server-protocol/schema/json/JSONRPCErrorError.json +++ b/code-rs/app-server-protocol/schema/json/JSONRPCErrorError.json @@ -16,4 +16,4 @@ ], "title": "JSONRPCErrorError", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/JSONRPCMessage.json b/code-rs/app-server-protocol/schema/json/JSONRPCMessage.json index 8a65f25ef29..27b78b90c92 100644 --- a/code-rs/app-server-protocol/schema/json/JSONRPCMessage.json +++ b/code-rs/app-server-protocol/schema/json/JSONRPCMessage.json @@ -70,7 +70,18 @@ "method": { "type": "string" }, - "params": true + "params": true, + "trace": { + "anyOf": [ + { + "$ref": "#/definitions/W3cTraceContext" + }, + { + "type": "null" + } + ], + "description": "Optional W3C Trace Context for distributed tracing." + } }, "required": [ "id", @@ -102,8 +113,25 @@ "type": "integer" } ] + }, + "W3cTraceContext": { + "properties": { + "traceparent": { + "type": [ + "string", + "null" + ] + }, + "tracestate": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" } }, "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.", "title": "JSONRPCMessage" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/JSONRPCNotification.json b/code-rs/app-server-protocol/schema/json/JSONRPCNotification.json index 47453f6bb1a..2ddd61a8ca7 100644 --- a/code-rs/app-server-protocol/schema/json/JSONRPCNotification.json +++ b/code-rs/app-server-protocol/schema/json/JSONRPCNotification.json @@ -12,4 +12,4 @@ ], "title": "JSONRPCNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/JSONRPCRequest.json b/code-rs/app-server-protocol/schema/json/JSONRPCRequest.json index 0a8a5010c52..e4ea7c206b0 100644 --- a/code-rs/app-server-protocol/schema/json/JSONRPCRequest.json +++ b/code-rs/app-server-protocol/schema/json/JSONRPCRequest.json @@ -11,6 +11,23 @@ "type": "integer" } ] + }, + "W3cTraceContext": { + "properties": { + "traceparent": { + "type": [ + "string", + "null" + ] + }, + "tracestate": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" } }, "description": "A request that expects a response.", @@ -21,7 +38,18 @@ "method": { "type": "string" }, - "params": true + "params": true, + "trace": { + "anyOf": [ + { + "$ref": "#/definitions/W3cTraceContext" + }, + { + "type": "null" + } + ], + "description": "Optional W3C Trace Context for distributed tracing." + } }, "required": [ "id", @@ -29,4 +57,4 @@ ], "title": "JSONRPCRequest", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/JSONRPCResponse.json b/code-rs/app-server-protocol/schema/json/JSONRPCResponse.json index 527100a7038..9f1ec295485 100644 --- a/code-rs/app-server-protocol/schema/json/JSONRPCResponse.json +++ b/code-rs/app-server-protocol/schema/json/JSONRPCResponse.json @@ -26,4 +26,4 @@ ], "title": "JSONRPCResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json b/code-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json new file mode 100644 index 00000000000..aa7fa817ae9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json @@ -0,0 +1,609 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpElicitationArrayType": { + "enum": [ + "array" + ], + "type": "string" + }, + "McpElicitationBooleanSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "boolean", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationBooleanType": { + "enum": [ + "boolean" + ], + "type": "string" + }, + "McpElicitationConstOption": { + "additionalProperties": false, + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "McpElicitationEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "additionalProperties": false, + "properties": { + "default": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "maximum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "minimum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationNumberType": { + "enum": [ + "number", + "integer" + ], + "type": "string" + }, + "McpElicitationObjectType": { + "enum": [ + "object" + ], + "type": "string" + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationStringSchema" + }, + { + "$ref": "#/definitions/McpElicitationNumberSchema" + }, + { + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "additionalProperties": false, + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "enum": [ + "email", + "uri", + "date", + "date-time" + ], + "type": "string" + }, + "McpElicitationStringSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationStringType": { + "enum": [ + "string" + ], + "type": "string" + }, + "McpElicitationTitledEnumItems": { + "additionalProperties": false, + "properties": { + "anyOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledEnumItems": { + "additionalProperties": false, + "properties": { + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + }, + "oneOf": [ + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" + } + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + } + ], + "properties": { + "serverName": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "serverName", + "threadId" + ], + "title": "McpServerElicitationRequestParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json b/code-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json new file mode 100644 index 00000000000..13390a06cfc --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/McpServerElicitationRequestResponse.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpServerElicitationAction": { + "enum": [ + "accept", + "decline", + "cancel" + ], + "type": "string" + } + }, + "properties": { + "_meta": { + "description": "Optional client metadata for form-mode action handling." + }, + "action": { + "$ref": "#/definitions/McpServerElicitationAction" + }, + "content": { + "description": "Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.\n\nThis is nullable because decline/cancel responses have no content." + } + }, + "required": [ + "action" + ], + "title": "McpServerElicitationRequestResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/code-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json new file mode 100644 index 00000000000..1383da6124e --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -0,0 +1,322 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "cwd", + "itemId", + "permissions", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "PermissionsRequestApprovalParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/code-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json new file mode 100644 index 00000000000..3e775a3da9f --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -0,0 +1,315 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "GrantedPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionGrantScope": { + "enum": [ + "turn", + "session" + ], + "type": "string" + } + }, + "properties": { + "permissions": { + "$ref": "#/definitions/GrantedPermissionProfile" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ], + "default": "turn" + }, + "strictAutoReview": { + "description": "Review every subsequent command in this turn before normal sandboxed execution.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "permissions" + ], + "title": "PermissionsRequestApprovalResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/RequestId.json b/code-rs/app-server-protocol/schema/json/RequestId.json index ae651b9b408..d0fa43db824 100644 --- a/code-rs/app-server-protocol/schema/json/RequestId.json +++ b/code-rs/app-server-protocol/schema/json/RequestId.json @@ -10,4 +10,4 @@ } ], "title": "RequestId" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ServerNotification.json b/code-rs/app-server-protocol/schema/json/ServerNotification.json index 48290efd482..4e9e63d3027 100644 --- a/code-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/code-rs/app-server-protocol/schema/json/ServerNotification.json @@ -50,33 +50,72 @@ "type": "null" } ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] } }, "type": "object" }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] } - ] + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" }, "AgentMessageDeltaNotification": { "properties": { @@ -101,73 +140,74 @@ ], "type": "object" }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" + "AgentPath": { + "type": "string" + }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" + "developer": { + "type": [ + "string", + "null" + ] }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" + "isDiscoverableApp": { + "type": "boolean" }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" + "privacyPolicy": { + "type": [ + "string", + "null" + ] }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" + "termsOfService": { + "type": [ + "string", + "null" + ] }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" + "website": { + "type": [ + "string", + "null" + ] } - ] + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -198,6 +238,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -212,6 +261,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ @@ -235,200 +291,178 @@ ], "type": "object" }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" + "type": [ + "array", + "null" + ] }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ - { - "description": "OpenAI API key provided by the caller and stored by Codex.", - "enum": [ - "apikey" - ], - "type": "string" + "developer": { + "type": [ + "string", + "null" + ] }, - { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", - "enum": [ - "chatgpt" - ], - "type": "string" + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] }, - { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", - "enum": [ - "chatgptAuthTokens" - ], - "type": "string" - } - ] - }, - "AuthStatusChangeNotification": { - "description": "Deprecated notification. Use AccountUpdatedNotification instead.", - "properties": { - "authMethod": { + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { "anyOf": [ { - "$ref": "#/definitions/AuthMode" + "$ref": "#/definitions/AppReview" }, { "type": "null" } ] - } - }, - "type": "object" - }, - "AutoContextPhase": { - "enum": [ - "checking", - "compacting" - ], - "type": "string" - }, - "AutomationOrigin": { - "properties": { - "actor": { - "description": "Actor reported by the source system as applying the trigger.", + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, "type": [ - "string", + "array", "null" ] }, - "event_id": { - "description": "GitHub event delivery id, webhook id, or local request id.", + "seoDescription": { "type": [ "string", "null" ] }, - "issue_number": { - "description": "GitHub issue or pull request number associated with the trigger.", - "format": "uint64", - "minimum": 0.0, + "showInComposerWhenUnlinked": { "type": [ - "integer", + "boolean", "null" ] }, - "kind": { - "$ref": "#/definitions/AutomationTriggerKind" - }, - "label": { - "description": "Label that triggered automation, such as `every-code`.", - "type": [ - "string", + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", "null" ] }, - "repository": { - "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", + "version": { "type": [ "string", "null" ] }, - "source": { - "description": "Tool, worker, or integration that launched this automated session.", + "versionId": { "type": [ "string", "null" ] }, - "url": { - "description": "Direct URL to the triggering issue, PR, event, or worker record.", + "versionNotes": { "type": [ "string", "null" ] } }, - "required": [ - "kind" - ], "type": "object" }, - "AutomationTriggerKind": { - "enum": [ - "github_label", - "other" + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" ], - "type": "string" + "type": "object" }, - "ByteRange": { + "AppScreenshot": { "properties": { - "end": { - "format": "uint", - "minimum": 0.0, - "type": "integer" + "fileId": { + "type": [ + "string", + "null" + ] }, - "start": { - "format": "uint", - "minimum": 0.0, - "type": "integer" + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" } }, "required": [ - "end", - "start" + "userPrompt" ], "type": "object" }, - "ByteRange2": { + "AuthMode": { + "description": "Authentication mode for OpenAI-backed providers.", + "oneOf": [ + { + "description": "OpenAI API key provided by the caller and stored by Codex.", + "enum": [ + "apikey" + ], + "type": "string" + }, + { + "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", + "enum": [ + "chatgpt" + ], + "type": "string" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", + "enum": [ + "chatgptAuthTokens" + ], + "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" + } + ] + }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, + "ByteRange": { "properties": { "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", "format": "uint", "minimum": 0.0, "type": "integer" }, "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", "format": "uint", "minimum": 0.0, "type": "integer" @@ -440,27 +474,6 @@ ], "type": "object" }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, "CodexErrorInfo": { "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", "oneOf": [ @@ -468,6 +481,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -478,35 +492,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -601,148 +586,27 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" - } - ] - }, - "CodexErrorInfo2": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "cyber_policy", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" }, { "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", "properties": { - "model_cap": { + "activeTurnNotSteerable": { "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" } }, "required": [ - "model" + "turnKind" ], "type": "object" } }, "required": [ - "model_cap" - ], - "title": "ModelCapCodexErrorInfo2", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo2", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo2", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo2", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" + "activeTurnNotSteerable" ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo2", + "title": "ActiveTurnNotSteerableCodexErrorInfo", "type": "object" } ] @@ -768,6 +632,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -795,26 +660,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -824,7 +669,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -923,37 +768,97 @@ } ] }, - "CommandExecutionOutputDeltaNotification": { + "CommandExecOutputDeltaNotification": { + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", "properties": { - "delta": { - "type": "string" + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" }, - "itemId": { + "deltaBase64": { + "description": "Base64-encoded output bytes.", "type": "string" }, - "threadId": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", "type": "string" }, - "turnId": { - "type": "string" + "stream": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." } }, "required": [ - "delta", - "itemId", - "threadId", - "turnId" + "capReached", + "deltaBase64", + "processId", + "stream" ], "type": "object" }, - "CommandExecutionStatus": { - "enum": [ - "inProgress", - "completed", - "failed", - "declined" - ], - "type": "string" + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, + "CommandExecutionOutputDeltaNotification": { + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "type": "object" + }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" }, "ConfigWarningNotification": { "properties": { @@ -992,70 +897,6 @@ ], "type": "object" }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, "ContextCompactedNotification": { "description": "Deprecated: Use `ContextCompaction` item type instead.", "properties": { @@ -1093,630 +934,678 @@ ], "type": "object" }, - "CreditsSnapshot2": { + "DeprecationNoticeNotification": { "properties": { - "balance": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", "type": [ "string", "null" ] }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" } }, "required": [ - "has_credits", - "unlimited" + "summary" ], "type": "object" }, - "CustomPrompt": { + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "ErrorNotification": { "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] + "error": { + "$ref": "#/definitions/TurnError" }, - "content": { + "threadId": { "type": "string" }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { + "turnId": { "type": "string" }, - "path": { - "type": "string" + "willRetry": { + "type": "boolean" } }, "required": [ - "content", - "name", - "path" + "error", + "threadId", + "turnId", + "willRetry" ], "type": "object" }, - "DeprecationNoticeNotification": { - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - } - }, - "required": [ - "summary" - ], + "ExternalAgentConfigImportCompletedNotification": { "type": "object" }, - "Duration": { + "FileChangeOutputDeltaNotification": { + "description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.", "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" + "delta": { + "type": "string" }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" } }, "required": [ - "nanos", - "secs" + "delta", + "itemId", + "threadId", + "turnId" ], "type": "object" }, - "ErrorNotification": { + "FileChangePatchUpdatedNotification": { "properties": { - "error": { - "$ref": "#/definitions/TurnError" + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "itemId": { + "type": "string" }, "threadId": { "type": "string" }, "turnId": { "type": "string" - }, - "willRetry": { - "type": "boolean" } }, "required": [ - "error", + "changes", + "itemId", "threadId", - "turnId", - "willRetry" + "turnId" ], "type": "object" }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { "oneOf": [ { - "description": "Error while executing a submission", "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo2" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" + "path": { + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "error" + "path" ], - "title": "ErrorEventMsgType", + "title": "PathFileSystemPathType", "type": "string" } }, "required": [ - "message", + "path", "type" ], - "title": "ErrorEventMsg", + "title": "PathFileSystemPath", "type": "object" }, { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", "properties": { - "message": { + "pattern": { "type": "string" }, "type": { "enum": [ - "warning" + "glob_pattern" ], - "title": "WarningEventMsgType", + "title": "GlobPatternFileSystemPathType", "type": "string" } }, "required": [ - "message", + "pattern", "type" ], - "title": "WarningEventMsg", + "title": "GlobPatternFileSystemPath", "type": "object" }, { - "description": "Conversation history was compacted (either automatically or manually).", "properties": { "type": { "enum": [ - "context_compacted" + "special" ], - "title": "ContextCompactedEventMsgType", + "title": "SpecialFileSystemPathType", "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" } }, "required": [ - "type" + "type", + "value" ], - "title": "ContextCompactedEventMsg", + "title": "SpecialFileSystemPath", "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ { - "description": "Conversation history was rolled back by dropping the last N user turns.", "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { + "kind": { "enum": [ - "thread_rolled_back" + "root" ], - "title": "ThreadRolledBackEventMsgType", "type": "string" } }, "required": [ - "num_turns", - "type" + "kind" ], - "title": "ThreadRolledBackEventMsg", + "title": "RootFileSystemSpecialPath", "type": "object" }, { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "type": { + "kind": { "enum": [ - "task_started" + "minimal" ], - "title": "TaskStartedEventMsgType", "type": "string" } }, "required": [ - "type" + "kind" ], - "title": "TaskStartedEventMsg", + "title": "MinimalFileSystemSpecialPath", "type": "object" }, { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", "properties": { - "last_agent_message": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { "type": [ "string", "null" ] - }, - "type": { + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { "enum": [ - "task_complete" + "tmpdir" ], - "title": "TaskCompleteEventMsgType", "type": "string" } }, "required": [ - "type" + "kind" ], - "title": "TaskCompleteEventMsg", + "title": "TmpdirFileSystemSpecialPath", "type": "object" }, { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot2" - }, - { - "type": "null" - } - ] - }, - "type": { + "kind": { "enum": [ - "token_count" + "slash_tmp" ], - "title": "TokenCountEventMsgType", "type": "string" } }, "required": [ - "type" + "kind" ], - "title": "TokenCountEventMsg", + "title": "SlashTmpFileSystemSpecialPath", "type": "object" }, { - "description": "Auto Context is evaluating whether to compact before the next turn.", "properties": { - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/AutoContextPhase" - }, - { - "type": "null" - } - ] - }, - "type": { + "kind": { "enum": [ - "auto_context_check" + "unknown" ], - "title": "AutoContextCheckEventMsgType", "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] } }, "required": [ - "type" + "kind", + "path" ], - "title": "AutoContextCheckEventMsg", "type": "object" + } + ] + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "FsChangedNotification": { + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "type": "object" + }, + "FuzzyFileSearchMatchType": { + "enum": [ + "file", + "directory" + ], + "type": "string" + }, + "FuzzyFileSearchResult": { + "description": "Superset of [`codex_file_search::FileMatch`]", + "properties": { + "file_name": { + "type": "string" + }, + "indices": { + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "match_type": { + "$ref": "#/definitions/FuzzyFileSearchMatchType" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "score": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "file_name", + "match_type", + "path", + "root", + "score" + ], + "type": "object" + }, + "FuzzyFileSearchSessionCompletedNotification": { + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" + }, + "FuzzyFileSearchSessionUpdatedNotification": { + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" + }, + "type": "array" + }, + "query": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "files", + "query", + "sessionId" + ], + "type": "object" + }, + "GitInfo": { + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "GuardianApprovalReview": { + "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", + "properties": { + "rationale": { + "type": [ + "string", + "null" + ] + }, + "riskLevel": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianRiskLevel" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/GuardianApprovalReviewStatus" }, + "userAuthorization": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianUserAuthorization" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "GuardianApprovalReviewAction": { + "oneOf": [ { - "description": "Agent text output message", "properties": { - "message": { + "command": { "type": "string" }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, "type": { "enum": [ - "agent_message" + "command" ], - "title": "AgentMessageEventMsgType", + "title": "CommandGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "message", + "command", + "cwd", + "source", "type" ], - "title": "AgentMessageEventMsg", + "title": "CommandGuardianApprovalReviewAction", "type": "object" }, { - "description": "User/system input message (what was sent to the model)", "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", + "argv": { "items": { "type": "string" }, "type": "array" }, - "message": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "program": { "type": "string" }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement2" - }, - "type": "array" + "source": { + "$ref": "#/definitions/GuardianCommandSource" }, "type": { "enum": [ - "user_message" + "execve" ], - "title": "UserMessageEventMsgType", + "title": "ExecveGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "message", + "argv", + "cwd", + "program", + "source", "type" ], - "title": "UserMessageEventMsg", + "title": "ExecveGuardianApprovalReviewAction", "type": "object" }, { - "description": "Agent text output delta message", "properties": { - "delta": { - "type": "string" + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" + "files": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" }, "type": { "enum": [ - "agent_reasoning" + "applyPatch" ], - "title": "AgentReasoningEventMsgType", + "title": "ApplyPatchGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "text", + "cwd", + "files", "type" ], - "title": "AgentReasoningEventMsg", + "title": "ApplyPatchGuardianApprovalReviewAction", "type": "object" }, { - "description": "Agent reasoning delta event from agent.", "properties": { - "delta": { + "host": { "type": "string" }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", + "target": { "type": "string" }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, "type": { "enum": [ - "agent_reasoning_section_break" + "networkAccess" ], - "title": "AgentReasoningSectionBreakEventMsgType", + "title": "NetworkAccessGuardianApprovalReviewActionType", "type": "string" } }, "required": [ + "host", + "port", + "protocol", + "target", "type" ], - "title": "AgentReasoningSectionBreakEventMsg", + "title": "NetworkAccessGuardianApprovalReviewAction", "type": "object" }, { - "description": "Ack the client's configure message.", "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "automation_origin": { - "anyOf": [ - { - "$ref": "#/definitions/AutomationOrigin" - }, - { - "type": "null" - } - ], - "description": "Structured metadata for automated sessions, if the launcher provided it." - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, + "connectorId": { "type": [ - "array", + "string", "null" ] }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", + "connectorName": { "type": [ "string", "null" ] }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" + "server": { + "type": "string" }, - "session_id": { - "$ref": "#/definitions/ThreadId" + "toolName": { + "type": "string" }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", + "toolTitle": { "type": [ "string", "null" @@ -1724,33 +1613,26 @@ }, "type": { "enum": [ - "session_configured" + "mcpToolCall" ], - "title": "SessionConfiguredEventMsgType", + "title": "McpToolCallGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", + "server", + "toolName", "type" ], - "title": "SessionConfiguredEventMsg", + "title": "McpToolCallGuardianApprovalReviewAction", "type": "object" }, { - "description": "Updated session metadata (e.g., thread name changes).", "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" }, - "thread_name": { + "reason": { "type": [ "string", "null" @@ -1758,4714 +1640,1215 @@ }, "type": { "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" + "requestPermissions" ], - "title": "McpStartupUpdateEventMsgType", + "title": "RequestPermissionsGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "server", - "status", + "permissions", "type" ], - "title": "McpStartupUpdateEventMsg", + "title": "RequestPermissionsGuardianApprovalReviewAction", "type": "object" + } + ] + }, + "GuardianApprovalReviewStatus": { + "description": "[UNSTABLE] Lifecycle state for an approval auto-review.", + "enum": [ + "inProgress", + "approved", + "denied", + "timedOut", + "aborted" + ], + "type": "string" + }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, + "GuardianRiskLevel": { + "description": "[UNSTABLE] Risk level assigned by approval auto-review.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "GuardianUserAuthorization": { + "description": "[UNSTABLE] Authorization level assigned by approval auto-review.", + "enum": [ + "unknown", + "low", + "medium", + "high" + ], + "type": "string" + }, + "GuardianWarningNotification": { + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "type": "object" + }, + "HookCompletedNotification": { + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" + "threadId": { + "type": "string" }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "run", + "threadId" + ], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookExecutionMode": { + "enum": [ + "sync", + "async" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookOutputEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/HookOutputEntryKind" }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" + "text": { + "type": "string" + } + }, + "required": [ + "kind", + "text" + ], + "type": "object" + }, + "HookOutputEntryKind": { + "enum": [ + "warning", + "stop", + "feedback", + "context", + "error" + ], + "type": "string" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction2" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, + "HookRunStatus": { + "enum": [ + "running", + "completed", + "failed", + "blocked", + "stopped" + ], + "type": "string" + }, + "HookRunSummary": { + "properties": { + "completedAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_begin" - ], - "title": "ImageGenerationBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "ImageGenerationBeginEventMsg", - "type": "object" + "displayOrder": { + "format": "int64", + "type": "integer" }, - { - "properties": { - "call_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_end" - ], - "title": "ImageGenerationEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "result", - "status", - "type" - ], - "title": "ImageGenerationEndEventMsg", - "type": "object" + "durationMs": { + "format": "int64", + "type": [ + "integer", + "null" + ] }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } + "entries": { + "items": { + "$ref": "#/definitions/HookOutputEntry" }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" + "type": "array" }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" + "eventName": { + "$ref": "#/definitions/HookEventName" }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" + "executionMode": { + "$ref": "#/definitions/HookExecutionMode" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "id": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/HookScope" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" ], - "title": "TerminalInteractionEventMsg", - "type": "object" + "default": "unknown" }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo2" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "default": {}, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "default": {}, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "default": {}, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "server_failures": { - "additionalProperties": { - "$ref": "#/definitions/McpServerFailure" - }, - "description": "Legacy server failure map keyed by server name.", - "type": [ - "object", - "null" - ] - }, - "server_tools": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "Legacy server -> tool names map used by existing UI surfaces.", - "type": [ - "object", - "null" - ] - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Optional note used by newer clients; when provided it supersedes `name`.", - "type": [ - "string", - "null" - ] - }, - "name": { - "default": null, - "description": "Legacy field name used by existing clients.", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "prompt": { - "default": "", - "description": "Legacy plain-text prompt retained for compatibility with older review flows.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "snapshot": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewSnapshotInfo" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FileChangeOutputDeltaNotification": { - "properties": { - "delta": { - "type": "string" - }, - "itemId": { - "type": "string" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "delta", - "itemId", - "threadId", - "turnId" - ], - "type": "object" - }, - "FileUpdateChange": { - "properties": { - "diff": { - "type": "string" - }, - "kind": { - "$ref": "#/definitions/PatchChangeKind" - }, - "path": { - "type": "string" - } - }, - "required": [ - "diff", - "kind", - "path" - ], - "type": "object" - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, - "GitInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "originUrl": { - "type": [ - "string", - "null" - ] - }, - "sha": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "ImageDetail": { - "enum": [ - "auto", - "low", - "high", - "original" - ], - "type": "string" - }, - "ItemCompletedNotification": { - "properties": { - "item": { - "$ref": "#/definitions/ThreadItem" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "item", - "threadId", - "turnId" - ], - "type": "object" - }, - "ItemStartedNotification": { - "properties": { - "item": { - "$ref": "#/definitions/ThreadItem" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "item", - "threadId", - "turnId" - ], - "type": "object" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "LoginChatGptCompleteNotification": { - "description": "Deprecated in favor of AccountLoginCompletedNotification.", - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "loginId": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "loginId", - "success" - ], - "type": "object" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpServerFailure": { - "properties": { - "message": { - "type": "string" - }, - "phase": { - "$ref": "#/definitions/McpServerFailurePhase" - } - }, - "required": [ - "message", - "phase" - ], - "type": "object" - }, - "McpServerFailurePhase": { - "enum": [ - "start", - "list_tools" - ], - "type": "string" - }, - "McpServerOauthLoginCompletedNotification": { - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "name", - "success" - ], - "type": "object" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "McpToolCallError": { - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - }, - "McpToolCallProgressNotification": { - "properties": { - "itemId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "itemId", - "message", - "threadId", - "turnId" - ], - "type": "object" - }, - "McpToolCallResult": { - "properties": { - "content": { - "items": true, - "type": "array" - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "McpToolCallStatus": { - "enum": [ - "inProgress", - "completed", - "failed" - ], - "type": "string" - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "read_command" - ], - "title": "ReadCommandParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ReadCommandParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PatchApplyStatus": { - "enum": [ - "inProgress", - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "PatchChangeKind": { - "oneOf": [ - { - "properties": { - "type": { - "enum": [ - "add" - ], - "title": "AddPatchChangeKindType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AddPatchChangeKind", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "delete" - ], - "title": "DeletePatchChangeKindType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DeletePatchChangeKind", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdatePatchChangeKindType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UpdatePatchChangeKind", - "type": "object" - } - ] - }, - "PlanDeltaNotification": { - "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", - "properties": { - "delta": { - "type": "string" - }, - "itemId": { - "type": "string" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "delta", - "itemId", - "threadId", - "turnId" - ], - "type": "object" - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitReachedType": { - "enum": [ - "rate_limit_reached", - "workspace_owner_credits_depleted", - "workspace_member_credits_depleted", - "workspace_owner_usage_limit_reached", - "workspace_member_usage_limit_reached" - ], - "type": "string" - }, - "RateLimitReachedType2": { - "enum": [ - "rate_limit_reached", - "workspace_owner_credits_depleted", - "workspace_member_credits_depleted", - "workspace_owner_usage_limit_reached", - "workspace_member_usage_limit_reached" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limitId": { - "type": [ - "string", - "null" - ] - }, - "limitName": { - "type": [ - "string", - "null" - ] - }, - "planType": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "rateLimitReachedType": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitReachedType" - }, - { - "type": "null" - } - ], - "default": null - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitSnapshot2": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot2" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow2" - }, - { - "type": "null" - } - ] - }, - "rate_limit_reached_type": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitReachedType2" - }, - { - "type": "null" - } - ], - "default": null - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow2" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resetsAt": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "usedPercent": { - "format": "int32", - "type": "integer" - }, - "windowDurationMins": { - "format": "int64", - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "usedPercent" - ], - "type": "object" - }, - "RateLimitWindow2": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "resets_in_seconds": { - "description": "Legacy relative reset in seconds.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "RawResponseItemCompletedNotification": { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "item", - "threadId", - "turnId" - ], - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "ReasoningSummaryPartAddedNotification": { - "properties": { - "itemId": { - "type": "string" - }, - "summaryIndex": { - "format": "int64", - "type": "integer" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "itemId", - "summaryIndex", - "threadId", - "turnId" - ], - "type": "object" - }, - "ReasoningSummaryTextDeltaNotification": { - "properties": { - "delta": { - "type": "string" - }, - "itemId": { - "type": "string" - }, - "summaryIndex": { - "format": "int64", - "type": "integer" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "delta", - "itemId", - "summaryIndex", - "threadId", - "turnId" - ], - "type": "object" - }, - "ReasoningTextDeltaNotification": { - "properties": { - "contentIndex": { - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "itemId": { - "type": "string" - }, - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "contentIndex", - "delta", - "itemId", - "threadId", - "turnId" - ], - "type": "object" - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] + "startedAt": { + "format": "int64", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/HookRunStatus" }, - "mimeType": { + "statusMessage": { "type": [ "string", "null" ] + } + }, + "required": [ + "displayOrder", + "entries", + "eventName", + "executionMode", + "handlerType", + "id", + "scope", + "sourcePath", + "startedAt", + "status" + ], + "type": "object" + }, + "HookScope": { + "enum": [ + "thread", + "turn" + ], + "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "cloudRequirements", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, + "HookStartedNotification": { + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" }, - "name": { + "threadId": { "type": "string" }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { + "turnId": { "type": [ "string", "null" ] - }, - "uri": { - "type": "string" } }, "required": [ - "name", - "uri" + "run", + "threadId" ], "type": "object" }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", + "ItemCompletedNotification": { "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" }, - "mimeType": { - "type": [ - "string", - "null" - ] + "item": { + "$ref": "#/definitions/ThreadItem" }, - "name": { + "threadId": { "type": "string" }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { + "turnId": { "type": "string" } }, "required": [ - "name", - "uriTemplate" + "completedAtMs", + "item", + "threadId", + "turnId" ], "type": "object" }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" + "ItemGuardianApprovalReviewCompletedNotification": { + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" + "decisionSource": { + "$ref": "#/definitions/AutoReviewDecisionSource" }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" + "review": { + "$ref": "#/definitions/GuardianApprovalReview" }, - { - "properties": { - "arguments": true, - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "tool_search_call" - ], - "title": "ToolSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "execution", - "type" - ], - "title": "ToolSearchCallResponseItem", - "type": "object" + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "action", + "completedAtMs", + "decisionSource", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "type": "object" + }, + "ItemGuardianApprovalReviewStartedNotification": { + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" + "review": { + "$ref": "#/definitions/GuardianApprovalReview" }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" }, - { - "properties": { - "call_id": { - "type": "string" - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" }, - { - "properties": { - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tools": { - "items": true, - "type": "array" - }, - "type": { - "enum": [ - "tool_search_output" - ], - "title": "ToolSearchOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "execution", - "status", - "tools", - "type" - ], - "title": "ToolSearchOutputResponseItem", - "type": "object" + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": [ + "string", + "null" + ] }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction2" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" + "threadId": { + "type": "string" }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_call" - ], - "title": "ImageGenerationCallResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationCallResponseItem", - "type": "object" + "turnId": { + "type": "string" + } + }, + "required": [ + "action", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "type": "object" + }, + "ItemStartedNotification": { + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction_summary" - ], - "title": "CompactionSummaryResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionSummaryResponseItem", - "type": "object" + "threadId": { + "type": "string" }, - { - "properties": { - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactionResponseItem", - "type": "object" + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "startedAtMs", + "threadId", + "turnId" + ], + "type": "object" + }, + "McpServerOauthLoginCompletedNotification": { + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" + "success": { + "type": "boolean" } - ] + }, + "required": [ + "name", + "success" + ], + "type": "object" }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" + "McpServerStartupState": { + "enum": [ + "starting", + "ready", + "failed", + "cancelled" + ], + "type": "string" + }, + "McpServerStatusUpdatedNotification": { + "properties": { + "error": { + "type": [ + "string", + "null" + ] }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" + "name": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpServerStartupState" } - ] + }, + "required": [ + "name", + "status" + ], + "type": "object" }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", + "McpToolCallError": { "properties": { - "absolute_file_path": { + "message": { "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" } }, "required": [ - "absolute_file_path", - "line_range" + "message" ], "type": "object" }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", + "McpToolCallProgressNotification": { "properties": { - "body": { + "itemId": { "type": "string" }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" + "message": { + "type": "string" }, - "priority": { - "format": "int32", - "type": "integer" + "threadId": { + "type": "string" }, - "title": { + "turnId": { "type": "string" } }, "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" + "itemId", + "message", + "threadId", + "turnId" ], "type": "object" }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", + "McpToolCallResult": { "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" + "_meta": true, + "content": { + "items": true, + "type": "array" }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } + "structuredContent": true }, "required": [ - "end", - "start" + "content" ], "type": "object" }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MemoryCitation": { "properties": { - "findings": { + "entries": { "items": { - "$ref": "#/definitions/ReviewFinding" + "$ref": "#/definitions/MemoryCitationEntry" }, "type": "array" }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" + "entries", + "threadIds" ], "type": "object" }, - "ReviewSnapshotInfo": { + "MemoryCitationEntry": { "properties": { - "branch": { - "type": [ - "string", - "null" - ] + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" }, - "repo_root": { - "type": [ - "string", - "null" - ] + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" }, - "snapshot_commit": { - "type": [ - "string", - "null" - ] + "note": { + "type": "string" }, - "worktree_path": { - "type": [ - "string", - "null" - ] + "path": { + "type": "string" } }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], "type": "object" }, - "ReviewTarget": { + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", "oneOf": [ { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" ], - "title": "UncommittedChangesReviewTarget", - "type": "object" + "type": "string" }, { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" ], - "title": "BaseBranchReviewTarget", - "type": "object" + "type": "string" + } + ] + }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "properties": { + "fromModel": { + "type": "string" }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" + "reason": { + "$ref": "#/definitions/ModelRerouteReason" }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "type": "object" + }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" + "type": "array" } - ] + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", + "PatchChangeKind": { "oneOf": [ { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", "properties": { "type": { "enum": [ - "read-only" + "add" ], - "title": "ReadOnlySandboxPolicyType", + "title": "AddPatchChangeKindType", "type": "string" } }, "required": [ "type" ], - "title": "ReadOnlySandboxPolicy", + "title": "AddPatchChangeKind", "type": "object" }, { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, "type": { "enum": [ - "external-sandbox" + "delete" ], - "title": "ExternalSandboxSandboxPolicyType", + "title": "DeletePatchChangeKindType", "type": "string" } }, "required": [ "type" ], - "title": "ExternalSandboxSandboxPolicy", + "title": "DeletePatchChangeKind", "type": "object" }, { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" + "move_path": { + "type": [ + "string", + "null" + ] }, "type": { "enum": [ - "workspace-write" + "update" ], - "title": "WorkspaceWriteSandboxPolicyType", + "title": "UpdatePatchChangeKindType", "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ "type" ], - "title": "WorkspaceWriteSandboxPolicy", + "title": "UpdatePatchChangeKind", "type": "object" } ] }, - "SessionConfiguredNotification": { + "PlanDeltaNotification": { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", "properties": { - "historyEntryCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" + "delta": { + "type": "string" }, - "historyLogId": { - "format": "uint64", - "minimum": 0.0, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "type": "object" + }, + "PlanType": { + "enum": [ + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_usage_based", + "business", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "unknown" + ], + "type": "string" + }, + "ProcessExitedNotification": { + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", "type": "integer" }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" }, - "model": { + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", "type": "string" }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" }, - "rolloutPath": { + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", "type": "string" }, - "sessionId": { - "$ref": "#/definitions/ThreadId" + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" } }, "required": [ - "historyEntryCount", - "historyLogId", - "model", - "rolloutPath", - "sessionId" + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" ], "type": "object" }, - "SessionSource": { + "ProcessOutputDeltaNotification": { + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" + ], + "type": "object" + }, + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", "oneOf": [ { + "description": "stdout stream. PTY mode multiplexes terminal output here.", "enum": [ - "cli", - "vscode", - "exec", - "appServer", - "unknown" + "stdout" ], "type": "string" }, { - "additionalProperties": false, - "properties": { - "subAgent": { - "$ref": "#/definitions/SubAgentSource" - } - }, - "required": [ - "subAgent" + "description": "stderr stream.", + "enum": [ + "stderr" ], - "title": "SubAgentSessionSource", - "type": "object" + "type": "string" } ] }, - "SkillDependencies": { + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, + "RateLimitSnapshot": { + "properties": { + "credits": { + "anyOf": [ + { + "$ref": "#/definitions/CreditsSnapshot" + }, + { + "type": "null" + } + ] + }, + "limitId": { + "type": [ + "string", + "null" + ] + }, + "limitName": { + "type": [ + "string", + "null" + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + }, + "primary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RateLimitWindow": { + "properties": { + "resetsAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "usedPercent": { + "format": "int32", + "type": "integer" + }, + "windowDurationMins": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "usedPercent" + ], + "type": "object" + }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningSummaryPartAddedNotification": { "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" } }, "required": [ - "tools" + "itemId", + "summaryIndex", + "threadId", + "turnId" ], "type": "object" }, - "SkillErrorInfo": { + "ReasoningSummaryTextDeltaNotification": { "properties": { - "message": { + "delta": { "type": "string" }, - "path": { + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { "type": "string" } }, "required": [ - "message", - "path" + "delta", + "itemId", + "summaryIndex", + "threadId", + "turnId" ], "type": "object" }, - "SkillInterface": { + "ReasoningTextDeltaNotification": { "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] + "contentIndex": { + "format": "int64", + "type": "integer" }, - "default_prompt": { - "type": [ - "string", - "null" - ] + "delta": { + "type": "string" }, - "display_name": { - "type": [ - "string", - "null" - ] + "itemId": { + "type": "string" }, - "icon_large": { - "type": [ - "string", - "null" - ] + "threadId": { + "type": "string" }, - "icon_small": { + "turnId": { + "type": "string" + } + }, + "required": [ + "contentIndex", + "delta", + "itemId", + "threadId", + "turnId" + ], + "type": "object" + }, + "RemoteControlConnectionStatus": { + "enum": [ + "disabled", + "connecting", + "connected", + "errored" + ], + "type": "string" + }, + "RemoteControlStatusChangedNotification": { + "description": "Current remote-control connection status and environment id exposed to clients.", + "properties": { + "environmentId": { "type": [ "string", "null" ] }, - "short_description": { - "type": [ - "string", - "null" - ] + "status": { + "$ref": "#/definitions/RemoteControlConnectionStatus" } }, + "required": [ + "status" + ], "type": "object" }, - "SkillMetadata": { - "properties": { - "allow_implicit_invocation": { - "default": true, - "type": "boolean" + "RequestId": { + "anyOf": [ + { + "type": "string" }, - "dependencies": { + { + "format": "int64", + "type": "integer" + } + ] + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { "anyOf": [ { - "$ref": "#/definitions/SkillDependencies" + "$ref": "#/definitions/AdditionalFileSystemPermissions" }, { "type": "null" } ] }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { + "network": { "anyOf": [ { - "$ref": "#/definitions/SkillInterface" + "$ref": "#/definitions/AdditionalNetworkPermissions" }, { "type": "null" } ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] } }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], "type": "object" }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { + "ServerRequestResolvedNotification": { "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] + "requestId": { + "$ref": "#/definitions/RequestId" }, - "value": { + "threadId": { "type": "string" } }, "required": [ - "type", - "value" + "requestId", + "threadId" ], "type": "object" }, - "SkillsListEntry": { - "properties": { - "cwd": { + "SessionSource": { + "oneOf": [ + { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ], "type": "string" }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } }, - "type": "array" + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } }, - "type": "array" + "required": [ + "subAgent" + ], + "title": "SubAgentSessionSource", + "type": "object" } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" + ] }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" + "SkillsChangedNotification": { + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "type": "object" }, "SubAgentSource": { "oneOf": [ @@ -6482,6 +2865,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -6539,41 +2947,18 @@ "required": [ "itemId", "processId", - "stdin", - "threadId", - "turnId" - ], - "type": "object" - }, - "TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byteRange" + "stdin", + "threadId", + "turnId" ], "type": "object" }, - "TextElement2": { + "TextElement": { "properties": { - "byte_range": { + "byteRange": { "allOf": [ { - "$ref": "#/definitions/ByteRange2" + "$ref": "#/definitions/ByteRange" } ], "description": "Byte range in the parent `text` buffer that this element occupies." @@ -6587,7 +2972,7 @@ } }, "required": [ - "byte_range" + "byteRange" ], "type": "object" }, @@ -6629,6 +3014,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -6639,8 +3038,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -6660,6 +3074,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -6671,6 +3092,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -6679,6 +3104,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -6696,15 +3140,134 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadArchivedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadClosedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -6737,11 +3300,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -6841,8 +3453,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -6870,6 +3486,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -6947,6 +3571,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -6982,7 +3612,66 @@ "tool", "type" ], - "title": "McpToolCallThreadItem", + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", "type": "object" }, { @@ -6998,6 +3687,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -7005,6 +3701,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -7092,7 +3799,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -7190,68 +3897,348 @@ "enum": [ "exitedReviewMode" ], - "title": "ExitedReviewModeThreadItemType", + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadNameUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "threadName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": [ + "string", + "null" + ] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + }, + "ThreadRealtimeClosedNotification": { + "description": "EXPERIMENTAL - emitted when thread realtime transport closes.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadRealtimeErrorNotification": { + "description": "EXPERIMENTAL - emitted when thread realtime encounters an error.", + "properties": { + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "type": "object" + }, + "ThreadRealtimeItemAddedNotification": { + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId" + ], + "type": "object" + }, + "ThreadRealtimeOutputAudioDeltaNotification": { + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" + ], + "type": "object" + }, + "ThreadRealtimeSdpNotification": { + "description": "EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session.", + "properties": { + "sdp": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "sdp", + "threadId" + ], + "type": "object" + }, + "ThreadRealtimeStartedNotification": { + "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", + "properties": { + "realtimeSessionId": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" + } + }, + "required": [ + "threadId", + "version" + ], + "type": "object" + }, + "ThreadRealtimeTranscriptDeltaNotification": { + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "delta": { + "description": "Live transcript delta from the realtime event.", + "type": "string" + }, + "role": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "delta", + "role", + "threadId" + ], + "type": "object" + }, + "ThreadRealtimeTranscriptDoneNotification": { + "description": "EXPERIMENTAL - final transcript text emitted when realtime completes a transcript part.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "description": "Final complete text for the transcript part.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "type": "object" + }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStartedNotification": { + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "type": "object" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", "type": "string" } }, "required": [ - "id", - "review", "type" ], - "title": "ExitedReviewModeThreadItem", + "title": "SystemErrorThreadStatus", "type": "object" }, { "properties": { - "id": { - "type": "string" + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" }, "type": { "enum": [ - "contextCompaction" + "active" ], - "title": "ContextCompactionThreadItemType", + "title": "ActiveThreadStatusType", "type": "string" } }, "required": [ - "id", + "activeFlags", "type" ], - "title": "ContextCompactionThreadItem", + "title": "ActiveThreadStatus", "type": "object" } ] }, - "ThreadNameUpdatedNotification": { + "ThreadStatusChangedNotification": { "properties": { + "status": { + "$ref": "#/definitions/ThreadStatus" + }, "threadId": { "type": "string" - }, - "threadName": { - "type": [ - "string", - "null" - ] } }, "required": [ + "status", "threadId" ], "type": "object" }, - "ThreadStartedNotification": { - "properties": { - "thread": { - "$ref": "#/definitions/Thread" - } - }, - "required": [ - "thread" - ], - "type": "object" - }, "ThreadTokenUsage": { "properties": { "last": { @@ -7293,39 +4280,14 @@ ], "type": "object" }, - "TokenUsage": { + "ThreadUnarchivedNotification": { "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "cached_input_tokens_reported": { - "default": false, - "type": "boolean" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" + "threadId": { + "type": "string" } }, "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" + "threadId" ], "type": "object" }, @@ -7361,78 +4323,24 @@ ], "type": "object" }, - "TokenUsageInfo": { + "Turn": { "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "latest_response_model": { - "type": [ - "string", - "null" - ] - }, - "model_context_window": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", "format": "int64", "type": [ "integer", "null" ] }, - "requested_model": { - "type": [ - "string", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", "type": [ - "array", + "integer", "null" ] }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "Turn": { - "properties": { "error": { "anyOf": [ { @@ -7448,12 +4356,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -7465,14 +4390,6 @@ ], "type": "object" }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, "TurnCompletedNotification": { "properties": { "threadId": { @@ -7536,1422 +4453,1669 @@ ], "type": "object" }, - "TurnItem": { + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, + "TurnPlanStep": { + "properties": { + "status": { + "$ref": "#/definitions/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "pending", + "inProgress", + "completed" + ], + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "properties": { + "explanation": { + "type": [ + "string", + "null" + ] + }, + "plan": { + "items": { + "$ref": "#/definitions/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "type": "object" + }, + "TurnStartedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "threadId", + "turn" + ], + "type": "object" + }, + "TurnStatus": { + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ], + "type": "string" + }, + "UserInput": { "oneOf": [ { "properties": { - "content": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", "items": { - "$ref": "#/definitions/UserInput2" + "$ref": "#/definitions/TextElement" }, "type": "array" }, - "id": { + "type": { + "enum": [ + "text" + ], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "image" + ], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "path": { "type": "string" }, "type": { "enum": [ - "UserMessage" + "localImage" ], - "title": "UserMessageTurnItemType", + "title": "LocalImageUserInputType", "type": "string" } }, "required": [ - "content", - "id", + "path", "type" ], - "title": "UserMessageTurnItem", + "title": "LocalImageUserInput", "type": "object" }, { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { + "name": { "type": "string" }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." + "path": { + "type": "string" }, "type": { "enum": [ - "AgentMessage" + "skill" ], - "title": "AgentMessageTurnItemType", + "title": "SkillUserInputType", "type": "string" } }, "required": [ - "content", - "id", + "name", + "path", "type" ], - "title": "AgentMessageTurnItem", + "title": "SkillUserInput", "type": "object" }, { "properties": { - "id": { + "name": { "type": "string" }, - "text": { + "path": { "type": "string" }, "type": { "enum": [ - "Plan" + "mention" ], - "title": "PlanTurnItemType", + "title": "MentionUserInputType", "type": "string" } }, "required": [ - "id", - "text", + "name", + "path", "type" ], - "title": "PlanTurnItem", + "title": "MentionUserInput", "type": "object" + } + ] + }, + "WarningNotification": { + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "WebSearchAction": { + "oneOf": [ { "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], + "queries": { "items": { "type": "string" }, - "type": "array" + "type": [ + "array", + "null" + ] }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" + "query": { + "type": [ + "string", + "null" + ] }, "type": { "enum": [ - "Reasoning" + "search" ], - "title": "ReasoningTurnItemType", + "title": "SearchWebSearchActionType", "type": "string" } }, "required": [ - "id", - "summary_text", "type" ], - "title": "ReasoningTurnItem", + "title": "SearchWebSearchAction", "type": "object" }, { "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction2" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, "type": { "enum": [ - "WebSearch" + "openPage" ], - "title": "WebSearchTurnItemType", + "title": "OpenPageWebSearchActionType", "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] } }, "required": [ - "action", - "id", - "query", "type" ], - "title": "WebSearchTurnItem", + "title": "OpenPageWebSearchAction", "type": "object" }, { "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { + "pattern": { "type": [ "string", "null" ] }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, "type": { "enum": [ - "ImageGeneration" + "findInPage" ], - "title": "ImageGenerationTurnItemType", + "title": "FindInPageWebSearchActionType", "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] } }, "required": [ - "id", - "result", - "status", "type" ], - "title": "ImageGenerationTurnItem", + "title": "FindInPageWebSearchAction", "type": "object" }, { "properties": { - "id": { - "type": "string" - }, "type": { "enum": [ - "ContextCompaction" + "other" ], - "title": "ContextCompactionTurnItemType", + "title": "OtherWebSearchActionType", "type": "string" } }, "required": [ - "id", "type" ], - "title": "ContextCompactionTurnItem", - "type": "object" + "title": "OtherWebSearchAction", + "type": "object" + } + ] + }, + "WindowsSandboxSetupCompletedNotification": { + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "mode", + "success" + ], + "type": "object" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsWorldWritableWarningNotification": { + "properties": { + "extraCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "failedScan": { + "type": "boolean" + }, + "samplePaths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "extraCount", + "failedScan", + "samplePaths" + ], + "type": "object" + } + }, + "description": "Notification sent from the server to the client.", + "oneOf": [ + { + "description": "NEW NOTIFICATIONS", + "properties": { + "method": { + "enum": [ + "error" + ], + "title": "ErrorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ErrorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/started" + ], + "title": "Thread/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/status/changed" + ], + "title": "Thread/status/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStatusChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/archived" + ], + "title": "Thread/archivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchivedNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "Thread/archivedNotification", + "type": "object" }, - "TurnPlanStep": { + { "properties": { - "status": { - "$ref": "#/definitions/TurnPlanStepStatus" - }, - "step": { + "method": { + "enum": [ + "thread/unarchived" + ], + "title": "Thread/unarchivedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchivedNotification" } }, "required": [ - "status", - "step" + "method", + "params" ], + "title": "Thread/unarchivedNotification", "type": "object" }, - "TurnPlanStepStatus": { - "enum": [ - "pending", - "inProgress", - "completed" + { + "properties": { + "method": { + "enum": [ + "thread/closed" + ], + "title": "Thread/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadClosedNotification" + } + }, + "required": [ + "method", + "params" ], - "type": "string" + "title": "Thread/closedNotification", + "type": "object" }, - "TurnPlanUpdatedNotification": { + { "properties": { - "explanation": { - "type": [ - "string", - "null" - ] + "method": { + "enum": [ + "skills/changed" + ], + "title": "Skills/changedNotificationMethod", + "type": "string" }, - "plan": { - "items": { - "$ref": "#/definitions/TurnPlanStep" - }, - "type": "array" + "params": { + "$ref": "#/definitions/SkillsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Skills/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/name/updated" + ], + "title": "Thread/name/updatedNotificationMethod", + "type": "string" }, - "threadId": { + "params": { + "$ref": "#/definitions/ThreadNameUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/name/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", "type": "string" }, - "turnId": { + "params": { + "$ref": "#/definitions/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearedNotification" } }, "required": [ - "plan", - "threadId", - "turnId" + "method", + "params" ], + "title": "Thread/goal/clearedNotification", "type": "object" }, - "TurnStartedNotification": { + { "properties": { - "threadId": { + "method": { + "enum": [ + "thread/tokenUsage/updated" + ], + "title": "Thread/tokenUsage/updatedNotificationMethod", "type": "string" }, - "turn": { - "$ref": "#/definitions/Turn" + "params": { + "$ref": "#/definitions/ThreadTokenUsageUpdatedNotification" } }, "required": [ - "threadId", - "turn" + "method", + "params" ], + "title": "Thread/tokenUsage/updatedNotification", "type": "object" }, - "TurnStatus": { - "enum": [ - "completed", - "interrupted", - "failed", - "inProgress" + { + "properties": { + "method": { + "enum": [ + "turn/started" + ], + "title": "Turn/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartedNotification" + } + }, + "required": [ + "method", + "params" ], - "type": "string" + "title": "Turn/startedNotification", + "type": "object" }, - "UserInput": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" + { + "properties": { + "method": { + "enum": [ + "hook/started" ], - "title": "TextUserInput", - "type": "object" + "title": "Hook/startedNotificationMethod", + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" + "params": { + "$ref": "#/definitions/HookStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Hook/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/completed" ], - "title": "ImageUserInput", - "type": "object" + "title": "Turn/completedNotificationMethod", + "type": "string" }, - { - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" + "params": { + "$ref": "#/definitions/TurnCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "hook/completed" ], - "title": "LocalImageUserInput", - "type": "object" + "title": "Hook/completedNotificationMethod", + "type": "string" }, - { - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" + "params": { + "$ref": "#/definitions/HookCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Hook/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/diff/updated" ], - "title": "SkillUserInput", - "type": "object" + "title": "Turn/diff/updatedNotificationMethod", + "type": "string" }, - { - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" + "params": { + "$ref": "#/definitions/TurnDiffUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/diff/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/plan/updated" ], - "title": "MentionUserInput", - "type": "object" + "title": "Turn/plan/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnPlanUpdatedNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "Turn/plan/updatedNotification", + "type": "object" }, - "UserInput2": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement2" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInput2Type", - "type": "string" - } - }, - "required": [ - "text", - "type" + { + "properties": { + "method": { + "enum": [ + "item/started" ], - "title": "TextUserInput2", - "type": "object" + "title": "Item/startedNotificationMethod", + "type": "string" }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInput2Type", - "type": "string" - } - }, - "required": [ - "image_url", - "type" + "params": { + "$ref": "#/definitions/ItemStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/autoApprovalReview/started" ], - "title": "ImageUserInput2", - "type": "object" + "title": "Item/autoApprovalReview/startedNotificationMethod", + "type": "string" }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInput2Type", - "type": "string" - } - }, - "required": [ - "path", - "type" + "params": { + "$ref": "#/definitions/ItemGuardianApprovalReviewStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/autoApprovalReview/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/autoApprovalReview/completed" ], - "title": "LocalImageUserInput2", - "type": "object" + "title": "Item/autoApprovalReview/completedNotificationMethod", + "type": "string" }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInput2Type", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" + "params": { + "$ref": "#/definitions/ItemGuardianApprovalReviewCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/autoApprovalReview/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/completed" ], - "title": "SkillUserInput2", - "type": "object" + "title": "Item/completedNotificationMethod", + "type": "string" }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInput2Type", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" + "params": { + "$ref": "#/definitions/ItemCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/agentMessage/delta" ], - "title": "MentionUserInput2", - "type": "object" + "title": "Item/agentMessage/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AgentMessageDeltaNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "Item/agentMessage/deltaNotification", + "type": "object" }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" + { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", + "properties": { + "method": { + "enum": [ + "item/plan/delta" ], - "title": "SearchWebSearchAction", - "type": "object" + "title": "Item/plan/deltaNotificationMethod", + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "openPage" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" + "params": { + "$ref": "#/definitions/PlanDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/plan/deltaNotification", + "type": "object" + }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" ], - "title": "OpenPageWebSearchAction", - "type": "object" + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "findInPage" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" + "params": { + "$ref": "#/definitions/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/outputDelta" ], - "title": "FindInPageWebSearchAction", - "type": "object" + "title": "Process/outputDeltaNotificationMethod", + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" + "params": { + "$ref": "#/definitions/ProcessOutputDeltaNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "Process/outputDeltaNotification", + "type": "object" }, - "WebSearchAction2": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchAction2Type", - "type": "string" - } - }, - "required": [ - "type" + { + "description": "Final exit notification for a `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/exited" ], - "title": "SearchWebSearchAction2", - "type": "object" + "title": "Process/exitedNotificationMethod", + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchAction2Type", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" + "params": { + "$ref": "#/definitions/ProcessExitedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/exitedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/outputDelta" ], - "title": "OpenPageWebSearchAction2", - "type": "object" + "title": "Item/commandExecution/outputDeltaNotificationMethod", + "type": "string" }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchAction2Type", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" + "params": { + "$ref": "#/definitions/CommandExecutionOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/terminalInteraction" ], - "title": "FindInPageWebSearchAction2", - "type": "object" + "title": "Item/commandExecution/terminalInteractionNotificationMethod", + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchAction2Type", - "type": "string" - } - }, - "required": [ - "type" + "params": { + "$ref": "#/definitions/TerminalInteractionNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/terminalInteractionNotification", + "type": "object" + }, + { + "description": "Deprecated legacy apply_patch output stream notification.", + "properties": { + "method": { + "enum": [ + "item/fileChange/outputDelta" ], - "title": "OtherWebSearchAction2", - "type": "object" + "title": "Item/fileChange/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangeOutputDeltaNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "Item/fileChange/outputDeltaNotification", + "type": "object" }, - "WindowsWorldWritableWarningNotification": { + { "properties": { - "extraCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" + "method": { + "enum": [ + "item/fileChange/patchUpdated" + ], + "title": "Item/fileChange/patchUpdatedNotificationMethod", + "type": "string" }, - "failedScan": { - "type": "boolean" + "params": { + "$ref": "#/definitions/FileChangePatchUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/fileChange/patchUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "serverRequest/resolved" + ], + "title": "ServerRequest/resolvedNotificationMethod", + "type": "string" }, - "samplePaths": { - "items": { - "type": "string" - }, - "type": "array" + "params": { + "$ref": "#/definitions/ServerRequestResolvedNotification" } }, "required": [ - "extraCount", - "failedScan", - "samplePaths" + "method", + "params" ], + "title": "ServerRequest/resolvedNotification", "type": "object" - } - }, - "description": "Notification sent from the server to the client.", - "oneOf": [ + }, { - "description": "NEW NOTIFICATIONS", "properties": { "method": { "enum": [ - "error" + "item/mcpToolCall/progress" ], - "title": "ErrorNotificationMethod", + "title": "Item/mcpToolCall/progressNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ErrorNotification" + "$ref": "#/definitions/McpToolCallProgressNotification" } }, "required": [ "method", "params" ], - "title": "ErrorNotification", + "title": "Item/mcpToolCall/progressNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "thread/started" + "mcpServer/oauthLogin/completed" ], - "title": "Thread/startedNotificationMethod", + "title": "McpServer/oauthLogin/completedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadStartedNotification" + "$ref": "#/definitions/McpServerOauthLoginCompletedNotification" } }, "required": [ "method", "params" ], - "title": "Thread/startedNotification", + "title": "McpServer/oauthLogin/completedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "thread/name/updated" + "mcpServer/startupStatus/updated" ], - "title": "Thread/name/updatedNotificationMethod", + "title": "McpServer/startupStatus/updatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadNameUpdatedNotification" + "$ref": "#/definitions/McpServerStatusUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Thread/name/updatedNotification", + "title": "McpServer/startupStatus/updatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "thread/tokenUsage/updated" + "account/updated" ], - "title": "Thread/tokenUsage/updatedNotificationMethod", + "title": "Account/updatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ThreadTokenUsageUpdatedNotification" + "$ref": "#/definitions/AccountUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Thread/tokenUsage/updatedNotification", + "title": "Account/updatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "turn/started" + "account/rateLimits/updated" ], - "title": "Turn/startedNotificationMethod", + "title": "Account/rateLimits/updatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TurnStartedNotification" + "$ref": "#/definitions/AccountRateLimitsUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Turn/startedNotification", + "title": "Account/rateLimits/updatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "turn/completed" + "app/list/updated" ], - "title": "Turn/completedNotificationMethod", + "title": "App/list/updatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TurnCompletedNotification" + "$ref": "#/definitions/AppListUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Turn/completedNotification", + "title": "App/list/updatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "turn/diff/updated" + "remoteControl/status/changed" ], - "title": "Turn/diff/updatedNotificationMethod", + "title": "RemoteControl/status/changedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TurnDiffUpdatedNotification" + "$ref": "#/definitions/RemoteControlStatusChangedNotification" } }, "required": [ "method", "params" ], - "title": "Turn/diff/updatedNotification", + "title": "RemoteControl/status/changedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "turn/plan/updated" + "externalAgentConfig/import/completed" ], - "title": "Turn/plan/updatedNotificationMethod", + "title": "ExternalAgentConfig/import/completedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TurnPlanUpdatedNotification" + "$ref": "#/definitions/ExternalAgentConfigImportCompletedNotification" } }, "required": [ "method", "params" ], - "title": "Turn/plan/updatedNotification", + "title": "ExternalAgentConfig/import/completedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/started" + "fs/changed" ], - "title": "Item/startedNotificationMethod", + "title": "Fs/changedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ItemStartedNotification" + "$ref": "#/definitions/FsChangedNotification" } }, "required": [ "method", "params" ], - "title": "Item/startedNotification", + "title": "Fs/changedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/completed" + "item/reasoning/summaryTextDelta" ], - "title": "Item/completedNotificationMethod", + "title": "Item/reasoning/summaryTextDeltaNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ItemCompletedNotification" + "$ref": "#/definitions/ReasoningSummaryTextDeltaNotification" } }, "required": [ "method", "params" ], - "title": "Item/completedNotification", + "title": "Item/reasoning/summaryTextDeltaNotification", "type": "object" }, { - "description": "This event is internal-only. Used by Codex Cloud.", "properties": { "method": { "enum": [ - "rawResponseItem/completed" + "item/reasoning/summaryPartAdded" ], - "title": "RawResponseItem/completedNotificationMethod", + "title": "Item/reasoning/summaryPartAddedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/RawResponseItemCompletedNotification" + "$ref": "#/definitions/ReasoningSummaryPartAddedNotification" } }, "required": [ "method", "params" ], - "title": "RawResponseItem/completedNotification", + "title": "Item/reasoning/summaryPartAddedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/agentMessage/delta" + "item/reasoning/textDelta" ], - "title": "Item/agentMessage/deltaNotificationMethod", + "title": "Item/reasoning/textDeltaNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AgentMessageDeltaNotification" + "$ref": "#/definitions/ReasoningTextDeltaNotification" } }, "required": [ "method", "params" ], - "title": "Item/agentMessage/deltaNotification", + "title": "Item/reasoning/textDeltaNotification", "type": "object" }, { - "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", + "description": "Deprecated: Use `ContextCompaction` item type instead.", "properties": { "method": { "enum": [ - "item/plan/delta" + "thread/compacted" ], - "title": "Item/plan/deltaNotificationMethod", + "title": "Thread/compactedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/PlanDeltaNotification" + "$ref": "#/definitions/ContextCompactedNotification" } }, "required": [ "method", "params" ], - "title": "Item/plan/deltaNotification", + "title": "Thread/compactedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/commandExecution/outputDelta" + "model/rerouted" ], - "title": "Item/commandExecution/outputDeltaNotificationMethod", + "title": "Model/reroutedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/CommandExecutionOutputDeltaNotification" + "$ref": "#/definitions/ModelReroutedNotification" } }, "required": [ "method", "params" ], - "title": "Item/commandExecution/outputDeltaNotification", + "title": "Model/reroutedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/commandExecution/terminalInteraction" + "model/verification" ], - "title": "Item/commandExecution/terminalInteractionNotificationMethod", + "title": "Model/verificationNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/TerminalInteractionNotification" + "$ref": "#/definitions/ModelVerificationNotification" } }, "required": [ "method", "params" ], - "title": "Item/commandExecution/terminalInteractionNotification", + "title": "Model/verificationNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/fileChange/outputDelta" + "warning" ], - "title": "Item/fileChange/outputDeltaNotificationMethod", + "title": "WarningNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/FileChangeOutputDeltaNotification" + "$ref": "#/definitions/WarningNotification" } }, "required": [ "method", "params" ], - "title": "Item/fileChange/outputDeltaNotification", + "title": "WarningNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/mcpToolCall/progress" + "guardianWarning" ], - "title": "Item/mcpToolCall/progressNotificationMethod", + "title": "GuardianWarningNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/McpToolCallProgressNotification" + "$ref": "#/definitions/GuardianWarningNotification" } }, "required": [ "method", "params" ], - "title": "Item/mcpToolCall/progressNotification", + "title": "GuardianWarningNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "mcpServer/oauthLogin/completed" + "deprecationNotice" ], - "title": "McpServer/oauthLogin/completedNotificationMethod", + "title": "DeprecationNoticeNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/McpServerOauthLoginCompletedNotification" + "$ref": "#/definitions/DeprecationNoticeNotification" } }, "required": [ "method", "params" ], - "title": "McpServer/oauthLogin/completedNotification", + "title": "DeprecationNoticeNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "account/updated" + "configWarning" ], - "title": "Account/updatedNotificationMethod", + "title": "ConfigWarningNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AccountUpdatedNotification" + "$ref": "#/definitions/ConfigWarningNotification" } }, "required": [ "method", "params" ], - "title": "Account/updatedNotification", + "title": "ConfigWarningNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "account/rateLimits/updated" + "fuzzyFileSearch/sessionUpdated" ], - "title": "Account/rateLimits/updatedNotificationMethod", + "title": "FuzzyFileSearch/sessionUpdatedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AccountRateLimitsUpdatedNotification" + "$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification" } }, "required": [ "method", "params" ], - "title": "Account/rateLimits/updatedNotification", + "title": "FuzzyFileSearch/sessionUpdatedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "app/list/updated" + "fuzzyFileSearch/sessionCompleted" ], - "title": "App/list/updatedNotificationMethod", + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AppListUpdatedNotification" + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" } }, "required": [ "method", "params" ], - "title": "App/list/updatedNotification", + "title": "FuzzyFileSearch/sessionCompletedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/reasoning/summaryTextDelta" + "thread/realtime/started" ], - "title": "Item/reasoning/summaryTextDeltaNotificationMethod", + "title": "Thread/realtime/startedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ReasoningSummaryTextDeltaNotification" + "$ref": "#/definitions/ThreadRealtimeStartedNotification" } }, "required": [ "method", "params" ], - "title": "Item/reasoning/summaryTextDeltaNotification", + "title": "Thread/realtime/startedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/reasoning/summaryPartAdded" + "thread/realtime/itemAdded" ], - "title": "Item/reasoning/summaryPartAddedNotificationMethod", + "title": "Thread/realtime/itemAddedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ReasoningSummaryPartAddedNotification" + "$ref": "#/definitions/ThreadRealtimeItemAddedNotification" } }, "required": [ "method", "params" ], - "title": "Item/reasoning/summaryPartAddedNotification", + "title": "Thread/realtime/itemAddedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "item/reasoning/textDelta" + "thread/realtime/transcript/delta" ], - "title": "Item/reasoning/textDeltaNotificationMethod", + "title": "Thread/realtime/transcript/deltaNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ReasoningTextDeltaNotification" + "$ref": "#/definitions/ThreadRealtimeTranscriptDeltaNotification" } }, "required": [ "method", "params" ], - "title": "Item/reasoning/textDeltaNotification", + "title": "Thread/realtime/transcript/deltaNotification", "type": "object" }, { - "description": "Deprecated: Use `ContextCompaction` item type instead.", "properties": { "method": { "enum": [ - "thread/compacted" + "thread/realtime/transcript/done" ], - "title": "Thread/compactedNotificationMethod", + "title": "Thread/realtime/transcript/doneNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ContextCompactedNotification" + "$ref": "#/definitions/ThreadRealtimeTranscriptDoneNotification" } }, "required": [ "method", "params" ], - "title": "Thread/compactedNotification", + "title": "Thread/realtime/transcript/doneNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "deprecationNotice" + "thread/realtime/outputAudio/delta" ], - "title": "DeprecationNoticeNotificationMethod", + "title": "Thread/realtime/outputAudio/deltaNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/DeprecationNoticeNotification" + "$ref": "#/definitions/ThreadRealtimeOutputAudioDeltaNotification" } }, "required": [ "method", "params" ], - "title": "DeprecationNoticeNotification", + "title": "Thread/realtime/outputAudio/deltaNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "configWarning" + "thread/realtime/sdp" ], - "title": "ConfigWarningNotificationMethod", + "title": "Thread/realtime/sdpNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ConfigWarningNotification" + "$ref": "#/definitions/ThreadRealtimeSdpNotification" } }, "required": [ "method", "params" ], - "title": "ConfigWarningNotification", + "title": "Thread/realtime/sdpNotification", "type": "object" }, { - "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", "properties": { "method": { "enum": [ - "windows/worldWritableWarning" + "thread/realtime/error" ], - "title": "Windows/worldWritableWarningNotificationMethod", + "title": "Thread/realtime/errorNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/WindowsWorldWritableWarningNotification" + "$ref": "#/definitions/ThreadRealtimeErrorNotification" } }, "required": [ "method", "params" ], - "title": "Windows/worldWritableWarningNotification", + "title": "Thread/realtime/errorNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "account/login/completed" + "thread/realtime/closed" ], - "title": "Account/login/completedNotificationMethod", + "title": "Thread/realtime/closedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AccountLoginCompletedNotification" + "$ref": "#/definitions/ThreadRealtimeClosedNotification" } }, "required": [ "method", "params" ], - "title": "Account/login/completedNotification", + "title": "Thread/realtime/closedNotification", "type": "object" }, { - "description": "DEPRECATED NOTIFICATIONS below", + "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", "properties": { "method": { "enum": [ - "authStatusChange" + "windows/worldWritableWarning" ], - "title": "AuthStatusChangeNotificationMethod", + "title": "Windows/worldWritableWarningNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AuthStatusChangeNotification" + "$ref": "#/definitions/WindowsWorldWritableWarningNotification" } }, "required": [ "method", "params" ], - "title": "AuthStatusChangeNotification", + "title": "Windows/worldWritableWarningNotification", "type": "object" }, { - "description": "Deprecated: use `account/login/completed` instead.", "properties": { "method": { "enum": [ - "loginChatGptComplete" + "windowsSandbox/setupCompleted" ], - "title": "LoginChatGptCompleteNotificationMethod", + "title": "WindowsSandbox/setupCompletedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/LoginChatGptCompleteNotification" + "$ref": "#/definitions/WindowsSandboxSetupCompletedNotification" } }, "required": [ "method", "params" ], - "title": "LoginChatGptCompleteNotification", + "title": "WindowsSandbox/setupCompletedNotification", "type": "object" }, { "properties": { "method": { "enum": [ - "sessionConfigured" + "account/login/completed" ], - "title": "SessionConfiguredNotificationMethod", + "title": "Account/login/completedNotificationMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SessionConfiguredNotification" + "$ref": "#/definitions/AccountLoginCompletedNotification" } }, "required": [ "method", "params" ], - "title": "SessionConfiguredNotification", + "title": "Account/login/completedNotification", "type": "object" } ], "title": "ServerNotification" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ServerRequest.json b/code-rs/app-server-protocol/schema/json/ServerRequest.json index 86112dfed52..9844eac0b83 100644 --- a/code-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/code-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1,11 +1,33 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": [ "array", @@ -13,8 +35,9 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": [ "array", @@ -24,39 +47,13 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { + "AdditionalNetworkPermissions": { "properties": { - "accessibility": { - "type": [ - "boolean", - "null" - ] - }, - "automations": { - "anyOf": [ - { - "$ref": "#/definitions/MacOsAutomationValue" - }, - { - "type": "null" - } - ] - }, - "calendar": { + "enabled": { "type": [ "boolean", "null" ] - }, - "preferences": { - "anyOf": [ - { - "$ref": "#/definitions/MacOsPreferencesValue" - }, - { - "type": "null" - } - ] } }, "type": "object" @@ -73,21 +70,16 @@ } ] }, - "macos": { + "network": { "anyOf": [ { - "$ref": "#/definitions/AdditionalMacOsPermissions" + "$ref": "#/definitions/AdditionalNetworkPermissions" }, { "type": "null" } - ] - }, - "network": { - "type": [ - "boolean", - "null" - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" @@ -95,7 +87,7 @@ "ApplyPatchApprovalParams": { "properties": { "callId": { - "description": "Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] and [codex_core::protocol::PatchApplyEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] and [codex_protocol::protocol::PatchApplyEndEvent].", "type": "string" }, "conversationId": { @@ -160,26 +152,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -189,7 +161,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -288,10 +260,89 @@ } ] }, + "CommandExecutionApprovalDecision": { + "oneOf": [ + { + "description": "User approved the command.", + "enum": [ + "accept" + ], + "type": "string" + }, + { + "description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.", + "enum": [ + "acceptForSession" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", + "properties": { + "acceptWithExecpolicyAmendment": { + "properties": { + "execpolicy_amendment": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "execpolicy_amendment" + ], + "type": "object" + } + }, + "required": [ + "acceptWithExecpolicyAmendment" + ], + "title": "AcceptWithExecpolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, + { + "additionalProperties": false, + "description": "User chose a persistent network policy rule (allow/deny) for this host.", + "properties": { + "applyNetworkPolicyAmendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "applyNetworkPolicyAmendment" + ], + "title": "ApplyNetworkPolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, + { + "description": "User denied the command. The agent will continue the turn.", + "enum": [ + "decline" + ], + "type": "string" + }, + { + "description": "User denied the command. The turn will also be immediately interrupted.", + "enum": [ + "cancel" + ], + "type": "string" + } + ] + }, "CommandExecutionRequestApprovalParams": { "properties": { "approvalId": { - "description": "Identifier for this specific approval callback.", + "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.", "type": [ "string", "null" @@ -315,11 +366,15 @@ ] }, "cwd": { - "description": "The command's working directory.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "The command's working directory." }, "itemId": { "type": "string" @@ -345,6 +400,16 @@ "null" ] }, + "proposedNetworkPolicyAmendments": { + "description": "Optional proposed network policy amendments (allow/deny host) for future requests.", + "items": { + "$ref": "#/definitions/NetworkPolicyAmendment" + }, + "type": [ + "array", + "null" + ] + }, "reason": { "description": "Optional explanatory reason (e.g. request for network access).", "type": [ @@ -352,6 +417,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -361,6 +431,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -407,7 +478,7 @@ ] }, "callId": { - "description": "Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] and [codex_core::protocol::ExecCommandEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] and [codex_protocol::protocol::ExecCommandEndEvent].", "type": "string" }, "command": { @@ -533,6 +604,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -542,200 +618,1046 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], "type": "object" }, - "MacOsAutomationValue": { - "anyOf": [ - { - "type": "boolean" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ] - }, - "MacOsPreferencesValue": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string" - } - ] - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { + "FileSystemAccessMode": { "enum": [ - "http", - "https", - "socks5Tcp", - "socks5Udp" + "read", + "write", + "none" ], "type": "string" }, - "ParsedCommand": { + "FileSystemPath": { "oneOf": [ { "properties": { - "cmd": { - "type": "string" + "path": { + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "read_command" + "path" ], - "title": "ReadCommandParsedCommandType", + "title": "PathFileSystemPathType", "type": "string" } }, "required": [ - "cmd", + "path", "type" ], - "title": "ReadCommandParsedCommand", + "title": "PathFileSystemPath", "type": "object" }, { "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", + "pattern": { "type": "string" }, "type": { "enum": [ - "read" + "glob_pattern" ], - "title": "ReadParsedCommandType", + "title": "GlobPatternFileSystemPathType", "type": "string" } }, "required": [ - "cmd", - "name", - "path", + "pattern", "type" ], - "title": "ReadParsedCommand", + "title": "GlobPatternFileSystemPath", "type": "object" }, { "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, "type": { "enum": [ - "list_files" + "special" ], - "title": "ListFilesParsedCommandType", + "title": "SpecialFileSystemPathType", "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" } }, "required": [ - "cmd", - "type" + "type", + "value" ], - "title": "ListFilesParsedCommand", + "title": "SpecialFileSystemPath", "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ { "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { + "kind": { "enum": [ - "search" + "root" ], - "title": "SearchParsedCommandType", "type": "string" } }, "required": [ - "cmd", - "type" + "kind" ], - "title": "SearchParsedCommand", + "title": "RootFileSystemSpecialPath", "type": "object" }, { "properties": { - "cmd": { - "type": "string" - }, - "type": { + "kind": { "enum": [ - "unknown" + "minimal" ], - "title": "UnknownParsedCommandType", "type": "string" } }, "required": [ - "cmd", - "type" + "kind" ], - "title": "UnknownParsedCommand", + "title": "MinimalFileSystemSpecialPath", "type": "object" - } - ] - }, - "RequestId": { - "anyOf": [ - { - "type": "string" }, { - "format": "int64", - "type": "integer" + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "McpElicitationArrayType": { + "enum": [ + "array" + ], + "type": "string" + }, + "McpElicitationBooleanSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "boolean", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationBooleanType": { + "enum": [ + "boolean" + ], + "type": "string" + }, + "McpElicitationConstOption": { + "additionalProperties": false, + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "McpElicitationEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "additionalProperties": false, + "properties": { + "default": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "maximum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "minimum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationNumberType": { + "enum": [ + "number", + "integer" + ], + "type": "string" + }, + "McpElicitationObjectType": { + "enum": [ + "object" + ], + "type": "string" + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationStringSchema" + }, + { + "$ref": "#/definitions/McpElicitationNumberSchema" + }, + { + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "additionalProperties": false, + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "enum": [ + "email", + "uri", + "date", + "date-time" + ], + "type": "string" + }, + "McpElicitationStringSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationStringType": { + "enum": [ + "string" + ], + "type": "string" + }, + "McpElicitationTitledEnumItems": { + "additionalProperties": false, + "properties": { + "anyOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledEnumItems": { + "additionalProperties": false, + "properties": { + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpServerElicitationRequestParams": { + "oneOf": [ + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "form" + ], + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" + } + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "url" + ], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + } + ], + "properties": { + "serverName": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "serverName", + "threadId" + ], + "type": "object" + }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "ParsedCommand": { + "oneOf": [ + { + "properties": { + "cmd": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", + "type": "string" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "name", + "path", + "type" + ], + "title": "ReadParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "list_files" + ], + "title": "ListFilesParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "ListFilesParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "SearchParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "UnknownParsedCommand", + "type": "object" + } + ] + }, + "PermissionsRequestApprovalParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "cwd", + "itemId", + "permissions", + "startedAtMs", + "threadId", + "turnId" + ], + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" } ] }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -897,6 +1819,56 @@ "title": "Item/tool/requestUserInputRequest", "type": "object" }, + { + "description": "Request input for an MCP server elicitation.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/elicitation/request" + ], + "title": "McpServer/elicitation/requestRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerElicitationRequestParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/elicitation/requestRequest", + "type": "object" + }, + { + "description": "Request approval for additional permissions from the user.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "item/permissions/requestApproval" + ], + "title": "Item/permissions/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionsRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/permissions/requestApprovalRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { @@ -998,4 +1970,4 @@ } ], "title": "ServerRequest" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json b/code-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json index 27cbcb75871..153d3bad67d 100644 --- a/code-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json +++ b/code-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json @@ -81,4 +81,4 @@ ], "title": "ToolRequestUserInputParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/ToolRequestUserInputResponse.json b/code-rs/app-server-protocol/schema/json/ToolRequestUserInputResponse.json index 816cccee619..3fd6fbc3354 100644 --- a/code-rs/app-server-protocol/schema/json/ToolRequestUserInputResponse.json +++ b/code-rs/app-server-protocol/schema/json/ToolRequestUserInputResponse.json @@ -31,4 +31,4 @@ ], "title": "ToolRequestUserInputResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 60ab0d6eae1..156f6ddc4ab 100644 --- a/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5,220 +5,37 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "AddConversationListenerParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "experimentalRawEvents": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "conversationId" - ], - "title": "AddConversationListenerParams", - "type": "object" - }, - "AddConversationSubscriptionResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "AddConversationSubscriptionResponse", - "type": "object" - }, - "AdditionalFileSystemPermissions": { - "properties": { - "read": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "write": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "type": "object" - }, - "AdditionalMacOsPermissions": { - "properties": { - "accessibility": { - "type": [ - "boolean", - "null" - ] - }, - "automations": { - "anyOf": [ - { - "$ref": "#/definitions/MacOsAutomationValue" - }, - { - "type": "null" - } - ] - }, - "calendar": { - "type": [ - "boolean", - "null" - ] - }, - "preferences": { - "anyOf": [ - { - "$ref": "#/definitions/MacOsPreferencesValue" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "AdditionalPermissionProfile": { "properties": { "fileSystem": { "anyOf": [ { - "$ref": "#/definitions/AdditionalFileSystemPermissions" + "$ref": "#/definitions/v2/AdditionalFileSystemPermissions" }, { "type": "null" } ] }, - "macos": { + "network": { "anyOf": [ { - "$ref": "#/definitions/AdditionalMacOsPermissions" + "$ref": "#/definitions/v2/AdditionalNetworkPermissions" }, { "type": "null" } - ] - }, - "network": { - "type": [ - "boolean", - "null" - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, "ApplyPatchApprovalParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "callId": { - "description": "Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] and [codex_core::protocol::PatchApplyEndEvent].", + "description": "Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] and [codex_protocol::protocol::PatchApplyEndEvent].", "type": "string" }, "conversationId": { @@ -266,315 +83,66 @@ "title": "ApplyPatchApprovalResponse", "type": "object" }, - "ArchiveConversationParams": { + "ChatgptAuthTokensRefreshParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" + "previousAccountId": { + "description": "Workspace/account identifier that Codex was previously using.\n\nClients that manage multiple accounts/workspaces can use this as a hint to refresh the token for the correct workspace.\n\nThis may be `null` when the prior auth state did not include a workspace identifier (`chatgpt_account_id`).", + "type": [ + "string", + "null" + ] }, - "rolloutPath": { - "type": "string" + "reason": { + "$ref": "#/definitions/ChatgptAuthTokensRefreshReason" } }, "required": [ - "conversationId", - "rolloutPath" + "reason" ], - "title": "ArchiveConversationParams", - "type": "object" - }, - "ArchiveConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ArchiveConversationResponse", + "title": "ChatgptAuthTokensRefreshParams", "type": "object" }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", + "ChatgptAuthTokensRefreshReason": { "oneOf": [ { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", + "description": "Codex attempted a backend request and received `401 Unauthorized`.", "enum": [ - "never" + "unauthorized" ], "type": "string" } ] }, - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ - { - "description": "OpenAI API key provided by the caller and stored by Codex.", - "enum": [ - "apikey" - ], + "ChatgptAuthTokensRefreshResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "accessToken": { "type": "string" }, - { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", - "enum": [ - "chatgpt" - ], + "chatgptAccountId": { "type": "string" }, - { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", - "enum": [ - "chatgptAuthTokens" - ], - "type": "string" - } - ] - }, - "AuthStatusChangeNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Deprecated notification. Use AccountUpdatedNotification instead.", - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } + "chatgptPlanType": { + "type": [ + "string", + "null" ] } }, - "title": "AuthStatusChangeNotification", - "type": "object" - }, - "AutoContextPhase": { - "enum": [ - "checking", - "compacting" + "required": [ + "accessToken", + "chatgptAccountId" ], - "type": "string" + "title": "ChatgptAuthTokensRefreshResponse", + "type": "object" }, - "AutomationOrigin": { + "ClientInfo": { "properties": { - "actor": { - "description": "Actor reported by the source system as applying the trigger.", - "type": [ - "string", - "null" - ] - }, - "event_id": { - "description": "GitHub event delivery id, webhook id, or local request id.", - "type": [ - "string", - "null" - ] - }, - "issue_number": { - "description": "GitHub issue or pull request number associated with the trigger.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "kind": { - "$ref": "#/definitions/AutomationTriggerKind" - }, - "label": { - "description": "Label that triggered automation, such as `every-code`.", - "type": [ - "string", - "null" - ] + "name": { + "type": "string" }, - "repository": { - "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", - "type": [ - "string", - "null" - ] - }, - "source": { - "description": "Tool, worker, or integration that launched this automated session.", - "type": [ - "string", - "null" - ] - }, - "url": { - "description": "Direct URL to the triggering issue, PR, event, or worker record.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - "AutomationTriggerKind": { - "enum": [ - "github_label", - "other" - ], - "type": "string" - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CancelLoginChatGptParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "loginId": { - "type": "string" - } - }, - "required": [ - "loginId" - ], - "title": "CancelLoginChatGptParams", - "type": "object" - }, - "CancelLoginChatGptResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CancelLoginChatGptResponse", - "type": "object" - }, - "ChatgptAuthTokensRefreshParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "previousAccountId": { - "description": "Workspace/account identifier that Codex was previously using.\n\nClients that manage multiple accounts/workspaces can use this as a hint to refresh the token for the correct workspace.\n\nThis may be `null` when the prior auth state did not include a workspace identifier (`chatgpt_account_id`).", - "type": [ - "string", - "null" - ] - }, - "reason": { - "$ref": "#/definitions/ChatgptAuthTokensRefreshReason" - } - }, - "required": [ - "reason" - ], - "title": "ChatgptAuthTokensRefreshParams", - "type": "object" - }, - "ChatgptAuthTokensRefreshReason": { - "oneOf": [ - { - "description": "Codex attempted a backend request and received `401 Unauthorized`.", - "enum": [ - "unauthorized" - ], - "type": "string" - } - ] - }, - "ChatgptAuthTokensRefreshResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "accessToken": { - "type": "string" - }, - "chatgptAccountId": { - "type": "string" - }, - "chatgptPlanType": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "accessToken", - "chatgptAccountId" - ], - "title": "ChatgptAuthTokensRefreshResponse", - "type": "object" - }, - "ClientInfo": { - "properties": { - "name": { - "type": "string" - }, - "title": { + "title": { "type": [ "string", "null" @@ -619,7 +187,7 @@ { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ @@ -644,7 +212,7 @@ "description": "NEW APIs", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ @@ -668,7 +236,7 @@ { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ @@ -692,7 +260,7 @@ { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ @@ -716,7 +284,7 @@ { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ @@ -740,17 +308,17 @@ { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "thread/name/set" + "thread/unsubscribe" ], - "title": "Thread/name/setRequestMethod", + "title": "Thread/unsubscribeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadSetNameParams" + "$ref": "#/definitions/v2/ThreadUnsubscribeParams" } }, "required": [ @@ -758,23 +326,23 @@ "method", "params" ], - "title": "Thread/name/setRequest", + "title": "Thread/unsubscribeRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "thread/unarchive" + "thread/name/set" ], - "title": "Thread/unarchiveRequestMethod", + "title": "Thread/name/setRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadUnarchiveParams" + "$ref": "#/definitions/v2/ThreadSetNameParams" } }, "required": [ @@ -782,23 +350,23 @@ "method", "params" ], - "title": "Thread/unarchiveRequest", + "title": "Thread/name/setRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "thread/compact/start" + "thread/metadata/update" ], - "title": "Thread/compact/startRequestMethod", + "title": "Thread/metadata/updateRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadCompactStartParams" + "$ref": "#/definitions/v2/ThreadMetadataUpdateParams" } }, "required": [ @@ -806,23 +374,23 @@ "method", "params" ], - "title": "Thread/compact/startRequest", + "title": "Thread/metadata/updateRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "thread/rollback" + "thread/unarchive" ], - "title": "Thread/rollbackRequestMethod", + "title": "Thread/unarchiveRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadRollbackParams" + "$ref": "#/definitions/v2/ThreadUnarchiveParams" } }, "required": [ @@ -830,23 +398,23 @@ "method", "params" ], - "title": "Thread/rollbackRequest", + "title": "Thread/unarchiveRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "thread/list" + "thread/compact/start" ], - "title": "Thread/listRequestMethod", + "title": "Thread/compact/startRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadListParams" + "$ref": "#/definitions/v2/ThreadCompactStartParams" } }, "required": [ @@ -854,23 +422,23 @@ "method", "params" ], - "title": "Thread/listRequest", + "title": "Thread/compact/startRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "thread/loaded/list" + "thread/shellCommand" ], - "title": "Thread/loaded/listRequestMethod", + "title": "Thread/shellCommandRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadLoadedListParams" + "$ref": "#/definitions/v2/ThreadShellCommandParams" } }, "required": [ @@ -878,23 +446,23 @@ "method", "params" ], - "title": "Thread/loaded/listRequest", + "title": "Thread/shellCommandRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "thread/read" + "thread/approveGuardianDeniedAction" ], - "title": "Thread/readRequestMethod", + "title": "Thread/approveGuardianDeniedActionRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ThreadReadParams" + "$ref": "#/definitions/v2/ThreadApproveGuardianDeniedActionParams" } }, "required": [ @@ -902,23 +470,23 @@ "method", "params" ], - "title": "Thread/readRequest", + "title": "Thread/approveGuardianDeniedActionRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "skills/list" + "thread/rollback" ], - "title": "Skills/listRequestMethod", + "title": "Thread/rollbackRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/SkillsListParams" + "$ref": "#/definitions/v2/ThreadRollbackParams" } }, "required": [ @@ -926,23 +494,23 @@ "method", "params" ], - "title": "Skills/listRequest", + "title": "Thread/rollbackRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "skills/remote/read" + "thread/list" ], - "title": "Skills/remote/readRequestMethod", + "title": "Thread/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/SkillsRemoteReadParams" + "$ref": "#/definitions/v2/ThreadListParams" } }, "required": [ @@ -950,23 +518,23 @@ "method", "params" ], - "title": "Skills/remote/readRequest", + "title": "Thread/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "skills/remote/write" + "thread/loaded/list" ], - "title": "Skills/remote/writeRequestMethod", + "title": "Thread/loaded/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/SkillsRemoteWriteParams" + "$ref": "#/definitions/v2/ThreadLoadedListParams" } }, "required": [ @@ -974,23 +542,23 @@ "method", "params" ], - "title": "Skills/remote/writeRequest", + "title": "Thread/loaded/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "app/list" + "thread/read" ], - "title": "App/listRequestMethod", + "title": "Thread/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/AppsListParams" + "$ref": "#/definitions/v2/ThreadReadParams" } }, "required": [ @@ -998,23 +566,24 @@ "method", "params" ], - "title": "App/listRequest", + "title": "Thread/readRequest", "type": "object" }, { + "description": "Append raw Responses API items to the thread history without starting a user turn.", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "skills/config/write" + "thread/inject_items" ], - "title": "Skills/config/writeRequestMethod", + "title": "Thread/injectItemsRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/SkillsConfigWriteParams" + "$ref": "#/definitions/v2/ThreadInjectItemsParams" } }, "required": [ @@ -1022,23 +591,23 @@ "method", "params" ], - "title": "Skills/config/writeRequest", + "title": "Thread/injectItemsRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "turn/start" + "skills/list" ], - "title": "Turn/startRequestMethod", + "title": "Skills/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/TurnStartParams" + "$ref": "#/definitions/v2/SkillsListParams" } }, "required": [ @@ -1046,23 +615,23 @@ "method", "params" ], - "title": "Turn/startRequest", + "title": "Skills/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "turn/steer" + "hooks/list" ], - "title": "Turn/steerRequestMethod", + "title": "Hooks/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/TurnSteerParams" + "$ref": "#/definitions/v2/HooksListParams" } }, "required": [ @@ -1070,23 +639,23 @@ "method", "params" ], - "title": "Turn/steerRequest", + "title": "Hooks/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "turn/interrupt" + "marketplace/add" ], - "title": "Turn/interruptRequestMethod", + "title": "Marketplace/addRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/TurnInterruptParams" + "$ref": "#/definitions/v2/MarketplaceAddParams" } }, "required": [ @@ -1094,23 +663,23 @@ "method", "params" ], - "title": "Turn/interruptRequest", + "title": "Marketplace/addRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "review/start" + "marketplace/remove" ], - "title": "Review/startRequestMethod", + "title": "Marketplace/removeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ReviewStartParams" + "$ref": "#/definitions/v2/MarketplaceRemoveParams" } }, "required": [ @@ -1118,23 +687,23 @@ "method", "params" ], - "title": "Review/startRequest", + "title": "Marketplace/removeRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "model/list" + "marketplace/upgrade" ], - "title": "Model/listRequestMethod", + "title": "Marketplace/upgradeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ModelListParams" + "$ref": "#/definitions/v2/MarketplaceUpgradeParams" } }, "required": [ @@ -1142,23 +711,23 @@ "method", "params" ], - "title": "Model/listRequest", + "title": "Marketplace/upgradeRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "experimentalFeature/list" + "plugin/list" ], - "title": "ExperimentalFeature/listRequestMethod", + "title": "Plugin/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ExperimentalFeatureListParams" + "$ref": "#/definitions/v2/PluginListParams" } }, "required": [ @@ -1166,23 +735,23 @@ "method", "params" ], - "title": "ExperimentalFeature/listRequest", + "title": "Plugin/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "mcpServer/oauth/login" + "plugin/read" ], - "title": "McpServer/oauth/loginRequestMethod", + "title": "Plugin/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/McpServerOauthLoginParams" + "$ref": "#/definitions/v2/PluginReadParams" } }, "required": [ @@ -1190,46 +759,47 @@ "method", "params" ], - "title": "McpServer/oauth/loginRequest", + "title": "Plugin/readRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "config/mcpServer/reload" + "plugin/skill/read" ], - "title": "Config/mcpServer/reloadRequestMethod", + "title": "Plugin/skill/readRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/v2/PluginSkillReadParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "Config/mcpServer/reloadRequest", + "title": "Plugin/skill/readRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "mcpServerStatus/list" + "plugin/share/save" ], - "title": "McpServerStatus/listRequestMethod", + "title": "Plugin/share/saveRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ListMcpServerStatusParams" + "$ref": "#/definitions/v2/PluginShareSaveParams" } }, "required": [ @@ -1237,23 +807,23 @@ "method", "params" ], - "title": "McpServerStatus/listRequest", + "title": "Plugin/share/saveRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "account/login/start" + "plugin/share/updateTargets" ], - "title": "Account/login/startRequestMethod", + "title": "Plugin/share/updateTargetsRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/LoginAccountParams" + "$ref": "#/definitions/v2/PluginShareUpdateTargetsParams" } }, "required": [ @@ -1261,23 +831,23 @@ "method", "params" ], - "title": "Account/login/startRequest", + "title": "Plugin/share/updateTargetsRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "account/login/cancel" + "plugin/share/list" ], - "title": "Account/login/cancelRequestMethod", + "title": "Plugin/share/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/CancelLoginAccountParams" + "$ref": "#/definitions/v2/PluginShareListParams" } }, "required": [ @@ -1285,69 +855,71 @@ "method", "params" ], - "title": "Account/login/cancelRequest", + "title": "Plugin/share/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "account/logout" + "plugin/share/delete" ], - "title": "Account/logoutRequestMethod", + "title": "Plugin/share/deleteRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/v2/PluginShareDeleteParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "Account/logoutRequest", + "title": "Plugin/share/deleteRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "account/rateLimits/read" + "app/list" ], - "title": "Account/rateLimits/readRequestMethod", + "title": "App/listRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/v2/AppsListParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "Account/rateLimits/readRequest", + "title": "App/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "feedback/upload" + "fs/readFile" ], - "title": "Feedback/uploadRequestMethod", + "title": "Fs/readFileRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/FeedbackUploadParams" + "$ref": "#/definitions/v2/FsReadFileParams" } }, "required": [ @@ -1355,24 +927,23 @@ "method", "params" ], - "title": "Feedback/uploadRequest", + "title": "Fs/readFileRequest", "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "command/exec" + "fs/writeFile" ], - "title": "Command/execRequestMethod", + "title": "Fs/writeFileRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/CommandExecParams" + "$ref": "#/definitions/v2/FsWriteFileParams" } }, "required": [ @@ -1380,23 +951,23 @@ "method", "params" ], - "title": "Command/execRequest", + "title": "Fs/writeFileRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "config/read" + "fs/createDirectory" ], - "title": "Config/readRequestMethod", + "title": "Fs/createDirectoryRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ConfigReadParams" + "$ref": "#/definitions/v2/FsCreateDirectoryParams" } }, "required": [ @@ -1404,23 +975,23 @@ "method", "params" ], - "title": "Config/readRequest", + "title": "Fs/createDirectoryRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "externalAgentConfig/detect" + "fs/getMetadata" ], - "title": "ExternalAgentConfig/detectRequestMethod", + "title": "Fs/getMetadataRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ExternalAgentConfigDetectParams" + "$ref": "#/definitions/v2/FsGetMetadataParams" } }, "required": [ @@ -1428,23 +999,23 @@ "method", "params" ], - "title": "ExternalAgentConfig/detectRequest", + "title": "Fs/getMetadataRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "externalAgentConfig/import" + "fs/readDirectory" ], - "title": "ExternalAgentConfig/importRequestMethod", + "title": "Fs/readDirectoryRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ExternalAgentConfigImportParams" + "$ref": "#/definitions/v2/FsReadDirectoryParams" } }, "required": [ @@ -1452,23 +1023,23 @@ "method", "params" ], - "title": "ExternalAgentConfig/importRequest", + "title": "Fs/readDirectoryRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "config/value/write" + "fs/remove" ], - "title": "Config/value/writeRequestMethod", + "title": "Fs/removeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ConfigValueWriteParams" + "$ref": "#/definitions/v2/FsRemoveParams" } }, "required": [ @@ -1476,23 +1047,23 @@ "method", "params" ], - "title": "Config/value/writeRequest", + "title": "Fs/removeRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "config/batchWrite" + "fs/copy" ], - "title": "Config/batchWriteRequestMethod", + "title": "Fs/copyRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ConfigBatchWriteParams" + "$ref": "#/definitions/v2/FsCopyParams" } }, "required": [ @@ -1500,46 +1071,47 @@ "method", "params" ], - "title": "Config/batchWriteRequest", + "title": "Fs/copyRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "configRequirements/read" + "fs/watch" ], - "title": "ConfigRequirements/readRequestMethod", + "title": "Fs/watchRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/v2/FsWatchParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "ConfigRequirements/readRequest", + "title": "Fs/watchRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "account/read" + "fs/unwatch" ], - "title": "Account/readRequestMethod", + "title": "Fs/unwatchRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/GetAccountParams" + "$ref": "#/definitions/v2/FsUnwatchParams" } }, "required": [ @@ -1547,24 +1119,23 @@ "method", "params" ], - "title": "Account/readRequest", + "title": "Fs/unwatchRequest", "type": "object" }, { - "description": "DEPRECATED APIs below", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "newConversation" + "skills/config/write" ], - "title": "NewConversationRequestMethod", + "title": "Skills/config/writeRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/NewConversationParams" + "$ref": "#/definitions/v2/SkillsConfigWriteParams" } }, "required": [ @@ -1572,23 +1143,23 @@ "method", "params" ], - "title": "NewConversationRequest", + "title": "Skills/config/writeRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "getConversationSummary" + "plugin/install" ], - "title": "GetConversationSummaryRequestMethod", + "title": "Plugin/installRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/GetConversationSummaryParams" + "$ref": "#/definitions/v2/PluginInstallParams" } }, "required": [ @@ -1596,24 +1167,23 @@ "method", "params" ], - "title": "GetConversationSummaryRequest", + "title": "Plugin/installRequest", "type": "object" }, { - "description": "List recorded Codex conversations (rollouts) with optional pagination and search.", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "listConversations" + "plugin/uninstall" ], - "title": "ListConversationsRequestMethod", + "title": "Plugin/uninstallRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ListConversationsParams" + "$ref": "#/definitions/v2/PluginUninstallParams" } }, "required": [ @@ -1621,24 +1191,23 @@ "method", "params" ], - "title": "ListConversationsRequest", + "title": "Plugin/uninstallRequest", "type": "object" }, { - "description": "Resume a recorded Codex conversation from a rollout file.", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "resumeConversation" + "turn/start" ], - "title": "ResumeConversationRequestMethod", + "title": "Turn/startRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ResumeConversationParams" + "$ref": "#/definitions/v2/TurnStartParams" } }, "required": [ @@ -1646,24 +1215,23 @@ "method", "params" ], - "title": "ResumeConversationRequest", + "title": "Turn/startRequest", "type": "object" }, { - "description": "Fork a recorded Codex conversation into a new session.", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "forkConversation" + "turn/steer" ], - "title": "ForkConversationRequestMethod", + "title": "Turn/steerRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ForkConversationParams" + "$ref": "#/definitions/v2/TurnSteerParams" } }, "required": [ @@ -1671,23 +1239,23 @@ "method", "params" ], - "title": "ForkConversationRequest", + "title": "Turn/steerRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "archiveConversation" + "turn/interrupt" ], - "title": "ArchiveConversationRequestMethod", + "title": "Turn/interruptRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ArchiveConversationParams" + "$ref": "#/definitions/v2/TurnInterruptParams" } }, "required": [ @@ -1695,23 +1263,23 @@ "method", "params" ], - "title": "ArchiveConversationRequest", + "title": "Turn/interruptRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "sendUserMessage" + "review/start" ], - "title": "SendUserMessageRequestMethod", + "title": "Review/startRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SendUserMessageParams" + "$ref": "#/definitions/v2/ReviewStartParams" } }, "required": [ @@ -1719,23 +1287,23 @@ "method", "params" ], - "title": "SendUserMessageRequest", + "title": "Review/startRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "sendUserTurn" + "model/list" ], - "title": "SendUserTurnRequestMethod", + "title": "Model/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SendUserTurnParams" + "$ref": "#/definitions/v2/ModelListParams" } }, "required": [ @@ -1743,23 +1311,23 @@ "method", "params" ], - "title": "SendUserTurnRequest", + "title": "Model/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "interruptConversation" + "modelProvider/capabilities/read" ], - "title": "InterruptConversationRequestMethod", + "title": "ModelProvider/capabilities/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/InterruptConversationParams" + "$ref": "#/definitions/v2/ModelProviderCapabilitiesReadParams" } }, "required": [ @@ -1767,23 +1335,23 @@ "method", "params" ], - "title": "InterruptConversationRequest", + "title": "ModelProvider/capabilities/readRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "addConversationListener" + "experimentalFeature/list" ], - "title": "AddConversationListenerRequestMethod", + "title": "ExperimentalFeature/listRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/AddConversationListenerParams" + "$ref": "#/definitions/v2/ExperimentalFeatureListParams" } }, "required": [ @@ -1791,23 +1359,23 @@ "method", "params" ], - "title": "AddConversationListenerRequest", + "title": "ExperimentalFeature/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "removeConversationListener" + "experimentalFeature/enablement/set" ], - "title": "RemoveConversationListenerRequestMethod", + "title": "ExperimentalFeature/enablement/setRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/RemoveConversationListenerParams" + "$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams" } }, "required": [ @@ -1815,23 +1383,23 @@ "method", "params" ], - "title": "RemoveConversationListenerRequest", + "title": "ExperimentalFeature/enablement/setRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "gitDiffToRemote" + "mcpServer/oauth/login" ], - "title": "GitDiffToRemoteRequestMethod", + "title": "McpServer/oauth/loginRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/GitDiffToRemoteParams" + "$ref": "#/definitions/v2/McpServerOauthLoginParams" } }, "required": [ @@ -1839,70 +1407,70 @@ "method", "params" ], - "title": "GitDiffToRemoteRequest", + "title": "McpServer/oauth/loginRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "loginApiKey" + "config/mcpServer/reload" ], - "title": "LoginApiKeyRequestMethod", + "title": "Config/mcpServer/reloadRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/LoginApiKeyParams" + "type": "null" } }, "required": [ "id", - "method", - "params" + "method" ], - "title": "LoginApiKeyRequest", + "title": "Config/mcpServer/reloadRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "loginChatGpt" + "mcpServerStatus/list" ], - "title": "LoginChatGptRequestMethod", + "title": "McpServerStatus/listRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/v2/ListMcpServerStatusParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "LoginChatGptRequest", + "title": "McpServerStatus/listRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "cancelLoginChatGpt" + "mcpServer/resource/read" ], - "title": "CancelLoginChatGptRequestMethod", + "title": "McpServer/resource/readRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/CancelLoginChatGptParams" + "$ref": "#/definitions/v2/McpResourceReadParams" } }, "required": [ @@ -1910,47 +1478,47 @@ "method", "params" ], - "title": "CancelLoginChatGptRequest", + "title": "McpServer/resource/readRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "logoutChatGpt" + "mcpServer/tool/call" ], - "title": "LogoutChatGptRequestMethod", + "title": "McpServer/tool/callRequestMethod", "type": "string" }, "params": { - "type": "null" + "$ref": "#/definitions/v2/McpServerToolCallParams" } }, "required": [ "id", - "method" + "method", + "params" ], - "title": "LogoutChatGptRequest", + "title": "McpServer/tool/callRequest", "type": "object" }, { - "description": "DEPRECATED in favor of GetAccount", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "getAuthStatus" + "windowsSandbox/setupStart" ], - "title": "GetAuthStatusRequestMethod", + "title": "WindowsSandbox/setupStartRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/GetAuthStatusParams" + "$ref": "#/definitions/v2/WindowsSandboxSetupStartParams" } }, "required": [ @@ -1958,19 +1526,19 @@ "method", "params" ], - "title": "GetAuthStatusRequest", + "title": "WindowsSandbox/setupStartRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "getUserSavedConfig" + "windowsSandbox/readiness" ], - "title": "GetUserSavedConfigRequestMethod", + "title": "WindowsSandbox/readinessRequestMethod", "type": "string" }, "params": { @@ -1981,23 +1549,47 @@ "id", "method" ], - "title": "GetUserSavedConfigRequest", + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/login/start" + ], + "title": "Account/login/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/LoginAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/login/startRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "setDefaultModel" + "account/login/cancel" ], - "title": "SetDefaultModelRequestMethod", + "title": "Account/login/cancelRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/SetDefaultModelParams" + "$ref": "#/definitions/v2/CancelLoginAccountParams" } }, "required": [ @@ -2005,19 +1597,19 @@ "method", "params" ], - "title": "SetDefaultModelRequest", + "title": "Account/login/cancelRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "getUserAgent" + "account/logout" ], - "title": "GetUserAgentRequestMethod", + "title": "Account/logoutRequestMethod", "type": "string" }, "params": { @@ -2028,19 +1620,19 @@ "id", "method" ], - "title": "GetUserAgentRequest", + "title": "Account/logoutRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "userInfo" + "account/rateLimits/read" ], - "title": "UserInfoRequestMethod", + "title": "Account/rateLimits/readRequestMethod", "type": "string" }, "params": { @@ -2051,23 +1643,23 @@ "id", "method" ], - "title": "UserInfoRequest", + "title": "Account/rateLimits/readRequest", "type": "object" }, { "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "fuzzyFileSearch" + "account/sendAddCreditsNudgeEmail" ], - "title": "FuzzyFileSearchRequestMethod", + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/FuzzyFileSearchParams" + "$ref": "#/definitions/v2/SendAddCreditsNudgeEmailParams" } }, "required": [ @@ -2075,24 +1667,23 @@ "method", "params" ], - "title": "FuzzyFileSearchRequest", + "title": "Account/sendAddCreditsNudgeEmailRequest", "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", "properties": { "id": { - "$ref": "#/definitions/RequestId" + "$ref": "#/definitions/v2/RequestId" }, "method": { "enum": [ - "execOneOffCommand" + "feedback/upload" ], - "title": "ExecOneOffCommandRequestMethod", + "title": "Feedback/uploadRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ExecOneOffCommandParams" + "$ref": "#/definitions/v2/FeedbackUploadParams" } }, "required": [ @@ -2100,180 +1691,328 @@ "method", "params" ], - "title": "ExecOneOffCommandRequest", + "title": "Feedback/uploadRequest", "type": "object" - } - ], - "title": "ClientRequest" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "cyber_policy", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" }, { - "additionalProperties": false, + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { - "model_cap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec" ], - "type": "object" + "title": "Command/execRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecParams" } }, "required": [ - "model_cap" + "id", + "method", + "params" ], - "title": "ModelCapCodexErrorInfo", + "title": "Command/execRequest", "type": "object" }, { - "additionalProperties": false, + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecWriteParams" } }, "required": [ - "http_connection_failed" + "id", + "method", + "params" ], - "title": "HttpConnectionFailedCodexErrorInfo", + "title": "Command/exec/writeRequest", "type": "object" }, { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecTerminateParams" } }, "required": [ - "response_stream_connection_failed" + "id", + "method", + "params" ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "title": "Command/exec/terminateRequest", "type": "object" }, { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecResizeParams" } }, "required": [ - "response_stream_disconnected" + "id", + "method", + "params" ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", + "title": "Command/exec/resizeRequest", "type": "object" }, { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "config/read" + ], + "title": "Config/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ConfigReadParams" } }, "required": [ - "response_too_many_failed_attempts" + "id", + "method", + "params" ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "title": "Config/readRequest", "type": "object" - } - ] - }, - "CommandExecutionApprovalDecision": { - "oneOf": [ - { - "description": "User approved the command.", - "enum": [ - "accept" - ], - "type": "string" - }, - { - "description": "User approved the command and future identical commands should run without prompting.", - "enum": [ - "acceptForSession" - ], - "type": "string" }, { - "additionalProperties": false, - "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", "properties": { - "acceptWithExecpolicyAmendment": { - "properties": { - "execpolicy_amendment": { - "items": { - "type": "string" + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/detect" + ], + "title": "ExternalAgentConfig/detectRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExternalAgentConfigDetectParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/detectRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/import" + ], + "title": "ExternalAgentConfig/importRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExternalAgentConfigImportParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/importRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "config/value/write" + ], + "title": "Config/value/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ConfigValueWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/value/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "config/batchWrite" + ], + "title": "Config/batchWriteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ConfigBatchWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/batchWriteRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "configRequirements/read" + ], + "title": "ConfigRequirements/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "ConfigRequirements/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/read" + ], + "title": "Account/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/GetAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "fuzzyFileSearch" + ], + "title": "FuzzyFileSearchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "FuzzyFileSearchRequest", + "type": "object" + } + ], + "title": "ClientRequest" + }, + "CommandExecutionApprovalDecision": { + "oneOf": [ + { + "description": "User approved the command.", + "enum": [ + "accept" + ], + "type": "string" + }, + { + "description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.", + "enum": [ + "acceptForSession" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", + "properties": { + "acceptWithExecpolicyAmendment": { + "properties": { + "execpolicy_amendment": { + "items": { + "type": "string" }, "type": "array" } @@ -2290,6 +2029,28 @@ "title": "AcceptWithExecpolicyAmendmentCommandExecutionApprovalDecision", "type": "object" }, + { + "additionalProperties": false, + "description": "User chose a persistent network policy rule (allow/deny) for this host.", + "properties": { + "applyNetworkPolicyAmendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "applyNetworkPolicyAmendment" + ], + "title": "ApplyNetworkPolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, { "description": "User denied the command. The agent will continue the turn.", "enum": [ @@ -2310,7 +2071,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "approvalId": { - "description": "Identifier for this specific approval callback.", + "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.", "type": [ "string", "null" @@ -2334,11 +2095,15 @@ ] }, "cwd": { - "description": "The command's working directory.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "The command's working directory." }, "itemId": { "type": "string" @@ -2364,6 +2129,16 @@ "null" ] }, + "proposedNetworkPolicyAmendments": { + "description": "Optional proposed network policy amendments (allow/deny host) for future requests.", + "items": { + "$ref": "#/definitions/NetworkPolicyAmendment" + }, + "type": [ + "array", + "null" + ] + }, "reason": { "description": "Optional explanatory reason (e.g. request for network access).", "type": [ @@ -2371,6 +2146,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2380,6 +2160,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -2399,282 +2180,249 @@ "title": "CommandExecutionRequestApprovalResponse", "type": "object" }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" + "DynamicToolCallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "arguments": true, + "callId": { + "type": "string" }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "ConversationGitInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "origin_url": { - "type": [ - "string", - "null" - ] - }, - "sha": { + "namespace": { "type": [ "string", "null" ] - } - }, - "type": "object" - }, - "ConversationSummary": { - "properties": { - "cliVersion": { - "type": "string" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "gitInfo": { - "anyOf": [ - { - "$ref": "#/definitions/ConversationGitInfo" - }, - { - "type": "null" - } - ] }, - "modelProvider": { + "threadId": { "type": "string" }, - "path": { + "tool": { "type": "string" }, - "preview": { + "turnId": { "type": "string" - }, - "source": { - "$ref": "#/definitions/SessionSource" - }, - "timestamp": { - "type": [ - "string", - "null" - ] - }, - "updatedAt": { - "type": [ - "string", - "null" - ] } }, "required": [ - "cliVersion", - "conversationId", - "cwd", - "modelProvider", - "path", - "preview", - "source" + "arguments", + "callId", + "threadId", + "tool", + "turnId" ], + "title": "DynamicToolCallParams", "type": "object" }, - "CreditsSnapshot": { + "DynamicToolCallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" + "contentItems": { + "items": { + "$ref": "#/definitions/v2/DynamicToolCallOutputContentItem" + }, + "type": "array" }, - "unlimited": { + "success": { "type": "boolean" } }, "required": [ - "has_credits", - "unlimited" + "contentItems", + "success" ], + "title": "DynamicToolCallResponse", "type": "object" }, - "CustomPrompt": { + "ExecCommandApprovalParams": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "argument_hint": { + "approvalId": { + "description": "Identifier for this specific approval callback.", "type": [ "string", "null" ] }, - "content": { + "callId": { + "description": "Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] and [codex_protocol::protocol::ExecCommandEndEvent].", "type": "string" }, - "description": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "conversationId": { + "$ref": "#/definitions/v2/ThreadId" + }, + "cwd": { + "type": "string" + }, + "parsedCmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "reason": { "type": [ "string", "null" ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" } }, "required": [ - "content", - "name", - "path" + "callId", + "command", + "conversationId", + "cwd", + "parsedCmd" ], + "title": "ExecCommandApprovalParams", "type": "object" }, - "Duration": { + "ExecCommandApprovalResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" + "decision": { + "$ref": "#/definitions/ReviewDecision" } }, "required": [ - "nanos", - "secs" + "decision" ], + "title": "ExecCommandApprovalResponse", "type": "object" }, - "DynamicToolCallOutputContentItem": { + "FileChange": { "oneOf": [ { "properties": { - "text": { + "content": { "type": "string" }, "type": { "enum": [ - "inputText" + "add" ], - "title": "InputTextDynamicToolCallOutputContentItemType", + "title": "AddFileChangeType", "type": "string" } }, "required": [ - "text", + "content", "type" ], - "title": "InputTextDynamicToolCallOutputContentItem", + "title": "AddFileChange", "type": "object" }, { "properties": { - "imageUrl": { + "content": { "type": "string" }, "type": { "enum": [ - "inputImage" + "delete" ], - "title": "InputImageDynamicToolCallOutputContentItemType", + "title": "DeleteFileChangeType", "type": "string" } }, "required": [ - "imageUrl", + "content", "type" ], - "title": "InputImageDynamicToolCallOutputContentItem", + "title": "DeleteFileChange", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdateFileChangeType", + "type": "string" + }, + "unified_diff": { + "type": "string" + } + }, + "required": [ + "type", + "unified_diff" + ], + "title": "UpdateFileChange", "type": "object" } ] }, - "DynamicToolCallParams": { + "FileChangeApprovalDecision": { + "oneOf": [ + { + "description": "User approved the file changes.", + "enum": [ + "accept" + ], + "type": "string" + }, + { + "description": "User approved the file changes and future changes to the same files should run without prompting.", + "enum": [ + "acceptForSession" + ], + "type": "string" + }, + { + "description": "User denied the file changes. The agent will continue the turn.", + "enum": [ + "decline" + ], + "type": "string" + }, + { + "description": "User denied the file changes. The turn will also be immediately interrupted.", + "enum": [ + "cancel" + ], + "type": "string" + } + ] + }, + "FileChangeRequestApprovalParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "arguments": true, - "callId": { + "grantRoot": { + "description": "[UNSTABLE] When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", + "type": [ + "string", + "null" + ] + }, + "itemId": { "type": "string" }, - "namespace": { + "reason": { + "description": "Optional explanatory reason (e.g. request for extra write access).", "type": [ "string", "null" ] }, - "threadId": { - "type": "string" + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" }, - "tool": { + "threadId": { "type": "string" }, "turnId": { @@ -2682,8239 +2430,5660 @@ } }, "required": [ - "arguments", - "callId", + "itemId", + "startedAtMs", "threadId", - "tool", "turnId" ], - "title": "DynamicToolCallParams", + "title": "FileChangeRequestApprovalParams", "type": "object" }, - "DynamicToolCallResponse": { + "FileChangeRequestApprovalResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "contentItems": { - "items": { - "$ref": "#/definitions/DynamicToolCallOutputContentItem" - }, - "type": "array" - }, - "success": { - "type": "boolean" + "decision": { + "$ref": "#/definitions/FileChangeApprovalDecision" } }, "required": [ - "contentItems", - "success" + "decision" ], - "title": "DynamicToolCallResponse", + "title": "FileChangeRequestApprovalResponse", "type": "object" }, - "EventMsg": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/v2/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" + "FuzzyFileSearchMatchType": { + "enum": [ + "file", + "directory" + ], + "type": "string" + }, + "FuzzyFileSearchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cancellationToken": { + "type": [ + "string", + "null" + ] }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" + "query": { + "type": "string" }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } + "roots": { + "items": { + "type": "string" }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } + "type": "array" + } + }, + "required": [ + "query", + "roots" + ], + "title": "FuzzyFileSearchParams", + "type": "object" + }, + "FuzzyFileSearchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" + "type": "array" + } + }, + "required": [ + "files" + ], + "title": "FuzzyFileSearchResponse", + "type": "object" + }, + "FuzzyFileSearchResult": { + "description": "Superset of [`codex_file_search::FileMatch`]", + "properties": { + "file_name": { + "type": "string" }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/v2/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } + "indices": { + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" }, - "required": [ - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" + "type": [ + "array", + "null" + ] }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" + "match_type": { + "$ref": "#/definitions/FuzzyFileSearchMatchType" }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/v2/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "score": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "file_name", + "match_type", + "path", + "root", + "score" + ], + "type": "object" + }, + "FuzzyFileSearchSessionCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" + }, + "FuzzyFileSearchSessionUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" + "type": "array" }, - { - "description": "Auto Context is evaluating whether to compact before the next turn.", - "properties": { - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/AutoContextPhase" - }, - { - "type": "null" - } - ] + "query": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "files", + "query", + "sessionId" + ], + "title": "FuzzyFileSearchSessionUpdatedNotification", + "type": "object" + }, + "GrantedPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AdditionalFileSystemPermissions" }, - "type": { - "enum": [ - "auto_context_check" - ], - "title": "AutoContextCheckEventMsgType", - "type": "string" + { + "type": "null" } - }, - "required": [ - "type" - ], - "title": "AutoContextCheckEventMsg", - "type": "object" + ] }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" + "network": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AdditionalNetworkPermissions" }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" + { + "type": "null" } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" + ] + } + }, + "type": "object" + }, + "InitializeCapabilities": { + "description": "Client-declared capabilities negotiated during initialize.", + "properties": { + "experimentalApi": { + "default": false, + "description": "Opt into receiving experimental API methods and fields.", + "type": "boolean" }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/v2/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } + "optOutNotificationMethods": { + "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", + "items": { + "type": "string" }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "InitializeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "capabilities": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeCapabilities" }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" + { + "type": "null" } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" + ] }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" + "clientInfo": { + "$ref": "#/definitions/ClientInfo" + } + }, + "required": [ + "clientInfo" + ], + "title": "InitializeParams", + "type": "object" + }, + "InitializeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "codexHome": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" } - }, - "required": [ - "text", - "type" ], - "title": "AgentReasoningEventMsg", - "type": "object" + "description": "Absolute path to the server's $CODEX_HOME directory." + }, + "platformFamily": { + "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", + "type": "string" + }, + "platformOs": { + "description": "Operating system for the running app-server target, for example `\"macos\"`, `\"linux\"`, or `\"windows\"`.", + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "required": [ + "codexHome", + "platformFamily", + "platformOs", + "userAgent" + ], + "title": "InitializeResponse", + "type": "object" + }, + "JSONRPCError": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/definitions/JSONRPCErrorError" + }, + "id": { + "$ref": "#/definitions/v2/RequestId" + } + }, + "required": [ + "error", + "id" + ], + "title": "JSONRPCError", + "type": "object" + }, + "JSONRPCErrorError": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "code": { + "format": "int64", + "type": "integer" }, + "data": true, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "title": "JSONRPCErrorError", + "type": "object" + }, + "JSONRPCMessage": { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" + "$ref": "#/definitions/JSONRPCRequest" }, { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" + "$ref": "#/definitions/JSONRPCNotification" }, { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" + "$ref": "#/definitions/JSONRPCResponse" }, { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" + "$ref": "#/definitions/JSONRPCError" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.", + "title": "JSONRPCMessage" + }, + "JSONRPCNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A notification which does not expect a response.", + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": [ + "method" + ], + "title": "JSONRPCNotification", + "type": "object" + }, + "JSONRPCRequest": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "type": "string" + }, + "params": true, + "trace": { + "anyOf": [ + { + "$ref": "#/definitions/W3cTraceContext" }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" + { + "type": "null" } - }, - "required": [ - "type" ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/v2/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "automation_origin": { - "anyOf": [ - { - "$ref": "#/definitions/AutomationOrigin" - }, - { - "type": "null" - } - ], - "description": "Structured metadata for automated sessions, if the launcher provided it." - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/v2/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/v2/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" + "description": "Optional W3C Trace Context for distributed tracing." + } + }, + "required": [ + "id", + "method" + ], + "title": "JSONRPCRequest", + "type": "object" + }, + "JSONRPCResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/v2/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" + "result": true + }, + "required": [ + "id", + "result" + ], + "title": "JSONRPCResponse", + "type": "object" + }, + "McpElicitationArrayType": { + "enum": [ + "array" + ], + "type": "string" + }, + "McpElicitationBooleanSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "boolean", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationBooleanType": { + "enum": [ + "boolean" + ], + "type": "string" + }, + "McpElicitationConstOption": { + "additionalProperties": false, + "properties": { + "const": { + "type": "string" }, + "title": { + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "McpElicitationEnumSchema": { + "anyOf": [ { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" }, { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" }, { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" + "type": "array" }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } + "enumNames": { + "items": { + "type": "string" }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" + "type": [ + "array", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" }, { - "properties": { - "action": { - "$ref": "#/definitions/v2/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "additionalProperties": false, + "properties": { + "default": { + "format": "double", + "type": [ + "number", + "null" + ] }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_begin" - ], - "title": "ImageGenerationBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "ImageGenerationBeginEventMsg", - "type": "object" + "description": { + "type": [ + "string", + "null" + ] + }, + "maximum": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "minimum": { + "format": "double", + "type": [ + "number", + "null" + ] }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationNumberType": { + "enum": [ + "number", + "integer" + ], + "type": "string" + }, + "McpElicitationObjectType": { + "enum": [ + "object" + ], + "type": "string" + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ { - "properties": { - "call_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_end" - ], - "title": "ImageGenerationEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "result", - "status", - "type" - ], - "title": "ImageGenerationEndEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationEnumSchema" }, { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationStringSchema" }, { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationNumberSchema" }, { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "additionalProperties": false, + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", "type": "object" }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } + "required": { + "items": { + "type": "string" }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" + "type": [ + "array", + "null" + ] }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" }, { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "enum": [ + "email", + "uri", + "date", + "date-time" + ], + "type": "string" + }, + "McpElicitationStringSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" + "description": { + "type": [ + "string", + "null" + ] }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" + { + "type": "null" } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" + ] }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" + "maxLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" + "minLength": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" + "title": { + "type": [ + "string", + "null" + ] }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "McpElicitationStringType": { + "enum": [ + "string" + ], + "type": "string" + }, + "McpElicitationTitledEnumItems": { + "additionalProperties": false, + "properties": { + "anyOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" + "type": [ + "array", + "null" + ] }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledEnumItems": { + "additionalProperties": false, + "properties": { + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": [ + "string", + "null" + ] }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "McpServerElicitationAction": { + "enum": [ + "accept", + "decline", + "cancel" + ], + "type": "string" + }, + "McpServerElicitationRequestParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/v2/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, + "_meta": true, "message": { "type": "string" }, - "type": { + "mode": { "enum": [ - "stream_error" + "form" ], - "title": "StreamErrorEventMsgType", "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" } }, "required": [ "message", - "type" + "mode", + "requestedSchema" ], - "title": "StreamErrorEventMsg", "type": "object" }, { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", + "_meta": true, + "elicitationId": { "type": "string" }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "message": { "type": "string" }, - "type": { + "mode": { "enum": [ - "patch_apply_begin" + "url" ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", "type": "string" }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", + "url": { "type": "string" } }, "required": [ - "call_id", - "stderr", - "stdout", - "success", - "type" + "elicitationId", + "message", + "mode", + "url" ], - "title": "PatchApplyEndEventMsg", "type": "object" + } + ], + "properties": { + "serverName": { + "type": "string" }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" + "threadId": { + "type": "string" }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/v2/McpAuthStatus" - }, - "default": {}, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/v2/ResourceTemplate" - }, - "type": "array" - }, - "default": {}, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/v2/Resource" - }, - "type": "array" - }, - "default": {}, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "server_failures": { - "additionalProperties": { - "$ref": "#/definitions/McpServerFailure" - }, - "description": "Legacy server failure map keyed by server name.", - "type": [ - "object", - "null" - ] - }, - "server_tools": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "Legacy server -> tool names map used by existing UI surfaces.", - "type": [ - "object", - "null" - ] - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/v2/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "serverName", + "threadId" + ], + "title": "McpServerElicitationRequestParams", + "type": "object" + }, + "McpServerElicitationRequestResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "_meta": { + "description": "Optional client metadata for form-mode action handling." }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" + "action": { + "$ref": "#/definitions/McpServerElicitationAction" }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/v2/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" + "content": { + "description": "Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.\n\nThis is nullable because decline/cancel responses have no content." + } + }, + "required": [ + "action" + ], + "title": "McpServerElicitationRequestResponse", + "type": "object" + }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/v2/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" + "protocol": { + "$ref": "#/definitions/v2/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "ParsedCommand": { + "oneOf": [ { - "description": "Remote skill downloaded to local cache.", "properties": { - "id": { + "cmd": { "type": "string" }, "name": { "type": "string" }, "path": { + "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", "type": "string" }, "type": { "enum": [ - "remote_skill_downloaded" + "read" ], - "title": "RemoteSkillDownloadedEventMsgType", + "title": "ReadParsedCommandType", "type": "string" } }, "required": [ - "id", + "cmd", "name", "path", "type" ], - "title": "RemoteSkillDownloadedEventMsg", + "title": "ReadParsedCommand", "type": "object" }, { - "description": "Notification that skill data may have been updated and clients may want to reload.", "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", + "cmd": { "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Optional note used by newer clients; when provided it supersedes `name`.", - "type": [ - "string", - "null" - ] }, - "name": { - "default": null, - "description": "Legacy field name used by existing clients.", + "path": { "type": [ "string", "null" ] }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { "type": { "enum": [ - "shutdown_complete" + "list_files" ], - "title": "ShutdownCompleteEventMsgType", + "title": "ListFilesParsedCommandType", "type": "string" } }, "required": [ + "cmd", "type" ], - "title": "ShutdownCompleteEventMsg", + "title": "ListFilesParsedCommand", "type": "object" }, { - "description": "Entered review mode.", "properties": { - "prompt": { - "default": "", - "description": "Legacy plain-text prompt retained for compatibility with older review flows.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/v2/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", + "cmd": { "type": "string" }, - "user_facing_hint": { + "path": { "type": [ "string", "null" ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] }, - "snapshot": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewSnapshotInfo" - }, - { - "type": "null" - } + "query": { + "type": [ + "string", + "null" ] }, "type": { "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/v2/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" + "search" ], - "title": "RawResponseItemEventMsgType", + "title": "SearchParsedCommandType", "type": "string" } }, "required": [ - "item", + "cmd", "type" ], - "title": "RawResponseItemEventMsg", + "title": "SearchParsedCommand", "type": "object" }, { "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/v2/ThreadId" - }, - "turn_id": { + "cmd": { "type": "string" }, "type": { "enum": [ - "item_started" + "unknown" ], - "title": "ItemStartedEventMsgType", + "title": "UnknownParsedCommandType", "type": "string" } }, "required": [ - "item", - "thread_id", - "turn_id", + "cmd", "type" ], - "title": "ItemStartedEventMsg", + "title": "UnknownParsedCommand", "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/v2/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" + } + ] + }, + "PermissionGrantScope": { + "enum": [ + "turn", + "session" + ], + "type": "string" + }, + "PermissionsRequestApprovalParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/v2/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "cwd", + "itemId", + "permissions", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "PermissionsRequestApprovalParams", + "type": "object" + }, + "PermissionsRequestApprovalResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "permissions": { + "$ref": "#/definitions/GrantedPermissionProfile" + }, + "scope": { + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ], + "default": "turn" + }, + "strictAutoReview": { + "description": "Review every subsequent command in this turn before normal sandboxed execution.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "permissions" + ], + "title": "PermissionsRequestApprovalResponse", + "type": "object" + }, + "RequestId": { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ], + "title": "RequestId" + }, + "ReviewDecision": { + "description": "User's decision in response to an ExecApprovalRequest.", + "oneOf": [ + { + "description": "User has approved this command and the agent should execute it.", + "enum": [ + "approved" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.", + "properties": { + "approved_execpolicy_amendment": { + "properties": { + "proposed_execpolicy_amendment": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "proposed_execpolicy_amendment" ], - "title": "ItemCompletedEventMsgType", - "type": "string" + "type": "object" } }, "required": [ - "item", - "thread_id", - "turn_id", - "type" + "approved_execpolicy_amendment" ], - "title": "ItemCompletedEventMsg", + "title": "ApprovedExecpolicyAmendmentReviewDecision", "type": "object" }, { + "description": "User has approved this request and wants future prompts in the same session-scoped approval cache to be automatically approved for the remainder of the session.", + "enum": [ + "approved_for_session" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" + "network_policy_amendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" + "type": "object" } }, "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" + "network_policy_amendment" ], - "title": "AgentMessageContentDeltaEventMsg", + "title": "NetworkPolicyAmendmentReviewDecision", "type": "object" }, { + "description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.", + "enum": [ + "denied" + ], + "type": "string" + }, + { + "description": "Automatic approval review timed out before reaching a decision.", + "enum": [ + "timed_out" + ], + "type": "string" + }, + { + "description": "User has denied this command and the agent should not do anything until the user's next command.", + "enum": [ + "abort" + ], + "type": "string" + } + ] + }, + "ServerNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification sent from the server to the client.", + "oneOf": [ + { + "description": "NEW NOTIFICATIONS", "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { + "method": { "enum": [ - "plan_delta" + "error" ], - "title": "PlanDeltaEventMsgType", + "title": "ErrorNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ErrorNotification" } }, "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" + "method", + "params" ], - "title": "PlanDeltaEventMsg", + "title": "ErrorNotification", "type": "object" }, { "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { + "method": { "enum": [ - "reasoning_content_delta" + "thread/started" ], - "title": "ReasoningContentDeltaEventMsgType", + "title": "Thread/startedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadStartedNotification" } }, "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" + "method", + "params" ], - "title": "ReasoningContentDeltaEventMsg", + "title": "Thread/startedNotification", "type": "object" }, { "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { + "method": { "enum": [ - "reasoning_raw_content_delta" + "thread/status/changed" ], - "title": "ReasoningRawContentDeltaEventMsgType", + "title": "Thread/status/changedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadStatusChangedNotification" } }, "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" + "method", + "params" ], - "title": "ReasoningRawContentDeltaEventMsg", + "title": "Thread/status/changedNotification", "type": "object" }, { - "description": "Collab interaction: agent spawn begin.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { + "method": { "enum": [ - "collab_agent_spawn_begin" + "thread/archived" ], - "title": "CollabAgentSpawnBeginEventMsgType", + "title": "Thread/archivedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadArchivedNotification" } }, "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" + "method", + "params" ], - "title": "CollabAgentSpawnBeginEventMsg", + "title": "Thread/archivedNotification", "type": "object" }, { - "description": "Collab interaction: agent spawn end.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { + "method": { "enum": [ - "collab_agent_spawn_end" + "thread/unarchived" ], - "title": "CollabAgentSpawnEndEventMsgType", + "title": "Thread/unarchivedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadUnarchivedNotification" } }, "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" + "method", + "params" ], - "title": "CollabAgentSpawnEndEventMsg", + "title": "Thread/unarchivedNotification", "type": "object" }, { - "description": "Collab interaction: agent interaction begin.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { + "method": { "enum": [ - "collab_agent_interaction_begin" + "thread/closed" ], - "title": "CollabAgentInteractionBeginEventMsgType", + "title": "Thread/closedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadClosedNotification" } }, "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" + "method", + "params" ], - "title": "CollabAgentInteractionBeginEventMsg", + "title": "Thread/closedNotification", "type": "object" }, { - "description": "Collab interaction: agent interaction end.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { + "method": { "enum": [ - "collab_agent_interaction_end" + "skills/changed" ], - "title": "CollabAgentInteractionEndEventMsgType", + "title": "Skills/changedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SkillsChangedNotification" } }, "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" + "method", + "params" ], - "title": "CollabAgentInteractionEndEventMsg", + "title": "Skills/changedNotification", "type": "object" }, { - "description": "Collab interaction: waiting begin.", "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/v2/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { + "method": { "enum": [ - "collab_waiting_begin" + "thread/name/updated" ], - "title": "CollabWaitingBeginEventMsgType", + "title": "Thread/name/updatedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadNameUpdatedNotification" } }, "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" + "method", + "params" ], - "title": "CollabWaitingBeginEventMsg", + "title": "Thread/name/updatedNotification", "type": "object" }, { - "description": "Collab interaction: waiting end.", "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { + "method": { "enum": [ - "collab_waiting_end" + "thread/goal/updated" ], - "title": "CollabWaitingEndEventMsgType", + "title": "Thread/goal/updatedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalUpdatedNotification" } }, "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" + "method", + "params" ], - "title": "CollabWaitingEndEventMsg", + "title": "Thread/goal/updatedNotification", "type": "object" }, { - "description": "Collab interaction: close begin.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { + "method": { "enum": [ - "collab_close_begin" + "thread/goal/cleared" ], - "title": "CollabCloseBeginEventMsgType", + "title": "Thread/goal/clearedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalClearedNotification" } }, "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" + "method", + "params" ], - "title": "CollabCloseBeginEventMsg", + "title": "Thread/goal/clearedNotification", "type": "object" }, { - "description": "Collab interaction: close end.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } + "method": { + "enum": [ + "thread/tokenUsage/updated" ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." + "title": "Thread/tokenUsage/updatedNotificationMethod", + "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/ThreadTokenUsageUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/tokenUsage/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "collab_close_end" + "turn/started" ], - "title": "CollabCloseEndEventMsgType", + "title": "Turn/startedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/TurnStartedNotification" } }, "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" + "method", + "params" ], - "title": "CollabCloseEndEventMsg", + "title": "Turn/startedNotification", "type": "object" }, { - "description": "Collab interaction: resume begin.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", + "method": { + "enum": [ + "hook/started" + ], + "title": "Hook/startedNotificationMethod", "type": "string" }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } + "params": { + "$ref": "#/definitions/v2/HookStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Hook/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/completed" ], - "description": "Thread ID of the receiver." + "title": "Turn/completedNotificationMethod", + "type": "string" }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } + "params": { + "$ref": "#/definitions/v2/TurnCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "hook/completed" ], - "description": "Thread ID of the sender." + "title": "Hook/completedNotificationMethod", + "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/HookCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Hook/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "collab_resume_begin" + "turn/diff/updated" ], - "title": "CollabResumeBeginEventMsgType", + "title": "Turn/diff/updatedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/TurnDiffUpdatedNotification" } }, "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" + "method", + "params" ], - "title": "CollabResumeBeginEventMsg", + "title": "Turn/diff/updatedNotification", "type": "object" }, { - "description": "Collab interaction: resume end.", "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", + "method": { + "enum": [ + "turn/plan/updated" + ], + "title": "Turn/plan/updatedNotificationMethod", "type": "string" }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } + "params": { + "$ref": "#/definitions/v2/TurnPlanUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/plan/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/started" ], - "description": "Thread ID of the receiver." + "title": "Item/startedNotificationMethod", + "type": "string" }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/v2/ThreadId" - } + "params": { + "$ref": "#/definitions/v2/ItemStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/autoApprovalReview/started" ], - "description": "Thread ID of the sender." + "title": "Item/autoApprovalReview/startedNotificationMethod", + "type": "string" }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } + "params": { + "$ref": "#/definitions/v2/ItemGuardianApprovalReviewStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/autoApprovalReview/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/autoApprovalReview/completed" ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." + "title": "Item/autoApprovalReview/completedNotificationMethod", + "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/ItemGuardianApprovalReviewCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/autoApprovalReview/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "collab_resume_end" + "item/completed" ], - "title": "CollabResumeEndEventMsgType", + "title": "Item/completedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ItemCompletedNotification" } }, "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" + "method", + "params" ], - "title": "CollabResumeEndEventMsg", + "title": "Item/completedNotification", "type": "object" - } - ], - "title": "EventMsg" - }, - "ExecCommandApprovalParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "approvalId": { - "description": "Identifier for this specific approval callback.", - "type": [ - "string", - "null" - ] - }, - "callId": { - "description": "Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] and [codex_core::protocol::ExecCommandEndEvent].", - "type": "string" - }, - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "conversationId": { - "$ref": "#/definitions/v2/ThreadId" - }, - "cwd": { - "type": "string" - }, - "parsedCmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "reason": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "callId", - "command", - "conversationId", - "cwd", - "parsedCmd" - ], - "title": "ExecCommandApprovalParams", - "type": "object" - }, - "ExecCommandApprovalResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "decision": { - "$ref": "#/definitions/ReviewDecision" - } - }, - "required": [ - "decision" - ], - "title": "ExecCommandApprovalResponse", - "type": "object" - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecOneOffCommandParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "type": [ - "string", - "null" - ] }, - "sandboxPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxPolicy" + { + "properties": { + "method": { + "enum": [ + "item/agentMessage/delta" + ], + "title": "Item/agentMessage/deltaNotificationMethod", + "type": "string" }, - { - "type": "null" + "params": { + "$ref": "#/definitions/v2/AgentMessageDeltaNotification" } - ] - }, - "timeoutMs": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "command" - ], - "title": "ExecOneOffCommandParams", - "type": "object" - }, - "ExecOneOffCommandResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "exitCode": { - "format": "int32", - "type": "integer" - }, - "stderr": { - "type": "string" + }, + "required": [ + "method", + "params" + ], + "title": "Item/agentMessage/deltaNotification", + "type": "object" }, - "stdout": { - "type": "string" - } - }, - "required": [ - "exitCode", - "stderr", - "stdout" - ], - "title": "ExecOneOffCommandResponse", - "type": "object" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", "properties": { - "content": { + "method": { + "enum": [ + "item/plan/delta" + ], + "title": "Item/plan/deltaNotificationMethod", "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/PlanDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/plan/deltaNotification", + "type": "object" + }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + "properties": { + "method": { "enum": [ - "add" + "command/exec/outputDelta" ], - "title": "AddFileChangeType", + "title": "Command/exec/outputDeltaNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecOutputDeltaNotification" } }, "required": [ - "content", - "type" + "method", + "params" ], - "title": "AddFileChange", + "title": "Command/exec/outputDeltaNotification", "type": "object" }, { + "description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", "properties": { - "content": { + "method": { + "enum": [ + "process/outputDelta" + ], + "title": "Process/outputDeltaNotificationMethod", "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/ProcessOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/outputDeltaNotification", + "type": "object" + }, + { + "description": "Final exit notification for a `process/spawn` session.", + "properties": { + "method": { "enum": [ - "delete" + "process/exited" ], - "title": "DeleteFileChangeType", + "title": "Process/exitedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ProcessExitedNotification" } }, "required": [ - "content", - "type" + "method", + "params" ], - "title": "DeleteFileChange", + "title": "Process/exitedNotification", "type": "object" }, { "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { + "method": { "enum": [ - "update" + "item/commandExecution/outputDelta" ], - "title": "UpdateFileChangeType", + "title": "Item/commandExecution/outputDeltaNotificationMethod", "type": "string" }, - "unified_diff": { - "type": "string" + "params": { + "$ref": "#/definitions/v2/CommandExecutionOutputDeltaNotification" } }, "required": [ - "type", - "unified_diff" + "method", + "params" ], - "title": "UpdateFileChange", + "title": "Item/commandExecution/outputDeltaNotification", "type": "object" - } - ] - }, - "FileChangeApprovalDecision": { - "oneOf": [ + }, { - "description": "User approved the file changes.", - "enum": [ - "accept" + "properties": { + "method": { + "enum": [ + "item/commandExecution/terminalInteraction" + ], + "title": "Item/commandExecution/terminalInteractionNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/TerminalInteractionNotification" + } + }, + "required": [ + "method", + "params" ], - "type": "string" + "title": "Item/commandExecution/terminalInteractionNotification", + "type": "object" }, { - "description": "User approved the file changes and future changes to the same files should run without prompting.", - "enum": [ - "acceptForSession" + "description": "Deprecated legacy apply_patch output stream notification.", + "properties": { + "method": { + "enum": [ + "item/fileChange/outputDelta" + ], + "title": "Item/fileChange/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FileChangeOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" ], - "type": "string" + "title": "Item/fileChange/outputDeltaNotification", + "type": "object" }, { - "description": "User denied the file changes. The agent will continue the turn.", - "enum": [ - "decline" + "properties": { + "method": { + "enum": [ + "item/fileChange/patchUpdated" + ], + "title": "Item/fileChange/patchUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FileChangePatchUpdatedNotification" + } + }, + "required": [ + "method", + "params" ], - "type": "string" + "title": "Item/fileChange/patchUpdatedNotification", + "type": "object" }, { - "description": "User denied the file changes. The turn will also be immediately interrupted.", - "enum": [ - "cancel" + "properties": { + "method": { + "enum": [ + "serverRequest/resolved" + ], + "title": "ServerRequest/resolvedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ServerRequestResolvedNotification" + } + }, + "required": [ + "method", + "params" ], - "type": "string" - } - ] - }, - "FileChangeRequestApprovalParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "grantRoot": { - "description": "[UNSTABLE] When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", - "type": [ - "string", - "null" - ] - }, - "itemId": { - "type": "string" - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "threadId": { - "type": "string" + "title": "ServerRequest/resolvedNotification", + "type": "object" }, - "turnId": { - "type": "string" - } - }, - "required": [ - "itemId", - "threadId", - "turnId" - ], - "title": "FileChangeRequestApprovalParams", - "type": "object" - }, - "FileChangeRequestApprovalResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "decision": { - "$ref": "#/definitions/FileChangeApprovalDecision" - } - }, - "required": [ - "decision" - ], - "title": "FileChangeRequestApprovalResponse", - "type": "object" - }, - "ForcedLoginMethod": { - "enum": [ - "chatgpt", - "api" - ], - "type": "string" - }, - "ForkConversationParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" + { + "properties": { + "method": { + "enum": [ + "item/mcpToolCall/progress" + ], + "title": "Item/mcpToolCall/progressNotificationMethod", + "type": "string" }, - { - "type": "null" + "params": { + "$ref": "#/definitions/v2/McpToolCallProgressNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "Item/mcpToolCall/progressNotification", + "type": "object" }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" + { + "properties": { + "method": { + "enum": [ + "mcpServer/oauthLogin/completed" + ], + "title": "McpServer/oauthLogin/completedNotificationMethod", + "type": "string" }, - { - "type": "null" + "params": { + "$ref": "#/definitions/v2/McpServerOauthLoginCompletedNotification" } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ForkConversationParams", - "type": "object" - }, - "ForkConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" + "required": [ + "method", + "params" + ], + "title": "McpServer/oauthLogin/completedNotification", + "type": "object" }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ForkConversationResponse", - "type": "object" - }, - "FunctionCallOutputBody": { - "anyOf": [ { - "type": "string" + "properties": { + "method": { + "enum": [ + "mcpServer/startupStatus/updated" + ], + "title": "McpServer/startupStatus/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/McpServerStatusUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "McpServer/startupStatus/updatedNotification", + "type": "object" }, { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" + "properties": { + "method": { + "enum": [ + "account/updated" + ], + "title": "Account/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/AccountUpdatedNotification" + } }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ + "required": [ + "method", + "params" + ], + "title": "Account/updatedNotification", + "type": "object" + }, { "properties": { - "text": { + "method": { + "enum": [ + "account/rateLimits/updated" + ], + "title": "Account/rateLimits/updatedNotificationMethod", "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/AccountRateLimitsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/rateLimits/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "input_text" + "app/list/updated" ], - "title": "InputTextFunctionCallOutputContentItemType", + "title": "App/list/updatedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/AppListUpdatedNotification" } }, "required": [ - "text", - "type" + "method", + "params" ], - "title": "InputTextFunctionCallOutputContentItem", + "title": "App/list/updatedNotification", "type": "object" }, { "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { + "method": { + "enum": [ + "remoteControl/status/changed" + ], + "title": "RemoteControl/status/changedNotificationMethod", "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/RemoteControlStatusChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "RemoteControl/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "input_image" + "externalAgentConfig/import/completed" ], - "title": "InputImageFunctionCallOutputContentItemType", + "title": "ExternalAgentConfig/import/completedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExternalAgentConfigImportCompletedNotification" } }, "required": [ - "image_url", - "type" + "method", + "params" ], - "title": "InputImageFunctionCallOutputContentItem", + "title": "ExternalAgentConfig/import/completedNotification", "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "FuzzyFileSearchParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cancellationToken": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": "string" }, - "roots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "query", - "roots" - ], - "title": "FuzzyFileSearchParams", - "type": "object" - }, - "FuzzyFileSearchResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "files": { - "items": { - "$ref": "#/definitions/FuzzyFileSearchResult" + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FsChangedNotification" + } }, - "type": "array" - } - }, - "required": [ - "files" - ], - "title": "FuzzyFileSearchResponse", - "type": "object" - }, - "FuzzyFileSearchResult": { - "description": "Superset of [`codex_file_search::FileMatch`]", - "properties": { - "file_name": { - "type": "string" + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" }, - "indices": { - "items": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryTextDelta" + ], + "title": "Item/reasoning/summaryTextDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ReasoningSummaryTextDeltaNotification" + } }, - "type": [ - "array", - "null" - ] + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryTextDeltaNotification", + "type": "object" }, - "path": { - "type": "string" + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryPartAdded" + ], + "title": "Item/reasoning/summaryPartAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ReasoningSummaryPartAddedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryPartAddedNotification", + "type": "object" }, - "root": { - "type": "string" + { + "properties": { + "method": { + "enum": [ + "item/reasoning/textDelta" + ], + "title": "Item/reasoning/textDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ReasoningTextDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/textDeltaNotification", + "type": "object" }, - "score": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "file_name", - "path", - "root", - "score" - ], - "type": "object" - }, - "GetAuthStatusParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "includeToken": { - "type": [ - "boolean", - "null" - ] + { + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "method": { + "enum": [ + "thread/compacted" + ], + "title": "Thread/compactedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ContextCompactedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/compactedNotification", + "type": "object" }, - "refreshToken": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusParams", - "type": "object" - }, - "GetAuthStatusResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" }, - { - "type": "null" + "params": { + "$ref": "#/definitions/v2/ModelReroutedNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" }, - "authToken": { - "type": [ - "string", - "null" - ] + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" }, - "requiresOpenaiAuth": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusResponse", - "type": "object" - }, - "GetConversationSummaryParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ { "properties": { - "rolloutPath": { + "method": { + "enum": [ + "warning" + ], + "title": "WarningNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WarningNotification" } }, "required": [ - "rolloutPath" + "method", + "params" ], - "title": "RolloutPathv1::GetConversationSummaryParams", + "title": "WarningNotification", "type": "object" }, { "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/GuardianWarningNotification" } }, "required": [ - "conversationId" + "method", + "params" ], - "title": "ConversationIdv1::GetConversationSummaryParams", + "title": "GuardianWarningNotification", "type": "object" - } - ], - "title": "GetConversationSummaryParams" - }, - "GetConversationSummaryResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "summary": { - "$ref": "#/definitions/ConversationSummary" - } - }, - "required": [ - "summary" - ], - "title": "GetConversationSummaryResponse", - "type": "object" - }, - "GetUserAgentResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "userAgent": { - "type": "string" - } - }, - "required": [ - "userAgent" - ], - "title": "GetUserAgentResponse", - "type": "object" - }, - "GetUserSavedConfigResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "config": { - "$ref": "#/definitions/UserSavedConfig" - } - }, - "required": [ - "config" - ], - "title": "GetUserSavedConfigResponse", - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, - "GitDiffToRemoteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cwd": { - "type": "string" - } - }, - "required": [ - "cwd" - ], - "title": "GitDiffToRemoteParams", - "type": "object" - }, - "GitDiffToRemoteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "diff": { - "type": "string" - }, - "sha": { - "$ref": "#/definitions/GitSha" - } - }, - "required": [ - "diff", - "sha" - ], - "title": "GitDiffToRemoteResponse", - "type": "object" - }, - "GitSha": { - "type": "string" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" }, - "text": { - "type": "string" + { + "properties": { + "method": { + "enum": [ + "deprecationNotice" + ], + "title": "DeprecationNoticeNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/DeprecationNoticeNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "DeprecationNoticeNotification", + "type": "object" }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "ImageDetail": { - "enum": [ - "auto", - "low", - "high", - "original" - ], - "type": "string" - }, - "InitializeCapabilities": { - "description": "Client-declared capabilities negotiated during initialize.", - "properties": { - "experimentalApi": { - "default": false, - "description": "Opt into receiving experimental API methods and fields.", - "type": "boolean" - } - }, - "type": "object" - }, - "InitializeParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "capabilities": { - "anyOf": [ - { - "$ref": "#/definitions/InitializeCapabilities" + { + "properties": { + "method": { + "enum": [ + "configWarning" + ], + "title": "ConfigWarningNotificationMethod", + "type": "string" }, - { - "type": "null" + "params": { + "$ref": "#/definitions/v2/ConfigWarningNotification" } - ] + }, + "required": [ + "method", + "params" + ], + "title": "ConfigWarningNotification", + "type": "object" }, - "clientInfo": { - "$ref": "#/definitions/ClientInfo" - } - }, - "required": [ - "clientInfo" - ], - "title": "InitializeParams", - "type": "object" - }, - "InitializeResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "userAgent": { - "type": "string" - } - }, - "required": [ - "userAgent" - ], - "title": "InitializeResponse", - "type": "object" - }, - "InputItem": { - "oneOf": [ { "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" + "method": { + "enum": [ + "fuzzyFileSearch/sessionUpdated" ], - "type": "object" + "title": "FuzzyFileSearch/sessionUpdatedNotificationMethod", + "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "text" + "fuzzyFileSearch/sessionCompleted" ], - "title": "TextInputItemType", + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" } }, "required": [ - "data", - "type" + "method", + "params" ], - "title": "TextInputItem", + "title": "FuzzyFileSearch/sessionCompletedNotification", "type": "object" }, { "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" + "method": { + "enum": [ + "thread/realtime/started" ], - "type": "object" + "title": "Thread/realtime/startedNotificationMethod", + "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "image" + "thread/realtime/itemAdded" ], - "title": "ImageInputItemType", + "title": "Thread/realtime/itemAddedNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeItemAddedNotification" } }, "required": [ - "data", - "type" + "method", + "params" ], - "title": "ImageInputItem", + "title": "Thread/realtime/itemAddedNotification", "type": "object" }, { "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" + "method": { + "enum": [ + "thread/realtime/transcript/delta" ], - "type": "object" + "title": "Thread/realtime/transcript/deltaNotificationMethod", + "type": "string" }, - "type": { + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeTranscriptDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/transcript/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { "enum": [ - "localImage" + "thread/realtime/transcript/done" ], - "title": "LocalImageInputItemType", + "title": "Thread/realtime/transcript/doneNotificationMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeTranscriptDoneNotification" } }, "required": [ - "data", - "type" + "method", + "params" ], - "title": "LocalImageInputItem", + "title": "Thread/realtime/transcript/doneNotification", "type": "object" - } - ] - }, - "InterruptConversationParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "InterruptConversationParams", - "type": "object" - }, - "InterruptConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "abortReason": { - "$ref": "#/definitions/TurnAbortReason" - } - }, - "required": [ - "abortReason" - ], - "title": "InterruptConversationResponse", - "type": "object" - }, - "JSONRPCError": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A response to a request that indicates an error occurred.", - "properties": { - "error": { - "$ref": "#/definitions/JSONRPCErrorError" - }, - "id": { - "$ref": "#/definitions/RequestId" - } - }, - "required": [ - "error", - "id" - ], - "title": "JSONRPCError", - "type": "object" - }, - "JSONRPCErrorError": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "code": { - "format": "int64", - "type": "integer" }, - "data": true, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" - ], - "title": "JSONRPCErrorError", - "type": "object" - }, - "JSONRPCMessage": { - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ { - "$ref": "#/definitions/JSONRPCRequest" + "properties": { + "method": { + "enum": [ + "thread/realtime/outputAudio/delta" + ], + "title": "Thread/realtime/outputAudio/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeOutputAudioDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/outputAudio/deltaNotification", + "type": "object" }, { - "$ref": "#/definitions/JSONRPCNotification" + "properties": { + "method": { + "enum": [ + "thread/realtime/sdp" + ], + "title": "Thread/realtime/sdpNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeSdpNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/sdpNotification", + "type": "object" }, { - "$ref": "#/definitions/JSONRPCResponse" + "properties": { + "method": { + "enum": [ + "thread/realtime/error" + ], + "title": "Thread/realtime/errorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/errorNotification", + "type": "object" }, { - "$ref": "#/definitions/JSONRPCError" - } - ], - "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.", - "title": "JSONRPCMessage" - }, - "JSONRPCNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A notification which does not expect a response.", - "properties": { - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "method" - ], - "title": "JSONRPCNotification", - "type": "object" - }, - "JSONRPCRequest": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A request that expects a response.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "type": "string" - }, - "params": true - }, - "required": [ - "id", - "method" - ], - "title": "JSONRPCRequest", - "type": "object" - }, - "JSONRPCResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "A successful (non-error) response to a request.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "result": true - }, - "required": [ - "id", - "result" - ], - "title": "JSONRPCResponse", - "type": "object" - }, - "ListConversationsParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "modelProviders": { - "items": { - "type": "string" + "properties": { + "method": { + "enum": [ + "thread/realtime/closed" + ], + "title": "Thread/realtime/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadRealtimeClosedNotification" + } }, - "type": [ - "array", - "null" - ] + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/closedNotification", + "type": "object" }, - "pageSize": { - "format": "uint", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "title": "ListConversationsParams", - "type": "object" - }, - "ListConversationsResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "items": { - "items": { - "$ref": "#/definitions/ConversationSummary" + { + "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", + "properties": { + "method": { + "enum": [ + "windows/worldWritableWarning" + ], + "title": "Windows/worldWritableWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WindowsWorldWritableWarningNotification" + } }, - "type": "array" + "required": [ + "method", + "params" + ], + "title": "Windows/worldWritableWarningNotification", + "type": "object" }, - "nextCursor": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "items" - ], - "title": "ListConversationsResponse", - "type": "object" - }, - "LocalShellAction": { - "oneOf": [ { "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { + "method": { "enum": [ - "exec" + "windowsSandbox/setupCompleted" ], - "title": "ExecLocalShellActionType", + "title": "WindowsSandbox/setupCompletedNotificationMethod", "type": "string" }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] + "params": { + "$ref": "#/definitions/v2/WindowsSandboxSetupCompletedNotification" } }, "required": [ - "command", - "type" + "method", + "params" ], - "title": "ExecLocalShellAction", + "title": "WindowsSandbox/setupCompletedNotification", "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "LoginApiKeyParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "apiKey": { - "type": "string" - } - }, - "required": [ - "apiKey" - ], - "title": "LoginApiKeyParams", - "type": "object" - }, - "LoginApiKeyResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LoginApiKeyResponse", - "type": "object" - }, - "LoginChatGptCompleteNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Deprecated in favor of AccountLoginCompletedNotification.", - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "loginId": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "loginId", - "success" - ], - "title": "LoginChatGptCompleteNotification", - "type": "object" - }, - "LoginChatGptResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authUrl": { - "type": "string" }, - "loginId": { - "type": "string" + { + "properties": { + "method": { + "enum": [ + "account/login/completed" + ], + "title": "Account/login/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/AccountLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/login/completedNotification", + "type": "object" } - }, - "required": [ - "authUrl", - "loginId" ], - "title": "LoginChatGptResponse", - "type": "object" + "title": "ServerNotification" }, - "LogoutChatGptResponse": { + "ServerRequest": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LogoutChatGptResponse", - "type": "object" - }, - "MacOsAutomationValue": { - "anyOf": [ + "description": "Request initiated from the server and sent to the client.", + "oneOf": [ { - "type": "boolean" + "description": "NEW APIs Sent when approval is requested for a specific command execution. This request is used for Turns started via turn/start.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "item/commandExecution/requestApproval" + ], + "title": "Item/commandExecution/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecutionRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/commandExecution/requestApprovalRequest", + "type": "object" }, { - "items": { - "type": "string" + "description": "Sent when approval is requested for a specific file change. This request is used for Turns started via turn/start.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "item/fileChange/requestApproval" + ], + "title": "Item/fileChange/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangeRequestApprovalParams" + } }, - "type": "array" - } - ] - }, - "MacOsPreferencesValue": { - "anyOf": [ - { - "type": "boolean" + "required": [ + "id", + "method", + "params" + ], + "title": "Item/fileChange/requestApprovalRequest", + "type": "object" }, { - "type": "string" - } - ] - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpServerFailure": { - "properties": { - "message": { - "type": "string" - }, - "phase": { - "$ref": "#/definitions/McpServerFailurePhase" - } - }, - "required": [ - "message", - "phase" - ], - "type": "object" - }, - "McpServerFailurePhase": { - "enum": [ - "start", - "list_tools" - ], - "type": "string" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" + "description": "EXPERIMENTAL - Request input from the user for a tool call.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "item/tool/requestUserInput" + ], + "title": "Item/tool/requestUserInputRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ToolRequestUserInputParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/tool/requestUserInputRequest", + "type": "object" }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ { + "description": "Request input for an MCP server elicitation.", "properties": { - "state": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { "enum": [ - "starting" + "mcpServer/elicitation/request" ], + "title": "McpServer/elicitation/requestRequestMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerElicitationRequestParams" } }, "required": [ - "state" + "id", + "method", + "params" ], - "title": "StateMcpStartupStatus", + "title": "McpServer/elicitation/requestRequest", "type": "object" }, { + "description": "Request approval for additional permissions from the user.", "properties": { - "state": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { "enum": [ - "ready" + "item/permissions/requestApproval" ], + "title": "Item/permissions/requestApprovalRequestMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionsRequestApprovalParams" } }, "required": [ - "state" + "id", + "method", + "params" ], - "title": "StateMcpStartupStatus2", + "title": "Item/permissions/requestApprovalRequest", "type": "object" }, { + "description": "Execute a dynamic tool call on the client.", "properties": { - "error": { - "type": "string" + "id": { + "$ref": "#/definitions/v2/RequestId" }, - "state": { + "method": { "enum": [ - "failed" + "item/tool/call" ], + "title": "Item/tool/callRequestMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/DynamicToolCallParams" } }, "required": [ - "error", - "state" + "id", + "method", + "params" ], + "title": "Item/tool/callRequest", "type": "object" }, { "properties": { - "state": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { "enum": [ - "cancelled" + "account/chatgptAuthTokens/refresh" ], + "title": "Account/chatgptAuthTokens/refreshRequestMethod", "type": "string" + }, + "params": { + "$ref": "#/definitions/ChatgptAuthTokensRefreshParams" } }, "required": [ - "state" + "id", + "method", + "params" ], - "title": "StateMcpStartupStatus3", + "title": "Account/chatgptAuthTokens/refreshRequest", "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ + }, { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" + "description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "applyPatchApproval" + ], + "title": "ApplyPatchApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ApplyPatchApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" ], - "type": "string" + "title": "ApplyPatchApprovalRequest", + "type": "object" }, { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" + "description": "Request to exec a command. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "execCommandApproval" + ], + "title": "ExecCommandApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExecCommandApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExecCommandApprovalRequest", + "type": "object" + } ], - "type": "string" + "title": "ServerRequest" }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" + "ToolRequestUserInputAnswer": { + "description": "EXPERIMENTAL. Captures a user's answer to a request_user_input question.", + "properties": { + "answers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "answers" ], - "type": "string" + "type": "object" }, - "NetworkApprovalContext": { + "ToolRequestUserInputOption": { + "description": "EXPERIMENTAL. Defines a single selectable option for request_user_input.", "properties": { - "host": { + "description": { "type": "string" }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" + "label": { + "type": "string" } }, "required": [ - "host", - "protocol" + "description", + "label" ], "type": "object" }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "NewConversationParams": { + "ToolRequestUserInputParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL. Params sent with a request_user_input event.", "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] + "itemId": { + "type": "string" }, - "baseInstructions": { - "type": [ - "string", - "null" - ] + "questions": { + "items": { + "$ref": "#/definitions/ToolRequestUserInputQuestion" + }, + "type": "array" }, - "compactPrompt": { - "type": [ - "string", - "null" - ] + "threadId": { + "type": "string" }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "questions", + "threadId", + "turnId" + ], + "title": "ToolRequestUserInputParams", + "type": "object" + }, + "ToolRequestUserInputQuestion": { + "description": "EXPERIMENTAL. Represents one request_user_input question and its required options.", + "properties": { + "header": { + "type": "string" }, - "cwd": { - "type": [ - "string", - "null" - ] + "id": { + "type": "string" }, - "developerInstructions": { - "type": [ - "string", - "null" - ] + "isOther": { + "default": false, + "type": "boolean" }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] + "isSecret": { + "default": false, + "type": "boolean" }, - "model": { + "options": { + "items": { + "$ref": "#/definitions/ToolRequestUserInputOption" + }, "type": [ - "string", + "array", "null" ] }, - "modelProvider": { + "question": { + "type": "string" + } + }, + "required": [ + "header", + "id", + "question" + ], + "type": "object" + }, + "ToolRequestUserInputResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL. Response payload mapping question ids to answers.", + "properties": { + "answers": { + "additionalProperties": { + "$ref": "#/definitions/ToolRequestUserInputAnswer" + }, + "type": "object" + } + }, + "required": [ + "answers" + ], + "title": "ToolRequestUserInputResponse", + "type": "object" + }, + "W3cTraceContext": { + "properties": { + "traceparent": { "type": [ "string", "null" ] }, - "profile": { + "tracestate": { "type": [ "string", "null" ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] } }, "type": "object" }, - "NewConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - } + "v2": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "NewConversationResponse", - "type": "object" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" + "Account": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyAccountType", + "type": "string" + } }, - "type": { - "enum": [ - "read_command" - ], - "title": "ReadCommandParsedCommandType", - "type": "string" - } + "required": [ + "type" + ], + "title": "ApiKeyAccount", + "type": "object" }, - "required": [ - "cmd", - "type" - ], - "title": "ReadCommandParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" + { + "properties": { + "email": { + "type": "string" + }, + "planType": { + "$ref": "#/definitions/v2/PlanType" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "ChatgptAccountType", + "type": "string" + } }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } + "required": [ + "email", + "planType", + "type" + ], + "title": "ChatgptAccount", + "type": "object" }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "amazonBedrock" + ], + "title": "AmazonBedrockAccountType", + "type": "string" + } }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } + "required": [ + "type" + ], + "title": "AmazonBedrockAccount", + "type": "object" + } + ] + }, + "AccountLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" + "loginId": { + "type": [ + "string", + "null" + ] + }, + "success": { + "type": "boolean" + } }, - { - "properties": { - "cmd": { - "type": "string" + "required": [ + "success" + ], + "title": "AccountLoginCompletedNotification", + "type": "object" + }, + "AccountRateLimitsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "$ref": "#/definitions/v2/RateLimitSnapshot" + } + }, + "required": [ + "rateLimits" + ], + "title": "AccountRateLimitsUpdatedNotification", + "type": "object" + }, + "AccountUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "authMode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AuthMode" + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PlanType" + }, + { + "type": "null" + } + ] + } + }, + "title": "AccountUpdatedNotification", + "type": "object" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/v2/ActivePermissionProfileModification" }, - "path": { - "type": [ - "string", - "null" - ] + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } }, - "query": { - "type": [ - "string", - "null" - ] + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/v2/FileSystemSandboxEntry" }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } + "type": [ + "array", + "null" + ] }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } + "type": [ + "array", + "null" + ] }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } }, - "step": { - "type": "string" - } + "type": "object" }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "Profile": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "chatgptBaseUrl": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - } + "type": "object" }, - "type": "object" - }, - "RateLimitReachedType": { - "enum": [ - "rate_limit_reached", - "workspace_owner_credits_depleted", - "workspace_member_credits_depleted", - "workspace_owner_usage_limit_reached", - "workspace_member_usage_limit_reached" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "rate_limit_reached_type": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitReachedType" - }, - { - "type": "null" - } - ], - "default": null + "AgentMessageDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "AgentMessageDeltaNotification", + "type": "object" }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "resets_in_seconds": { - "description": "Legacy relative reset in seconds.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" + "AgentPath": { + "type": "string" + }, + "AnalyticsConfig": { + "additionalProperties": true, + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } + "type": "object" }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } + "developer": { + "type": [ + "string", + "null" + ] }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } + "isDiscoverableApp": { + "type": "boolean" }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "ReasoningSummary": { - "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", - "oneOf": [ - { - "enum": [ - "auto", - "concise", - "detailed" - ], - "type": "string" + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } }, - { - "description": "Option to disable reasoning summaries.", - "enum": [ - "none" - ], - "type": "string" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RemoveConversationListenerParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "RemoveConversationListenerParams", - "type": "object" - }, - "RemoveConversationSubscriptionResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RemoveConversationSubscriptionResponse", - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": true, - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "tool_search_call" - ], - "title": "ToolSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "execution", - "type" - ], - "title": "ToolSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tools": { - "items": true, - "type": "array" - }, - "type": { - "enum": [ - "tool_search_output" - ], - "title": "ToolSearchOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "execution", - "status", - "tools", - "type" - ], - "title": "ToolSearchOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_call" - ], - "title": "ImageGenerationCallResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction_summary" - ], - "title": "CompactionSummaryResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionSummaryResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ResumeConversationParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history": { - "items": { - "$ref": "#/definitions/ResponseItem" - }, - "type": [ - "array", - "null" - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ResumeConversationParams", - "type": "object" - }, - "ResumeConversationResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ResumeConversationResponse", - "type": "object" - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewDecision": { - "description": "User's decision in response to an ExecApprovalRequest.", - "oneOf": [ - { - "description": "User has approved this command and the agent should execute it.", - "enum": [ - "approved" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.", - "properties": { - "approved_execpolicy_amendment": { - "properties": { - "proposed_execpolicy_amendment": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "proposed_execpolicy_amendment" - ], - "type": "object" - } - }, - "required": [ - "approved_execpolicy_amendment" - ], - "title": "ApprovedExecpolicyAmendmentReviewDecision", - "type": "object" - }, - { - "description": "User has approved this command and wants to automatically approve any future identical instances (`command` and `cwd` match exactly) for the remainder of the session.", - "enum": [ - "approved_for_session" - ], - "type": "string" - }, - { - "description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.", - "enum": [ - "denied" - ], - "type": "string" - }, - { - "description": "User has denied this command and the agent should not do anything until the user's next command.", - "enum": [ - "abort" - ], - "type": "string" - } - ] - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewSnapshotInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "repo_root": { - "type": [ - "string", - "null" - ] - }, - "snapshot_commit": { - "type": [ - "string", - "null" - ] - }, - "worktree_path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, + "AppConfig": { + "properties": { + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolApproval" + }, + { + "type": "null" + } + ] }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } + "enabled": { + "default": true, + "type": "boolean" }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolsConfig" }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SandboxSettings": { - "properties": { - "excludeSlashTmp": { - "type": [ - "boolean", - "null" - ] - }, - "excludeTmpdirEnvVar": { - "type": [ - "boolean", - "null" - ] - }, - "networkAccess": { - "type": [ - "boolean", - "null" - ] + { + "type": "null" + } + ] + } }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } + "type": "object" }, - "type": "object" - }, - "SendUserMessageParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" + "AppInfo": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppMetadata" + }, + { + "type": "null" + } + ] }, - "type": "array" - } - }, - "required": [ - "conversationId", - "items" - ], - "title": "SendUserMessageParams", - "type": "object" - }, - "SendUserMessageResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserMessageResponse", - "type": "object" - }, - "SendUserTurnParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "approvalPolicy": { - "$ref": "#/definitions/AskForApproval" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppBranding" + }, + { + "type": "null" + } + ] }, - "type": "array" - }, - "model": { - "type": "string" - }, - "outputSchema": { - "description": "Optional JSON Schema used to constrain the final assistant message for this turn." - }, - "sandboxPolicy": { - "$ref": "#/definitions/SandboxPolicy" - }, - "summary": { - "$ref": "#/definitions/ReasoningSummary" - } - }, - "required": [ - "approvalPolicy", - "conversationId", - "cwd", - "items", - "model", - "sandboxPolicy", - "summary" - ], - "title": "SendUserTurnParams", - "type": "object" - }, - "SendUserTurnResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserTurnResponse", - "type": "object" - }, - "ServerNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Notification sent from the server to the client.", - "oneOf": [ - { - "description": "NEW NOTIFICATIONS", - "properties": { - "method": { - "enum": [ - "error" - ], - "title": "ErrorNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ErrorNotification" - } + "description": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "ErrorNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "thread/started" - ], - "title": "Thread/startedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ThreadStartedNotification" - } + "distributionChannel": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Thread/startedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "thread/name/updated" - ], - "title": "Thread/name/updatedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ThreadNameUpdatedNotification" - } + "id": { + "type": "string" }, - "required": [ - "method", - "params" - ], - "title": "Thread/name/updatedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "thread/tokenUsage/updated" - ], - "title": "Thread/tokenUsage/updatedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ThreadTokenUsageUpdatedNotification" - } + "installUrl": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Thread/tokenUsage/updatedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "turn/started" - ], - "title": "Turn/startedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/TurnStartedNotification" - } + "isAccessible": { + "default": false, + "type": "boolean" }, - "required": [ - "method", - "params" - ], - "title": "Turn/startedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "turn/completed" - ], - "title": "Turn/completedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/TurnCompletedNotification" - } + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" }, - "required": [ - "method", - "params" - ], - "title": "Turn/completedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "turn/diff/updated" - ], - "title": "Turn/diff/updatedNotificationMethod", + "labels": { + "additionalProperties": { "type": "string" }, - "params": { - "$ref": "#/definitions/v2/TurnDiffUpdatedNotification" - } + "type": [ + "object", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Turn/diff/updatedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "turn/plan/updated" - ], - "title": "Turn/plan/updatedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/TurnPlanUpdatedNotification" - } + "logoUrl": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Turn/plan/updatedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "item/started" - ], - "title": "Item/startedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ItemStartedNotification" - } + "logoUrlDark": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Item/startedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "item/completed" - ], - "title": "Item/completedNotificationMethod", + "name": { + "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { "type": "string" }, - "params": { - "$ref": "#/definitions/v2/ItemCompletedNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "Item/completedNotification", - "type": "object" + "type": "array" + } }, - { - "description": "This event is internal-only. Used by Codex Cloud.", - "properties": { - "method": { - "enum": [ - "rawResponseItem/completed" - ], - "title": "RawResponseItem/completedNotificationMethod", - "type": "string" + "required": [ + "id", + "name" + ], + "type": "object" + }, + "AppListUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - notification emitted when the app list changes.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/AppInfo" }, - "params": { - "$ref": "#/definitions/v2/RawResponseItemCompletedNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "RawResponseItem/completedNotification", - "type": "object" + "type": "array" + } }, - { - "properties": { - "method": { - "enum": [ - "item/agentMessage/delta" - ], - "title": "Item/agentMessage/deltaNotificationMethod", + "required": [ + "data" + ], + "title": "AppListUpdatedNotification", + "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { "type": "string" }, - "params": { - "$ref": "#/definitions/v2/AgentMessageDeltaNotification" - } + "type": [ + "array", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Item/agentMessage/deltaNotification", - "type": "object" - }, - { - "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", - "properties": { - "method": { - "enum": [ - "item/plan/delta" - ], - "title": "Item/plan/deltaNotificationMethod", - "type": "string" + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/v2/AppScreenshot" }, - "params": { - "$ref": "#/definitions/v2/PlanDeltaNotification" - } + "type": [ + "array", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Item/plan/deltaNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "item/commandExecution/outputDelta" - ], - "title": "Item/commandExecution/outputDeltaNotificationMethod", + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { "type": "string" }, - "params": { - "$ref": "#/definitions/v2/CommandExecutionOutputDeltaNotification" - } + "type": [ + "array", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Item/commandExecution/outputDeltaNotification", - "type": "object" + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } }, - { - "properties": { - "method": { - "enum": [ - "item/commandExecution/terminalInteraction" - ], - "title": "Item/commandExecution/terminalInteractionNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/TerminalInteractionNotification" - } + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Item/commandExecution/terminalInteractionNotification", - "type": "object" + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } }, - { - "properties": { - "method": { - "enum": [ - "item/fileChange/outputDelta" - ], - "title": "Item/fileChange/outputDeltaNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/FileChangeOutputDeltaNotification" - } + "required": [ + "userPrompt" + ], + "type": "object" + }, + "AppSummary": { + "description": "EXPERIMENTAL - app metadata summary for plugin responses.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Item/fileChange/outputDeltaNotification", - "type": "object" + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "needsAuth": { + "type": "boolean" + } }, - { - "properties": { - "method": { - "enum": [ - "item/mcpToolCall/progress" - ], - "title": "Item/mcpToolCall/progressNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/McpToolCallProgressNotification" - } + "required": [ + "id", + "name", + "needsAuth" + ], + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolApproval" + }, + { + "type": "null" + } + ] }, - "required": [ - "method", - "params" - ], - "title": "Item/mcpToolCall/progressNotification", - "type": "object" + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolsConfig": { + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } }, - { - "properties": { - "method": { - "enum": [ - "mcpServer/oauthLogin/completed" - ], - "title": "McpServer/oauthLogin/completedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/McpServerOauthLoginCompletedNotification" - } + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" }, - "required": [ - "method", - "params" - ], - "title": "McpServer/oauthLogin/completedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "account/updated" - ], - "title": "Account/updatedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/AccountUpdatedNotification" - } + "enabled": { + "default": true, + "type": "boolean" }, - "required": [ - "method", - "params" - ], - "title": "Account/updatedNotification", - "type": "object" + "open_world_enabled": { + "default": true, + "type": "boolean" + } }, - { - "properties": { - "method": { - "enum": [ - "account/rateLimits/updated" - ], - "title": "Account/rateLimits/updatedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/AccountRateLimitsUpdatedNotification" - } + "type": "object" + }, + "AppsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - list available apps/connectors.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "Account/rateLimits/updatedNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "app/list/updated" - ], - "title": "App/list/updatedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/AppListUpdatedNotification" - } + "forceRefetch": { + "description": "When true, bypass app caches and fetch the latest data from sources.", + "type": "boolean" }, - "required": [ - "method", - "params" - ], - "title": "App/list/updatedNotification", - "type": "object" + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "threadId": { + "description": "Optional thread id used to evaluate app feature gating from that thread's config.", + "type": [ + "string", + "null" + ] + } }, - { - "properties": { - "method": { - "enum": [ - "item/reasoning/summaryTextDelta" - ], - "title": "Item/reasoning/summaryTextDeltaNotificationMethod", - "type": "string" + "title": "AppsListParams", + "type": "object" + }, + "AppsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - app list response.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/AppInfo" }, - "params": { - "$ref": "#/definitions/v2/ReasoningSummaryTextDeltaNotification" - } + "type": "array" }, - "required": [ - "method", - "params" - ], - "title": "Item/reasoning/summaryTextDeltaNotification", - "type": "object" + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } }, - { - "properties": { - "method": { - "enum": [ - "item/reasoning/summaryPartAdded" - ], - "title": "Item/reasoning/summaryPartAddedNotificationMethod", - "type": "string" + "required": [ + "data" + ], + "title": "AppsListResponse", + "type": "object" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } }, - "params": { - "$ref": "#/definitions/v2/ReasoningSummaryPartAddedNotification" - } + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "AuthMode": { + "description": "Authentication mode for OpenAI-backed providers.", + "oneOf": [ + { + "description": "OpenAI API key provided by the caller and stored by Codex.", + "enum": [ + "apikey" + ], + "type": "string" + }, + { + "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", + "enum": [ + "chatgpt" + ], + "type": "string" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", + "enum": [ + "chatgptAuthTokens" + ], + "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" + } + ] + }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" }, - "required": [ - "method", - "params" - ], - "title": "Item/reasoning/summaryPartAddedNotification", - "type": "object" + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } }, - { - "properties": { - "method": { - "enum": [ - "item/reasoning/textDelta" - ], - "title": "Item/reasoning/textDeltaNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ReasoningTextDeltaNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "Item/reasoning/textDeltaNotification", - "type": "object" + "required": [ + "end", + "start" + ], + "type": "object" + }, + "CancelLoginAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "loginId": { + "type": "string" + } }, - { - "description": "Deprecated: Use `ContextCompaction` item type instead.", - "properties": { - "method": { - "enum": [ - "thread/compacted" - ], - "title": "Thread/compactedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ContextCompactedNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "Thread/compactedNotification", - "type": "object" + "required": [ + "loginId" + ], + "title": "CancelLoginAccountParams", + "type": "object" + }, + "CancelLoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/CancelLoginAccountStatus" + } }, - { - "properties": { - "method": { - "enum": [ - "deprecationNotice" - ], - "title": "DeprecationNoticeNotificationMethod", - "type": "string" + "required": [ + "status" + ], + "title": "CancelLoginAccountResponse", + "type": "object" + }, + "CancelLoginAccountStatus": { + "enum": [ + "canceled", + "notFound" + ], + "type": "string" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } }, - "params": { - "$ref": "#/definitions/v2/DeprecationNoticeNotification" - } + "required": [ + "httpConnectionFailed" + ], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" }, - "required": [ - "method", - "params" - ], - "title": "DeprecationNoticeNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "configWarning" - ], - "title": "ConfigWarningNotificationMethod", - "type": "string" + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } }, - "params": { - "$ref": "#/definitions/v2/ConfigWarningNotification" - } + "required": [ + "responseStreamConnectionFailed" + ], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" }, - "required": [ - "method", - "params" - ], - "title": "ConfigWarningNotification", - "type": "object" - }, - { - "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", - "properties": { - "method": { - "enum": [ - "windows/worldWritableWarning" - ], - "title": "Windows/worldWritableWarningNotificationMethod", - "type": "string" + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } }, - "params": { - "$ref": "#/definitions/v2/WindowsWorldWritableWarningNotification" - } + "required": [ + "responseStreamDisconnected" + ], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" }, - "required": [ - "method", - "params" - ], - "title": "Windows/worldWritableWarningNotification", - "type": "object" - }, - { - "properties": { - "method": { - "enum": [ - "account/login/completed" - ], - "title": "Account/login/completedNotificationMethod", - "type": "string" + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } }, - "params": { - "$ref": "#/definitions/v2/AccountLoginCompletedNotification" - } + "required": [ + "responseTooManyFailedAttempts" + ], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" }, - "required": [ - "method", - "params" - ], - "title": "Account/login/completedNotification", - "type": "object" - }, - { - "description": "DEPRECATED NOTIFICATIONS below", - "properties": { - "method": { - "enum": [ - "authStatusChange" - ], - "title": "AuthStatusChangeNotificationMethod", - "type": "string" + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/v2/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } }, - "params": { - "$ref": "#/definitions/AuthStatusChangeNotification" - } + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": [ + "string", + "null" + ] }, - "required": [ - "method", - "params" - ], - "title": "AuthStatusChangeNotification", - "type": "object" + "status": { + "$ref": "#/definitions/v2/CollabAgentStatus" + } }, - { - "description": "Deprecated: use `account/login/completed` instead.", - "properties": { - "method": { - "enum": [ - "loginChatGptComplete" - ], - "title": "LoginChatGptCompleteNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/LoginChatGptCompleteNotification" - } + "required": [ + "status" + ], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/v2/ModeKind" }, - "required": [ - "method", - "params" - ], - "title": "LoginChatGptCompleteNotification", - "type": "object" + "settings": { + "$ref": "#/definitions/v2/Settings" + } }, - { - "properties": { - "method": { - "enum": [ - "sessionConfigured" - ], - "title": "SessionConfiguredNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/SessionConfiguredNotification" - } + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "CollaborationModeMask": { + "description": "EXPERIMENTAL - collaboration mode preset metadata for clients.", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ModeKind" + }, + { + "type": "null" + } + ] }, - "required": [ - "method", - "params" - ], - "title": "SessionConfiguredNotification", - "type": "object" - } - ], - "title": "ServerNotification" - }, - "ServerRequest": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Request initiated from the server and sent to the client.", - "oneOf": [ - { - "description": "NEW APIs Sent when approval is requested for a specific command execution. This request is used for Turns started via turn/start.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "item/commandExecution/requestApproval" - ], - "title": "Item/commandExecution/requestApprovalRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/CommandExecutionRequestApprovalParams" - } + "model": { + "type": [ + "string", + "null" + ] }, - "required": [ - "id", - "method", - "params" - ], - "title": "Item/commandExecution/requestApprovalRequest", - "type": "object" + "name": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } }, - { - "description": "Sent when approval is requested for a specific file change. This request is used for Turns started via turn/start.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" + "required": [ + "name" + ], + "type": "object" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadCommandActionType", + "type": "string" + } }, - "method": { - "enum": [ - "item/fileChange/requestApproval" - ], - "title": "Item/fileChange/requestApprovalRequestMethod", - "type": "string" + "required": [ + "command", + "name", + "path", + "type" + ], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType", + "type": "string" + } }, - "params": { - "$ref": "#/definitions/FileChangeRequestApprovalParams" - } + "required": [ + "command", + "type" + ], + "title": "ListFilesCommandAction", + "type": "object" }, - "required": [ - "id", - "method", - "params" - ], - "title": "Item/fileChange/requestApprovalRequest", - "type": "object" - }, - { - "description": "EXPERIMENTAL - Request input from the user for a tool call.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "item/tool/requestUserInput" - ], - "title": "Item/tool/requestUserInputRequestMethod", - "type": "string" + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchCommandActionType", + "type": "string" + } }, - "params": { - "$ref": "#/definitions/ToolRequestUserInputParams" - } + "required": [ + "command", + "type" + ], + "title": "SearchCommandAction", + "type": "object" }, - "required": [ - "id", - "method", - "params" - ], - "title": "Item/tool/requestUserInputRequest", - "type": "object" - }, - { - "description": "Execute a dynamic tool call on the client.", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "item/tool/call" - ], - "title": "Item/tool/callRequestMethod", - "type": "string" + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType", + "type": "string" + } }, - "params": { - "$ref": "#/definitions/DynamicToolCallParams" - } + "required": [ + "command", + "type" + ], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" }, - "required": [ - "id", - "method", - "params" - ], - "title": "Item/tool/callRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "account/chatgptAuthTokens/refresh" - ], - "title": "Account/chatgptAuthTokens/refreshRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ChatgptAuthTokensRefreshParams" - } + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" }, - "required": [ - "id", - "method", - "params" - ], - "title": "Account/chatgptAuthTokens/refreshRequest", - "type": "object" + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } }, - { - "description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "applyPatchApproval" - ], - "title": "ApplyPatchApprovalRequestMethod", + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" + }, + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, + "CommandExecParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", + "properties": { + "command": { + "description": "Command argv vector. Empty arrays are rejected.", + "items": { "type": "string" }, - "params": { - "$ref": "#/definitions/ApplyPatchApprovalParams" - } + "type": "array" }, - "required": [ - "id", - "method", - "params" - ], - "title": "ApplyPatchApprovalRequest", - "type": "object" - }, - { - "description": "Request to exec a command. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "execCommandApproval" - ], - "title": "ExecCommandApprovalRequestMethod", - "type": "string" + "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] }, - "params": { - "$ref": "#/definitions/ExecCommandApprovalParams" - } + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", + "type": [ + "string", + "null" + ] + }, + "sandboxPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicy" + }, + { + "type": "null" + } + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/v2/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" }, - "required": [ - "id", - "method", - "params" - ], - "title": "ExecCommandApprovalRequest", - "type": "object" - } - ], - "title": "ServerRequest" - }, - "SessionConfiguredNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "historyEntryCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "historyLogId": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - }, - "sessionId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "historyEntryCount", - "historyLogId", - "model", - "rolloutPath", - "sessionId" - ], - "title": "SessionConfiguredNotification", - "type": "object" - }, - "SessionSource": { - "oneOf": [ - { - "enum": [ - "cli", - "vscode", - "exec", - "mcp", - "unknown" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "subagent": { - "$ref": "#/definitions/SubAgentSource" - } + "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", + "format": "int64", + "type": [ + "integer", + "null" + ] }, - "required": [ - "subagent" - ], - "title": "SubagentSessionSource", - "type": "object" - } - ] - }, - "SetDefaultModelParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "model": { - "type": [ - "string", - "null" - ] + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" + } }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - } + "required": [ + "command" + ], + "title": "CommandExecParams", + "type": "object" }, - "title": "SetDefaultModelParams", - "type": "object" - }, - "SetDefaultModelResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SetDefaultModelResponse", - "type": "object" - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" + "CommandExecResizeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" + "size": { + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } }, - "path": { - "type": "string" - } + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } + "CommandExecResizeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", + "title": "CommandExecResizeResponse", + "type": "object" }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "allow_implicit_invocation": { - "default": true, - "type": "boolean" - }, - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" + "CommandExecResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", + "type": "string" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", + "type": "string" + } }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } + "required": [ + "exitCode", + "stderr", + "stdout" + ], + "title": "CommandExecResponse", + "type": "object" }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } }, - "value": { - "type": "string" - } + "required": [ + "cols", + "rows" + ], + "type": "object" }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" + "CommandExecTerminateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" + }, + "CommandExecTerminateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", + "title": "CommandExecTerminateResponse", + "type": "object" + }, + "CommandExecWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" }, - "type": "array" + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" + }, + "CommandExecWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", + "title": "CommandExecWriteResponse", + "type": "object" + }, + "CommandExecutionOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" }, - "type": "array" - } + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "CommandExecutionOutputDeltaNotification", + "type": "object" }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "SubAgentSource": { - "oneOf": [ - { - "enum": [ - "review", - "compact", - "memory_consolidation" - ], - "type": "string" + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "CommandMigration": { + "properties": { + "name": { + "type": "string" + } }, - { - "additionalProperties": false, - "properties": { - "thread_spawn": { - "properties": { - "depth": { - "format": "int32", - "type": "integer" - }, - "parent_thread_id": { - "$ref": "#/definitions/ThreadId" - } + "required": [ + "name" + ], + "type": "object" + }, + "Config": { + "additionalProperties": true, + "properties": { + "analytics": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AnalyticsConfig" + }, + { + "type": "null" + } + ] + }, + "approval_policy": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "[UNSTABLE] Optional default for where approval requests are routed for review." + }, + "compact_prompt": { + "type": [ + "string", + "null" + ] + }, + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "forced_chatgpt_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "forced_login_method": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedLoginMethod" + }, + { + "type": "null" + } + ] + }, + "instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_auto_compact_token_limit": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_provider": { + "type": [ + "string", + "null" + ] + }, + "model_reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model_reasoning_summary": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "model_verbosity": { + "anyOf": [ + { + "$ref": "#/definitions/v2/Verbosity" + }, + { + "type": "null" + } + ] + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "profiles": { + "additionalProperties": { + "$ref": "#/definitions/v2/ProfileV2" + }, + "default": {}, + "type": "object" + }, + "review_model": { + "type": [ + "string", + "null" + ] + }, + "sandbox_mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SandboxMode" }, - "required": [ - "depth", - "parent_thread_id" - ], - "type": "object" - } + { + "type": "null" + } + ] }, - "required": [ - "thread_spawn" - ], - "title": "ThreadSpawnSubAgentSource", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "other": { - "type": "string" - } + "sandbox_workspace_write": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SandboxWorkspaceWrite" + }, + { + "type": "null" + } + ] }, - "required": [ - "other" - ], - "title": "OtherSubAgentSource", - "type": "object" - } - ] - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "cached_input_tokens_reported": { - "default": false, - "type": "boolean" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "latest_response_model": { - "type": [ - "string", - "null" - ] - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "requested_model": { - "type": [ - "string", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "ToolRequestUserInputAnswer": { - "description": "EXPERIMENTAL. Captures a user's answer to a request_user_input question.", - "properties": { - "answers": { - "items": { - "type": "string" + "service_tier": { + "type": [ + "string", + "null" + ] }, - "type": "array" - } - }, - "required": [ - "answers" - ], - "type": "object" - }, - "ToolRequestUserInputOption": { - "description": "EXPERIMENTAL. Defines a single selectable option for request_user_input.", - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "ToolRequestUserInputParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "EXPERIMENTAL. Params sent with a request_user_input event.", - "properties": { - "itemId": { - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/ToolRequestUserInputQuestion" + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ToolsV2" + }, + { + "type": "null" + } + ] }, - "type": "array" - }, - "threadId": { - "type": "string" + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/v2/WebSearchMode" + }, + { + "type": "null" + } + ] + } }, - "turnId": { - "type": "string" - } + "type": "object" }, - "required": [ - "itemId", - "questions", - "threadId", - "turnId" - ], - "title": "ToolRequestUserInputParams", - "type": "object" - }, - "ToolRequestUserInputQuestion": { - "description": "EXPERIMENTAL. Represents one request_user_input question and its required options.", - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/ToolRequestUserInputOption" + "ConfigBatchWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "edits": { + "items": { + "$ref": "#/definitions/v2/ConfigEdit" + }, + "type": "array" }, - "type": [ - "array", - "null" - ] + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" + } }, - "question": { - "type": "string" - } + "required": [ + "edits" + ], + "title": "ConfigBatchWriteParams", + "type": "object" }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "ToolRequestUserInputResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "EXPERIMENTAL. Response payload mapping question ids to answers.", - "properties": { - "answers": { - "additionalProperties": { - "$ref": "#/definitions/ToolRequestUserInputAnswer" + "ConfigEdit": { + "properties": { + "keyPath": { + "type": "string" }, - "type": "object" - } - }, - "required": [ - "answers" - ], - "title": "ToolRequestUserInputResponse", - "type": "object" - }, - "Tools": { - "properties": { - "viewImage": { - "type": [ - "boolean", - "null" - ] + "mergeStrategy": { + "$ref": "#/definitions/v2/MergeStrategy" + }, + "value": true }, - "webSearch": { - "type": [ - "boolean", - "null" - ] - } + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "type": "object" }, - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } + "ConfigLayer": { + "properties": { + "config": true, + "disabledReason": { + "type": [ + "string", + "null" + ] }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } + "name": { + "$ref": "#/definitions/v2/ConfigLayerSource" }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" + "version": { + "type": "string" + } }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } + "required": [ + "config", + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerMetadata": { + "properties": { + "name": { + "$ref": "#/definitions/v2/ConfigLayerSource" }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" + "version": { + "type": "string" + } }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { + "required": [ + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerSource": { + "oneOf": [ + { + "description": "Managed preferences layer delivered by MDM (macOS only).", + "properties": { + "domain": { "type": "string" }, - "type": "array" - }, - "summary_text": { - "items": { + "key": { "type": "string" }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" + "type": { + "enum": [ + "mdm" + ], + "title": "MdmConfigLayerSourceType", + "type": "string" + } }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } + "required": [ + "domain", + "key", + "type" + ], + "title": "MdmConfigLayerSource", + "type": "object" }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] + { + "description": "Managed config layer from a file (usually `managed_config.toml`).", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "This is the path to the system config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "system" + ], + "title": "SystemConfigLayerSourceType", + "type": "string" + } }, - "status": { - "type": "string" + "required": [ + "file", + "type" + ], + "title": "SystemConfigLayerSource", + "type": "object" + }, + { + "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "user" + ], + "title": "UserConfigLayerSourceType", + "type": "string" + } }, - "type": { - "enum": [ - "ImageGeneration" - ], - "title": "ImageGenerationTurnItemType", - "type": "string" - } + "required": [ + "file", + "type" + ], + "title": "UserConfigLayerSource", + "type": "object" }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" + { + "description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.", + "properties": { + "dotCodexFolder": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "project" + ], + "title": "ProjectConfigLayerSourceType", + "type": "string" + } }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } + "required": [ + "dotCodexFolder", + "type" + ], + "title": "ProjectConfigLayerSource", + "type": "object" }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInfoResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "allegedUserEmail": { - "type": [ - "string", - "null" - ] - } - }, - "title": "UserInfoResponse", - "type": "object" - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" + { + "description": "Session-layer overrides supplied via `-c`/`--config`.", + "properties": { + "type": { + "enum": [ + "sessionFlags" + ], + "title": "SessionFlagsConfigLayerSourceType", + "type": "string" + } }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" + "required": [ + "type" + ], + "title": "SessionFlagsConfigLayerSource", + "type": "object" + }, + { + "description": "`managed_config.toml` was designed to be a config that was loaded as the last layer on top of everything else. This scheme did not quite work out as intended, but we keep this variant as a \"best effort\" while we phase out `managed_config.toml` in favor of `requirements.toml`.", + "properties": { + "file": { + "$ref": "#/definitions/v2/AbsolutePathBuf" }, - "type": "array" + "type": { + "enum": [ + "legacyManagedConfigTomlFromFile" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSourceType", + "type": "string" + } }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } + "required": [ + "file", + "type" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSource", + "type": "object" }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" + { + "properties": { + "type": { + "enum": [ + "legacyManagedConfigTomlFromMdm" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSourceType", + "type": "string" + } }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } + "required": [ + "type" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSource", + "type": "object" + } + ] + }, + "ConfigReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "description": "Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root).", + "type": [ + "string", + "null" + ] }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" + "includeLayers": { + "default": false, + "type": "boolean" + } }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } + "title": "ConfigReadParams", + "type": "object" + }, + "ConfigReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "config": { + "$ref": "#/definitions/v2/Config" }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" + "layers": { + "items": { + "$ref": "#/definitions/v2/ConfigLayer" }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } + "type": [ + "array", + "null" + ] }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" + "origins": { + "additionalProperties": { + "$ref": "#/definitions/v2/ConfigLayerMetadata" }, - "path": { - "type": "string" + "type": "object" + } + }, + "required": [ + "config", + "origins" + ], + "title": "ConfigReadResponse", + "type": "object" + }, + "ConfigRequirements": { + "properties": { + "allowedApprovalPolicies": { + "items": { + "$ref": "#/definitions/v2/AskForApproval" }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } + "type": [ + "array", + "null" + ] }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "UserSavedConfig": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" + "allowedSandboxModes": { + "items": { + "$ref": "#/definitions/v2/SandboxMode" }, - { - "type": "null" - } - ] - }, - "forcedChatgptWorkspaceId": { - "type": [ - "string", - "null" - ] - }, - "forcedLoginMethod": { - "anyOf": [ - { - "$ref": "#/definitions/ForcedLoginMethod" + "type": [ + "array", + "null" + ] + }, + "allowedWebSearchModes": { + "items": { + "$ref": "#/definitions/v2/WebSearchMode" }, - { - "type": "null" - } - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" + "type": [ + "array", + "null" + ] + }, + "enforceResidency": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ResidencyRequirement" + }, + { + "type": "null" + } + ] + }, + "featureRequirements": { + "additionalProperties": { + "type": "boolean" }, - { - "type": "null" - } - ] + "type": [ + "object", + "null" + ] + } }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] + "type": "object" + }, + "ConfigRequirementsReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requirements": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ConfigRequirements" + }, + { + "type": "null" + } + ], + "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + } }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] + "title": "ConfigRequirementsReadResponse", + "type": "object" + }, + "ConfigValueWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + }, + "keyPath": { + "type": "string" + }, + "mergeStrategy": { + "$ref": "#/definitions/v2/MergeStrategy" + }, + "value": true }, - "profile": { - "type": [ - "string", - "null" - ] + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "title": "ConfigValueWriteParams", + "type": "object" + }, + "ConfigWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance or error details.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "Optional path to the config file that triggered the warning.", + "type": [ + "string", + "null" + ] + }, + "range": { + "anyOf": [ + { + "$ref": "#/definitions/v2/TextRange" + }, + { + "type": "null" + } + ], + "description": "Optional range for the error location inside the config file." + }, + "summary": { + "description": "Concise summary of the warning.", + "type": "string" + } }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/Profile" + "required": [ + "summary" + ], + "title": "ConfigWarningNotification", + "type": "object" + }, + "ConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "filePath": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Canonical path to the config file that was written." }, - "type": "object" + "overriddenMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/v2/OverriddenMetadata" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/v2/WriteStatus" + }, + "version": { + "type": "string" + } }, - "sandboxMode": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" + "required": [ + "filePath", + "status", + "version" + ], + "title": "ConfigWriteResponse", + "type": "object" + }, + "ConfiguredHookHandler": { + "oneOf": [ + { + "properties": { + "async": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandConfiguredHookHandlerType", + "type": "string" + } }, - { - "type": "null" - } - ] - }, - "sandboxSettings": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxSettings" + "required": [ + "async", + "command", + "type" + ], + "title": "CommandConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "title": "PromptConfiguredHookHandlerType", + "type": "string" + } }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/Tools" + "required": [ + "type" + ], + "title": "PromptConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "title": "AgentConfiguredHookHandlerType", + "type": "string" + } }, - { - "type": "null" - } - ] - } - }, - "required": [ - "profiles" - ], - "type": "object" - }, - "V1ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } + "required": [ + "type" + ], + "title": "AgentConfiguredHookHandler", + "type": "object" + } + ] }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "V1TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." + "ConfiguredHookMatcherGroup": { + "properties": { + "hooks": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookHandler" + }, + "type": "array" + }, + "matcher": { + "type": [ + "string", + "null" + ] + } }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } + "required": [ + "hooks" + ], + "type": "object" }, - "required": [ - "byteRange" - ], - "type": "object" - }, - "Verbosity": { - "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { + "ContentItem": { + "oneOf": [ + { + "properties": { + "text": { "type": "string" }, - "type": [ - "array", - "null" - ] + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextContentItemType", + "type": "string" + } }, - "query": { - "type": [ - "string", - "null" - ] + "required": [ + "text", + "type" + ], + "title": "InputTextContentItem", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageContentItemType", + "type": "string" + } }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } + "required": [ + "image_url", + "type" + ], + "title": "InputImageContentItem", + "type": "object" }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "output_text" + ], + "title": "OutputTextContentItemType", + "type": "string" + } }, - "url": { - "type": [ - "string", - "null" - ] - } + "required": [ + "text", + "type" + ], + "title": "OutputTextContentItem", + "type": "object" + } + ] + }, + "ContextCompactedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "threadId": { + "type": "string" }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" + "turnId": { + "type": "string" + } }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } + "required": [ + "threadId", + "turnId" + ], + "title": "ContextCompactedNotification", + "type": "object" + }, + "CreditsSnapshot": { + "properties": { + "balance": { + "type": [ + "string", + "null" + ] }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + } }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } + "required": [ + "hasCredits", + "unlimited" + ], + "type": "object" + }, + "DeprecationNoticeNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", + "type": [ + "string", + "null" + ] }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - }, - "v2": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "DeprecationNoticeNotification", + "type": "object" }, - "Account": { + "DynamicToolCallOutputContentItem": { "oneOf": [ { "properties": { + "text": { + "type": "string" + }, "type": { "enum": [ - "apiKey" + "inputText" ], - "title": "ApiKeyAccountType", + "title": "InputTextDynamicToolCallOutputContentItemType", "type": "string" } }, "required": [ + "text", "type" ], - "title": "ApiKeyAccount", + "title": "InputTextDynamicToolCallOutputContentItem", "type": "object" }, { "properties": { - "email": { + "imageUrl": { "type": "string" }, - "planType": { - "$ref": "#/definitions/v2/PlanType" - }, "type": { "enum": [ - "chatgpt" + "inputImage" ], - "title": "ChatgptAccountType", + "title": "InputImageDynamicToolCallOutputContentItemType", "type": "string" } }, "required": [ - "email", - "planType", + "imageUrl", "type" ], - "title": "ChatgptAccount", + "title": "InputImageDynamicToolCallOutputContentItem", "type": "object" } ] }, - "AccountLoginCompletedNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "DynamicToolSpec": { "properties": { - "error": { - "type": [ - "string", - "null" - ] + "deferLoading": { + "type": "boolean" }, - "loginId": { + "description": { + "type": "string" + }, + "inputSchema": true, + "name": { + "type": "string" + }, + "namespace": { "type": [ "string", "null" ] - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ], - "title": "AccountLoginCompletedNotification", - "type": "object" - }, - "AccountRateLimitsUpdatedNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "rateLimits": { - "$ref": "#/definitions/v2/RateLimitSnapshot" } }, "required": [ - "rateLimits" + "description", + "inputSchema", + "name" ], - "title": "AccountRateLimitsUpdatedNotification", - "type": "object" - }, - "AccountUpdatedNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authMode": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AuthMode" - }, - { - "type": "null" - } - ] - } - }, - "title": "AccountUpdatedNotification", "type": "object" }, - "AgentMessageDeltaNotification": { + "ErrorNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "delta": { - "type": "string" - }, - "itemId": { - "type": "string" + "error": { + "$ref": "#/definitions/v2/TurnError" }, "threadId": { "type": "string" }, "turnId": { "type": "string" + }, + "willRetry": { + "type": "boolean" } }, "required": [ - "delta", - "itemId", + "error", "threadId", - "turnId" + "turnId", + "willRetry" ], - "title": "AgentMessageDeltaNotification", + "title": "ErrorNotification", "type": "object" }, - "AnalyticsConfig": { - "additionalProperties": true, + "ExperimentalFeature": { "properties": { - "enabled": { + "announcement": { + "description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.", "type": [ - "boolean", + "string", "null" ] - } - }, - "type": "object" - }, - "AppConfig": { - "properties": { - "disabled_reason": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AppDisabledReason" - }, - { - "type": "null" - } - ] }, - "enabled": { - "default": true, + "defaultEnabled": { + "description": "Whether this feature is enabled by default.", "type": "boolean" - } - }, - "type": "object" - }, - "AppDisabledReason": { - "enum": [ - "unknown", - "user" - ], - "type": "string" - }, - "AppInfo": { - "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", - "properties": { - "description": { - "type": [ - "string", - "null" - ] }, - "distributionChannel": { + "description": { + "description": "Short summary describing what the feature does. Null when this feature is not in beta.", "type": [ "string", "null" ] }, - "id": { - "type": "string" - }, - "installUrl": { + "displayName": { + "description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", "type": [ "string", "null" ] }, - "isAccessible": { - "default": false, - "type": "boolean" - }, - "isEnabled": { - "default": true, - "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "enabled": { + "description": "Whether this feature is currently enabled in the loaded config.", "type": "boolean" }, - "logoUrl": { - "type": [ - "string", - "null" - ] - }, - "logoUrlDark": { - "type": [ - "string", - "null" - ] - }, "name": { + "description": "Stable key used in config.toml and CLI flag toggles.", "type": "string" + }, + "stage": { + "allOf": [ + { + "$ref": "#/definitions/v2/ExperimentalFeatureStage" + } + ], + "description": "Lifecycle stage of this feature flag." } }, "required": [ - "id", - "name" + "defaultEnabled", + "enabled", + "name", + "stage" ], "type": "object" }, - "AppListUpdatedNotification": { + "ExperimentalFeatureEnablementSetParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "EXPERIMENTAL - notification emitted when the app list changes.", "properties": { - "data": { - "items": { - "$ref": "#/definitions/v2/AppInfo" + "enablement": { + "additionalProperties": { + "type": "boolean" }, - "type": "array" + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" } }, "required": [ - "data" + "enablement" ], - "title": "AppListUpdatedNotification", + "title": "ExperimentalFeatureEnablementSetParams", "type": "object" }, - "AppsConfig": { + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", "type": "object" }, - "AppsListParams": { + "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "EXPERIMENTAL - list available apps/connectors.", "properties": { "cursor": { "description": "Opaque pagination cursor returned by a previous call.", @@ -10923,10 +8092,6 @@ "null" ] }, - "forceRefetch": { - "description": "When true, bypass app caches and fetch the latest data from sources.", - "type": "boolean" - }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -10935,25 +8100,17 @@ "integer", "null" ] - }, - "threadId": { - "description": "Optional thread id used to evaluate app feature gating from that thread's config.", - "type": [ - "string", - "null" - ] } }, - "title": "AppsListParams", + "title": "ExperimentalFeatureListParams", "type": "object" }, - "AppsListResponse": { + "ExperimentalFeatureListResponse": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "EXPERIMENTAL - app list response.", "properties": { "data": { "items": { - "$ref": "#/definitions/v2/AppInfo" + "$ref": "#/definitions/v2/ExperimentalFeature" }, "type": "array" }, @@ -10968,1564 +8125,2542 @@ "required": [ "data" ], - "title": "AppsListResponse", + "title": "ExperimentalFeatureListResponse", "type": "object" }, - "AskForApproval": { + "ExperimentalFeatureStage": { "oneOf": [ { + "description": "Feature is available for user testing and feedback.", "enum": [ - "untrusted", - "on-failure", - "on-request", - "never" + "beta" ], "type": "string" }, { - "additionalProperties": false, - "properties": { - "reject": { - "properties": { - "mcp_elicitations": { - "type": "boolean" - }, - "request_permissions": { - "default": false, - "type": "boolean" - }, - "rules": { - "type": "boolean" - }, - "sandbox_approval": { - "type": "boolean" - }, - "skill_approval": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - } - }, - "required": [ - "reject" + "description": "Feature is still being built and not ready for broad use.", + "enum": [ + "underDevelopment" ], - "title": "RejectAskForApproval", - "type": "object" - } - ] - }, - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ + "type": "string" + }, { - "description": "OpenAI API key provided by the caller and stored by Codex.", + "description": "Feature is production-ready.", "enum": [ - "apikey" + "stable" ], "type": "string" }, { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", + "description": "Feature is deprecated and should be avoided.", "enum": [ - "chatgpt" + "deprecated" ], "type": "string" }, { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", + "description": "Feature flag is retained only for backwards compatibility.", "enum": [ - "chatgptAuthTokens" + "removed" ], "type": "string" } ] }, - "ByteRange": { - "properties": { - "end": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CancelLoginAccountParams": { + "ExternalAgentConfigDetectParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "loginId": { - "type": "string" + "cwds": { + "description": "Zero or more working directories to include for repo-scoped detection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeHome": { + "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "type": "boolean" } }, - "required": [ - "loginId" - ], - "title": "CancelLoginAccountParams", + "title": "ExternalAgentConfigDetectParams", "type": "object" }, - "CancelLoginAccountResponse": { + "ExternalAgentConfigDetectResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "status": { - "$ref": "#/definitions/v2/CancelLoginAccountStatus" + "items": { + "items": { + "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" + }, + "type": "array" } }, "required": [ - "status" - ], - "title": "CancelLoginAccountResponse", - "type": "object" - }, - "CancelLoginAccountStatus": { - "enum": [ - "canceled", - "notFound" + "items" ], - "type": "string" - }, - "CodexErrorInfo": { - "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", - "oneOf": [ - { - "enum": [ - "contextWindowExceeded", - "usageLimitExceeded", - "cyberPolicy", - "internalServerError", - "unauthorized", - "badRequest", - "threadRollbackFailed", - "sandboxError", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "httpConnectionFailed": { - "properties": { - "httpStatusCode": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "httpConnectionFailed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "responseStreamConnectionFailed": { - "properties": { - "httpStatusCode": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "responseStreamConnectionFailed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turn before completion.", - "properties": { - "responseStreamDisconnected": { - "properties": { - "httpStatusCode": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "responseStreamDisconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "responseTooManyFailedAttempts": { - "properties": { - "httpStatusCode": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } + "title": "ExternalAgentConfigDetectResponse", + "type": "object" + }, + "ExternalAgentConfigImportCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportCompletedNotification", + "type": "object" + }, + "ExternalAgentConfigImportParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "migrationItems": { + "items": { + "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" }, - "required": [ - "responseTooManyFailedAttempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" + "type": "array" } - ] + }, + "required": [ + "migrationItems" + ], + "title": "ExternalAgentConfigImportParams", + "type": "object" }, - "CollabAgentState": { + "ExternalAgentConfigImportResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportResponse", + "type": "object" + }, + "ExternalAgentConfigMigrationItem": { "properties": { - "message": { + "cwd": { + "description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.", "type": [ "string", "null" ] }, - "status": { - "$ref": "#/definitions/v2/CollabAgentStatus" + "description": { + "type": "string" + }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/v2/MigrationDetails" + }, + { + "type": "null" + } + ] + }, + "itemType": { + "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType" } }, "required": [ - "status" + "description", + "itemType" ], "type": "object" }, - "CollabAgentStatus": { + "ExternalAgentConfigMigrationItemType": { "enum": [ - "pendingInit", - "running", - "completed", - "errored", - "shutdown", - "notFound" + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS" ], "type": "string" }, - "CollabAgentTool": { - "enum": [ - "spawnAgent", - "sendInput", - "resumeAgent", - "wait", - "closeAgent" + "FeedbackUploadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "classification": { + "type": "string" + }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeLogs": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "threadId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "classification", + "includeLogs" ], - "type": "string" + "title": "FeedbackUploadParams", + "type": "object" }, - "CollabAgentToolCallStatus": { - "enum": [ - "inProgress", - "completed", - "failed" + "FeedbackUploadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" ], - "type": "string" + "title": "FeedbackUploadResponse", + "type": "object" }, - "CollaborationMode": { - "description": "Collaboration mode for a Codex session.", + "FileChangeOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.", "properties": { - "mode": { - "$ref": "#/definitions/v2/ModeKind" + "delta": { + "type": "string" }, - "settings": { - "$ref": "#/definitions/v2/Settings" + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" } }, "required": [ - "mode", - "settings" + "delta", + "itemId", + "threadId", + "turnId" ], + "title": "FileChangeOutputDeltaNotification", "type": "object" }, - "CollaborationModeMask": { - "description": "A mask for collaboration mode settings, allowing partial updates. All fields except `name` are optional, enabling selective updates.", + "FileChangePatchUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "developer_instructions": { - "type": [ - "string", - "null" - ] + "changes": { + "items": { + "$ref": "#/definitions/v2/FileUpdateChange" + }, + "type": "array" }, - "mode": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ModeKind" - }, - { - "type": "null" - } - ] + "itemId": { + "type": "string" }, - "model": { - "type": [ - "string", - "null" - ] + "threadId": { + "type": "string" }, - "name": { + "turnId": { "type": "string" + } + }, + "required": [ + "changes", + "itemId", + "threadId", + "turnId" + ], + "title": "FileChangePatchUpdatedNotification", + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" }, - "reasoning_effort": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningEffort" - }, - { - "type": "null" - } - ] + { + "properties": { + "pattern": { + "type": "string" }, - { - "type": "null" + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" } - ] + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/v2/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/v2/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/v2/FileSystemPath" } }, "required": [ - "name" + "access", + "path" ], "type": "object" }, - "CommandAction": { + "FileSystemSpecialPath": { "oneOf": [ { "properties": { - "command": { - "type": "string" - }, - "type": { + "kind": { "enum": [ - "readCommand" + "root" ], - "title": "ReadCommandCommandActionType", "type": "string" } }, "required": [ - "command", - "type" + "kind" ], - "title": "ReadCommandCommandAction", + "title": "RootFileSystemSpecialPath", "type": "object" }, { "properties": { - "command": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { + "kind": { "enum": [ - "read" + "minimal" ], - "title": "ReadCommandActionType", "type": "string" } }, "required": [ - "command", - "name", - "path", - "type" + "kind" ], - "title": "ReadCommandAction", + "title": "MinimalFileSystemSpecialPath", "type": "object" }, { "properties": { - "command": { + "kind": { + "enum": [ + "project_roots" + ], "type": "string" }, - "path": { + "subpath": { "type": [ "string", "null" ] - }, - "type": { + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { "enum": [ - "listFiles" + "tmpdir" ], - "title": "ListFilesCommandActionType", "type": "string" } }, "required": [ - "command", - "type" + "kind" ], - "title": "ListFilesCommandAction", + "title": "TmpdirFileSystemSpecialPath", "type": "object" }, { "properties": { - "command": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { + "kind": { "enum": [ - "search" + "slash_tmp" ], - "title": "SearchCommandActionType", "type": "string" } }, "required": [ - "command", - "type" + "kind" ], - "title": "SearchCommandAction", + "title": "SlashTmpFileSystemSpecialPath", "type": "object" }, { "properties": { - "command": { - "type": "string" - }, - "type": { + "kind": { "enum": [ "unknown" ], - "title": "UnknownCommandActionType", "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/v2/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "ForcedLoginMethod": { + "enum": [ + "chatgpt", + "api" + ], + "type": "string" + }, + "FsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" + }, + "FsCopyParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Copy a file or directory tree on the host filesystem.", + "properties": { + "destinationPath": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute destination path." + }, + "recursive": { + "description": "Required for directory copies; ignored for file copies.", + "type": "boolean" + }, + "sourcePath": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute source path." + } + }, + "required": [ + "destinationPath", + "sourcePath" + ], + "title": "FsCopyParams", + "type": "object" + }, + "FsCopyResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/copy`.", + "title": "FsCopyResponse", + "type": "object" + }, + "FsCreateDirectoryParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Create a directory on the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to create." + }, + "recursive": { + "description": "Whether parent directories should also be created. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "path" + ], + "title": "FsCreateDirectoryParams", + "type": "object" + }, + "FsCreateDirectoryResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/createDirectory`.", + "title": "FsCreateDirectoryResponse", + "type": "object" + }, + "FsGetMetadataParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request metadata for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute path to inspect." + } + }, + "required": [ + "path" + ], + "title": "FsGetMetadataParams", + "type": "object" + }, + "FsGetMetadataResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Metadata returned by `fs/getMetadata`.", + "properties": { + "createdAtMs": { + "description": "File creation time in Unix milliseconds when available, otherwise `0`.", + "format": "int64", + "type": "integer" + }, + "isDirectory": { + "description": "Whether the path resolves to a directory.", + "type": "boolean" + }, + "isFile": { + "description": "Whether the path resolves to a regular file.", + "type": "boolean" + }, + "isSymlink": { + "description": "Whether the path itself is a symbolic link.", + "type": "boolean" + }, + "modifiedAtMs": { + "description": "File modification time in Unix milliseconds when available, otherwise `0`.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAtMs", + "isDirectory", + "isFile", + "isSymlink", + "modifiedAtMs" + ], + "title": "FsGetMetadataResponse", + "type": "object" + }, + "FsReadDirectoryEntry": { + "description": "A directory entry returned by `fs/readDirectory`.", + "properties": { + "fileName": { + "description": "Direct child entry name only, not an absolute or relative path.", + "type": "string" + }, + "isDirectory": { + "description": "Whether this entry resolves to a directory.", + "type": "boolean" + }, + "isFile": { + "description": "Whether this entry resolves to a regular file.", + "type": "boolean" + } + }, + "required": [ + "fileName", + "isDirectory", + "isFile" + ], + "type": "object" + }, + "FsReadDirectoryParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "List direct child names for a directory.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to read." + } + }, + "required": [ + "path" + ], + "title": "FsReadDirectoryParams", + "type": "object" + }, + "FsReadDirectoryResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Directory entries returned by `fs/readDirectory`.", + "properties": { + "entries": { + "description": "Direct child entries in the requested directory.", + "items": { + "$ref": "#/definitions/v2/FsReadDirectoryEntry" + }, + "type": "array" + } + }, + "required": [ + "entries" + ], + "title": "FsReadDirectoryResponse", + "type": "object" + }, + "FsReadFileParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Read a file from the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" } - }, - "required": [ - "command", - "type" ], - "title": "UnknownCommandAction", - "type": "object" + "description": "Absolute path to read." } - ] + }, + "required": [ + "path" + ], + "title": "FsReadFileParams", + "type": "object" }, - "CommandExecParams": { + "FsReadFileResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded file contents returned by `fs/readFile`.", "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { + "dataBase64": { + "description": "File contents encoded as base64.", + "type": "string" + } + }, + "required": [ + "dataBase64" + ], + "title": "FsReadFileResponse", + "type": "object" + }, + "FsRemoveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove a file or directory tree from the host filesystem.", + "properties": { + "force": { + "description": "Whether missing paths should be ignored. Defaults to `true`.", "type": [ - "string", + "boolean", "null" ] }, - "sandboxPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/v2/SandboxPolicy" - }, + "path": { + "allOf": [ { - "type": "null" + "$ref": "#/definitions/v2/AbsolutePathBuf" } - ] + ], + "description": "Absolute path to remove." }, - "timeoutMs": { - "format": "int64", + "recursive": { + "description": "Whether directory removal should recurse. Defaults to `true`.", "type": [ - "integer", + "boolean", "null" ] } }, "required": [ - "command" + "path" ], - "title": "CommandExecParams", + "title": "FsRemoveParams", "type": "object" }, - "CommandExecResponse": { + "FsRemoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/remove`.", + "title": "FsRemoveResponse", + "type": "object" + }, + "FsUnwatchParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", "properties": { - "exitCode": { - "format": "int32", - "type": "integer" - }, - "stderr": { - "type": "string" - }, - "stdout": { + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, "required": [ - "exitCode", - "stderr", - "stdout" + "watchId" ], - "title": "CommandExecResponse", + "title": "FsUnwatchParams", "type": "object" }, - "CommandExecutionOutputDeltaNotification": { + "FsUnwatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" + }, + "FsWatchParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Start filesystem watch notifications for an absolute path.", "properties": { - "delta": { - "type": "string" - }, - "itemId": { - "type": "string" - }, - "threadId": { - "type": "string" + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." }, - "turnId": { + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", "type": "string" } }, "required": [ - "delta", - "itemId", - "threadId", - "turnId" + "path", + "watchId" ], - "title": "CommandExecutionOutputDeltaNotification", + "title": "FsWatchParams", "type": "object" }, - "CommandExecutionStatus": { - "enum": [ - "inProgress", - "completed", - "failed", - "declined" - ], - "type": "string" - }, - "Config": { - "additionalProperties": true, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/watch`.", "properties": { - "analytics": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AnalyticsConfig" - }, - { - "type": "null" - } - ] - }, - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AskForApproval" - }, + "path": { + "allOf": [ { - "type": "null" + "$ref": "#/definitions/v2/AbsolutePathBuf" } - ] - }, - "compact_prompt": { - "type": [ - "string", - "null" - ] - }, - "developer_instructions": { - "type": [ - "string", - "null" - ] - }, - "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" - ] + ], + "description": "Canonicalized path associated with the watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchResponse", + "type": "object" + }, + "FsWriteFileParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write a file on the host filesystem.", + "properties": { + "dataBase64": { + "description": "File contents encoded as base64.", + "type": "string" }, - "forced_login_method": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ForcedLoginMethod" - }, + "path": { + "allOf": [ { - "type": "null" + "$ref": "#/definitions/v2/AbsolutePathBuf" } - ] - }, - "instructions": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_auto_compact_token_limit": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] + ], + "description": "Absolute path to write." + } + }, + "required": [ + "dataBase64", + "path" + ], + "title": "FsWriteFileParams", + "type": "object" + }, + "FsWriteFileResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/writeFile`.", + "title": "FsWriteFileResponse", + "type": "object" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningEffort" + { + "items": { + "$ref": "#/definitions/v2/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" }, - { - "type": "null" + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" } - ] + }, + "required": [ + "text", + "type" + ], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningSummary" + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Verbosity" + "image_url": { + "type": "string" }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "image_url", + "type" + ], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "GetAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refreshToken": { + "default": false, + "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", + "type": "boolean" + } + }, + "title": "GetAccountParams", + "type": "object" + }, + "GetAccountRateLimitsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "allOf": [ { - "type": "null" + "$ref": "#/definitions/v2/RateLimitSnapshot" } - ] - }, - "profile": { - "type": [ - "string", - "null" - ] + ], + "description": "Backward-compatible single-bucket view; mirrors the historical payload." }, - "profiles": { + "rateLimitsByLimitId": { "additionalProperties": { - "$ref": "#/definitions/v2/ProfileV2" + "$ref": "#/definitions/v2/RateLimitSnapshot" }, - "default": {}, - "type": "object" - }, - "review_model": { + "description": "Multi-bucket view keyed by metered `limit_id` (for example, `codex`).", "type": [ - "string", + "object", "null" ] - }, - "sandbox_mode": { - "anyOf": [ - { - "$ref": "#/definitions/v2/SandboxMode" - }, - { - "type": "null" - } - ] - }, - "sandbox_workspace_write": { - "anyOf": [ - { - "$ref": "#/definitions/v2/SandboxWorkspaceWrite" - }, - { - "type": "null" - } - ] - }, - "tools": { + } + }, + "required": [ + "rateLimits" + ], + "title": "GetAccountRateLimitsResponse", + "type": "object" + }, + "GetAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "account": { "anyOf": [ { - "$ref": "#/definitions/v2/ToolsV2" + "$ref": "#/definitions/v2/Account" }, { "type": "null" } ] }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/v2/WebSearchMode" - }, - { - "type": "null" - } - ] + "requiresOpenaiAuth": { + "type": "boolean" } }, + "required": [ + "requiresOpenaiAuth" + ], + "title": "GetAccountResponse", "type": "object" }, - "ConfigBatchWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", + "GitInfo": { "properties": { - "edits": { - "items": { - "$ref": "#/definitions/v2/ConfigEdit" - }, - "type": "array" + "branch": { + "type": [ + "string", + "null" + ] }, - "expectedVersion": { + "originUrl": { "type": [ "string", "null" ] }, - "filePath": { - "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "sha": { "type": [ "string", "null" ] } }, - "required": [ - "edits" - ], - "title": "ConfigBatchWriteParams", - "type": "object" - }, - "ConfigEdit": { - "properties": { - "keyPath": { - "type": "string" - }, - "mergeStrategy": { - "$ref": "#/definitions/v2/MergeStrategy" - }, - "value": true - }, - "required": [ - "keyPath", - "mergeStrategy", - "value" - ], "type": "object" }, - "ConfigLayer": { + "GuardianApprovalReview": { + "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { - "config": true, - "disabledReason": { + "rationale": { "type": [ "string", "null" ] }, - "name": { - "$ref": "#/definitions/v2/ConfigLayerSource" + "riskLevel": { + "anyOf": [ + { + "$ref": "#/definitions/v2/GuardianRiskLevel" + }, + { + "type": "null" + } + ] }, - "version": { - "type": "string" - } - }, - "required": [ - "config", - "name", - "version" - ], - "type": "object" - }, - "ConfigLayerMetadata": { - "properties": { - "name": { - "$ref": "#/definitions/v2/ConfigLayerSource" + "status": { + "$ref": "#/definitions/v2/GuardianApprovalReviewStatus" }, - "version": { - "type": "string" + "userAuthorization": { + "anyOf": [ + { + "$ref": "#/definitions/v2/GuardianUserAuthorization" + }, + { + "type": "null" + } + ] } }, "required": [ - "name", - "version" + "status" ], "type": "object" }, - "ConfigLayerSource": { + "GuardianApprovalReviewAction": { "oneOf": [ { - "description": "Managed preferences layer delivered by MDM (macOS only).", "properties": { - "domain": { + "command": { "type": "string" }, - "key": { - "type": "string" + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "source": { + "$ref": "#/definitions/v2/GuardianCommandSource" }, "type": { "enum": [ - "mdm" + "command" ], - "title": "MdmConfigLayerSourceType", + "title": "CommandGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "domain", - "key", + "command", + "cwd", + "source", "type" ], - "title": "MdmConfigLayerSource", + "title": "CommandGuardianApprovalReviewAction", "type": "object" }, { - "description": "Managed config layer from a file (usually `managed_config.toml`).", "properties": { - "file": { - "allOf": [ - { - "$ref": "#/definitions/v2/AbsolutePathBuf" - } + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/v2/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" ], - "description": "This is the path to the system config.toml file, though it is not guaranteed to exist." + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "files": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" }, "type": { "enum": [ - "system" + "applyPatch" ], - "title": "SystemConfigLayerSourceType", + "title": "ApplyPatchGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "file", + "cwd", + "files", "type" ], - "title": "SystemConfigLayerSource", + "title": "ApplyPatchGuardianApprovalReviewAction", "type": "object" }, { - "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { - "file": { - "allOf": [ - { - "$ref": "#/definitions/v2/AbsolutePathBuf" - } - ], - "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/v2/NetworkApprovalProtocol" + }, + "target": { + "type": "string" }, "type": { "enum": [ - "user" + "networkAccess" ], - "title": "UserConfigLayerSourceType", + "title": "NetworkAccessGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "file", + "host", + "port", + "protocol", + "target", "type" ], - "title": "UserConfigLayerSource", + "title": "NetworkAccessGuardianApprovalReviewAction", "type": "object" }, { - "description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.", "properties": { - "dotCodexFolder": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] }, "type": { "enum": [ - "project" + "mcpToolCall" ], - "title": "ProjectConfigLayerSourceType", + "title": "McpToolCallGuardianApprovalReviewActionType", "type": "string" } }, "required": [ - "dotCodexFolder", + "server", + "toolName", "type" ], - "title": "ProjectConfigLayerSource", + "title": "McpToolCallGuardianApprovalReviewAction", "type": "object" }, { - "description": "Session-layer overrides supplied via `-c`/`--config`.", "properties": { + "permissions": { + "$ref": "#/definitions/v2/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ - "sessionFlags" + "requestPermissions" ], - "title": "SessionFlagsConfigLayerSourceType", + "title": "RequestPermissionsGuardianApprovalReviewActionType", "type": "string" } }, "required": [ + "permissions", "type" ], - "title": "SessionFlagsConfigLayerSource", + "title": "RequestPermissionsGuardianApprovalReviewAction", "type": "object" + } + ] + }, + "GuardianApprovalReviewStatus": { + "description": "[UNSTABLE] Lifecycle state for an approval auto-review.", + "enum": [ + "inProgress", + "approved", + "denied", + "timedOut", + "aborted" + ], + "type": "string" + }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, + "GuardianRiskLevel": { + "description": "[UNSTABLE] Risk level assigned by approval auto-review.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "GuardianUserAuthorization": { + "description": "[UNSTABLE] Authorization level assigned by approval auto-review.", + "enum": [ + "unknown", + "low", + "medium", + "high" + ], + "type": "string" + }, + "GuardianWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" }, - { - "description": "`managed_config.toml` was designed to be a config that was loaded as the last layer on top of everything else. This scheme did not quite work out as intended, but we keep this variant as a \"best effort\" while we phase out `managed_config.toml` in favor of `requirements.toml`.", - "properties": { - "file": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "legacyManagedConfigTomlFromFile" - ], - "title": "LegacyManagedConfigTomlFromFileConfigLayerSourceType", - "type": "string" - } + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, + "HookCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "run": { + "$ref": "#/definitions/v2/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "run", + "threadId" + ], + "title": "HookCompletedNotification", + "type": "object" + }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookExecutionMode": { + "enum": [ + "sync", + "async" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "currentHash": { + "type": "string" + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/v2/HookHandlerType" + }, + "isManaged": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/v2/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/v2/HookTrustStatus" + } + }, + "required": [ + "currentHash", + "displayOrder", + "enabled", + "eventName", + "handlerType", + "isManaged", + "key", + "source", + "sourcePath", + "timeoutSec", + "trustStatus" + ], + "type": "object" + }, + "HookMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "HookOutputEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/v2/HookOutputEntryKind" + }, + "text": { + "type": "string" + } + }, + "required": [ + "kind", + "text" + ], + "type": "object" + }, + "HookOutputEntryKind": { + "enum": [ + "warning", + "stop", + "feedback", + "context", + "error" + ], + "type": "string" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, + "HookRunStatus": { + "enum": [ + "running", + "completed", + "failed", + "blocked", + "stopped" + ], + "type": "string" + }, + "HookRunSummary": { + "properties": { + "completedAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "durationMs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "entries": { + "items": { + "$ref": "#/definitions/v2/HookOutputEntry" }, - "required": [ - "file", - "type" - ], - "title": "LegacyManagedConfigTomlFromFileConfigLayerSource", - "type": "object" + "type": "array" }, - { - "properties": { - "type": { - "enum": [ - "legacyManagedConfigTomlFromMdm" - ], - "title": "LegacyManagedConfigTomlFromMdmConfigLayerSourceType", - "type": "string" + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "executionMode": { + "$ref": "#/definitions/v2/HookExecutionMode" + }, + "handlerType": { + "$ref": "#/definitions/v2/HookHandlerType" + }, + "id": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/v2/HookScope" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/v2/HookSource" } - }, - "required": [ - "type" ], - "title": "LegacyManagedConfigTomlFromMdmConfigLayerSource", - "type": "object" + "default": "unknown" + }, + "sourcePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "startedAt": { + "format": "int64", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/v2/HookRunStatus" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] } - ] + }, + "required": [ + "displayOrder", + "entries", + "eventName", + "executionMode", + "handlerType", + "id", + "scope", + "sourcePath", + "startedAt", + "status" + ], + "type": "object" }, - "ConfigReadParams": { + "HookScope": { + "enum": [ + "thread", + "turn" + ], + "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "cloudRequirements", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, + "HookStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "cwd": { - "description": "Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root).", + "run": { + "$ref": "#/definitions/v2/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { "type": [ "string", "null" ] - }, - "includeLayers": { - "default": false, - "type": "boolean" } }, - "title": "ConfigReadParams", + "required": [ + "run", + "threadId" + ], + "title": "HookStartedNotification", "type": "object" }, - "ConfigReadResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, + "HooksListEntry": { "properties": { - "config": { - "$ref": "#/definitions/v2/Config" + "cwd": { + "type": "string" }, - "layers": { + "errors": { "items": { - "$ref": "#/definitions/v2/ConfigLayer" + "$ref": "#/definitions/v2/HookErrorInfo" }, - "type": [ - "array", - "null" - ] + "type": "array" }, - "origins": { - "additionalProperties": { - "$ref": "#/definitions/v2/ConfigLayerMetadata" + "hooks": { + "items": { + "$ref": "#/definitions/v2/HookMetadata" }, - "type": "object" + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ - "config", - "origins" + "cwd", + "errors", + "hooks", + "warnings" ], - "title": "ConfigReadResponse", "type": "object" }, - "ConfigRequirements": { + "HooksListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "allowedApprovalPolicies": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", "items": { - "$ref": "#/definitions/v2/AskForApproval" + "type": "string" }, - "type": [ - "array", - "null" - ] - }, - "allowedSandboxModes": { + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" + }, + "HooksListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { "items": { - "$ref": "#/definitions/v2/SandboxMode" + "$ref": "#/definitions/v2/HooksListEntry" }, - "type": [ - "array", - "null" - ] + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" + }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, + "InputModality": { + "description": "Canonical user-input modality tags advertised by a model.", + "oneOf": [ + { + "description": "Plain text turns and tool payloads.", + "enum": [ + "text" + ], + "type": "string" }, - "allowedWebSearchModes": { - "items": { - "$ref": "#/definitions/v2/WebSearchMode" - }, - "type": [ - "array", - "null" - ] + { + "description": "Image attachments included in user turns.", + "enum": [ + "image" + ], + "type": "string" + } + ] + }, + "ItemCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" }, - "enforceResidency": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ResidencyRequirement" - }, - { - "type": "null" - } - ] + "item": { + "$ref": "#/definitions/v2/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" } }, + "required": [ + "completedAtMs", + "item", + "threadId", + "turnId" + ], + "title": "ItemCompletedNotification", "type": "object" }, - "ConfigRequirementsReadResponse": { + "ItemGuardianApprovalReviewCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", "properties": { - "requirements": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ConfigRequirements" - }, - { - "type": "null" - } - ], - "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + "action": { + "$ref": "#/definitions/v2/GuardianApprovalReviewAction" + }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, + "decisionSource": { + "$ref": "#/definitions/v2/AutoReviewDecisionSource" + }, + "review": { + "$ref": "#/definitions/v2/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" } }, - "title": "ConfigRequirementsReadResponse", + "required": [ + "action", + "completedAtMs", + "decisionSource", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "ItemGuardianApprovalReviewCompletedNotification", "type": "object" }, - "ConfigValueWriteParams": { + "ItemGuardianApprovalReviewStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", "properties": { - "expectedVersion": { - "type": [ - "string", - "null" - ] + "action": { + "$ref": "#/definitions/v2/GuardianApprovalReviewAction" }, - "filePath": { - "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "review": { + "$ref": "#/definitions/v2/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ "string", "null" ] }, - "keyPath": { + "threadId": { "type": "string" }, - "mergeStrategy": { - "$ref": "#/definitions/v2/MergeStrategy" - }, - "value": true + "turnId": { + "type": "string" + } }, "required": [ - "keyPath", - "mergeStrategy", - "value" + "action", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" ], - "title": "ConfigValueWriteParams", + "title": "ItemGuardianApprovalReviewStartedNotification", "type": "object" }, - "ConfigWarningNotification": { + "ItemStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "details": { - "description": "Optional extra guidance or error details.", - "type": [ - "string", - "null" - ] + "item": { + "$ref": "#/definitions/v2/ThreadItem" }, - "path": { - "description": "Optional path to the config file that triggered the warning.", + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "ItemStartedNotification", + "type": "object" + }, + "ListMcpServerStatusParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", "type": [ "string", "null" ] }, - "range": { + "detail": { "anyOf": [ { - "$ref": "#/definitions/v2/TextRange" + "$ref": "#/definitions/v2/McpServerStatusDetail" }, { "type": "null" } ], - "description": "Optional range for the error location inside the config file." + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." }, - "summary": { - "description": "Concise summary of the warning.", - "type": "string" + "limit": { + "description": "Optional page size; defaults to a server-defined value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ListMcpServerStatusParams", + "type": "object" + }, + "ListMcpServerStatusResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/McpServerStatus" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] } }, "required": [ - "summary" + "data" ], - "title": "ConfigWarningNotification", + "title": "ListMcpServerStatusResponse", "type": "object" }, - "ConfigWriteResponse": { + "LocalShellAction": { + "oneOf": [ + { + "properties": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "timeout_ms": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "exec" + ], + "title": "ExecLocalShellActionType", + "type": "string" + }, + "user": { + "type": [ + "string", + "null" + ] + }, + "working_directory": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "command", + "type" + ], + "title": "ExecLocalShellAction", + "type": "object" + } + ] + }, + "LocalShellStatus": { + "enum": [ + "completed", + "in_progress", + "incomplete" + ], + "type": "string" + }, + "LoginAccountParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "filePath": { - "allOf": [ - { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "oneOf": [ + { + "properties": { + "apiKey": { + "type": "string" + }, + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyv2::LoginAccountParamsType", + "type": "string" } + }, + "required": [ + "apiKey", + "type" ], - "description": "Canonical path to the config file that was written." + "title": "ApiKeyv2::LoginAccountParams", + "type": "object" }, - "overriddenMetadata": { - "anyOf": [ - { - "$ref": "#/definitions/v2/OverriddenMetadata" + { + "properties": { + "codexStreamlinedLogin": { + "type": "boolean" }, - { - "type": "null" + "type": { + "enum": [ + "chatgpt" + ], + "title": "Chatgptv2::LoginAccountParamsType", + "type": "string" } - ] + }, + "required": [ + "type" + ], + "title": "Chatgptv2::LoginAccountParams", + "type": "object" }, - "status": { - "$ref": "#/definitions/v2/WriteStatus" + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" }, - "version": { - "type": "string" + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", + "properties": { + "accessToken": { + "description": "Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", + "type": "string" + }, + "chatgptAccountId": { + "description": "Workspace/account identifier supplied by the client.", + "type": "string" + }, + "chatgptPlanType": { + "description": "Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "accessToken", + "chatgptAccountId", + "type" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParams", + "type": "object" } - }, - "required": [ - "filePath", - "status", - "version" ], - "title": "ConfigWriteResponse", - "type": "object" + "title": "LoginAccountParams" }, - "ContentItem": { + "LoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", "oneOf": [ { "properties": { - "text": { - "type": "string" - }, "type": { "enum": [ - "input_text" + "apiKey" ], - "title": "InputTextContentItemType", + "title": "ApiKeyv2::LoginAccountResponseType", "type": "string" } }, "required": [ - "text", "type" ], - "title": "InputTextContentItem", + "title": "ApiKeyv2::LoginAccountResponse", "type": "object" }, { "properties": { - "image_url": { + "authUrl": { + "description": "URL the client should open in a browser to initiate the OAuth flow.", + "type": "string" + }, + "loginId": { "type": "string" }, "type": { "enum": [ - "input_image" + "chatgpt" ], - "title": "InputImageContentItemType", + "title": "Chatgptv2::LoginAccountResponseType", "type": "string" } }, "required": [ - "image_url", + "authUrl", + "loginId", "type" ], - "title": "InputImageContentItem", + "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, { "properties": { - "text": { + "loginId": { "type": "string" }, "type": { "enum": [ - "output_text" + "chatgptDeviceCode" ], - "title": "OutputTextContentItemType", + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountResponseType", "type": "string" } }, "required": [ - "text", "type" ], - "title": "OutputTextContentItem", + "title": "ChatgptAuthTokensv2::LoginAccountResponse", "type": "object" } - ] + ], + "title": "LoginAccountResponse" }, - "ContextCompactedNotification": { + "LogoutAccountResponse": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Deprecated: Use `ContextCompaction` item type instead.", - "properties": { - "threadId": { - "type": "string" - }, - "turnId": { - "type": "string" - } - }, - "required": [ - "threadId", - "turnId" - ], - "title": "ContextCompactedNotification", + "title": "LogoutAccountResponse", "type": "object" }, - "CreditsSnapshot": { + "ManagedHooksRequirements": { "properties": { - "balance": { + "PermissionRequest": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostCompact": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreCompact": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "Stop": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "managedDir": { "type": [ "string", "null" ] }, - "hasCredits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" + "windowsManagedDir": { + "type": [ + "string", + "null" + ] } }, "required": [ - "hasCredits", - "unlimited" + "PermissionRequest", + "PostCompact", + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionStart", + "Stop", + "UserPromptSubmit" ], "type": "object" }, - "DeprecationNoticeNotification": { + "MarketplaceAddParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", + "refName": { "type": [ "string", "null" ] }, - "summary": { - "description": "Concise summary of what is deprecated.", + "source": { "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "required": [ - "summary" + "source" ], - "title": "DeprecationNoticeNotification", + "title": "MarketplaceAddParams", "type": "object" }, - "DynamicToolSpec": { + "MarketplaceAddResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "deferLoading": { + "alreadyAdded": { "type": "boolean" }, - "description": { - "type": "string" + "installedRoot": { + "$ref": "#/definitions/v2/AbsolutePathBuf" }, - "inputSchema": true, - "name": { + "marketplaceName": { "type": "string" - }, - "namespace": { + } + }, + "required": [ + "alreadyAdded", + "installedRoot", + "marketplaceName" + ], + "title": "MarketplaceAddResponse", + "type": "object" + }, + "MarketplaceInterface": { + "properties": { + "displayName": { "type": [ "string", "null" ] } }, + "type": "object" + }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, "required": [ - "description", - "inputSchema", - "name" + "marketplacePath", + "message" ], "type": "object" }, - "ErrorNotification": { + "MarketplaceRemoveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "error": { - "$ref": "#/definitions/v2/TurnError" - }, - "threadId": { - "type": "string" - }, - "turnId": { + "marketplaceName": { "type": "string" - }, - "willRetry": { - "type": "boolean" } }, "required": [ - "error", - "threadId", - "turnId", - "willRetry" + "marketplaceName" ], - "title": "ErrorNotification", + "title": "MarketplaceRemoveParams", "type": "object" }, - "ExperimentalFeature": { + "MarketplaceRemoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "announcement": { - "description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.", - "type": [ - "string", - "null" + "installedRoot": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } ] }, - "defaultEnabled": { - "description": "Whether this feature is enabled by default.", - "type": "boolean" - }, - "description": { - "description": "Short summary describing what the feature does. Null when this feature is not in beta.", - "type": [ - "string", - "null" - ] + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "marketplaceName" + ], + "title": "MarketplaceRemoveResponse", + "type": "object" + }, + "MarketplaceUpgradeErrorInfo": { + "properties": { + "marketplaceName": { + "type": "string" }, - "displayName": { - "description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", + "message": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "message" + ], + "type": "object" + }, + "MarketplaceUpgradeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { "type": [ "string", "null" ] + } + }, + "title": "MarketplaceUpgradeParams", + "type": "object" + }, + "MarketplaceUpgradeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/v2/MarketplaceUpgradeErrorInfo" + }, + "type": "array" }, - "enabled": { - "description": "Whether this feature is currently enabled in the loaded config.", - "type": "boolean" - }, - "name": { - "description": "Stable key used in config.toml and CLI flag toggles.", - "type": "string" + "selectedMarketplaces": { + "items": { + "type": "string" + }, + "type": "array" }, - "stage": { - "allOf": [ - { - "$ref": "#/definitions/v2/ExperimentalFeatureStage" - } - ], - "description": "Lifecycle stage of this feature flag." + "upgradedRoots": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" } }, "required": [ - "defaultEnabled", - "enabled", - "name", - "stage" + "errors", + "selectedMarketplaces", + "upgradedRoots" ], + "title": "MarketplaceUpgradeResponse", "type": "object" }, - "ExperimentalFeatureListParams": { + "McpAuthStatus": { + "enum": [ + "unsupported", + "notLoggedIn", + "bearerToken", + "oAuth" + ], + "type": "string" + }, + "McpResourceReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "cursor": { - "description": "Opaque pagination cursor returned by a previous call.", + "server": { + "type": "string" + }, + "threadId": { "type": [ "string", "null" ] }, - "limit": { - "description": "Optional page size; defaults to a reasonable server-side value.", - "format": "uint32", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] + "uri": { + "type": "string" } }, - "title": "ExperimentalFeatureListParams", + "required": [ + "server", + "uri" + ], + "title": "McpResourceReadParams", "type": "object" }, - "ExperimentalFeatureListResponse": { + "McpResourceReadResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "data": { + "contents": { "items": { - "$ref": "#/definitions/v2/ExperimentalFeature" + "$ref": "#/definitions/v2/ResourceContent" }, "type": "array" - }, - "nextCursor": { - "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", - "type": [ - "string", - "null" - ] } }, "required": [ - "data" + "contents" ], - "title": "ExperimentalFeatureListResponse", + "title": "McpResourceReadResponse", "type": "object" }, - "ExperimentalFeatureStage": { - "oneOf": [ - { - "description": "Feature is available for user testing and feedback.", - "enum": [ - "beta" - ], - "type": "string" - }, - { - "description": "Feature is still being built and not ready for broad use.", - "enum": [ - "underDevelopment" - ], - "type": "string" - }, - { - "description": "Feature is production-ready.", - "enum": [ - "stable" - ], + "McpServerMigration": { + "properties": { + "name": { "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "McpServerOauthLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] }, - { - "description": "Feature is deprecated and should be avoided.", - "enum": [ - "deprecated" - ], + "name": { "type": "string" }, - { - "description": "Feature flag is retained only for backwards compatibility.", - "enum": [ - "removed" - ], - "type": "string" + "success": { + "type": "boolean" } - ] + }, + "required": [ + "name", + "success" + ], + "title": "McpServerOauthLoginCompletedNotification", + "type": "object" }, - "ExternalAgentConfigDetectParams": { + "McpServerOauthLoginParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "cwds": { - "description": "Zero or more working directories to include for repo-scoped detection.", + "name": { + "type": "string" + }, + "scopes": { "items": { "type": "string" }, @@ -12534,131 +10669,176 @@ "null" ] }, - "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", - "type": "boolean" + "timeoutSecs": { + "format": "int64", + "type": [ + "integer", + "null" + ] } }, - "title": "ExternalAgentConfigDetectParams", + "required": [ + "name" + ], + "title": "McpServerOauthLoginParams", "type": "object" }, - "ExternalAgentConfigDetectResponse": { + "McpServerOauthLoginResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "items": { - "items": { - "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" - }, - "type": "array" + "authorizationUrl": { + "type": "string" } }, "required": [ - "items" + "authorizationUrl" ], - "title": "ExternalAgentConfigDetectResponse", + "title": "McpServerOauthLoginResponse", "type": "object" }, - "ExternalAgentConfigImportParams": { + "McpServerRefreshResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "title": "McpServerRefreshResponse", + "type": "object" + }, + "McpServerStartupState": { + "enum": [ + "starting", + "ready", + "failed", + "cancelled" + ], + "type": "string" + }, + "McpServerStatus": { "properties": { - "migrationItems": { + "authStatus": { + "$ref": "#/definitions/v2/McpAuthStatus" + }, + "name": { + "type": "string" + }, + "resourceTemplates": { "items": { - "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" + "$ref": "#/definitions/v2/ResourceTemplate" + }, + "type": "array" + }, + "resources": { + "items": { + "$ref": "#/definitions/v2/Resource" }, "type": "array" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/v2/Tool" + }, + "type": "object" } }, "required": [ - "migrationItems" + "authStatus", + "name", + "resourceTemplates", + "resources", + "tools" ], - "title": "ExternalAgentConfigImportParams", "type": "object" }, - "ExternalAgentConfigImportResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ExternalAgentConfigImportResponse", - "type": "object" + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" }, - "ExternalAgentConfigMigrationItem": { + "McpServerStatusUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "cwd": { - "description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.", + "error": { "type": [ "string", "null" ] }, - "description": { + "name": { "type": "string" }, - "itemType": { - "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType" + "status": { + "$ref": "#/definitions/v2/McpServerStartupState" } }, "required": [ - "description", - "itemType" + "name", + "status" ], + "title": "McpServerStatusUpdatedNotification", "type": "object" }, - "ExternalAgentConfigMigrationItemType": { - "enum": [ - "AGENTS_MD", - "CONFIG", - "SKILLS", - "MCP_SERVER_CONFIG" - ], - "type": "string" - }, - "FeedbackUploadParams": { + "McpServerToolCallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "classification": { + "_meta": true, + "arguments": true, + "server": { "type": "string" }, - "includeLogs": { - "type": "boolean" + "threadId": { + "type": "string" }, - "reason": { - "type": [ - "string", - "null" - ] + "tool": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "tool" + ], + "title": "McpServerToolCallParams", + "type": "object" + }, + "McpServerToolCallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" }, - "threadId": { + "isError": { "type": [ - "string", + "boolean", "null" ] - } + }, + "structuredContent": true }, "required": [ - "classification", - "includeLogs" + "content" ], - "title": "FeedbackUploadParams", + "title": "McpServerToolCallResponse", "type": "object" }, - "FeedbackUploadResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", + "McpToolCallError": { "properties": { - "threadId": { + "message": { "type": "string" } }, "required": [ - "threadId" + "message" ], - "title": "FeedbackUploadResponse", "type": "object" }, - "FileChangeOutputDeltaNotification": { + "McpToolCallProgressNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "delta": { + "itemId": { "type": "string" }, - "itemId": { + "message": { "type": "string" }, "threadId": { @@ -12669,1151 +10849,1958 @@ } }, "required": [ - "delta", "itemId", + "message", "threadId", "turnId" ], - "title": "FileChangeOutputDeltaNotification", + "title": "McpToolCallProgressNotification", "type": "object" }, - "FileUpdateChange": { + "McpToolCallResult": { "properties": { - "diff": { - "type": "string" + "_meta": true, + "content": { + "items": true, + "type": "array" }, - "kind": { - "$ref": "#/definitions/v2/PatchChangeKind" + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/v2/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" }, "path": { "type": "string" } }, "required": [ - "diff", - "kind", + "lineEnd", + "lineStart", + "note", "path" ], "type": "object" }, - "ForcedLoginMethod": { + "MergeStrategy": { "enum": [ - "chatgpt", - "api" + "replace", + "upsert" ], "type": "string" }, - "FunctionCallOutputBody": { - "anyOf": [ + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], "type": "string" }, { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "MigrationDetails": { + "properties": { + "commands": { + "default": [], "items": { - "$ref": "#/definitions/v2/FunctionCallOutputContentItem" + "$ref": "#/definitions/v2/CommandMigration" + }, + "type": "array" + }, + "hooks": { + "default": [], + "items": { + "$ref": "#/definitions/v2/HookMigration" + }, + "type": "array" + }, + "mcpServers": { + "default": [], + "items": { + "$ref": "#/definitions/v2/McpServerMigration" + }, + "type": "array" + }, + "plugins": { + "default": [], + "items": { + "$ref": "#/definitions/v2/PluginsMigration" + }, + "type": "array" + }, + "sessions": { + "default": [], + "items": { + "$ref": "#/definitions/v2/SessionMigration" + }, + "type": "array" + }, + "subagents": { + "default": [], + "items": { + "$ref": "#/definitions/v2/SubagentMigration" }, "type": "array" } - ] + }, + "type": "object" }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "Model": { + "properties": { + "additionalSpeedTiers": { + "default": [], + "description": "Deprecated: use `serviceTiers` instead.", + "items": { + "type": "string" + }, + "type": "array" + }, + "availabilityNux": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ModelAvailabilityNux" }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" + { + "type": "null" } - }, - "required": [ + ] + }, + "defaultReasoningEffort": { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "inputModalities": { + "default": [ "text", - "type" + "image" ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" + "items": { + "$ref": "#/definitions/v2/InputModality" + }, + "type": "array" }, - { - "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } + "isDefault": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/v2/ModelServiceTier" }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" + "type": "array" + }, + "supportedReasoningEfforts": { + "items": { + "$ref": "#/definitions/v2/ReasoningEffortOption" + }, + "type": "array" + }, + "supportsPersonality": { + "default": false, + "type": "boolean" + }, + "upgrade": { + "type": [ + "string", + "null" + ] + }, + "upgradeInfo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ModelUpgradeInfo" + }, + { + "type": "null" + } + ] } - ] + }, + "required": [ + "defaultReasoningEffort", + "description", + "displayName", + "hidden", + "id", + "isDefault", + "model", + "supportedReasoningEfforts" + ], + "type": "object" }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", + "ModelAvailabilityNux": { "properties": { - "body": { - "$ref": "#/definitions/v2/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] + "message": { + "type": "string" } }, "required": [ - "body" + "message" ], "type": "object" }, - "GetAccountParams": { + "ModelListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "refreshToken": { - "default": false, - "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", - "type": "boolean" + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] } }, - "title": "GetAccountParams", + "title": "ModelListParams", "type": "object" }, - "GetAccountRateLimitsResponse": { + "ModelListResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "rateLimits": { - "allOf": [ - { - "$ref": "#/definitions/v2/RateLimitSnapshot" - } - ], - "description": "Backward-compatible single-bucket view." - }, - "rateLimitsByLimitId": { - "additionalProperties": { - "$ref": "#/definitions/v2/RateLimitSnapshot" + "data": { + "items": { + "$ref": "#/definitions/v2/Model" }, - "description": "Multi-bucket view keyed by metered `limit_id`.", + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", "type": [ - "object", + "string", "null" ] } }, "required": [ - "rateLimits" + "data" ], - "title": "GetAccountRateLimitsResponse", + "title": "ModelListResponse", "type": "object" }, - "GetAccountResponse": { + "ModelProviderCapabilitiesReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ModelProviderCapabilitiesReadParams", + "type": "object" + }, + "ModelProviderCapabilitiesReadResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "account": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Account" - }, - { - "type": "null" - } - ] + "imageGeneration": { + "type": "boolean" }, - "requiresOpenaiAuth": { + "namespaceTools": { + "type": "boolean" + }, + "webSearch": { "type": "boolean" } }, "required": [ - "requiresOpenaiAuth" + "imageGeneration", + "namespaceTools", + "webSearch" ], - "title": "GetAccountResponse", + "title": "ModelProviderCapabilitiesReadResponse", + "type": "object" + }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/v2/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", + "ModelServiceTier": { "properties": { + "description": { + "type": "string" + }, "id": { "type": "string" }, - "parent": { - "type": [ - "string", - "null" - ] + "name": { + "type": "string" } }, "required": [ - "id" + "description", + "id", + "name" ], "type": "object" }, - "GitInfo": { + "ModelUpgradeInfo": { "properties": { - "branch": { + "migrationMarkdown": { "type": [ "string", "null" ] }, - "originUrl": { + "model": { + "type": "string" + }, + "modelLink": { "type": [ "string", "null" ] }, - "sha": { + "upgradeCopy": { "type": [ "string", "null" ] } }, + "required": [ + "model" + ], "type": "object" }, - "ImageDetail": { + "ModelVerification": { "enum": [ - "auto", - "low", - "high", - "original" + "trustedAccessForCyber" ], "type": "string" }, - "InputModality": { - "description": "Canonical user-input modality tags advertised by a model.", - "oneOf": [ - { - "description": "Plain text turns and tool payloads.", - "enum": [ - "text" - ], + "ModelVerificationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { "type": "string" }, - { - "description": "Image attachments included in user turns.", - "enum": [ - "image" - ], + "turnId": { "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/v2/ModelVerification" + }, + "type": "array" } - ] + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" }, - "ItemCompletedNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkRequirements": { "properties": { - "item": { - "$ref": "#/definitions/v2/ThreadItem" + "allowLocalBinding": { + "type": [ + "boolean", + "null" + ] }, - "threadId": { - "type": "string" + "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "allowUpstreamProxy": { + "type": [ + "boolean", + "null" + ] + }, + "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, + "dangerouslyAllowNonLoopbackProxy": { + "type": [ + "boolean", + "null" + ] + }, + "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] }, - "turnId": { - "type": "string" - } - }, - "required": [ - "item", - "threadId", - "turnId" - ], - "title": "ItemCompletedNotification", - "type": "object" - }, - "ItemStartedNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "item": { - "$ref": "#/definitions/v2/ThreadItem" + "enabled": { + "type": [ + "boolean", + "null" + ] }, - "threadId": { - "type": "string" + "httpPort": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] }, - "turnId": { - "type": "string" - } - }, - "required": [ - "item", - "threadId", - "turnId" - ], - "title": "ItemStartedNotification", - "type": "object" - }, - "ListMcpServerStatusParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cursor": { - "description": "Opaque pagination cursor returned by a previous call.", + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", "type": [ - "string", + "boolean", "null" ] }, - "limit": { - "description": "Optional page size; defaults to a server-defined value.", - "format": "uint32", + "socksPort": { + "format": "uint16", "minimum": 0.0, "type": [ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, - "title": "ListMcpServerStatusParams", "type": "object" }, - "ListMcpServerStatusResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, + "OverriddenMetadata": { "properties": { - "data": { - "items": { - "$ref": "#/definitions/v2/McpServerStatus" - }, - "type": "array" + "effectiveValue": true, + "message": { + "type": "string" }, - "nextCursor": { - "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", - "type": [ - "string", - "null" - ] + "overridingLayer": { + "$ref": "#/definitions/v2/ConfigLayerMetadata" } }, "required": [ - "data" + "effectiveValue", + "message", + "overridingLayer" ], - "title": "ListMcpServerStatusResponse", "type": "object" }, - "LocalShellAction": { + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "PatchChangeKind": { "oneOf": [ { "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, "type": { "enum": [ - "exec" + "add" ], - "title": "ExecLocalShellActionType", + "title": "AddPatchChangeKindType", "type": "string" - }, - "user": { + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { "type": [ "string", "null" ] }, - "working_directory": { - "type": [ - "string", - "null" - ] + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" } }, "required": [ - "command", "type" ], - "title": "ExecLocalShellAction", + "title": "UpdatePatchChangeKind", "type": "object" } ] }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "LoginAccountParams": { - "$schema": "http://json-schema.org/draft-07/schema#", + "PermissionProfile": { "oneOf": [ { + "description": "Codex owns sandbox construction for this profile.", "properties": { - "apiKey": { - "type": "string" + "fileSystem": { + "$ref": "#/definitions/v2/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" }, "type": { "enum": [ - "apiKey" + "managed" ], - "title": "ApiKeyv2::LoginAccountParamsType", + "title": "ManagedPermissionProfileType", "type": "string" } }, "required": [ - "apiKey", + "fileSystem", + "network", "type" ], - "title": "ApiKeyv2::LoginAccountParams", + "title": "ManagedPermissionProfile", "type": "object" }, { + "description": "Do not apply an outer sandbox.", "properties": { "type": { "enum": [ - "chatgpt" + "disabled" ], - "title": "Chatgptv2::LoginAccountParamsType", + "title": "DisabledPermissionProfileType", "type": "string" } }, "required": [ "type" ], - "title": "Chatgptv2::LoginAccountParams", + "title": "DisabledPermissionProfile", "type": "object" }, { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", + "description": "Filesystem isolation is enforced by an external caller.", "properties": { - "accessToken": { - "description": "Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", - "type": "string" - }, - "chatgptAccountId": { - "description": "Workspace/account identifier supplied by the client.", - "type": "string" - }, - "chatgptPlanType": { - "description": "Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", - "type": [ - "string", - "null" - ] + "network": { + "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" }, "type": { "enum": [ - "chatgptAuthTokens" + "external" ], - "title": "ChatgptAuthTokensv2::LoginAccountParamsType", + "title": "ExternalPermissionProfileType", "type": "string" } }, "required": [ - "accessToken", - "chatgptAccountId", + "network", "type" ], - "title": "ChatgptAuthTokensv2::LoginAccountParams", + "title": "ExternalPermissionProfile", "type": "object" } - ], - "title": "LoginAccountParams" + ] }, - "LoginAccountResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", + "PermissionProfileFileSystemPermissions": { "oneOf": [ { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/v2/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ - "apiKey" + "restricted" ], - "title": "ApiKeyv2::LoginAccountResponseType", + "title": "RestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ + "entries", "type" ], - "title": "ApiKeyv2::LoginAccountResponse", + "title": "RestrictedPermissionProfileFileSystemPermissions", "type": "object" }, { "properties": { - "authUrl": { - "description": "URL the client should open in a browser to initiate the OAuth flow.", - "type": "string" - }, - "loginId": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": { "enum": [ - "chatgpt" + "additionalWritableRoot" ], - "title": "Chatgptv2::LoginAccountResponseType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, "required": [ - "authUrl", - "loginId", + "path", "type" ], - "title": "Chatgptv2::LoginAccountResponse", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" - }, + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "PermissionProfileSelectionParams": { + "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/v2/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, "type": { "enum": [ - "chatgptAuthTokens" + "profile" ], - "title": "ChatgptAuthTokensv2::LoginAccountResponseType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ + "id", "type" ], - "title": "ChatgptAuthTokensv2::LoginAccountResponse", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } + ] + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" ], - "title": "LoginAccountResponse" + "type": "string" }, - "LogoutAccountResponse": { + "PlanDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LogoutAccountResponse", + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "PlanDeltaNotification", "type": "object" }, - "McpAuthStatus": { + "PlanType": { "enum": [ - "unsupported", - "notLoggedIn", - "bearerToken", - "oAuth" + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_usage_based", + "business", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "unknown" ], "type": "string" }, - "McpServerOauthLoginCompletedNotification": { + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginDetail": { + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/v2/AppSummary" + }, + "type": "array" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/PluginHookSummary" + }, + "type": "array" + }, + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "mcpServers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/definitions/v2/SkillSummary" + }, + "type": "array" + }, + "summary": { + "$ref": "#/definitions/v2/PluginSummary" + } + }, + "required": [ + "apps", + "hooks", + "marketplaceName", + "mcpServers", + "skills", + "summary" + ], + "type": "object" + }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "eventName", + "key" + ], + "type": "object" + }, + "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "error": { + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginName" + ], + "title": "PluginInstallParams", + "type": "object" + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "appsNeedingAuth": { + "items": { + "$ref": "#/definitions/v2/AppSummary" + }, + "type": "array" + }, + "authPolicy": { + "$ref": "#/definitions/v2/PluginAuthPolicy" + } + }, + "required": [ + "appsNeedingAuth", + "authPolicy" + ], + "title": "PluginInstallResponse", + "type": "object" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", "type": [ "string", "null" ] }, - "name": { - "type": "string" + "longDescription": { + "type": [ + "string", + "null" + ] }, - "success": { - "type": "boolean" - } - }, - "required": [ - "name", - "success" - ], - "title": "McpServerOauthLoginCompletedNotification", - "type": "object" - }, - "McpServerOauthLoginParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "name": { - "type": "string" + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] }, - "scopes": { + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", "items": { "type": "string" }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { "type": [ - "array", + "string", "null" ] }, - "timeoutSecs": { - "format": "int64", + "termsOfServiceUrl": { "type": [ - "integer", + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", "null" ] } }, "required": [ - "name" + "capabilities", + "screenshotUrls", + "screenshots" ], - "title": "McpServerOauthLoginParams", "type": "object" }, - "McpServerOauthLoginResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authorizationUrl": { - "type": "string" - } - }, - "required": [ - "authorizationUrl" + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" ], - "title": "McpServerOauthLoginResponse", - "type": "object" + "type": "string" }, - "McpServerRefreshResponse": { + "PluginListParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "McpServerRefreshResponse", - "type": "object" - }, - "McpServerStatus": { "properties": { - "authStatus": { - "$ref": "#/definitions/v2/McpAuthStatus" - }, - "name": { - "type": "string" - }, - "resourceTemplates": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { - "$ref": "#/definitions/v2/ResourceTemplate" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, - "type": "array" + "type": [ + "array", + "null" + ] }, - "resources": { + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", "items": { - "$ref": "#/definitions/v2/Resource" - }, - "type": "array" - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/v2/Tool" + "$ref": "#/definitions/v2/PluginListMarketplaceKind" }, - "type": "object" - } - }, - "required": [ - "authStatus", - "name", - "resourceTemplates", - "resources", - "tools" - ], - "type": "object" - }, - "McpToolCallError": { - "properties": { - "message": { - "type": "string" + "type": [ + "array", + "null" + ] } }, - "required": [ - "message" - ], + "title": "PluginListParams", "type": "object" }, - "McpToolCallProgressNotification": { + "PluginListResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "itemId": { - "type": "string" - }, - "message": { - "type": "string" - }, - "threadId": { - "type": "string" + "featuredPluginIds": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" }, - "turnId": { - "type": "string" - } - }, - "required": [ - "itemId", - "message", - "threadId", - "turnId" - ], - "title": "McpToolCallProgressNotification", - "type": "object" - }, - "McpToolCallResult": { - "properties": { - "content": { - "items": true, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/v2/MarketplaceLoadErrorInfo" + }, "type": "array" }, - "structuredContent": true + "marketplaces": { + "items": { + "$ref": "#/definitions/v2/PluginMarketplaceEntry" + }, + "type": "array" + } }, "required": [ - "content" + "marketplaces" ], + "title": "PluginListResponse", "type": "object" }, - "McpToolCallStatus": { - "enum": [ - "inProgress", - "completed", - "failed" - ], - "type": "string" - }, - "MergeStrategy": { - "enum": [ - "replace", - "upsert" - ], - "type": "string" - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "Model": { + "PluginMarketplaceEntry": { "properties": { - "availabilityNux": { + "interface": { "anyOf": [ { - "$ref": "#/definitions/v2/ModelAvailabilityNux" + "$ref": "#/definitions/v2/MarketplaceInterface" }, { "type": "null" } ] }, - "defaultReasoningEffort": { - "$ref": "#/definitions/v2/ReasoningEffort" - }, - "description": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "hidden": { - "type": "boolean" - }, - "id": { + "name": { "type": "string" }, - "inputModalities": { - "default": [ - "text", - "image" + "path": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } ], - "items": { - "$ref": "#/definitions/v2/InputModality" - }, - "type": "array" - }, - "isDefault": { - "type": "boolean" - }, - "model": { - "type": "string" + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." }, - "supportedReasoningEfforts": { + "plugins": { "items": { - "$ref": "#/definitions/v2/ReasoningEffortOption" + "$ref": "#/definitions/v2/PluginSummary" }, "type": "array" - }, - "supportsPersonality": { - "default": false, - "type": "boolean" - }, - "upgrade": { - "type": [ - "string", - "null" - ] - }, - "upgradeInfo": { + } + }, + "required": [ + "name", + "plugins" + ], + "type": "object" + }, + "PluginReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplacePath": { "anyOf": [ { - "$ref": "#/definitions/v2/ModelUpgradeInfo" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, { "type": "null" } ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "defaultReasoningEffort", - "description", - "displayName", - "hidden", - "id", - "isDefault", - "model", - "supportedReasoningEfforts" + "pluginName" ], + "title": "PluginReadParams", "type": "object" }, - "ModelAvailabilityNux": { + "PluginReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "message": { - "type": "string" + "plugin": { + "$ref": "#/definitions/v2/PluginDetail" } }, "required": [ - "message" + "plugin" ], + "title": "PluginReadResponse", "type": "object" }, - "ModelListParams": { - "$schema": "http://json-schema.org/draft-07/schema#", + "PluginShareContext": { "properties": { - "cursor": { - "description": "Opaque pagination cursor returned by a previous call.", + "creatorAccountUserId": { "type": [ "string", "null" ] }, - "includeHidden": { - "description": "When true, include models that are hidden from the default picker list.", + "creatorName": { "type": [ - "boolean", + "string", "null" ] }, - "limit": { - "description": "Optional page size; defaults to a reasonable server-side value.", - "format": "uint32", - "minimum": 0.0, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginSharePrincipal" + }, "type": [ - "integer", + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", "null" ] } }, - "title": "ModelListParams", + "required": [ + "remotePluginId" + ], "type": "object" }, - "ModelListResponse": { + "PluginShareDeleteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareDeleteParams", + "type": "object" + }, + "PluginShareDeleteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareDeleteResponse", + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareListItem": { + "properties": { + "localPluginPath": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "plugin": { + "$ref": "#/definitions/v2/PluginSummary" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "plugin", + "shareUrl" + ], + "type": "object" + }, + "PluginShareListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareListParams", + "type": "object" + }, + "PluginShareListResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "data": { "items": { - "$ref": "#/definitions/v2/Model" + "$ref": "#/definitions/v2/PluginShareListItem" }, "type": "array" - }, - "nextCursor": { - "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", - "type": [ - "string", - "null" - ] } }, "required": [ "data" ], - "title": "ModelListResponse", + "title": "PluginShareListResponse", "type": "object" }, - "ModelUpgradeInfo": { + "PluginSharePrincipal": { "properties": { - "migrationMarkdown": { - "type": [ - "string", - "null" - ] - }, - "model": { + "name": { "type": "string" }, - "modelLink": { - "type": [ - "string", - "null" - ] + "principalId": { + "type": "string" }, - "upgradeCopy": { - "type": [ - "string", - "null" - ] + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" } }, "required": [ - "model" + "name", + "principalId", + "principalType" ], "type": "object" }, - "NetworkAccess": { + "PluginSharePrincipalType": { "enum": [ - "restricted", - "enabled" + "user", + "group", + "workspace" ], "type": "string" }, - "NetworkRequirements": { + "PluginShareSaveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "allowLocalBinding": { - "type": [ - "boolean", - "null" + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } ] }, - "allowUnixSockets": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] + "pluginPath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" }, - "allowUpstreamProxy": { + "remotePluginId": { "type": [ - "boolean", + "string", "null" ] }, - "allowedDomains": { + "shareTargets": { "items": { - "type": "string" + "$ref": "#/definitions/v2/PluginShareTarget" }, "type": [ "array", "null" ] + } + }, + "required": [ + "pluginPath" + ], + "title": "PluginShareSaveParams", + "type": "object" + }, + "PluginShareSaveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" }, - "dangerouslyAllowNonLoopbackAdmin": { - "type": [ - "boolean", - "null" - ] + "shareUrl": { + "type": "string" + } + }, + "required": [ + "remotePluginId", + "shareUrl" + ], + "title": "PluginShareSaveResponse", + "type": "object" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" }, - "dangerouslyAllowNonLoopbackProxy": { - "type": [ - "boolean", - "null" - ] + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/v2/PluginShareUpdateDiscoverability" }, - "deniedDomains": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { "items": { - "type": "string" + "$ref": "#/definitions/v2/PluginShareTarget" }, - "type": [ - "array", - "null" - ] - }, - "enabled": { - "type": [ - "boolean", - "null" - ] - }, - "httpPort": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/v2/PluginShareDiscoverability" }, - "socksPort": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] + "principals": { + "items": { + "$ref": "#/definitions/v2/PluginSharePrincipal" + }, + "type": "array" } }, + "required": [ + "discoverability", + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", "type": "object" }, - "OverriddenMetadata": { + "PluginSkillReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "effectiveValue": true, - "message": { + "remoteMarketplaceName": { "type": "string" }, - "overridingLayer": { - "$ref": "#/definitions/v2/ConfigLayerMetadata" + "remotePluginId": { + "type": "string" + }, + "skillName": { + "type": "string" } }, "required": [ - "effectiveValue", - "message", - "overridingLayer" + "remoteMarketplaceName", + "remotePluginId", + "skillName" ], + "title": "PluginSkillReadParams", "type": "object" }, - "PatchApplyStatus": { - "enum": [ - "inProgress", - "completed", - "failed", - "declined" - ], - "type": "string" + "PluginSkillReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "type": [ + "string", + "null" + ] + } + }, + "title": "PluginSkillReadResponse", + "type": "object" }, - "PatchChangeKind": { + "PluginSource": { "oneOf": [ { "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, "type": { "enum": [ - "add" + "local" ], - "title": "AddPatchChangeKindType", + "title": "LocalPluginSourceType", "type": "string" } }, "required": [ + "path", "type" ], - "title": "AddPatchChangeKind", + "title": "LocalPluginSource", "type": "object" }, { "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ - "delete" + "git" ], - "title": "DeletePatchChangeKindType", + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { "type": "string" } }, "required": [ - "type" + "type", + "url" ], - "title": "DeletePatchChangeKind", + "title": "GitPluginSource", "type": "object" }, { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, "type": { "enum": [ - "update" + "remote" ], - "title": "UpdatePatchChangeKindType", + "title": "RemotePluginSourceType", "type": "string" } }, "required": [ "type" ], - "title": "UpdatePatchChangeKind", + "title": "RemotePluginSource", "type": "object" } ] }, - "Personality": { - "enum": [ - "none", - "friendly", - "pragmatic" + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/v2/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/v2/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/v2/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/v2/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" ], - "type": "string" + "type": "object" }, - "PlanDeltaNotification": { + "PluginUninstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", "properties": { - "delta": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" + }, + "PluginUninstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" + }, + "PluginsMigration": { + "properties": { + "marketplaceName": { "type": "string" }, - "itemId": { + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, + "ProcessExitedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", "type": "string" }, - "threadId": { + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", "type": "string" }, - "turnId": { + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" } }, "required": [ - "delta", - "itemId", - "threadId", - "turnId" + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" ], - "title": "PlanDeltaNotification", + "title": "ProcessExitedNotification", "type": "object" }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" + "ProcessOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/v2/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" ], - "type": "string" + "title": "ProcessOutputDeltaNotification", + "type": "object" + }, + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, + "ProcessTerminalSize": { + "description": "PTY size in character cells for `process/spawn` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" }, "ProfileV2": { "additionalProperties": true, @@ -13828,6 +12815,17 @@ } ] }, + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." + }, "chatgpt_base_url": { "type": [ "string", @@ -13876,6 +12874,22 @@ } ] }, + "service_tier": { + "type": [ + "string", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ToolsV2" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -13951,8 +12965,7 @@ { "type": "null" } - ], - "default": null + ] }, "secondary": { "anyOf": [ @@ -14014,6 +13027,73 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, + "RealtimeOutputModality": { + "enum": [ + "text", + "audio" + ], + "type": "string" + }, + "RealtimeVoice": { + "enum": [ + "alloy", + "arbor", + "ash", + "ballad", + "breeze", + "cedar", + "coral", + "cove", + "echo", + "ember", + "juniper", + "maple", + "marin", + "sage", + "shimmer", + "sol", + "spruce", + "vale", + "verse" + ], + "type": "string" + }, + "RealtimeVoicesList": { + "properties": { + "defaultV1": { + "$ref": "#/definitions/v2/RealtimeVoice" + }, + "defaultV2": { + "$ref": "#/definitions/v2/RealtimeVoice" + }, + "v1": { + "items": { + "$ref": "#/definitions/v2/RealtimeVoice" + }, + "type": "array" + }, + "v2": { + "items": { + "$ref": "#/definitions/v2/RealtimeVoice" + }, + "type": "array" + } + }, + "required": [ + "defaultV1", + "defaultV2", + "v1", + "v2" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -14215,23 +13295,70 @@ "title": "ReasoningTextDeltaNotification", "type": "object" }, - "RemoteSkillSummary": { + "RemoteControlConnectionStatus": { + "enum": [ + "disabled", + "connecting", + "connected", + "errored" + ], + "type": "string" + }, + "RemoteControlStatusChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Current remote-control connection status and environment id exposed to clients.", "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" + "environmentId": { + "type": [ + "string", + "null" + ] }, - "name": { - "type": "string" + "status": { + "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ - "description", - "id", - "name" + "status" ], + "title": "RemoteControlStatusChangedNotification", + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ] + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, "type": "object" }, "ResidencyRequirement": { @@ -14290,6 +13417,57 @@ ], "type": "object" }, + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + }, "ResourceTemplate": { "description": "A template description for resources available on the server.", "properties": { @@ -14335,12 +13513,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -14395,10 +13567,6 @@ "null" ] }, - "id": { - "type": "string", - "writeOnly": true - }, "summary": { "items": { "$ref": "#/definitions/v2/ReasoningItemReasoningSummary" @@ -14414,7 +13582,6 @@ } }, "required": [ - "id", "summary", "type" ], @@ -14548,7 +13715,7 @@ "type": "string" }, "output": { - "$ref": "#/definitions/v2/FunctionCallOutputPayload" + "$ref": "#/definitions/v2/FunctionCallOutputBody" }, "type": { "enum": [ @@ -14619,7 +13786,7 @@ ] }, "output": { - "$ref": "#/definitions/v2/FunctionCallOutputPayload" + "$ref": "#/definitions/v2/FunctionCallOutputBody" }, "type": { "enum": [ @@ -14677,7 +13844,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/v2/WebSearchAction" + "$ref": "#/definitions/v2/ResponsesApiWebSearchAction" }, { "type": "null" @@ -14747,47 +13914,122 @@ }, { "properties": { - "ghost_commit": { - "$ref": "#/definitions/v2/GhostCommit" + "encrypted_content": { + "type": "string" }, "type": { "enum": [ - "ghost_snapshot" + "compaction" ], - "title": "GhostSnapshotResponseItemType", + "title": "CompactionResponseItemType", "type": "string" } }, "required": [ - "ghost_commit", + "encrypted_content", "type" ], - "title": "GhostSnapshotResponseItem", + "title": "CompactionResponseItem", "type": "object" }, { "properties": { "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponseItemType", "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponseItem", + "type": "object" + } + ] + }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] }, "type": { "enum": [ - "compaction_summary" + "search" ], - "title": "CompactionSummaryResponseItemType", + "title": "SearchResponsesApiWebSearchActionType", "type": "string" } }, "required": [ - "encrypted_content", "type" ], - "title": "CompactionSummaryResponseItem", + "title": "SearchResponsesApiWebSearchAction", "type": "object" }, { "properties": { - "encrypted_content": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { "type": [ "string", "null" @@ -14795,16 +14037,22 @@ }, "type": { "enum": [ - "context_compaction" + "find_in_page" ], - "title": "ContextCompactionResponseItemType", + "title": "FindInPageResponsesApiWebSearchActionType", "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] } }, "required": [ "type" ], - "title": "ContextCompactionResponseItem", + "title": "FindInPageResponsesApiWebSearchAction", "type": "object" }, { @@ -14813,14 +14061,14 @@ "enum": [ "other" ], - "title": "OtherResponseItemType", + "title": "OtherResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "OtherResponseItem", + "title": "OtherResponsesApiWebSearchAction", "type": "object" } ] @@ -14998,6 +14246,10 @@ }, { "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" @@ -15097,6 +14349,70 @@ }, "type": "object" }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/v2/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, + "ServerRequestResolvedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requestId": { + "$ref": "#/definitions/v2/RequestId" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "requestId", + "threadId" + ], + "title": "ServerRequestResolvedNotification", + "type": "object" + }, + "SessionMigration": { + "properties": { + "cwd": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "cwd", + "path" + ], + "type": "object" + }, "SessionSource": { "oneOf": [ { @@ -15109,6 +14425,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -15202,15 +14531,23 @@ ] }, "iconLarge": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } ] }, "iconSmall": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } ] }, "shortDescription": { @@ -15254,7 +14591,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "scope": { "$ref": "#/definitions/v2/SkillScope" @@ -15285,6 +14622,51 @@ ], "type": "string" }, + "SkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SkillInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "enabled", + "name" + ], + "type": "object" + }, "SkillToolDependency": { "properties": { "command": { @@ -15324,19 +14706,39 @@ ], "type": "object" }, + "SkillsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "title": "SkillsChangedNotification", + "type": "object" + }, "SkillsConfigWriteParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" @@ -15379,24 +14781,6 @@ ], "type": "object" }, - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" - }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "extraUserRoots" - ], - "type": "object" - }, "SkillsListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -15410,17 +14794,6 @@ "forceReload": { "description": "When true, bypass the skills cache and re-scan skills from disk.", "type": "boolean" - }, - "perCwdExtraUserRoots": { - "default": null, - "description": "Optional per-cwd extra roots to scan as user-scoped skills.", - "items": { - "$ref": "#/definitions/v2/SkillsListExtraRootsForCwd" - }, - "type": [ - "array", - "null" - ] } }, "title": "SkillsListParams", @@ -15442,64 +14815,12 @@ "title": "SkillsListResponse", "type": "object" }, - "SkillsRemoteReadParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SkillsRemoteReadParams", - "type": "object" - }, - "SkillsRemoteReadResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "data": { - "items": { - "$ref": "#/definitions/v2/RemoteSkillSummary" - }, - "type": "array" - } - }, - "required": [ - "data" - ], - "title": "SkillsRemoteReadResponse", - "type": "object" - }, - "SkillsRemoteWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "hazelnutId": { - "type": "string" - }, - "isPreload": { - "type": "boolean" - } - }, - "required": [ - "hazelnutId", - "isPreload" - ], - "title": "SkillsRemoteWriteParams", - "type": "object" - }, - "SkillsRemoteWriteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "path" + "SortDirection": { + "enum": [ + "asc", + "desc" ], - "title": "SkillsRemoteWriteResponse", - "type": "object" + "type": "string" }, "SubAgentSource": { "oneOf": [ @@ -15516,6 +14837,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -15552,6 +14898,17 @@ } ] }, + "SubagentMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -15642,6 +14999,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -15652,8 +15023,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -15673,6 +15059,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -15684,6 +15077,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -15692,6 +15089,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/v2/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -15709,16 +15125,66 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, - "ThreadArchiveParams": { + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadApproveGuardianDeniedActionParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "title": "ThreadApproveGuardianDeniedActionParams", + "type": "object" + }, + "ThreadApproveGuardianDeniedActionResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadApproveGuardianDeniedActionResponse", + "type": "object" + }, + "ThreadArchiveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchiveParams", + "type": "object" + }, + "ThreadArchiveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadArchiveResponse", + "type": "object" + }, + "ThreadArchivedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "threadId": { @@ -15728,12 +15194,20 @@ "required": [ "threadId" ], - "title": "ThreadArchiveParams", + "title": "ThreadArchivedNotification", "type": "object" }, - "ThreadArchiveResponse": { + "ThreadClosedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ThreadArchiveResponse", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadClosedNotification", "type": "object" }, "ThreadCompactStartParams": { @@ -15768,6 +15242,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -15793,6 +15278,9 @@ "null" ] }, + "ephemeral": { + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ @@ -15816,8 +15304,25 @@ } ] }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -15832,8 +15337,24 @@ "approvalPolicy": { "$ref": "#/definitions/v2/AskForApproval" }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, "cwd": { - "type": "string" + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" }, "model": { "type": "string" @@ -15852,7 +15373,18 @@ ] }, "sandbox": { - "$ref": "#/definitions/v2/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] }, "thread": { "$ref": "#/definitions/v2/Thread" @@ -15860,6 +15392,7 @@ }, "required": [ "approvalPolicy", + "approvalsReviewer", "cwd", "model", "modelProvider", @@ -15869,9 +15402,124 @@ "title": "ThreadForkResponse", "type": "object" }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/v2/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/v2/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" + }, "ThreadId": { "type": "string" }, + "ThreadInjectItemsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "items": { + "description": "Raw Responses API items to append to the thread's model-visible history.", + "items": true, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "items", + "threadId" + ], + "title": "ThreadInjectItemsParams", + "type": "object" + }, + "ThreadInjectItemsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadInjectItemsResponse", + "type": "object" + }, "ThreadItem": { "oneOf": [ { @@ -15901,11 +15549,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/v2/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/v2/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/v2/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -16005,8 +15702,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -16034,6 +15735,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/v2/CommandExecutionStatus" }, @@ -16111,6 +15820,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -16141,12 +15856,71 @@ "required": [ "arguments", "id", - "server", + "server", + "status", + "tool", + "type" + ], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/v2/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/v2/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", "status", "tool", "type" ], - "title": "McpToolCallThreadItem", + "title": "DynamicToolCallThreadItem", "type": "object" }, { @@ -16162,6 +15936,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -16169,6 +15950,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -16256,7 +16048,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": { "enum": [ @@ -16388,6 +16180,19 @@ } ] }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -16406,11 +16211,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -16431,6 +16240,24 @@ "null" ] }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, "sortKey": { "anyOf": [ { @@ -16451,6 +16278,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", @@ -16459,6 +16290,13 @@ "ThreadListResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.", + "type": [ + "string", + "null" + ] + }, "data": { "items": { "$ref": "#/definitions/v2/Thread" @@ -16521,59 +16359,369 @@ } }, "required": [ - "data" + "data" + ], + "title": "ThreadLoadedListResponse", + "type": "object" + }, + "ThreadMemoryMode": { + "enum": [ + "enabled", + "disabled" + ], + "type": "string" + }, + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ThreadMetadataUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMetadataUpdateParams", + "type": "object" + }, + "ThreadMetadataUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/v2/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadMetadataUpdateResponse", + "type": "object" + }, + "ThreadNameUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "title": "ThreadNameUpdatedNotification", + "type": "object" + }, + "ThreadReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "includeTurns": { + "default": false, + "description": "When true, include turns and their items from rollout history.", + "type": "boolean" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadReadParams", + "type": "object" + }, + "ThreadReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/v2/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadReadResponse", + "type": "object" + }, + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": [ + "string", + "null" + ] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + }, + "ThreadRealtimeClosedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime transport closes.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadRealtimeClosedNotification", + "type": "object" + }, + "ThreadRealtimeErrorNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime encounters an error.", + "properties": { + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "ThreadRealtimeErrorNotification", + "type": "object" + }, + "ThreadRealtimeItemAddedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId" + ], + "title": "ThreadRealtimeItemAddedNotification", + "type": "object" + }, + "ThreadRealtimeOutputAudioDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/v2/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" ], - "title": "ThreadLoadedListResponse", + "title": "ThreadRealtimeOutputAudioDeltaNotification", "type": "object" }, - "ThreadNameUpdatedNotification": { + "ThreadRealtimeSdpNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session.", "properties": { + "sdp": { + "type": "string" + }, "threadId": { "type": "string" + } + }, + "required": [ + "sdp", + "threadId" + ], + "title": "ThreadRealtimeSdpNotification", + "type": "object" + }, + "ThreadRealtimeStartTransport": { + "description": "EXPERIMENTAL - transport used by thread realtime.", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "websocket" + ], + "title": "WebsocketThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "WebsocketThreadRealtimeStartTransport", + "type": "object" }, - "threadName": { + { + "properties": { + "sdp": { + "description": "SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the realtime events data channel.", + "type": "string" + }, + "type": { + "enum": [ + "webrtc" + ], + "title": "WebrtcThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "sdp", + "type" + ], + "title": "WebrtcThreadRealtimeStartTransport", + "type": "object" + } + ] + }, + "ThreadRealtimeStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", + "properties": { + "realtimeSessionId": { "type": [ "string", "null" ] + }, + "threadId": { + "type": "string" + }, + "version": { + "$ref": "#/definitions/v2/RealtimeConversationVersion" } }, "required": [ - "threadId" + "threadId", + "version" ], - "title": "ThreadNameUpdatedNotification", + "title": "ThreadRealtimeStartedNotification", "type": "object" }, - "ThreadReadParams": { + "ThreadRealtimeTranscriptDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", "properties": { - "includeTurns": { - "default": false, - "description": "When true, include turns and their items from rollout history.", - "type": "boolean" + "delta": { + "description": "Live transcript delta from the realtime event.", + "type": "string" + }, + "role": { + "type": "string" }, "threadId": { "type": "string" } }, "required": [ + "delta", + "role", "threadId" ], - "title": "ThreadReadParams", + "title": "ThreadRealtimeTranscriptDeltaNotification", "type": "object" }, - "ThreadReadResponse": { + "ThreadRealtimeTranscriptDoneNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - final transcript text emitted when realtime completes a transcript part.", "properties": { - "thread": { - "$ref": "#/definitions/v2/Thread" + "role": { + "type": "string" + }, + "text": { + "description": "Final complete text for the transcript part.", + "type": "string" + }, + "threadId": { + "type": "string" } }, "required": [ - "thread" + "role", + "text", + "threadId" ], - "title": "ThreadReadResponse", + "title": "ThreadRealtimeTranscriptDoneNotification", "type": "object" }, "ThreadResumeParams": { @@ -16590,6 +16738,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -16648,6 +16807,12 @@ } ] }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" } @@ -16664,8 +16829,24 @@ "approvalPolicy": { "$ref": "#/definitions/v2/AskForApproval" }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, "cwd": { - "type": "string" + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" }, "model": { "type": "string" @@ -16684,7 +16865,18 @@ ] }, "sandbox": { - "$ref": "#/definitions/v2/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] }, "thread": { "$ref": "#/definitions/v2/Thread" @@ -16692,6 +16884,7 @@ }, "required": [ "approvalPolicy", + "approvalsReviewer", "cwd", "model", "modelProvider", @@ -16761,6 +16954,29 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadShellCommandParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "title": "ThreadShellCommandParams", + "type": "object" + }, + "ThreadShellCommandResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadShellCommandResponse", + "type": "object" + }, "ThreadSortKey": { "enum": [ "created_at", @@ -16768,6 +16984,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -16796,6 +17020,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -16858,6 +17093,39 @@ "type": "null" } ] + }, + "serviceName": { + "type": [ + "string", + "null" + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadStartSource" + }, + { + "type": "null" + } + ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", @@ -16869,54 +17137,181 @@ "approvalPolicy": { "$ref": "#/definitions/v2/AskForApproval" }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, "cwd": { - "type": "string" + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" }, "model": { "type": "string" }, - "modelProvider": { - "type": "string" + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "allOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "thread": { + "$ref": "#/definitions/v2/Thread" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadStartResponse", + "type": "object" + }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" + }, + "ThreadStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/v2/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadStartedNotification", + "type": "object" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningEffort" + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/v2/ThreadActiveFlag" + }, + "type": "array" }, - { - "type": "null" + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" } - ] - }, - "sandbox": { - "$ref": "#/definitions/v2/SandboxPolicy" - }, - "thread": { - "$ref": "#/definitions/v2/Thread" + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" } - }, - "required": [ - "approvalPolicy", - "cwd", - "model", - "modelProvider", - "sandbox", - "thread" - ], - "title": "ThreadStartResponse", - "type": "object" + ] }, - "ThreadStartedNotification": { + "ThreadStatusChangedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "thread": { - "$ref": "#/definitions/v2/Thread" + "status": { + "$ref": "#/definitions/v2/ThreadStatus" + }, + "threadId": { + "type": "string" } }, "required": [ - "thread" + "status", + "threadId" ], - "title": "ThreadStartedNotification", + "title": "ThreadStatusChangedNotification", "type": "object" }, "ThreadTokenUsage": { @@ -16988,6 +17383,53 @@ "title": "ThreadUnarchiveResponse", "type": "object" }, + "ThreadUnarchivedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchivedNotification", + "type": "object" + }, + "ThreadUnsubscribeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnsubscribeParams", + "type": "object" + }, + "ThreadUnsubscribeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/ThreadUnsubscribeStatus" + } + }, + "required": [ + "status" + ], + "title": "ThreadUnsubscribeResponse", + "type": "object" + }, + "ThreadUnsubscribeStatus": { + "enum": [ + "notLoaded", + "notSubscribed", + "unsubscribed" + ], + "type": "string" + }, "TokenUsageBreakdown": { "properties": { "cachedInputTokens": { @@ -17079,6 +17521,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -17094,12 +17552,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/v2/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/v2/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } @@ -17150,6 +17625,21 @@ "title": "TurnDiffUpdatedNotification", "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnError": { "properties": { "additionalDetails": { @@ -17200,6 +17690,31 @@ "title": "TurnInterruptResponse", "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnPlanStep": { "properties": { "status": { @@ -17267,6 +17782,17 @@ ], "description": "Override the approval policy for this turn and subsequent turns." }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this turn and subsequent turns." + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -17323,6 +17849,13 @@ ], "description": "Override the sandbox policy for this turn and subsequent turns." }, + "serviceTier": { + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, "summary": { "anyOf": [ { @@ -17551,6 +18084,27 @@ ], "type": "string" }, + "WarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "title": "WarningNotification", + "type": "object" + }, "WebSearchAction": { "oneOf": [ { @@ -17588,7 +18142,7 @@ "properties": { "type": { "enum": [ - "open_page" + "openPage" ], "title": "OpenPageWebSearchActionType", "type": "string" @@ -17616,7 +18170,7 @@ }, "type": { "enum": [ - "find_in_page" + "findInPage" ], "title": "FindInPageWebSearchActionType", "type": "string" @@ -17733,6 +18287,93 @@ }, "type": "object" }, + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + }, + "WindowsSandboxReadinessResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" + }, + "WindowsSandboxSetupCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/definitions/v2/WindowsSandboxSetupMode" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "mode", + "success" + ], + "title": "WindowsSandboxSetupCompletedNotification", + "type": "object" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsSandboxSetupStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "mode": { + "$ref": "#/definitions/v2/WindowsSandboxSetupMode" + } + }, + "required": [ + "mode" + ], + "title": "WindowsSandboxSetupStartParams", + "type": "object" + }, + "WindowsSandboxSetupStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "started": { + "type": "boolean" + } + }, + "required": [ + "started" + ], + "title": "WindowsSandboxSetupStartResponse", + "type": "object" + }, "WindowsWorldWritableWarningNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -17770,4 +18411,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json new file mode 100644 index 00000000000..3c5eb030c5c --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -0,0 +1,16281 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "Account": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ApiKeyAccount", + "type": "object" + }, + { + "properties": { + "email": { + "type": "string" + }, + "planType": { + "$ref": "#/definitions/PlanType" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "ChatgptAccountType", + "type": "string" + } + }, + "required": [ + "email", + "planType", + "type" + ], + "title": "ChatgptAccount", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "amazonBedrock" + ], + "title": "AmazonBedrockAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AmazonBedrockAccount", + "type": "object" + } + ] + }, + "AccountLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "loginId": { + "type": [ + "string", + "null" + ] + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ], + "title": "AccountLoginCompletedNotification", + "type": "object" + }, + "AccountRateLimitsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "$ref": "#/definitions/RateLimitSnapshot" + } + }, + "required": [ + "rateLimits" + ], + "title": "AccountRateLimitsUpdatedNotification", + "type": "object" + }, + "AccountUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "authMode": { + "anyOf": [ + { + "$ref": "#/definitions/AuthMode" + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + } + }, + "title": "AccountUpdatedNotification", + "type": "object" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AgentMessageDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "AgentMessageDeltaNotification", + "type": "object" + }, + "AgentPath": { + "type": "string" + }, + "AnalyticsConfig": { + "additionalProperties": true, + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, + "AppConfig": { + "properties": { + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "AppInfo": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "isAccessible": { + "default": false, + "type": "boolean" + }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "AppListUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - notification emitted when the app list changes.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/AppInfo" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "AppListUpdatedNotification", + "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, + "AppSummary": { + "description": "EXPERIMENTAL - app metadata summary for plugin responses.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "needsAuth": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "needsAuth" + ], + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolsConfig": { + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "AppsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - list available apps/connectors.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "forceRefetch": { + "description": "When true, bypass app caches and fetch the latest data from sources.", + "type": "boolean" + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "threadId": { + "description": "Optional thread id used to evaluate app feature gating from that thread's config.", + "type": [ + "string", + "null" + ] + } + }, + "title": "AppsListParams", + "type": "object" + }, + "AppsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - app list response.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/AppInfo" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "AppsListResponse", + "type": "object" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "AuthMode": { + "description": "Authentication mode for OpenAI-backed providers.", + "oneOf": [ + { + "description": "OpenAI API key provided by the caller and stored by Codex.", + "enum": [ + "apikey" + ], + "type": "string" + }, + { + "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", + "enum": [ + "chatgpt" + ], + "type": "string" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", + "enum": [ + "chatgptAuthTokens" + ], + "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" + } + ] + }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "CancelLoginAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "loginId": { + "type": "string" + } + }, + "required": [ + "loginId" + ], + "title": "CancelLoginAccountParams", + "type": "object" + }, + "CancelLoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/CancelLoginAccountStatus" + } + }, + "required": [ + "status" + ], + "title": "CancelLoginAccountResponse", + "type": "object" + }, + "CancelLoginAccountStatus": { + "enum": [ + "canceled", + "notFound" + ], + "type": "string" + }, + "ClientInfo": { + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "ClientRequest": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request from the client to the server.", + "oneOf": [ + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "initialize" + ], + "title": "InitializeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/InitializeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "InitializeRequest", + "type": "object" + }, + { + "description": "NEW APIs", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/start" + ], + "title": "Thread/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/resume" + ], + "title": "Thread/resumeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadResumeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/resumeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/fork" + ], + "title": "Thread/forkRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadForkParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/forkRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/archive" + ], + "title": "Thread/archiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/archiveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unsubscribe" + ], + "title": "Thread/unsubscribeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnsubscribeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unsubscribeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/name/set" + ], + "title": "Thread/name/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSetNameParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/name/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/metadata/update" + ], + "title": "Thread/metadata/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMetadataUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/metadata/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unarchive" + ], + "title": "Thread/unarchiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unarchiveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/compact/start" + ], + "title": "Thread/compact/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadCompactStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/compact/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/shellCommand" + ], + "title": "Thread/shellCommandRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadShellCommandParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/shellCommandRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/approveGuardianDeniedAction" + ], + "title": "Thread/approveGuardianDeniedActionRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadApproveGuardianDeniedActionParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/approveGuardianDeniedActionRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/rollback" + ], + "title": "Thread/rollbackRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRollbackParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/rollbackRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/list" + ], + "title": "Thread/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/loaded/list" + ], + "title": "Thread/loaded/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadLoadedListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/loaded/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/read" + ], + "title": "Thread/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/readRequest", + "type": "object" + }, + { + "description": "Append raw Responses API items to the thread history without starting a user turn.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/inject_items" + ], + "title": "Thread/injectItemsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadInjectItemsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/injectItemsRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/list" + ], + "title": "Skills/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/list" + ], + "title": "Hooks/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "marketplace/add" + ], + "title": "Marketplace/addRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MarketplaceAddParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/addRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "marketplace/remove" + ], + "title": "Marketplace/removeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MarketplaceRemoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/removeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "marketplace/upgrade" + ], + "title": "Marketplace/upgradeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MarketplaceUpgradeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/upgradeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/list" + ], + "title": "Plugin/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/read" + ], + "title": "Plugin/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/skill/read" + ], + "title": "Plugin/skill/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginSkillReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/skill/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/save" + ], + "title": "Plugin/share/saveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareSaveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/saveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/list" + ], + "title": "Plugin/share/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/delete" + ], + "title": "Plugin/share/deleteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareDeleteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/deleteRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "app/list" + ], + "title": "App/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/readFile" + ], + "title": "Fs/readFileRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsReadFileParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/readFileRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/writeFile" + ], + "title": "Fs/writeFileRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsWriteFileParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/writeFileRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/createDirectory" + ], + "title": "Fs/createDirectoryRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsCreateDirectoryParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/createDirectoryRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/getMetadata" + ], + "title": "Fs/getMetadataRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsGetMetadataParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/getMetadataRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/readDirectory" + ], + "title": "Fs/readDirectoryRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsReadDirectoryParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/readDirectoryRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/remove" + ], + "title": "Fs/removeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsRemoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/removeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/copy" + ], + "title": "Fs/copyRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsCopyParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/copyRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/watch" + ], + "title": "Fs/watchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsWatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/watchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/unwatch" + ], + "title": "Fs/unwatchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsUnwatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/unwatchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/config/write" + ], + "title": "Skills/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/config/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/install" + ], + "title": "Plugin/installRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/uninstall" + ], + "title": "Plugin/uninstallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/uninstallRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/start" + ], + "title": "Turn/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/steer" + ], + "title": "Turn/steerRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnSteerParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/steerRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/interrupt" + ], + "title": "Turn/interruptRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnInterruptParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/interruptRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "review/start" + ], + "title": "Review/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReviewStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Review/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "model/list" + ], + "title": "Model/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Model/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "modelProvider/capabilities/read" + ], + "title": "ModelProvider/capabilities/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelProviderCapabilitiesReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ModelProvider/capabilities/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/list" + ], + "title": "ExperimentalFeature/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/oauth/login" + ], + "title": "McpServer/oauth/loginRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerOauthLoginParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/oauth/loginRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/mcpServer/reload" + ], + "title": "Config/mcpServer/reloadRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Config/mcpServer/reloadRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServerStatus/list" + ], + "title": "McpServerStatus/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ListMcpServerStatusParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServerStatus/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/resource/read" + ], + "title": "McpServer/resource/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpResourceReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/resource/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/tool/call" + ], + "title": "McpServer/tool/callRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerToolCallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/tool/callRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/setupStart" + ], + "title": "WindowsSandbox/setupStartRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsSandboxSetupStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "WindowsSandbox/setupStartRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/readiness" + ], + "title": "WindowsSandbox/readinessRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/login/start" + ], + "title": "Account/login/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/LoginAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/login/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/login/cancel" + ], + "title": "Account/login/cancelRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CancelLoginAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/login/cancelRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/logout" + ], + "title": "Account/logoutRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/logoutRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/rateLimits/read" + ], + "title": "Account/rateLimits/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/rateLimits/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "feedback/upload" + ], + "title": "Feedback/uploadRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FeedbackUploadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Feedback/uploadRequest", + "type": "object" + }, + { + "description": "Execute a standalone command (argv vector) under the server's sandbox.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec" + ], + "title": "Command/execRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/execRequest", + "type": "object" + }, + { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/writeRequest", + "type": "object" + }, + { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecTerminateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/terminateRequest", + "type": "object" + }, + { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecResizeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/resizeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/read" + ], + "title": "Config/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/detect" + ], + "title": "ExternalAgentConfig/detectRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigDetectParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/detectRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/import" + ], + "title": "ExternalAgentConfig/importRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/importRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/value/write" + ], + "title": "Config/value/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigValueWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/value/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/batchWrite" + ], + "title": "Config/batchWriteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigBatchWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/batchWriteRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "configRequirements/read" + ], + "title": "ConfigRequirements/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "ConfigRequirements/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/read" + ], + "title": "Account/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GetAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fuzzyFileSearch" + ], + "title": "FuzzyFileSearchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "FuzzyFileSearchRequest", + "type": "object" + } + ], + "title": "ClientRequest" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "CollaborationModeMask": { + "description": "EXPERIMENTAL - collaboration mode preset metadata for clients.", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ModeKind" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "name", + "path", + "type" + ], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" + }, + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, + "CommandExecParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", + "properties": { + "command": { + "description": "Command argv vector. Empty arrays are rejected.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", + "type": [ + "string", + "null" + ] + }, + "sandboxPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + }, + { + "type": "null" + } + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" + }, + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" + }, + "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" + } + }, + "required": [ + "command" + ], + "title": "CommandExecParams", + "type": "object" + }, + "CommandExecResizeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "size": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" + }, + "CommandExecResizeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", + "title": "CommandExecResizeResponse", + "type": "object" + }, + "CommandExecResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", + "type": "string" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", + "type": "string" + } + }, + "required": [ + "exitCode", + "stderr", + "stdout" + ], + "title": "CommandExecResponse", + "type": "object" + }, + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" + }, + "CommandExecTerminateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", + "title": "CommandExecTerminateResponse", + "type": "object" + }, + "CommandExecWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" + }, + "CommandExecWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", + "title": "CommandExecWriteResponse", + "type": "object" + }, + "CommandExecutionOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "CommandExecutionOutputDeltaNotification", + "type": "object" + }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "CommandMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "Config": { + "additionalProperties": true, + "properties": { + "analytics": { + "anyOf": [ + { + "$ref": "#/definitions/AnalyticsConfig" + }, + { + "type": "null" + } + ] + }, + "approval_policy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "[UNSTABLE] Optional default for where approval requests are routed for review." + }, + "compact_prompt": { + "type": [ + "string", + "null" + ] + }, + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "forced_chatgpt_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "forced_login_method": { + "anyOf": [ + { + "$ref": "#/definitions/ForcedLoginMethod" + }, + { + "type": "null" + } + ] + }, + "instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_auto_compact_token_limit": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_provider": { + "type": [ + "string", + "null" + ] + }, + "model_reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model_reasoning_summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "model_verbosity": { + "anyOf": [ + { + "$ref": "#/definitions/Verbosity" + }, + { + "type": "null" + } + ] + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "profiles": { + "additionalProperties": { + "$ref": "#/definitions/ProfileV2" + }, + "default": {}, + "type": "object" + }, + "review_model": { + "type": [ + "string", + "null" + ] + }, + "sandbox_mode": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "sandbox_workspace_write": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxWorkspaceWrite" + }, + { + "type": "null" + } + ] + }, + "service_tier": { + "type": [ + "string", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchMode" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ConfigBatchWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "edits": { + "items": { + "$ref": "#/definitions/ConfigEdit" + }, + "type": "array" + }, + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" + } + }, + "required": [ + "edits" + ], + "title": "ConfigBatchWriteParams", + "type": "object" + }, + "ConfigEdit": { + "properties": { + "keyPath": { + "type": "string" + }, + "mergeStrategy": { + "$ref": "#/definitions/MergeStrategy" + }, + "value": true + }, + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "type": "object" + }, + "ConfigLayer": { + "properties": { + "config": true, + "disabledReason": { + "type": [ + "string", + "null" + ] + }, + "name": { + "$ref": "#/definitions/ConfigLayerSource" + }, + "version": { + "type": "string" + } + }, + "required": [ + "config", + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerMetadata": { + "properties": { + "name": { + "$ref": "#/definitions/ConfigLayerSource" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerSource": { + "oneOf": [ + { + "description": "Managed preferences layer delivered by MDM (macOS only).", + "properties": { + "domain": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "enum": [ + "mdm" + ], + "title": "MdmConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "domain", + "key", + "type" + ], + "title": "MdmConfigLayerSource", + "type": "object" + }, + { + "description": "Managed config layer from a file (usually `managed_config.toml`).", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "This is the path to the system config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "system" + ], + "title": "SystemConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "SystemConfigLayerSource", + "type": "object" + }, + { + "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "user" + ], + "title": "UserConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "UserConfigLayerSource", + "type": "object" + }, + { + "description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.", + "properties": { + "dotCodexFolder": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "project" + ], + "title": "ProjectConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "dotCodexFolder", + "type" + ], + "title": "ProjectConfigLayerSource", + "type": "object" + }, + { + "description": "Session-layer overrides supplied via `-c`/`--config`.", + "properties": { + "type": { + "enum": [ + "sessionFlags" + ], + "title": "SessionFlagsConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SessionFlagsConfigLayerSource", + "type": "object" + }, + { + "description": "`managed_config.toml` was designed to be a config that was loaded as the last layer on top of everything else. This scheme did not quite work out as intended, but we keep this variant as a \"best effort\" while we phase out `managed_config.toml` in favor of `requirements.toml`.", + "properties": { + "file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "legacyManagedConfigTomlFromFile" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSource", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "legacyManagedConfigTomlFromMdm" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSource", + "type": "object" + } + ] + }, + "ConfigReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "description": "Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root).", + "type": [ + "string", + "null" + ] + }, + "includeLayers": { + "default": false, + "type": "boolean" + } + }, + "title": "ConfigReadParams", + "type": "object" + }, + "ConfigReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "layers": { + "items": { + "$ref": "#/definitions/ConfigLayer" + }, + "type": [ + "array", + "null" + ] + }, + "origins": { + "additionalProperties": { + "$ref": "#/definitions/ConfigLayerMetadata" + }, + "type": "object" + } + }, + "required": [ + "config", + "origins" + ], + "title": "ConfigReadResponse", + "type": "object" + }, + "ConfigRequirements": { + "properties": { + "allowedApprovalPolicies": { + "items": { + "$ref": "#/definitions/AskForApproval" + }, + "type": [ + "array", + "null" + ] + }, + "allowedSandboxModes": { + "items": { + "$ref": "#/definitions/SandboxMode" + }, + "type": [ + "array", + "null" + ] + }, + "allowedWebSearchModes": { + "items": { + "$ref": "#/definitions/WebSearchMode" + }, + "type": [ + "array", + "null" + ] + }, + "enforceResidency": { + "anyOf": [ + { + "$ref": "#/definitions/ResidencyRequirement" + }, + { + "type": "null" + } + ] + }, + "featureRequirements": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, + "ConfigRequirementsReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requirements": { + "anyOf": [ + { + "$ref": "#/definitions/ConfigRequirements" + }, + { + "type": "null" + } + ], + "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + } + }, + "title": "ConfigRequirementsReadResponse", + "type": "object" + }, + "ConfigValueWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + }, + "keyPath": { + "type": "string" + }, + "mergeStrategy": { + "$ref": "#/definitions/MergeStrategy" + }, + "value": true + }, + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "title": "ConfigValueWriteParams", + "type": "object" + }, + "ConfigWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance or error details.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "Optional path to the config file that triggered the warning.", + "type": [ + "string", + "null" + ] + }, + "range": { + "anyOf": [ + { + "$ref": "#/definitions/TextRange" + }, + { + "type": "null" + } + ], + "description": "Optional range for the error location inside the config file." + }, + "summary": { + "description": "Concise summary of the warning.", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "ConfigWarningNotification", + "type": "object" + }, + "ConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "filePath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonical path to the config file that was written." + }, + "overriddenMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/OverriddenMetadata" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/WriteStatus" + }, + "version": { + "type": "string" + } + }, + "required": [ + "filePath", + "status", + "version" + ], + "title": "ConfigWriteResponse", + "type": "object" + }, + "ConfiguredHookHandler": { + "oneOf": [ + { + "properties": { + "async": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "async", + "command", + "type" + ], + "title": "CommandConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "title": "PromptConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "PromptConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "title": "AgentConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AgentConfiguredHookHandler", + "type": "object" + } + ] + }, + "ConfiguredHookMatcherGroup": { + "properties": { + "hooks": { + "items": { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + "type": "array" + }, + "matcher": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "hooks" + ], + "type": "object" + }, + "ContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextContentItem", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageContentItemType", + "type": "string" + } + }, + "required": [ + "image_url", + "type" + ], + "title": "InputImageContentItem", + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "output_text" + ], + "title": "OutputTextContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "OutputTextContentItem", + "type": "object" + } + ] + }, + "ContextCompactedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "turnId" + ], + "title": "ContextCompactedNotification", + "type": "object" + }, + "CreditsSnapshot": { + "properties": { + "balance": { + "type": [ + "string", + "null" + ] + }, + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + } + }, + "required": [ + "hasCredits", + "unlimited" + ], + "type": "object" + }, + "DeprecationNoticeNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "DeprecationNoticeNotification", + "type": "object" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "DynamicToolSpec": { + "properties": { + "deferLoading": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "inputSchema": true, + "name": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "inputSchema", + "name" + ], + "type": "object" + }, + "ErrorNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "$ref": "#/definitions/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "type": "boolean" + } + }, + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "title": "ErrorNotification", + "type": "object" + }, + "ExperimentalFeature": { + "properties": { + "announcement": { + "description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "defaultEnabled": { + "description": "Whether this feature is enabled by default.", + "type": "boolean" + }, + "description": { + "description": "Short summary describing what the feature does. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "displayName": { + "description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "description": "Whether this feature is currently enabled in the loaded config.", + "type": "boolean" + }, + "name": { + "description": "Stable key used in config.toml and CLI flag toggles.", + "type": "string" + }, + "stage": { + "allOf": [ + { + "$ref": "#/definitions/ExperimentalFeatureStage" + } + ], + "description": "Lifecycle stage of this feature flag." + } + }, + "required": [ + "defaultEnabled", + "enabled", + "name", + "stage" + ], + "type": "object" + }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, + "ExperimentalFeatureListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ExperimentalFeatureListParams", + "type": "object" + }, + "ExperimentalFeatureListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ExperimentalFeature" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ExperimentalFeatureListResponse", + "type": "object" + }, + "ExperimentalFeatureStage": { + "oneOf": [ + { + "description": "Feature is available for user testing and feedback.", + "enum": [ + "beta" + ], + "type": "string" + }, + { + "description": "Feature is still being built and not ready for broad use.", + "enum": [ + "underDevelopment" + ], + "type": "string" + }, + { + "description": "Feature is production-ready.", + "enum": [ + "stable" + ], + "type": "string" + }, + { + "description": "Feature is deprecated and should be avoided.", + "enum": [ + "deprecated" + ], + "type": "string" + }, + { + "description": "Feature flag is retained only for backwards compatibility.", + "enum": [ + "removed" + ], + "type": "string" + } + ] + }, + "ExternalAgentConfigDetectParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Zero or more working directories to include for repo-scoped detection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeHome": { + "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "type": "boolean" + } + }, + "title": "ExternalAgentConfigDetectParams", + "type": "object" + }, + "ExternalAgentConfigDetectResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "items": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItem" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "ExternalAgentConfigDetectResponse", + "type": "object" + }, + "ExternalAgentConfigImportCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportCompletedNotification", + "type": "object" + }, + "ExternalAgentConfigImportParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "migrationItems": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItem" + }, + "type": "array" + } + }, + "required": [ + "migrationItems" + ], + "title": "ExternalAgentConfigImportParams", + "type": "object" + }, + "ExternalAgentConfigImportResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportResponse", + "type": "object" + }, + "ExternalAgentConfigMigrationItem": { + "properties": { + "cwd": { + "description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.", + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + } + }, + "required": [ + "description", + "itemType" + ], + "type": "object" + }, + "ExternalAgentConfigMigrationItemType": { + "enum": [ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS" + ], + "type": "string" + }, + "FeedbackUploadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "classification": { + "type": "string" + }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeLogs": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "threadId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "classification", + "includeLogs" + ], + "title": "FeedbackUploadParams", + "type": "object" + }, + "FeedbackUploadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "FeedbackUploadResponse", + "type": "object" + }, + "FileChangeOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "FileChangeOutputDeltaNotification", + "type": "object" + }, + "FileChangePatchUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "changes", + "itemId", + "threadId", + "turnId" + ], + "title": "FileChangePatchUpdatedNotification", + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "ForcedLoginMethod": { + "enum": [ + "chatgpt", + "api" + ], + "type": "string" + }, + "FsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" + }, + "FsCopyParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Copy a file or directory tree on the host filesystem.", + "properties": { + "destinationPath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute destination path." + }, + "recursive": { + "description": "Required for directory copies; ignored for file copies.", + "type": "boolean" + }, + "sourcePath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute source path." + } + }, + "required": [ + "destinationPath", + "sourcePath" + ], + "title": "FsCopyParams", + "type": "object" + }, + "FsCopyResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/copy`.", + "title": "FsCopyResponse", + "type": "object" + }, + "FsCreateDirectoryParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Create a directory on the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to create." + }, + "recursive": { + "description": "Whether parent directories should also be created. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "path" + ], + "title": "FsCreateDirectoryParams", + "type": "object" + }, + "FsCreateDirectoryResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/createDirectory`.", + "title": "FsCreateDirectoryResponse", + "type": "object" + }, + "FsGetMetadataParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request metadata for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to inspect." + } + }, + "required": [ + "path" + ], + "title": "FsGetMetadataParams", + "type": "object" + }, + "FsGetMetadataResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Metadata returned by `fs/getMetadata`.", + "properties": { + "createdAtMs": { + "description": "File creation time in Unix milliseconds when available, otherwise `0`.", + "format": "int64", + "type": "integer" + }, + "isDirectory": { + "description": "Whether the path resolves to a directory.", + "type": "boolean" + }, + "isFile": { + "description": "Whether the path resolves to a regular file.", + "type": "boolean" + }, + "isSymlink": { + "description": "Whether the path itself is a symbolic link.", + "type": "boolean" + }, + "modifiedAtMs": { + "description": "File modification time in Unix milliseconds when available, otherwise `0`.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAtMs", + "isDirectory", + "isFile", + "isSymlink", + "modifiedAtMs" + ], + "title": "FsGetMetadataResponse", + "type": "object" + }, + "FsReadDirectoryEntry": { + "description": "A directory entry returned by `fs/readDirectory`.", + "properties": { + "fileName": { + "description": "Direct child entry name only, not an absolute or relative path.", + "type": "string" + }, + "isDirectory": { + "description": "Whether this entry resolves to a directory.", + "type": "boolean" + }, + "isFile": { + "description": "Whether this entry resolves to a regular file.", + "type": "boolean" + } + }, + "required": [ + "fileName", + "isDirectory", + "isFile" + ], + "type": "object" + }, + "FsReadDirectoryParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "List direct child names for a directory.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to read." + } + }, + "required": [ + "path" + ], + "title": "FsReadDirectoryParams", + "type": "object" + }, + "FsReadDirectoryResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Directory entries returned by `fs/readDirectory`.", + "properties": { + "entries": { + "description": "Direct child entries in the requested directory.", + "items": { + "$ref": "#/definitions/FsReadDirectoryEntry" + }, + "type": "array" + } + }, + "required": [ + "entries" + ], + "title": "FsReadDirectoryResponse", + "type": "object" + }, + "FsReadFileParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Read a file from the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to read." + } + }, + "required": [ + "path" + ], + "title": "FsReadFileParams", + "type": "object" + }, + "FsReadFileResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded file contents returned by `fs/readFile`.", + "properties": { + "dataBase64": { + "description": "File contents encoded as base64.", + "type": "string" + } + }, + "required": [ + "dataBase64" + ], + "title": "FsReadFileResponse", + "type": "object" + }, + "FsRemoveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove a file or directory tree from the host filesystem.", + "properties": { + "force": { + "description": "Whether missing paths should be ignored. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + }, + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to remove." + }, + "recursive": { + "description": "Whether directory removal should recurse. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "path" + ], + "title": "FsRemoveParams", + "type": "object" + }, + "FsRemoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/remove`.", + "title": "FsRemoveResponse", + "type": "object" + }, + "FsUnwatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" + }, + "FsUnwatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" + }, + "FsWatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchParams", + "type": "object" + }, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchResponse", + "type": "object" + }, + "FsWriteFileParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write a file on the host filesystem.", + "properties": { + "dataBase64": { + "description": "File contents encoded as base64.", + "type": "string" + }, + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to write." + } + }, + "required": [ + "dataBase64", + "path" + ], + "title": "FsWriteFileParams", + "type": "object" + }, + "FsWriteFileResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/writeFile`.", + "title": "FsWriteFileResponse", + "type": "object" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "image_url", + "type" + ], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "FuzzyFileSearchMatchType": { + "enum": [ + "file", + "directory" + ], + "type": "string" + }, + "FuzzyFileSearchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cancellationToken": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": "string" + }, + "roots": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "query", + "roots" + ], + "title": "FuzzyFileSearchParams", + "type": "object" + }, + "FuzzyFileSearchResult": { + "description": "Superset of [`codex_file_search::FileMatch`]", + "properties": { + "file_name": { + "type": "string" + }, + "indices": { + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "match_type": { + "$ref": "#/definitions/FuzzyFileSearchMatchType" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "score": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "file_name", + "match_type", + "path", + "root", + "score" + ], + "type": "object" + }, + "FuzzyFileSearchSessionCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" + }, + "FuzzyFileSearchSessionUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" + }, + "type": "array" + }, + "query": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "files", + "query", + "sessionId" + ], + "title": "FuzzyFileSearchSessionUpdatedNotification", + "type": "object" + }, + "GetAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refreshToken": { + "default": false, + "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", + "type": "boolean" + } + }, + "title": "GetAccountParams", + "type": "object" + }, + "GetAccountRateLimitsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "allOf": [ + { + "$ref": "#/definitions/RateLimitSnapshot" + } + ], + "description": "Backward-compatible single-bucket view; mirrors the historical payload." + }, + "rateLimitsByLimitId": { + "additionalProperties": { + "$ref": "#/definitions/RateLimitSnapshot" + }, + "description": "Multi-bucket view keyed by metered `limit_id` (for example, `codex`).", + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "rateLimits" + ], + "title": "GetAccountRateLimitsResponse", + "type": "object" + }, + "GetAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "account": { + "anyOf": [ + { + "$ref": "#/definitions/Account" + }, + { + "type": "null" + } + ] + }, + "requiresOpenaiAuth": { + "type": "boolean" + } + }, + "required": [ + "requiresOpenaiAuth" + ], + "title": "GetAccountResponse", + "type": "object" + }, + "GitInfo": { + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "GuardianApprovalReview": { + "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", + "properties": { + "rationale": { + "type": [ + "string", + "null" + ] + }, + "riskLevel": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianRiskLevel" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/GuardianApprovalReviewStatus" + }, + "userAuthorization": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianUserAuthorization" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "files": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" + } + ] + }, + "GuardianApprovalReviewStatus": { + "description": "[UNSTABLE] Lifecycle state for an approval auto-review.", + "enum": [ + "inProgress", + "approved", + "denied", + "timedOut", + "aborted" + ], + "type": "string" + }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, + "GuardianRiskLevel": { + "description": "[UNSTABLE] Risk level assigned by approval auto-review.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "GuardianUserAuthorization": { + "description": "[UNSTABLE] Authorization level assigned by approval auto-review.", + "enum": [ + "unknown", + "low", + "medium", + "high" + ], + "type": "string" + }, + "GuardianWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, + "HookCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "run", + "threadId" + ], + "title": "HookCompletedNotification", + "type": "object" + }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookExecutionMode": { + "enum": [ + "sync", + "async" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "currentHash": { + "type": "string" + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "isManaged": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/HookTrustStatus" + } + }, + "required": [ + "currentHash", + "displayOrder", + "enabled", + "eventName", + "handlerType", + "isManaged", + "key", + "source", + "sourcePath", + "timeoutSec", + "trustStatus" + ], + "type": "object" + }, + "HookMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "HookOutputEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/HookOutputEntryKind" + }, + "text": { + "type": "string" + } + }, + "required": [ + "kind", + "text" + ], + "type": "object" + }, + "HookOutputEntryKind": { + "enum": [ + "warning", + "stop", + "feedback", + "context", + "error" + ], + "type": "string" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, + "HookRunStatus": { + "enum": [ + "running", + "completed", + "failed", + "blocked", + "stopped" + ], + "type": "string" + }, + "HookRunSummary": { + "properties": { + "completedAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "durationMs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "entries": { + "items": { + "$ref": "#/definitions/HookOutputEntry" + }, + "type": "array" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "executionMode": { + "$ref": "#/definitions/HookExecutionMode" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "id": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/HookScope" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "startedAt": { + "format": "int64", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/HookRunStatus" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "displayOrder", + "entries", + "eventName", + "executionMode", + "handlerType", + "id", + "scope", + "sourcePath", + "startedAt", + "status" + ], + "type": "object" + }, + "HookScope": { + "enum": [ + "thread", + "turn" + ], + "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "cloudRequirements", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, + "HookStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "run", + "threadId" + ], + "title": "HookStartedNotification", + "type": "object" + }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + }, + "HooksListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" + }, + "HooksListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" + }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, + "InitializeCapabilities": { + "description": "Client-declared capabilities negotiated during initialize.", + "properties": { + "experimentalApi": { + "default": false, + "description": "Opt into receiving experimental API methods and fields.", + "type": "boolean" + }, + "optOutNotificationMethods": { + "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "InitializeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "capabilities": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeCapabilities" + }, + { + "type": "null" + } + ] + }, + "clientInfo": { + "$ref": "#/definitions/ClientInfo" + } + }, + "required": [ + "clientInfo" + ], + "title": "InitializeParams", + "type": "object" + }, + "InputModality": { + "description": "Canonical user-input modality tags advertised by a model.", + "oneOf": [ + { + "description": "Plain text turns and tool payloads.", + "enum": [ + "text" + ], + "type": "string" + }, + { + "description": "Image attachments included in user turns.", + "enum": [ + "image" + ], + "type": "string" + } + ] + }, + "ItemCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "completedAtMs", + "item", + "threadId", + "turnId" + ], + "title": "ItemCompletedNotification", + "type": "object" + }, + "ItemGuardianApprovalReviewCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, + "decisionSource": { + "$ref": "#/definitions/AutoReviewDecisionSource" + }, + "review": { + "$ref": "#/definitions/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "action", + "completedAtMs", + "decisionSource", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "ItemGuardianApprovalReviewCompletedNotification", + "type": "object" + }, + "ItemGuardianApprovalReviewStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, + "review": { + "$ref": "#/definitions/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "action", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "ItemGuardianApprovalReviewStartedNotification", + "type": "object" + }, + "ItemStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "ItemStartedNotification", + "type": "object" + }, + "ListMcpServerStatusParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, + "limit": { + "description": "Optional page size; defaults to a server-defined value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ListMcpServerStatusParams", + "type": "object" + }, + "ListMcpServerStatusResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/McpServerStatus" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ListMcpServerStatusResponse", + "type": "object" + }, + "LocalShellAction": { + "oneOf": [ + { + "properties": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "timeout_ms": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "exec" + ], + "title": "ExecLocalShellActionType", + "type": "string" + }, + "user": { + "type": [ + "string", + "null" + ] + }, + "working_directory": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "command", + "type" + ], + "title": "ExecLocalShellAction", + "type": "object" + } + ] + }, + "LocalShellStatus": { + "enum": [ + "completed", + "in_progress", + "incomplete" + ], + "type": "string" + }, + "LoginAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "apiKey": { + "type": "string" + }, + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "apiKey", + "type" + ], + "title": "ApiKeyv2::LoginAccountParams", + "type": "object" + }, + { + "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "Chatgptv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "Chatgptv2::LoginAccountParams", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", + "properties": { + "accessToken": { + "description": "Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", + "type": "string" + }, + "chatgptAccountId": { + "description": "Workspace/account identifier supplied by the client.", + "type": "string" + }, + "chatgptPlanType": { + "description": "Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "accessToken", + "chatgptAccountId", + "type" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParams", + "type": "object" + } + ], + "title": "LoginAccountParams" + }, + "LoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ApiKeyv2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "authUrl": { + "description": "URL the client should open in a browser to initiate the OAuth flow.", + "type": "string" + }, + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "Chatgptv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "authUrl", + "loginId", + "type" + ], + "title": "Chatgptv2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptAuthTokensv2::LoginAccountResponse", + "type": "object" + } + ], + "title": "LoginAccountResponse" + }, + "LogoutAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LogoutAccountResponse", + "type": "object" + }, + "ManagedHooksRequirements": { + "properties": { + "PermissionRequest": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "Stop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "managedDir": { + "type": [ + "string", + "null" + ] + }, + "windowsManagedDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "PermissionRequest", + "PostCompact", + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionStart", + "Stop", + "UserPromptSubmit" + ], + "type": "object" + }, + "MarketplaceAddParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refName": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source" + ], + "title": "MarketplaceAddParams", + "type": "object" + }, + "MarketplaceAddResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "alreadyAdded": { + "type": "boolean" + }, + "installedRoot": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "alreadyAdded", + "installedRoot", + "marketplaceName" + ], + "title": "MarketplaceAddResponse", + "type": "object" + }, + "MarketplaceInterface": { + "properties": { + "displayName": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, + "MarketplaceRemoveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "marketplaceName" + ], + "title": "MarketplaceRemoveParams", + "type": "object" + }, + "MarketplaceRemoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "installedRoot": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "marketplaceName" + ], + "title": "MarketplaceRemoveResponse", + "type": "object" + }, + "MarketplaceUpgradeErrorInfo": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "message" + ], + "type": "object" + }, + "MarketplaceUpgradeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "title": "MarketplaceUpgradeParams", + "type": "object" + }, + "MarketplaceUpgradeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/MarketplaceUpgradeErrorInfo" + }, + "type": "array" + }, + "selectedMarketplaces": { + "items": { + "type": "string" + }, + "type": "array" + }, + "upgradedRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "errors", + "selectedMarketplaces", + "upgradedRoots" + ], + "title": "MarketplaceUpgradeResponse", + "type": "object" + }, + "McpAuthStatus": { + "enum": [ + "unsupported", + "notLoggedIn", + "bearerToken", + "oAuth" + ], + "type": "string" + }, + "McpResourceReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" + }, + "McpResourceReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" + }, + "McpServerMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "McpServerOauthLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "name", + "success" + ], + "title": "McpServerOauthLoginCompletedNotification", + "type": "object" + }, + "McpServerOauthLoginParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "name": { + "type": "string" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "timeoutSecs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "name" + ], + "title": "McpServerOauthLoginParams", + "type": "object" + }, + "McpServerOauthLoginResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "authorizationUrl": { + "type": "string" + } + }, + "required": [ + "authorizationUrl" + ], + "title": "McpServerOauthLoginResponse", + "type": "object" + }, + "McpServerRefreshResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "McpServerRefreshResponse", + "type": "object" + }, + "McpServerStartupState": { + "enum": [ + "starting", + "ready", + "failed", + "cancelled" + ], + "type": "string" + }, + "McpServerStatus": { + "properties": { + "authStatus": { + "$ref": "#/definitions/McpAuthStatus" + }, + "name": { + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + }, + "resources": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/Tool" + }, + "type": "object" + } + }, + "required": [ + "authStatus", + "name", + "resourceTemplates", + "resources", + "tools" + ], + "type": "object" + }, + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + }, + "McpServerStatusUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpServerStartupState" + } + }, + "required": [ + "name", + "status" + ], + "title": "McpServerStatusUpdatedNotification", + "type": "object" + }, + "McpServerToolCallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "_meta": true, + "arguments": true, + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "tool": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "tool" + ], + "title": "McpServerToolCallParams", + "type": "object" + }, + "McpServerToolCallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "title": "McpServerToolCallResponse", + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "McpToolCallProgressNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "message", + "threadId", + "turnId" + ], + "title": "McpToolCallProgressNotification", + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MergeStrategy": { + "enum": [ + "replace", + "upsert" + ], + "type": "string" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "MigrationDetails": { + "properties": { + "commands": { + "default": [], + "items": { + "$ref": "#/definitions/CommandMigration" + }, + "type": "array" + }, + "hooks": { + "default": [], + "items": { + "$ref": "#/definitions/HookMigration" + }, + "type": "array" + }, + "mcpServers": { + "default": [], + "items": { + "$ref": "#/definitions/McpServerMigration" + }, + "type": "array" + }, + "plugins": { + "default": [], + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + }, + "sessions": { + "default": [], + "items": { + "$ref": "#/definitions/SessionMigration" + }, + "type": "array" + }, + "subagents": { + "default": [], + "items": { + "$ref": "#/definitions/SubagentMigration" + }, + "type": "array" + } + }, + "type": "object" + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "Model": { + "properties": { + "additionalSpeedTiers": { + "default": [], + "description": "Deprecated: use `serviceTiers` instead.", + "items": { + "type": "string" + }, + "type": "array" + }, + "availabilityNux": { + "anyOf": [ + { + "$ref": "#/definitions/ModelAvailabilityNux" + }, + { + "type": "null" + } + ] + }, + "defaultReasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "inputModalities": { + "default": [ + "text", + "image" + ], + "items": { + "$ref": "#/definitions/InputModality" + }, + "type": "array" + }, + "isDefault": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/ModelServiceTier" + }, + "type": "array" + }, + "supportedReasoningEfforts": { + "items": { + "$ref": "#/definitions/ReasoningEffortOption" + }, + "type": "array" + }, + "supportsPersonality": { + "default": false, + "type": "boolean" + }, + "upgrade": { + "type": [ + "string", + "null" + ] + }, + "upgradeInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ModelUpgradeInfo" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "defaultReasoningEffort", + "description", + "displayName", + "hidden", + "id", + "isDefault", + "model", + "supportedReasoningEfforts" + ], + "type": "object" + }, + "ModelAvailabilityNux": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "ModelListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ModelListParams", + "type": "object" + }, + "ModelListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/Model" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ModelListResponse", + "type": "object" + }, + "ModelProviderCapabilitiesReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ModelProviderCapabilitiesReadParams", + "type": "object" + }, + "ModelProviderCapabilitiesReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "imageGeneration": { + "type": "boolean" + }, + "namespaceTools": { + "type": "boolean" + }, + "webSearch": { + "type": "boolean" + } + }, + "required": [ + "imageGeneration", + "namespaceTools", + "webSearch" + ], + "title": "ModelProviderCapabilitiesReadResponse", + "type": "object" + }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" + }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, + "ModelUpgradeInfo": { + "properties": { + "migrationMarkdown": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "modelLink": { + "type": [ + "string", + "null" + ] + }, + "upgradeCopy": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkRequirements": { + "properties": { + "allowLocalBinding": { + "type": [ + "boolean", + "null" + ] + }, + "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "allowUpstreamProxy": { + "type": [ + "boolean", + "null" + ] + }, + "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, + "dangerouslyAllowNonLoopbackProxy": { + "type": [ + "boolean", + "null" + ] + }, + "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "httpPort": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, + "socksPort": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, + "OverriddenMetadata": { + "properties": { + "effectiveValue": true, + "message": { + "type": "string" + }, + "overridingLayer": { + "$ref": "#/definitions/ConfigLayerMetadata" + } + }, + "required": [ + "effectiveValue", + "message", + "overridingLayer" + ], + "type": "object" + }, + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "PlanDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "PlanDeltaNotification", + "type": "object" + }, + "PlanType": { + "enum": [ + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_usage_based", + "business", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "unknown" + ], + "type": "string" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginDetail": { + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/AppSummary" + }, + "type": "array" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "mcpServers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/definitions/SkillSummary" + }, + "type": "array" + }, + "summary": { + "$ref": "#/definitions/PluginSummary" + } + }, + "required": [ + "apps", + "hooks", + "marketplaceName", + "mcpServers", + "skills", + "summary" + ], + "type": "object" + }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "eventName", + "key" + ], + "type": "object" + }, + "PluginInstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginName" + ], + "title": "PluginInstallParams", + "type": "object" + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "appsNeedingAuth": { + "items": { + "$ref": "#/definitions/AppSummary" + }, + "type": "array" + }, + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + } + }, + "required": [ + "appsNeedingAuth", + "authPolicy" + ], + "title": "PluginInstallResponse", + "type": "object" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, + "PluginListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginListParams", + "type": "object" + }, + "PluginListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "featuredPluginIds": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginListResponse", + "type": "object" + }, + "PluginMarketplaceEntry": { + "properties": { + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/MarketplaceInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." + }, + "plugins": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "plugins" + ], + "type": "object" + }, + "PluginReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginName" + ], + "title": "PluginReadParams", + "type": "object" + }, + "PluginReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "plugin": { + "$ref": "#/definitions/PluginDetail" + } + }, + "required": [ + "plugin" + ], + "title": "PluginReadResponse", + "type": "object" + }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDeleteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareDeleteParams", + "type": "object" + }, + "PluginShareDeleteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareDeleteResponse", + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareListItem": { + "properties": { + "localPluginPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "plugin": { + "$ref": "#/definitions/PluginSummary" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "plugin", + "shareUrl" + ], + "type": "object" + }, + "PluginShareListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareListParams", + "type": "object" + }, + "PluginShareListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PluginShareListItem" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PluginShareListResponse", + "type": "object" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareSaveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "pluginPath" + ], + "title": "PluginShareSaveParams", + "type": "object" + }, + "PluginShareSaveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "remotePluginId", + "shareUrl" + ], + "title": "PluginShareSaveResponse", + "type": "object" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareUpdateDiscoverability" + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, + "PluginSkillReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remoteMarketplaceName": { + "type": "string" + }, + "remotePluginId": { + "type": "string" + }, + "skillName": { + "type": "string" + } + }, + "required": [ + "remoteMarketplaceName", + "remotePluginId", + "skillName" + ], + "title": "PluginSkillReadParams", + "type": "object" + }, + "PluginSkillReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "type": [ + "string", + "null" + ] + } + }, + "title": "PluginSkillReadResponse", + "type": "object" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + }, + "PluginUninstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" + }, + "PluginUninstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" + }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, + "ProcessExitedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", + "type": "string" + }, + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", + "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" + } + }, + "required": [ + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" + ], + "title": "ProcessExitedNotification", + "type": "object" + }, + "ProcessOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" + ], + "title": "ProcessOutputDeltaNotification", + "type": "object" + }, + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, + "ProcessTerminalSize": { + "description": "PTY size in character cells for `process/spawn` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "ProfileV2": { + "additionalProperties": true, + "properties": { + "approval_policy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." + }, + "chatgpt_base_url": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_provider": { + "type": [ + "string", + "null" + ] + }, + "model_reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model_reasoning_summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "model_verbosity": { + "anyOf": [ + { + "$ref": "#/definitions/Verbosity" + }, + { + "type": "null" + } + ] + }, + "service_tier": { + "type": [ + "string", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchMode" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, + "RateLimitSnapshot": { + "properties": { + "credits": { + "anyOf": [ + { + "$ref": "#/definitions/CreditsSnapshot" + }, + { + "type": "null" + } + ] + }, + "limitId": { + "type": [ + "string", + "null" + ] + }, + "limitName": { + "type": [ + "string", + "null" + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + }, + "primary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RateLimitWindow": { + "properties": { + "resetsAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "usedPercent": { + "format": "int32", + "type": "integer" + }, + "windowDurationMins": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "usedPercent" + ], + "type": "object" + }, + "RawResponseItemCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ResponseItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId", + "turnId" + ], + "title": "RawResponseItemCompletedNotification", + "type": "object" + }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, + "RealtimeOutputModality": { + "enum": [ + "text", + "audio" + ], + "type": "string" + }, + "RealtimeVoice": { + "enum": [ + "alloy", + "arbor", + "ash", + "ballad", + "breeze", + "cedar", + "coral", + "cove", + "echo", + "ember", + "juniper", + "maple", + "marin", + "sage", + "shimmer", + "sol", + "spruce", + "vale", + "verse" + ], + "type": "string" + }, + "RealtimeVoicesList": { + "properties": { + "defaultV1": { + "$ref": "#/definitions/RealtimeVoice" + }, + "defaultV2": { + "$ref": "#/definitions/RealtimeVoice" + }, + "v1": { + "items": { + "$ref": "#/definitions/RealtimeVoice" + }, + "type": "array" + }, + "v2": { + "items": { + "$ref": "#/definitions/RealtimeVoice" + }, + "type": "array" + } + }, + "required": [ + "defaultV1", + "defaultV2", + "v1", + "v2" + ], + "type": "object" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningEffortOption": { + "properties": { + "description": { + "type": "string" + }, + "reasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + } + }, + "required": [ + "description", + "reasoningEffort" + ], + "type": "object" + }, + "ReasoningItemContent": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "reasoning_text" + ], + "title": "ReasoningTextReasoningItemContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "ReasoningTextReasoningItemContent", + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextReasoningItemContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextReasoningItemContent", + "type": "object" + } + ] + }, + "ReasoningItemReasoningSummary": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "summary_text" + ], + "title": "SummaryTextReasoningItemReasoningSummaryType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "SummaryTextReasoningItemReasoningSummary", + "type": "object" + } + ] + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "ReasoningSummaryPartAddedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "title": "ReasoningSummaryPartAddedNotification", + "type": "object" + }, + "ReasoningSummaryTextDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "title": "ReasoningSummaryTextDeltaNotification", + "type": "object" + }, + "ReasoningTextDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contentIndex": { + "format": "int64", + "type": "integer" + }, + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "contentIndex", + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "ReasoningTextDeltaNotification", + "type": "object" + }, + "RemoteControlConnectionStatus": { + "enum": [ + "disabled", + "connecting", + "connected", + "errored" + ], + "type": "string" + }, + "RemoteControlStatusChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Current remote-control connection status and environment id exposed to clients.", + "properties": { + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/RemoteControlConnectionStatus" + } + }, + "required": [ + "status" + ], + "title": "RemoteControlStatusChangedNotification", + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ] + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ResidencyRequirement": { + "enum": [ + "us" + ], + "type": "string" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": true, + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "size": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResponseItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/ContentItem" + }, + "type": "array" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string" + }, + "type": { + "enum": [ + "message" + ], + "title": "MessageResponseItemType", + "type": "string" + } + }, + "required": [ + "content", + "role", + "type" + ], + "title": "MessageResponseItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": null, + "items": { + "$ref": "#/definitions/ReasoningItemContent" + }, + "type": [ + "array", + "null" + ] + }, + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "items": { + "$ref": "#/definitions/ReasoningItemReasoningSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningResponseItemType", + "type": "string" + } + }, + "required": [ + "summary", + "type" + ], + "title": "ReasoningResponseItem", + "type": "object" + }, + { + "properties": { + "action": { + "$ref": "#/definitions/LocalShellAction" + }, + "call_id": { + "description": "Set when using the Responses API.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "$ref": "#/definitions/LocalShellStatus" + }, + "type": { + "enum": [ + "local_shell_call" + ], + "title": "LocalShellCallResponseItemType", + "type": "string" + } + }, + "required": [ + "action", + "status", + "type" + ], + "title": "LocalShellCallResponseItem", + "type": "object" + }, + { + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "name": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "function_call" + ], + "title": "FunctionCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "call_id", + "name", + "type" + ], + "title": "FunctionCallResponseItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "execution", + "type" + ], + "title": "ToolSearchCallResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "enum": [ + "function_call_output" + ], + "title": "FunctionCallOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "output", + "type" + ], + "title": "FunctionCallOutputResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "input": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "custom_tool_call" + ], + "title": "CustomToolCallResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "input", + "name", + "type" + ], + "title": "CustomToolCallResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "enum": [ + "custom_tool_call_output" + ], + "title": "CustomToolCallOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "output", + "type" + ], + "title": "CustomToolCallOutputResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tools": { + "items": true, + "type": "array" + }, + "type": { + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "execution", + "status", + "tools", + "type" + ], + "title": "ToolSearchOutputResponseItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/ResponsesApiWebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "web_search_call" + ], + "title": "WebSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "WebSearchCallResponseItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationCallResponseItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "compaction" + ], + "title": "CompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "CompactionResponseItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponseItem", + "type": "object" + } + ] + }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponsesApiWebSearchAction", + "type": "object" + } + ] + }, + "ReviewDelivery": { + "enum": [ + "inline", + "detached" + ], + "type": "string" + }, + "ReviewStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/ReviewDelivery" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`)." + }, + "target": { + "$ref": "#/definitions/ReviewTarget" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "target", + "threadId" + ], + "title": "ReviewStartParams", + "type": "object" + }, + "ReviewStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "reviewThreadId": { + "description": "Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.", + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "reviewThreadId", + "turn" + ], + "title": "ReviewStartResponse", + "type": "object" + }, + "ReviewTarget": { + "oneOf": [ + { + "description": "Review the working tree: staged, unstaged, and untracked files.", + "properties": { + "type": { + "enum": [ + "uncommittedChanges" + ], + "title": "UncommittedChangesReviewTargetType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UncommittedChangesReviewTarget", + "type": "object" + }, + { + "description": "Review changes between the current branch and the given base branch.", + "properties": { + "branch": { + "type": "string" + }, + "type": { + "enum": [ + "baseBranch" + ], + "title": "BaseBranchReviewTargetType", + "type": "string" + } + }, + "required": [ + "branch", + "type" + ], + "title": "BaseBranchReviewTarget", + "type": "object" + }, + { + "description": "Review the changes introduced by a specific commit.", + "properties": { + "sha": { + "type": "string" + }, + "title": { + "description": "Optional human-readable label (e.g., commit subject) for UIs.", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "commit" + ], + "title": "CommitReviewTargetType", + "type": "string" + } + }, + "required": [ + "sha", + "type" + ], + "title": "CommitReviewTarget", + "type": "object" + }, + { + "description": "Arbitrary instructions, equivalent to the old free-form prompt.", + "properties": { + "instructions": { + "type": "string" + }, + "type": { + "enum": [ + "custom" + ], + "title": "CustomReviewTargetType", + "type": "string" + } + }, + "required": [ + "instructions", + "type" + ], + "title": "CustomReviewTarget", + "type": "object" + } + ] + }, + "SandboxMode": { + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ], + "type": "string" + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "SandboxWorkspaceWrite": { + "properties": { + "exclude_slash_tmp": { + "default": false, + "type": "boolean" + }, + "exclude_tmpdir_env_var": { + "default": false, + "type": "boolean" + }, + "network_access": { + "default": false, + "type": "boolean" + }, + "writable_roots": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, + "ServerNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification sent from the server to the client.", + "oneOf": [ + { + "description": "NEW NOTIFICATIONS", + "properties": { + "method": { + "enum": [ + "error" + ], + "title": "ErrorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ErrorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/started" + ], + "title": "Thread/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/status/changed" + ], + "title": "Thread/status/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStatusChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/archived" + ], + "title": "Thread/archivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchivedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/archivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/unarchived" + ], + "title": "Thread/unarchivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchivedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/unarchivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/closed" + ], + "title": "Thread/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadClosedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/closedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "skills/changed" + ], + "title": "Skills/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Skills/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/name/updated" + ], + "title": "Thread/name/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadNameUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/name/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/tokenUsage/updated" + ], + "title": "Thread/tokenUsage/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadTokenUsageUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/tokenUsage/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/started" + ], + "title": "Turn/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "hook/started" + ], + "title": "Hook/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HookStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Hook/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/completed" + ], + "title": "Turn/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "hook/completed" + ], + "title": "Hook/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HookCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Hook/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/diff/updated" + ], + "title": "Turn/diff/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnDiffUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/diff/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/plan/updated" + ], + "title": "Turn/plan/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnPlanUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/plan/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/started" + ], + "title": "Item/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/autoApprovalReview/started" + ], + "title": "Item/autoApprovalReview/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemGuardianApprovalReviewStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/autoApprovalReview/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/autoApprovalReview/completed" + ], + "title": "Item/autoApprovalReview/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemGuardianApprovalReviewCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/autoApprovalReview/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/completed" + ], + "title": "Item/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/agentMessage/delta" + ], + "title": "Item/agentMessage/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AgentMessageDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/agentMessage/deltaNotification", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", + "properties": { + "method": { + "enum": [ + "item/plan/delta" + ], + "title": "Item/plan/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PlanDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/plan/deltaNotification", + "type": "object" + }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" + ], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/outputDelta" + ], + "title": "Process/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/outputDeltaNotification", + "type": "object" + }, + { + "description": "Final exit notification for a `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/exited" + ], + "title": "Process/exitedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessExitedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/exitedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/outputDelta" + ], + "title": "Item/commandExecution/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecutionOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/terminalInteraction" + ], + "title": "Item/commandExecution/terminalInteractionNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TerminalInteractionNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/terminalInteractionNotification", + "type": "object" + }, + { + "description": "Deprecated legacy apply_patch output stream notification.", + "properties": { + "method": { + "enum": [ + "item/fileChange/outputDelta" + ], + "title": "Item/fileChange/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangeOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/fileChange/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/fileChange/patchUpdated" + ], + "title": "Item/fileChange/patchUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangePatchUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/fileChange/patchUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "serverRequest/resolved" + ], + "title": "ServerRequest/resolvedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ServerRequestResolvedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ServerRequest/resolvedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/mcpToolCall/progress" + ], + "title": "Item/mcpToolCall/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpToolCallProgressNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/mcpToolCall/progressNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "mcpServer/oauthLogin/completed" + ], + "title": "McpServer/oauthLogin/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerOauthLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "McpServer/oauthLogin/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "mcpServer/startupStatus/updated" + ], + "title": "McpServer/startupStatus/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerStatusUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "McpServer/startupStatus/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/updated" + ], + "title": "Account/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/rateLimits/updated" + ], + "title": "Account/rateLimits/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountRateLimitsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/rateLimits/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "app/list/updated" + ], + "title": "App/list/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppListUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "App/list/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "remoteControl/status/changed" + ], + "title": "RemoteControl/status/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/RemoteControlStatusChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "RemoteControl/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "externalAgentConfig/import/completed" + ], + "title": "ExternalAgentConfig/import/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ExternalAgentConfig/import/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryTextDelta" + ], + "title": "Item/reasoning/summaryTextDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryTextDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryTextDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryPartAdded" + ], + "title": "Item/reasoning/summaryPartAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryPartAddedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryPartAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/textDelta" + ], + "title": "Item/reasoning/textDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningTextDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/textDeltaNotification", + "type": "object" + }, + { + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "method": { + "enum": [ + "thread/compacted" + ], + "title": "Thread/compactedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ContextCompactedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/compactedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "warning" + ], + "title": "WarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "WarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GuardianWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "deprecationNotice" + ], + "title": "DeprecationNoticeNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/DeprecationNoticeNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "DeprecationNoticeNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "configWarning" + ], + "title": "ConfigWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ConfigWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionUpdated" + ], + "title": "FuzzyFileSearch/sessionUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionCompleted" + ], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/started" + ], + "title": "Thread/realtime/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/itemAdded" + ], + "title": "Thread/realtime/itemAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeItemAddedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/itemAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/transcript/delta" + ], + "title": "Thread/realtime/transcript/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeTranscriptDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/transcript/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/transcript/done" + ], + "title": "Thread/realtime/transcript/doneNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeTranscriptDoneNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/transcript/doneNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/outputAudio/delta" + ], + "title": "Thread/realtime/outputAudio/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeOutputAudioDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/outputAudio/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/sdp" + ], + "title": "Thread/realtime/sdpNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeSdpNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/sdpNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/error" + ], + "title": "Thread/realtime/errorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/errorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/closed" + ], + "title": "Thread/realtime/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeClosedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/closedNotification", + "type": "object" + }, + { + "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", + "properties": { + "method": { + "enum": [ + "windows/worldWritableWarning" + ], + "title": "Windows/worldWritableWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsWorldWritableWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Windows/worldWritableWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "windowsSandbox/setupCompleted" + ], + "title": "WindowsSandbox/setupCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsSandboxSetupCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "WindowsSandbox/setupCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/login/completed" + ], + "title": "Account/login/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/login/completedNotification", + "type": "object" + } + ], + "title": "ServerNotification" + }, + "ServerRequestResolvedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requestId": { + "$ref": "#/definitions/RequestId" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "requestId", + "threadId" + ], + "title": "ServerRequestResolvedNotification", + "type": "object" + }, + "SessionMigration": { + "properties": { + "cwd": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "cwd", + "path" + ], + "type": "object" + }, + "SessionSource": { + "oneOf": [ + { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": [ + "subAgent" + ], + "title": "SubAgentSessionSource", + "type": "object" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "SkillDependencies": { + "properties": { + "tools": { + "items": { + "$ref": "#/definitions/SkillToolDependency" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "SkillErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "SkillInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "iconLarge": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "iconSmall": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "SkillMetadata": { + "properties": { + "dependencies": { + "anyOf": [ + { + "$ref": "#/definitions/SkillDependencies" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/SkillInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "scope": { + "$ref": "#/definitions/SkillScope" + }, + "shortDescription": { + "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "enabled", + "name", + "path", + "scope" + ], + "type": "object" + }, + "SkillScope": { + "enum": [ + "user", + "repo", + "system", + "admin" + ], + "type": "string" + }, + "SkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/SkillInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "enabled", + "name" + ], + "type": "object" + }, + "SkillToolDependency": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "transport": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "SkillsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "title": "SkillsChangedNotification", + "type": "object" + }, + "SkillsConfigWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." + } + }, + "required": [ + "enabled" + ], + "title": "SkillsConfigWriteParams", + "type": "object" + }, + "SkillsConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "SkillsConfigWriteResponse", + "type": "object" + }, + "SkillsListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/SkillErrorInfo" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/definitions/SkillMetadata" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "skills" + ], + "type": "object" + }, + "SkillsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + }, + "forceReload": { + "description": "When true, bypass the skills cache and re-scan skills from disk.", + "type": "boolean" + } + }, + "title": "SkillsListParams", + "type": "object" + }, + "SkillsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/SkillsListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "SkillsListResponse", + "type": "object" + }, + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": [ + "review", + "compact", + "memory_consolidation" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": [ + "depth", + "parent_thread_id" + ], + "type": "object" + } + }, + "required": [ + "thread_spawn" + ], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "SubagentMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "TerminalInteractionNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "processId", + "stdin", + "threadId", + "turnId" + ], + "title": "TerminalInteractionNotification", + "type": "object" + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "byteRange" + ], + "type": "object" + }, + "TextPosition": { + "properties": { + "column": { + "description": "1-based column number (in Unicode scalar values).", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "line": { + "description": "1-based line number.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "column", + "line" + ], + "type": "object" + }, + "TextRange": { + "properties": { + "end": { + "$ref": "#/definitions/TextPosition" + }, + "start": { + "$ref": "#/definitions/TextPosition" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "id": { + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadApproveGuardianDeniedActionParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "title": "ThreadApproveGuardianDeniedActionParams", + "type": "object" + }, + "ThreadApproveGuardianDeniedActionResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadApproveGuardianDeniedActionResponse", + "type": "object" + }, + "ThreadArchiveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchiveParams", + "type": "object" + }, + "ThreadArchiveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadArchiveResponse", + "type": "object" + }, + "ThreadArchivedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchivedNotification", + "type": "object" + }, + "ThreadClosedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadClosedNotification", + "type": "object" + }, + "ThreadCompactStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadCompactStartParams", + "type": "object" + }, + "ThreadCompactStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadCompactStartResponse", + "type": "object" + }, + "ThreadForkParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "ephemeral": { + "type": "boolean" + }, + "model": { + "description": "Configuration overrides for the forked thread, if any.", + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." + } + }, + "required": [ + "threadId" + ], + "title": "ThreadForkParams", + "type": "object" + }, + "ThreadForkResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadForkResponse", + "type": "object" + }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" + }, + "ThreadId": { + "type": "string" + }, + "ThreadInjectItemsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "items": { + "description": "Raw Responses API items to append to the thread's model-visible history.", + "items": true, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "items", + "threadId" + ], + "title": "ThreadInjectItemsParams", + "type": "object" + }, + "ThreadInjectItemsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadInjectItemsResponse", + "type": "object" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "plan" + ], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": [ + "changes", + "id", + "status", + "type" + ], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "query", + "type" + ], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "path", + "type" + ], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "ThreadListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "archived": { + "description": "Optional archived filter; when set to true, only archived threads are returned. If false or null, only non-archived threads are returned.", + "type": [ + "boolean", + "null" + ] + }, + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "modelProviders": { + "description": "Optional provider filter; when set, only sessions recorded under these providers are returned. When present but empty, includes all providers.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, + "sortKey": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSortKey" + }, + { + "type": "null" + } + ], + "description": "Optional sort key; defaults to created_at." + }, + "sourceKinds": { + "description": "Optional source filter; when set, only sessions from these source kinds are returned. When omitted or empty, defaults to interactive sources.", + "items": { + "$ref": "#/definitions/ThreadSourceKind" + }, + "type": [ + "array", + "null" + ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" + } + }, + "title": "ThreadListParams", + "type": "object" + }, + "ThreadListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.", + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Thread" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadListResponse", + "type": "object" + }, + "ThreadLoadedListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to no limit.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ThreadLoadedListParams", + "type": "object" + }, + "ThreadLoadedListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "description": "Thread ids for sessions currently loaded in memory.", + "items": { + "type": "string" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadLoadedListResponse", + "type": "object" + }, + "ThreadMemoryMode": { + "enum": [ + "enabled", + "disabled" + ], + "type": "string" + }, + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ThreadMetadataUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMetadataUpdateParams", + "type": "object" + }, + "ThreadMetadataUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadMetadataUpdateResponse", + "type": "object" + }, + "ThreadNameUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "title": "ThreadNameUpdatedNotification", + "type": "object" + }, + "ThreadReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "includeTurns": { + "default": false, + "description": "When true, include turns and their items from rollout history.", + "type": "boolean" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadReadParams", + "type": "object" + }, + "ThreadReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadReadResponse", + "type": "object" + }, + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": [ + "string", + "null" + ] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + }, + "ThreadRealtimeClosedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime transport closes.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadRealtimeClosedNotification", + "type": "object" + }, + "ThreadRealtimeErrorNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime encounters an error.", + "properties": { + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "ThreadRealtimeErrorNotification", + "type": "object" + }, + "ThreadRealtimeItemAddedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId" + ], + "title": "ThreadRealtimeItemAddedNotification", + "type": "object" + }, + "ThreadRealtimeOutputAudioDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" + ], + "title": "ThreadRealtimeOutputAudioDeltaNotification", + "type": "object" + }, + "ThreadRealtimeSdpNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session.", + "properties": { + "sdp": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "sdp", + "threadId" + ], + "title": "ThreadRealtimeSdpNotification", + "type": "object" + }, + "ThreadRealtimeStartTransport": { + "description": "EXPERIMENTAL - transport used by thread realtime.", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "websocket" + ], + "title": "WebsocketThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "WebsocketThreadRealtimeStartTransport", + "type": "object" + }, + { + "properties": { + "sdp": { + "description": "SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the realtime events data channel.", + "type": "string" + }, + "type": { + "enum": [ + "webrtc" + ], + "title": "WebrtcThreadRealtimeStartTransportType", + "type": "string" + } + }, + "required": [ + "sdp", + "type" + ], + "title": "WebrtcThreadRealtimeStartTransport", + "type": "object" + } + ] + }, + "ThreadRealtimeStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", + "properties": { + "realtimeSessionId": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" + } + }, + "required": [ + "threadId", + "version" + ], + "title": "ThreadRealtimeStartedNotification", + "type": "object" + }, + "ThreadRealtimeTranscriptDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "delta": { + "description": "Live transcript delta from the realtime event.", + "type": "string" + }, + "role": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "delta", + "role", + "threadId" + ], + "title": "ThreadRealtimeTranscriptDeltaNotification", + "type": "object" + }, + "ThreadRealtimeTranscriptDoneNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - final transcript text emitted when realtime completes a transcript part.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "description": "Final complete text for the transcript part.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "title": "ThreadRealtimeTranscriptDoneNotification", + "type": "object" + }, + "ThreadResumeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "description": "Configuration overrides for the resumed thread, if any.", + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadResumeParams", + "type": "object" + }, + "ThreadResumeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadResumeResponse", + "type": "object" + }, + "ThreadRollbackParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "numTurns": { + "description": "The number of turns to drop from the end of the thread. Must be >= 1.\n\nThis only modifies the thread's history and does not revert local file changes that have been made by the agent. Clients are responsible for reverting these changes.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "numTurns", + "threadId" + ], + "title": "ThreadRollbackParams", + "type": "object" + }, + "ThreadRollbackResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "allOf": [ + { + "$ref": "#/definitions/Thread" + } + ], + "description": "The updated thread after applying the rollback, with `turns` populated.\n\nThe ThreadItems stored in each Turn are lossy since we explicitly do not persist all agent interactions, such as command executions. This is the same behavior as `thread/resume`." + } + }, + "required": [ + "thread" + ], + "title": "ThreadRollbackResponse", + "type": "object" + }, + "ThreadSetNameParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "name": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "name", + "threadId" + ], + "title": "ThreadSetNameParams", + "type": "object" + }, + "ThreadSetNameResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadSetNameResponse", + "type": "object" + }, + "ThreadShellCommandParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "title": "ThreadShellCommandParams", + "type": "object" + }, + "ThreadShellCommandResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadShellCommandResponse", + "type": "object" + }, + "ThreadSortKey": { + "enum": [ + "created_at", + "updated_at" + ], + "type": "string" + }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadSourceKind": { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "subAgent", + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "subAgentOther", + "unknown" + ], + "type": "string" + }, + "ThreadStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "ephemeral": { + "type": [ + "boolean", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceName": { + "type": [ + "string", + "null" + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadStartSource" + }, + { + "type": "null" + } + ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." + } + }, + "title": "ThreadStartParams", + "type": "object" + }, + "ThreadStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadStartResponse", + "type": "object" + }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" + }, + "ThreadStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadStartedNotification", + "type": "object" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "ThreadStatusChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/ThreadStatus" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "status", + "threadId" + ], + "title": "ThreadStatusChangedNotification", + "type": "object" + }, + "ThreadTokenUsage": { + "properties": { + "last": { + "$ref": "#/definitions/TokenUsageBreakdown" + }, + "modelContextWindow": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "total": { + "$ref": "#/definitions/TokenUsageBreakdown" + } + }, + "required": [ + "last", + "total" + ], + "type": "object" + }, + "ThreadTokenUsageUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "tokenUsage": { + "$ref": "#/definitions/ThreadTokenUsage" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "tokenUsage", + "turnId" + ], + "title": "ThreadTokenUsageUpdatedNotification", + "type": "object" + }, + "ThreadUnarchiveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchiveParams", + "type": "object" + }, + "ThreadUnarchiveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadUnarchiveResponse", + "type": "object" + }, + "ThreadUnarchivedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchivedNotification", + "type": "object" + }, + "ThreadUnsubscribeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnsubscribeParams", + "type": "object" + }, + "ThreadUnsubscribeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/ThreadUnsubscribeStatus" + } + }, + "required": [ + "status" + ], + "title": "ThreadUnsubscribeResponse", + "type": "object" + }, + "ThreadUnsubscribeStatus": { + "enum": [ + "notLoaded", + "notSubscribed", + "unsubscribed" + ], + "type": "string" + }, + "TokenUsageBreakdown": { + "properties": { + "cachedInputTokens": { + "format": "int64", + "type": "integer" + }, + "inputTokens": { + "format": "int64", + "type": "integer" + }, + "outputTokens": { + "format": "int64", + "type": "integer" + }, + "reasoningOutputTokens": { + "format": "int64", + "type": "integer" + }, + "totalTokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cachedInputTokens", + "inputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": true, + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "inputSchema": true, + "name": { + "type": "string" + }, + "outputSchema": true, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolsV2": { + "properties": { + "view_image": { + "type": [ + "boolean", + "null" + ] + }, + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchToolConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "Turn": { + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": [ + "id", + "items", + "status" + ], + "type": "object" + }, + "TurnCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "threadId", + "turn" + ], + "title": "TurnCompletedNotification", + "type": "object" + }, + "TurnDiffUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification that the turn-level unified diff has changed. Contains the latest aggregated diff across all file changes in the turn.", + "properties": { + "diff": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "diff", + "threadId", + "turnId" + ], + "title": "TurnDiffUpdatedNotification", + "type": "object" + }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "TurnInterruptParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "turnId" + ], + "title": "TurnInterruptParams", + "type": "object" + }, + "TurnInterruptResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnInterruptResponse", + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, + "TurnPlanStep": { + "properties": { + "status": { + "$ref": "#/definitions/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "pending", + "inProgress", + "completed" + ], + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "explanation": { + "type": [ + "string", + "null" + ] + }, + "plan": { + "items": { + "$ref": "#/definitions/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "title": "TurnPlanUpdatedNotification", + "type": "object" + }, + "TurnStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ], + "description": "Override the approval policy for this turn and subsequent turns." + }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this turn and subsequent turns." + }, + "cwd": { + "description": "Override the working directory for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Override the reasoning effort for this turn and subsequent turns." + }, + "input": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "model": { + "description": "Override the model for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "outputSchema": { + "description": "Optional JSON Schema used to constrain the final assistant message for this turn." + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ], + "description": "Override the personality for this turn and subsequent turns." + }, + "sandboxPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + }, + { + "type": "null" + } + ], + "description": "Override the sandbox policy for this turn and subsequent turns." + }, + "serviceTier": { + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ], + "description": "Override the reasoning summary for this turn and subsequent turns." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "input", + "threadId" + ], + "title": "TurnStartParams", + "type": "object" + }, + "TurnStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "turn" + ], + "title": "TurnStartResponse", + "type": "object" + }, + "TurnStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "threadId", + "turn" + ], + "title": "TurnStartedNotification", + "type": "object" + }, + "TurnStatus": { + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ], + "type": "string" + }, + "TurnSteerParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expectedTurnId": { + "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", + "type": "string" + }, + "input": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "expectedTurnId", + "input", + "threadId" + ], + "title": "TurnSteerParams", + "type": "object" + }, + "TurnSteerResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "turnId": { + "type": "string" + } + }, + "required": [ + "turnId" + ], + "title": "TurnSteerResponse", + "type": "object" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "image" + ], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "skill" + ], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "mention" + ], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "Verbosity": { + "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "title": "WarningNotification", + "type": "object" + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + }, + "WebSearchContextSize": { + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchLocation": { + "additionalProperties": false, + "properties": { + "city": { + "type": [ + "string", + "null" + ] + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "WebSearchMode": { + "enum": [ + "disabled", + "cached", + "live" + ], + "type": "string" + }, + "WebSearchToolConfig": { + "additionalProperties": false, + "properties": { + "allowed_domains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "context_size": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchContextSize" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchLocation" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + }, + "WindowsSandboxReadinessResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" + }, + "WindowsSandboxSetupCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "mode", + "success" + ], + "title": "WindowsSandboxSetupCompletedNotification", + "type": "object" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsSandboxSetupStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + } + }, + "required": [ + "mode" + ], + "title": "WindowsSandboxSetupStartParams", + "type": "object" + }, + "WindowsSandboxSetupStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "started": { + "type": "boolean" + } + }, + "required": [ + "started" + ], + "title": "WindowsSandboxSetupStartResponse", + "type": "object" + }, + "WindowsWorldWritableWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "extraCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "failedScan": { + "type": "boolean" + }, + "samplePaths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "extraCount", + "failedScan", + "samplePaths" + ], + "title": "WindowsWorldWritableWarningNotification", + "type": "object" + }, + "WriteStatus": { + "enum": [ + "ok", + "okOverridden" + ], + "type": "string" + } + }, + "title": "CodexAppServerProtocolV2", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v1/AddConversationListenerParams.json b/code-rs/app-server-protocol/schema/json/v1/AddConversationListenerParams.json deleted file mode 100644 index da31281affa..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/AddConversationListenerParams.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "experimentalRawEvents": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "conversationId" - ], - "title": "AddConversationListenerParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/AddConversationSubscriptionResponse.json b/code-rs/app-server-protocol/schema/json/v1/AddConversationSubscriptionResponse.json deleted file mode 100644 index 69924df05c0..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/AddConversationSubscriptionResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "AddConversationSubscriptionResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ArchiveConversationParams.json b/code-rs/app-server-protocol/schema/json/v1/ArchiveConversationParams.json deleted file mode 100644 index f32190e5fc1..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ArchiveConversationParams.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "rolloutPath" - ], - "title": "ArchiveConversationParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ArchiveConversationResponse.json b/code-rs/app-server-protocol/schema/json/v1/ArchiveConversationResponse.json deleted file mode 100644 index 2b77c7d7bfd..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ArchiveConversationResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ArchiveConversationResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/AuthStatusChangeNotification.json b/code-rs/app-server-protocol/schema/json/v1/AuthStatusChangeNotification.json deleted file mode 100644 index 61410ccfaba..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/AuthStatusChangeNotification.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ - { - "description": "OpenAI API key provided by the caller and stored by Codex.", - "enum": [ - "apikey" - ], - "type": "string" - }, - { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", - "enum": [ - "chatgpt" - ], - "type": "string" - }, - { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", - "enum": [ - "chatgptAuthTokens" - ], - "type": "string" - } - ] - } - }, - "description": "Deprecated notification. Use AccountUpdatedNotification instead.", - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } - ] - } - }, - "title": "AuthStatusChangeNotification", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptParams.json b/code-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptParams.json deleted file mode 100644 index ccf53afad01..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "loginId": { - "type": "string" - } - }, - "required": [ - "loginId" - ], - "title": "CancelLoginChatGptParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptResponse.json b/code-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptResponse.json deleted file mode 100644 index 0a27bc3c2ac..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/CancelLoginChatGptResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CancelLoginChatGptResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json b/code-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json deleted file mode 100644 index 2fa6b200777..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - } - }, - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "sandboxPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - }, - { - "type": "null" - } - ] - }, - "timeoutMs": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "command" - ], - "title": "ExecOneOffCommandParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandResponse.json b/code-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandResponse.json deleted file mode 100644 index 5a3cb1e0ca3..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandResponse.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "exitCode": { - "format": "int32", - "type": "integer" - }, - "stderr": { - "type": "string" - }, - "stdout": { - "type": "string" - } - }, - "required": [ - "exitCode", - "stderr", - "stdout" - ], - "title": "ExecOneOffCommandResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json b/code-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json deleted file mode 100644 index 75af24810fe..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json +++ /dev/null @@ -1,205 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "NewConversationParams": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ForkConversationParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/code-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json deleted file mode 100644 index 0ca6fefee8e..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ /dev/null @@ -1,5842 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "AutoContextPhase": { - "enum": [ - "checking", - "compacting" - ], - "type": "string" - }, - "AutomationOrigin": { - "properties": { - "actor": { - "description": "Actor reported by the source system as applying the trigger.", - "type": [ - "string", - "null" - ] - }, - "event_id": { - "description": "GitHub event delivery id, webhook id, or local request id.", - "type": [ - "string", - "null" - ] - }, - "issue_number": { - "description": "GitHub issue or pull request number associated with the trigger.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "kind": { - "$ref": "#/definitions/AutomationTriggerKind" - }, - "label": { - "description": "Label that triggered automation, such as `every-code`.", - "type": [ - "string", - "null" - ] - }, - "repository": { - "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", - "type": [ - "string", - "null" - ] - }, - "source": { - "description": "Tool, worker, or integration that launched this automated session.", - "type": [ - "string", - "null" - ] - }, - "url": { - "description": "Direct URL to the triggering issue, PR, event, or worker record.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - "AutomationTriggerKind": { - "enum": [ - "github_label", - "other" - ], - "type": "string" - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "cyber_policy", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "model_cap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "model_cap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Auto Context is evaluating whether to compact before the next turn.", - "properties": { - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/AutoContextPhase" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "auto_context_check" - ], - "title": "AutoContextCheckEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AutoContextCheckEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "automation_origin": { - "anyOf": [ - { - "$ref": "#/definitions/AutomationOrigin" - }, - { - "type": "null" - } - ], - "description": "Structured metadata for automated sessions, if the launcher provided it." - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_begin" - ], - "title": "ImageGenerationBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "ImageGenerationBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_end" - ], - "title": "ImageGenerationEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "result", - "status", - "type" - ], - "title": "ImageGenerationEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "default": {}, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "default": {}, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "default": {}, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "server_failures": { - "additionalProperties": { - "$ref": "#/definitions/McpServerFailure" - }, - "description": "Legacy server failure map keyed by server name.", - "type": [ - "object", - "null" - ] - }, - "server_tools": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "Legacy server -> tool names map used by existing UI surfaces.", - "type": [ - "object", - "null" - ] - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Optional note used by newer clients; when provided it supersedes `name`.", - "type": [ - "string", - "null" - ] - }, - "name": { - "default": null, - "description": "Legacy field name used by existing clients.", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "prompt": { - "default": "", - "description": "Legacy plain-text prompt retained for compatibility with older review flows.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "snapshot": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewSnapshotInfo" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "ImageDetail": { - "enum": [ - "auto", - "low", - "high", - "original" - ], - "type": "string" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpServerFailure": { - "properties": { - "message": { - "type": "string" - }, - "phase": { - "$ref": "#/definitions/McpServerFailurePhase" - } - }, - "required": [ - "message", - "phase" - ], - "type": "object" - }, - "McpServerFailurePhase": { - "enum": [ - "start", - "list_tools" - ], - "type": "string" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "read_command" - ], - "title": "ReadCommandParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ReadCommandParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitReachedType": { - "enum": [ - "rate_limit_reached", - "workspace_owner_credits_depleted", - "workspace_member_credits_depleted", - "workspace_owner_usage_limit_reached", - "workspace_member_usage_limit_reached" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "rate_limit_reached_type": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitReachedType" - }, - { - "type": "null" - } - ], - "default": null - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "resets_in_seconds": { - "description": "Legacy relative reset in seconds.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": true, - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "tool_search_call" - ], - "title": "ToolSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "execution", - "type" - ], - "title": "ToolSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tools": { - "items": true, - "type": "array" - }, - "type": { - "enum": [ - "tool_search_output" - ], - "title": "ToolSearchOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "execution", - "status", - "tools", - "type" - ], - "title": "ToolSearchOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_call" - ], - "title": "ImageGenerationCallResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction_summary" - ], - "title": "CompactionSummaryResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionSummaryResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewSnapshotInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "repo_root": { - "type": [ - "string", - "null" - ] - }, - "snapshot_commit": { - "type": [ - "string", - "null" - ] - }, - "worktree_path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "allow_implicit_invocation": { - "default": true, - "type": "boolean" - }, - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "cached_input_tokens_reported": { - "default": false, - "type": "boolean" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "latest_response_model": { - "type": [ - "string", - "null" - ] - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "requested_model": { - "type": [ - "string", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "ImageGeneration" - ], - "title": "ImageGenerationTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ForkConversationResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GetAuthStatusParams.json b/code-rs/app-server-protocol/schema/json/v1/GetAuthStatusParams.json deleted file mode 100644 index b305c79a94c..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GetAuthStatusParams.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "includeToken": { - "type": [ - "boolean", - "null" - ] - }, - "refreshToken": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GetAuthStatusResponse.json b/code-rs/app-server-protocol/schema/json/v1/GetAuthStatusResponse.json deleted file mode 100644 index 92d03db4954..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GetAuthStatusResponse.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AuthMode": { - "description": "Authentication mode for OpenAI-backed providers.", - "oneOf": [ - { - "description": "OpenAI API key provided by the caller and stored by Codex.", - "enum": [ - "apikey" - ], - "type": "string" - }, - { - "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", - "enum": [ - "chatgpt" - ], - "type": "string" - }, - { - "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", - "enum": [ - "chatgptAuthTokens" - ], - "type": "string" - } - ] - } - }, - "properties": { - "authMethod": { - "anyOf": [ - { - "$ref": "#/definitions/AuthMode" - }, - { - "type": "null" - } - ] - }, - "authToken": { - "type": [ - "string", - "null" - ] - }, - "requiresOpenaiAuth": { - "type": [ - "boolean", - "null" - ] - } - }, - "title": "GetAuthStatusResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GetConversationSummaryParams.json b/code-rs/app-server-protocol/schema/json/v1/GetConversationSummaryParams.json deleted file mode 100644 index e5a46b666c9..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GetConversationSummaryParams.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ - { - "properties": { - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "rolloutPath" - ], - "title": "RolloutPathv1::GetConversationSummaryParams", - "type": "object" - }, - { - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "ConversationIdv1::GetConversationSummaryParams", - "type": "object" - } - ], - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "title": "GetConversationSummaryParams" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json b/code-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json deleted file mode 100644 index 69cdea1661f..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ConversationGitInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "origin_url": { - "type": [ - "string", - "null" - ] - }, - "sha": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ConversationSummary": { - "properties": { - "cliVersion": { - "type": "string" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "gitInfo": { - "anyOf": [ - { - "$ref": "#/definitions/ConversationGitInfo" - }, - { - "type": "null" - } - ] - }, - "modelProvider": { - "type": "string" - }, - "path": { - "type": "string" - }, - "preview": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/SessionSource" - }, - "timestamp": { - "type": [ - "string", - "null" - ] - }, - "updatedAt": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "cliVersion", - "conversationId", - "cwd", - "modelProvider", - "path", - "preview", - "source" - ], - "type": "object" - }, - "SessionSource": { - "oneOf": [ - { - "enum": [ - "cli", - "vscode", - "exec", - "mcp", - "unknown" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "subagent": { - "$ref": "#/definitions/SubAgentSource" - } - }, - "required": [ - "subagent" - ], - "title": "SubagentSessionSource", - "type": "object" - } - ] - }, - "SubAgentSource": { - "oneOf": [ - { - "enum": [ - "review", - "compact", - "memory_consolidation" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "thread_spawn": { - "properties": { - "depth": { - "format": "int32", - "type": "integer" - }, - "parent_thread_id": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "depth", - "parent_thread_id" - ], - "type": "object" - } - }, - "required": [ - "thread_spawn" - ], - "title": "ThreadSpawnSubAgentSource", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "other": { - "type": "string" - } - }, - "required": [ - "other" - ], - "title": "OtherSubAgentSource", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "summary": { - "$ref": "#/definitions/ConversationSummary" - } - }, - "required": [ - "summary" - ], - "title": "GetConversationSummaryResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GetUserAgentResponse.json b/code-rs/app-server-protocol/schema/json/v1/GetUserAgentResponse.json deleted file mode 100644 index b25ed32b664..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GetUserAgentResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "userAgent": { - "type": "string" - } - }, - "required": [ - "userAgent" - ], - "title": "GetUserAgentResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json b/code-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json deleted file mode 100644 index 2043f0ae136..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "ForcedLoginMethod": { - "enum": [ - "chatgpt", - "api" - ], - "type": "string" - }, - "Profile": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "chatgptBaseUrl": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningSummary": { - "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", - "oneOf": [ - { - "enum": [ - "auto", - "concise", - "detailed" - ], - "type": "string" - }, - { - "description": "Option to disable reasoning summaries.", - "enum": [ - "none" - ], - "type": "string" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "SandboxSettings": { - "properties": { - "excludeSlashTmp": { - "type": [ - "boolean", - "null" - ] - }, - "excludeTmpdirEnvVar": { - "type": [ - "boolean", - "null" - ] - }, - "networkAccess": { - "type": [ - "boolean", - "null" - ] - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "type": "object" - }, - "Tools": { - "properties": { - "viewImage": { - "type": [ - "boolean", - "null" - ] - }, - "webSearch": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "UserSavedConfig": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "forcedChatgptWorkspaceId": { - "type": [ - "string", - "null" - ] - }, - "forcedLoginMethod": { - "anyOf": [ - { - "$ref": "#/definitions/ForcedLoginMethod" - }, - { - "type": "null" - } - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelReasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "modelReasoningSummary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "modelVerbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/Profile" - }, - "type": "object" - }, - "sandboxMode": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - }, - "sandboxSettings": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxSettings" - }, - { - "type": "null" - } - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/Tools" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "profiles" - ], - "type": "object" - }, - "Verbosity": { - "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", - "enum": [ - "low", - "medium", - "high" - ], - "type": "string" - } - }, - "properties": { - "config": { - "$ref": "#/definitions/UserSavedConfig" - } - }, - "required": [ - "config" - ], - "title": "GetUserSavedConfigResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteParams.json b/code-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteParams.json deleted file mode 100644 index 81c6cd33974..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cwd": { - "type": "string" - } - }, - "required": [ - "cwd" - ], - "title": "GitDiffToRemoteParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteResponse.json b/code-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteResponse.json deleted file mode 100644 index 1271b8109a4..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/GitDiffToRemoteResponse.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "GitSha": { - "type": "string" - } - }, - "properties": { - "diff": { - "type": "string" - }, - "sha": { - "$ref": "#/definitions/GitSha" - } - }, - "required": [ - "diff", - "sha" - ], - "title": "GitDiffToRemoteResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/code-rs/app-server-protocol/schema/json/v1/InitializeParams.json index f663e48ec80..6048b822426 100644 --- a/code-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/code-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -29,6 +29,16 @@ "default": false, "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" + }, + "optOutNotificationMethods": { + "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "type": "object" @@ -54,4 +64,4 @@ ], "title": "InitializeParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v1/InitializeResponse.json b/code-rs/app-server-protocol/schema/json/v1/InitializeResponse.json index f79beb75791..1de65f82f4d 100644 --- a/code-rs/app-server-protocol/schema/json/v1/InitializeResponse.json +++ b/code-rs/app-server-protocol/schema/json/v1/InitializeResponse.json @@ -1,13 +1,38 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, "properties": { + "codexHome": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to the server's $CODEX_HOME directory." + }, + "platformFamily": { + "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", + "type": "string" + }, + "platformOs": { + "description": "Operating system for the running app-server target, for example `\"macos\"`, `\"linux\"`, or `\"windows\"`.", + "type": "string" + }, "userAgent": { "type": "string" } }, "required": [ + "codexHome", + "platformFamily", + "platformOs", "userAgent" ], "title": "InitializeResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v1/InterruptConversationParams.json b/code-rs/app-server-protocol/schema/json/v1/InterruptConversationParams.json deleted file mode 100644 index 28bdedca794..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/InterruptConversationParams.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "conversationId" - ], - "title": "InterruptConversationParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/InterruptConversationResponse.json b/code-rs/app-server-protocol/schema/json/v1/InterruptConversationResponse.json deleted file mode 100644 index cc698bbe63c..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/InterruptConversationResponse.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - } - }, - "properties": { - "abortReason": { - "$ref": "#/definitions/TurnAbortReason" - } - }, - "required": [ - "abortReason" - ], - "title": "InterruptConversationResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ListConversationsParams.json b/code-rs/app-server-protocol/schema/json/v1/ListConversationsParams.json deleted file mode 100644 index aec41ab59a5..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ListConversationsParams.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "modelProviders": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "pageSize": { - "format": "uint", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "title": "ListConversationsParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json b/code-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json deleted file mode 100644 index 3b760817e17..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ConversationGitInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "origin_url": { - "type": [ - "string", - "null" - ] - }, - "sha": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ConversationSummary": { - "properties": { - "cliVersion": { - "type": "string" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "gitInfo": { - "anyOf": [ - { - "$ref": "#/definitions/ConversationGitInfo" - }, - { - "type": "null" - } - ] - }, - "modelProvider": { - "type": "string" - }, - "path": { - "type": "string" - }, - "preview": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/SessionSource" - }, - "timestamp": { - "type": [ - "string", - "null" - ] - }, - "updatedAt": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "cliVersion", - "conversationId", - "cwd", - "modelProvider", - "path", - "preview", - "source" - ], - "type": "object" - }, - "SessionSource": { - "oneOf": [ - { - "enum": [ - "cli", - "vscode", - "exec", - "mcp", - "unknown" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "subagent": { - "$ref": "#/definitions/SubAgentSource" - } - }, - "required": [ - "subagent" - ], - "title": "SubagentSessionSource", - "type": "object" - } - ] - }, - "SubAgentSource": { - "oneOf": [ - { - "enum": [ - "review", - "compact", - "memory_consolidation" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "thread_spawn": { - "properties": { - "depth": { - "format": "int32", - "type": "integer" - }, - "parent_thread_id": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "depth", - "parent_thread_id" - ], - "type": "object" - } - }, - "required": [ - "thread_spawn" - ], - "title": "ThreadSpawnSubAgentSource", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "other": { - "type": "string" - } - }, - "required": [ - "other" - ], - "title": "OtherSubAgentSource", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "items": { - "items": { - "$ref": "#/definitions/ConversationSummary" - }, - "type": "array" - }, - "nextCursor": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "items" - ], - "title": "ListConversationsResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/LoginApiKeyParams.json b/code-rs/app-server-protocol/schema/json/v1/LoginApiKeyParams.json deleted file mode 100644 index f862e45ddc0..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/LoginApiKeyParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "apiKey": { - "type": "string" - } - }, - "required": [ - "apiKey" - ], - "title": "LoginApiKeyParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/LoginApiKeyResponse.json b/code-rs/app-server-protocol/schema/json/v1/LoginApiKeyResponse.json deleted file mode 100644 index 151f3925bb9..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/LoginApiKeyResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LoginApiKeyResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/LoginChatGptCompleteNotification.json b/code-rs/app-server-protocol/schema/json/v1/LoginChatGptCompleteNotification.json deleted file mode 100644 index 106f5c95924..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/LoginChatGptCompleteNotification.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Deprecated in favor of AccountLoginCompletedNotification.", - "properties": { - "error": { - "type": [ - "string", - "null" - ] - }, - "loginId": { - "type": "string" - }, - "success": { - "type": "boolean" - } - }, - "required": [ - "loginId", - "success" - ], - "title": "LoginChatGptCompleteNotification", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/LoginChatGptResponse.json b/code-rs/app-server-protocol/schema/json/v1/LoginChatGptResponse.json deleted file mode 100644 index 6260b8a3ecf..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/LoginChatGptResponse.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "authUrl": { - "type": "string" - }, - "loginId": { - "type": "string" - } - }, - "required": [ - "authUrl", - "loginId" - ], - "title": "LoginChatGptResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/LogoutChatGptResponse.json b/code-rs/app-server-protocol/schema/json/v1/LogoutChatGptResponse.json deleted file mode 100644 index 6935b81f222..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/LogoutChatGptResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "LogoutChatGptResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/NewConversationParams.json b/code-rs/app-server-protocol/schema/json/v1/NewConversationParams.json deleted file mode 100644 index 6fcc45f3e08..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/NewConversationParams.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - } - }, - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - } - }, - "title": "NewConversationParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/NewConversationResponse.json b/code-rs/app-server-protocol/schema/json/v1/NewConversationResponse.json deleted file mode 100644 index 5e7c4f1ea37..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/NewConversationResponse.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ThreadId": { - "type": "string" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "NewConversationResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/RemoveConversationListenerParams.json b/code-rs/app-server-protocol/schema/json/v1/RemoveConversationListenerParams.json deleted file mode 100644 index 13ea84714f7..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/RemoveConversationListenerParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "subscriptionId": { - "type": "string" - } - }, - "required": [ - "subscriptionId" - ], - "title": "RemoveConversationListenerParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/RemoveConversationSubscriptionResponse.json b/code-rs/app-server-protocol/schema/json/v1/RemoveConversationSubscriptionResponse.json deleted file mode 100644 index a136c5d6e2b..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/RemoveConversationSubscriptionResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RemoveConversationSubscriptionResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json b/code-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json deleted file mode 100644 index e99c77e6360..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json +++ /dev/null @@ -1,1143 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, - "ImageDetail": { - "enum": [ - "auto", - "low", - "high", - "original" - ], - "type": "string" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "NewConversationParams": { - "properties": { - "approvalPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "baseInstructions": { - "type": [ - "string", - "null" - ] - }, - "compactPrompt": { - "type": [ - "string", - "null" - ] - }, - "config": { - "additionalProperties": true, - "type": [ - "object", - "null" - ] - }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "developerInstructions": { - "type": [ - "string", - "null" - ] - }, - "includeApplyPatchTool": { - "type": [ - "boolean", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "modelProvider": { - "type": [ - "string", - "null" - ] - }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "sandbox": { - "anyOf": [ - { - "$ref": "#/definitions/SandboxMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": true, - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "tool_search_call" - ], - "title": "ToolSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "execution", - "type" - ], - "title": "ToolSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tools": { - "items": true, - "type": "array" - }, - "type": { - "enum": [ - "tool_search_output" - ], - "title": "ToolSearchOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "execution", - "status", - "tools", - "type" - ], - "title": "ToolSearchOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_call" - ], - "title": "ImageGenerationCallResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction_summary" - ], - "title": "CompactionSummaryResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionSummaryResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "ThreadId": { - "type": "string" - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "conversationId": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history": { - "items": { - "$ref": "#/definitions/ResponseItem" - }, - "type": [ - "array", - "null" - ] - }, - "overrides": { - "anyOf": [ - { - "$ref": "#/definitions/NewConversationParams" - }, - { - "type": "null" - } - ] - }, - "path": { - "type": [ - "string", - "null" - ] - } - }, - "title": "ResumeConversationParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/code-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json deleted file mode 100644 index b7c5e43a9ad..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ /dev/null @@ -1,5842 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "AutoContextPhase": { - "enum": [ - "checking", - "compacting" - ], - "type": "string" - }, - "AutomationOrigin": { - "properties": { - "actor": { - "description": "Actor reported by the source system as applying the trigger.", - "type": [ - "string", - "null" - ] - }, - "event_id": { - "description": "GitHub event delivery id, webhook id, or local request id.", - "type": [ - "string", - "null" - ] - }, - "issue_number": { - "description": "GitHub issue or pull request number associated with the trigger.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "kind": { - "$ref": "#/definitions/AutomationTriggerKind" - }, - "label": { - "description": "Label that triggered automation, such as `every-code`.", - "type": [ - "string", - "null" - ] - }, - "repository": { - "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", - "type": [ - "string", - "null" - ] - }, - "source": { - "description": "Tool, worker, or integration that launched this automated session.", - "type": [ - "string", - "null" - ] - }, - "url": { - "description": "Direct URL to the triggering issue, PR, event, or worker record.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - "AutomationTriggerKind": { - "enum": [ - "github_label", - "other" - ], - "type": "string" - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "cyber_policy", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "model_cap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "model_cap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Auto Context is evaluating whether to compact before the next turn.", - "properties": { - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/AutoContextPhase" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "auto_context_check" - ], - "title": "AutoContextCheckEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AutoContextCheckEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "automation_origin": { - "anyOf": [ - { - "$ref": "#/definitions/AutomationOrigin" - }, - { - "type": "null" - } - ], - "description": "Structured metadata for automated sessions, if the launcher provided it." - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_begin" - ], - "title": "ImageGenerationBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "ImageGenerationBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_end" - ], - "title": "ImageGenerationEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "result", - "status", - "type" - ], - "title": "ImageGenerationEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "default": {}, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "default": {}, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "default": {}, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "server_failures": { - "additionalProperties": { - "$ref": "#/definitions/McpServerFailure" - }, - "description": "Legacy server failure map keyed by server name.", - "type": [ - "object", - "null" - ] - }, - "server_tools": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "Legacy server -> tool names map used by existing UI surfaces.", - "type": [ - "object", - "null" - ] - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Optional note used by newer clients; when provided it supersedes `name`.", - "type": [ - "string", - "null" - ] - }, - "name": { - "default": null, - "description": "Legacy field name used by existing clients.", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "prompt": { - "default": "", - "description": "Legacy plain-text prompt retained for compatibility with older review flows.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "snapshot": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewSnapshotInfo" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "ImageDetail": { - "enum": [ - "auto", - "low", - "high", - "original" - ], - "type": "string" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpServerFailure": { - "properties": { - "message": { - "type": "string" - }, - "phase": { - "$ref": "#/definitions/McpServerFailurePhase" - } - }, - "required": [ - "message", - "phase" - ], - "type": "object" - }, - "McpServerFailurePhase": { - "enum": [ - "start", - "list_tools" - ], - "type": "string" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "read_command" - ], - "title": "ReadCommandParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ReadCommandParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitReachedType": { - "enum": [ - "rate_limit_reached", - "workspace_owner_credits_depleted", - "workspace_member_credits_depleted", - "workspace_owner_usage_limit_reached", - "workspace_member_usage_limit_reached" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "rate_limit_reached_type": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitReachedType" - }, - { - "type": "null" - } - ], - "default": null - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "resets_in_seconds": { - "description": "Legacy relative reset in seconds.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": true, - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "tool_search_call" - ], - "title": "ToolSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "execution", - "type" - ], - "title": "ToolSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tools": { - "items": true, - "type": "array" - }, - "type": { - "enum": [ - "tool_search_output" - ], - "title": "ToolSearchOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "execution", - "status", - "tools", - "type" - ], - "title": "ToolSearchOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_call" - ], - "title": "ImageGenerationCallResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction_summary" - ], - "title": "CompactionSummaryResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionSummaryResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewSnapshotInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "repo_root": { - "type": [ - "string", - "null" - ] - }, - "snapshot_commit": { - "type": [ - "string", - "null" - ] - }, - "worktree_path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "allow_implicit_invocation": { - "default": true, - "type": "boolean" - }, - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "cached_input_tokens_reported": { - "default": false, - "type": "boolean" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "latest_response_model": { - "type": [ - "string", - "null" - ] - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "requested_model": { - "type": [ - "string", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "ImageGeneration" - ], - "title": "ImageGenerationTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "rolloutPath": { - "type": "string" - } - }, - "required": [ - "conversationId", - "model", - "rolloutPath" - ], - "title": "ResumeConversationResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/SendUserMessageParams.json b/code-rs/app-server-protocol/schema/json/v1/SendUserMessageParams.json deleted file mode 100644 index 1d3b7dbda78..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/SendUserMessageParams.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "InputItem": { - "oneOf": [ - { - "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "TextInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" - ], - "type": "object" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "ImageInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "LocalImageInputItem", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - }, - "V1ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "V1TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byteRange" - ], - "type": "object" - } - }, - "properties": { - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - } - }, - "required": [ - "conversationId", - "items" - ], - "title": "SendUserMessageParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/SendUserMessageResponse.json b/code-rs/app-server-protocol/schema/json/v1/SendUserMessageResponse.json deleted file mode 100644 index 69011702db0..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/SendUserMessageResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserMessageResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json b/code-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json deleted file mode 100644 index d09fd72e1c0..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json +++ /dev/null @@ -1,430 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "InputItem": { - "oneOf": [ - { - "properties": { - "data": { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/V1TextElement" - }, - "type": "array" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "TextInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "image_url": { - "type": "string" - } - }, - "required": [ - "image_url" - ], - "type": "object" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "ImageInputItem", - "type": "object" - }, - { - "properties": { - "data": { - "properties": { - "path": { - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "type": { - "enum": [ - "localImage" - ], - "title": "LocalImageInputItemType", - "type": "string" - } - }, - "required": [ - "data", - "type" - ], - "title": "LocalImageInputItem", - "type": "object" - } - ] - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningSummary": { - "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", - "oneOf": [ - { - "enum": [ - "auto", - "concise", - "detailed" - ], - "type": "string" - }, - { - "description": "Option to disable reasoning summaries.", - "enum": [ - "none" - ], - "type": "string" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "ThreadId": { - "type": "string" - }, - "V1ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "V1TextElement": { - "properties": { - "byteRange": { - "allOf": [ - { - "$ref": "#/definitions/V1ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byteRange" - ], - "type": "object" - } - }, - "properties": { - "approvalPolicy": { - "$ref": "#/definitions/AskForApproval" - }, - "conversationId": { - "$ref": "#/definitions/ThreadId" - }, - "cwd": { - "type": "string" - }, - "effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "items": { - "items": { - "$ref": "#/definitions/InputItem" - }, - "type": "array" - }, - "model": { - "type": "string" - }, - "outputSchema": { - "description": "Optional JSON Schema used to constrain the final assistant message for this turn." - }, - "sandboxPolicy": { - "$ref": "#/definitions/SandboxPolicy" - }, - "summary": { - "$ref": "#/definitions/ReasoningSummary" - } - }, - "required": [ - "approvalPolicy", - "conversationId", - "cwd", - "items", - "model", - "sandboxPolicy", - "summary" - ], - "title": "SendUserTurnParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/SendUserTurnResponse.json b/code-rs/app-server-protocol/schema/json/v1/SendUserTurnResponse.json deleted file mode 100644 index 528d05fcc87..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/SendUserTurnResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SendUserTurnResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/code-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json deleted file mode 100644 index f2a6f9dcec8..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ /dev/null @@ -1,5864 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, - "AgentMessageContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Text" - ], - "title": "TextAgentMessageContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextAgentMessageContent", - "type": "object" - } - ] - }, - "AgentStatus": { - "description": "Agent lifecycle status, derived from emitted events.", - "oneOf": [ - { - "description": "Agent is waiting for initialization.", - "enum": [ - "pending_init" - ], - "type": "string" - }, - { - "description": "Agent is currently running.", - "enum": [ - "running" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Agent is done. Contains the final assistant message.", - "properties": { - "completed": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "completed" - ], - "title": "CompletedAgentStatus", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Agent encountered an error.", - "properties": { - "errored": { - "type": "string" - } - }, - "required": [ - "errored" - ], - "title": "ErroredAgentStatus", - "type": "object" - }, - { - "description": "Agent has been shutdown.", - "enum": [ - "shutdown" - ], - "type": "string" - }, - { - "description": "Agent is not found.", - "enum": [ - "not_found" - ], - "type": "string" - } - ] - }, - "AskForApproval": { - "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", - "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, - { - "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", - "enum": [ - "on-failure" - ], - "type": "string" - }, - { - "description": "The model decides when to ask the user for approval.", - "enum": [ - "on-request" - ], - "type": "string" - }, - { - "additionalProperties": false, - "description": "Fine-grained rejection controls for approval prompts.\n\nWhen a field is `true`, prompts of that category are automatically rejected instead of shown to the user.", - "properties": { - "reject": { - "$ref": "#/definitions/RejectConfig" - } - }, - "required": [ - "reject" - ], - "title": "RejectAskForApproval", - "type": "object" - }, - { - "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "enum": [ - "never" - ], - "type": "string" - } - ] - }, - "AutoContextPhase": { - "enum": [ - "checking", - "compacting" - ], - "type": "string" - }, - "AutomationOrigin": { - "properties": { - "actor": { - "description": "Actor reported by the source system as applying the trigger.", - "type": [ - "string", - "null" - ] - }, - "event_id": { - "description": "GitHub event delivery id, webhook id, or local request id.", - "type": [ - "string", - "null" - ] - }, - "issue_number": { - "description": "GitHub issue or pull request number associated with the trigger.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "kind": { - "$ref": "#/definitions/AutomationTriggerKind" - }, - "label": { - "description": "Label that triggered automation, such as `every-code`.", - "type": [ - "string", - "null" - ] - }, - "repository": { - "description": "Repository name in `owner/repo` form, when the trigger came from GitHub.", - "type": [ - "string", - "null" - ] - }, - "source": { - "description": "Tool, worker, or integration that launched this automated session.", - "type": [ - "string", - "null" - ] - }, - "url": { - "description": "Direct URL to the triggering issue, PR, event, or worker record.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "type": "object" - }, - "AutomationTriggerKind": { - "enum": [ - "github_label", - "other" - ], - "type": "string" - }, - "ByteRange": { - "properties": { - "end": { - "description": "End byte offset (exclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "description": "Start byte offset (inclusive) within the UTF-8 text buffer.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "CallToolResult": { - "description": "The server's response to a tool call.", - "properties": { - "_meta": true, - "content": { - "items": true, - "type": "array" - }, - "isError": { - "type": [ - "boolean", - "null" - ] - }, - "structuredContent": true - }, - "required": [ - "content" - ], - "type": "object" - }, - "CodexErrorInfo": { - "description": "Codex errors that we expose to clients.", - "oneOf": [ - { - "enum": [ - "context_window_exceeded", - "usage_limit_exceeded", - "cyber_policy", - "internal_server_error", - "unauthorized", - "bad_request", - "sandbox_error", - "thread_rollback_failed", - "other" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "model_cap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "model_cap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "http_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "http_connection_failed" - ], - "title": "HttpConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Failed to connect to the response SSE stream.", - "properties": { - "response_stream_connection_failed": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_connection_failed" - ], - "title": "ResponseStreamConnectionFailedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "The response SSE stream disconnected in the middle of a turnbefore completion.", - "properties": { - "response_stream_disconnected": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_stream_disconnected" - ], - "title": "ResponseStreamDisconnectedCodexErrorInfo", - "type": "object" - }, - { - "additionalProperties": false, - "description": "Reached the retry limit for responses.", - "properties": { - "response_too_many_failed_attempts": { - "properties": { - "http_status_code": { - "format": "uint16", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "type": "object" - } - }, - "required": [ - "response_too_many_failed_attempts" - ], - "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", - "type": "object" - } - ] - }, - "ContentItem": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextContentItem", - "type": "object" - }, - { - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageContentItem", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "output_text" - ], - "title": "OutputTextContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "OutputTextContentItem", - "type": "object" - } - ] - }, - "CreditsSnapshot": { - "properties": { - "balance": { - "type": [ - "string", - "null" - ] - }, - "has_credits": { - "type": "boolean" - }, - "unlimited": { - "type": "boolean" - } - }, - "required": [ - "has_credits", - "unlimited" - ], - "type": "object" - }, - "CustomPrompt": { - "properties": { - "argument_hint": { - "type": [ - "string", - "null" - ] - }, - "content": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "content", - "name", - "path" - ], - "type": "object" - }, - "Duration": { - "properties": { - "nanos": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "secs": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "nanos", - "secs" - ], - "type": "object" - }, - "EventMsg": { - "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", - "oneOf": [ - { - "description": "Error while executing a submission", - "properties": { - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "error" - ], - "title": "ErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "ErrorEventMsg", - "type": "object" - }, - { - "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "warning" - ], - "title": "WarningEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "WarningEventMsg", - "type": "object" - }, - { - "description": "Conversation history was compacted (either automatically or manually).", - "properties": { - "type": { - "enum": [ - "context_compacted" - ], - "title": "ContextCompactedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactedEventMsg", - "type": "object" - }, - { - "description": "Conversation history was rolled back by dropping the last N user turns.", - "properties": { - "num_turns": { - "description": "Number of user turns that were removed from context.", - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "thread_rolled_back" - ], - "title": "ThreadRolledBackEventMsgType", - "type": "string" - } - }, - "required": [ - "num_turns", - "type" - ], - "title": "ThreadRolledBackEventMsg", - "type": "object" - }, - { - "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", - "properties": { - "collaboration_mode_kind": { - "allOf": [ - { - "$ref": "#/definitions/ModeKind" - } - ], - "default": "default" - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "task_started" - ], - "title": "TaskStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskStartedEventMsg", - "type": "object" - }, - { - "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", - "properties": { - "last_agent_message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "task_complete" - ], - "title": "TaskCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TaskCompleteEventMsg", - "type": "object" - }, - { - "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", - "properties": { - "info": { - "anyOf": [ - { - "$ref": "#/definitions/TokenUsageInfo" - }, - { - "type": "null" - } - ] - }, - "rate_limits": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitSnapshot" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "token_count" - ], - "title": "TokenCountEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "TokenCountEventMsg", - "type": "object" - }, - { - "description": "Auto Context is evaluating whether to compact before the next turn.", - "properties": { - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/AutoContextPhase" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "auto_context_check" - ], - "title": "AutoContextCheckEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AutoContextCheckEventMsg", - "type": "object" - }, - { - "description": "Agent text output message", - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message" - ], - "title": "AgentMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "AgentMessageEventMsg", - "type": "object" - }, - { - "description": "User/system input message (what was sent to the model)", - "properties": { - "images": { - "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "local_images": { - "default": [], - "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `message` used to render or persist special elements.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "user_message" - ], - "title": "UserMessageEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "UserMessageEventMsg", - "type": "object" - }, - { - "description": "Agent text output delta message", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_delta" - ], - "title": "AgentMessageDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentMessageDeltaEventMsg", - "type": "object" - }, - { - "description": "Reasoning event from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning" - ], - "title": "AgentReasoningEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_delta" - ], - "title": "AgentReasoningDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningDeltaEventMsg", - "type": "object" - }, - { - "description": "Raw chain-of-thought from agent.", - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content" - ], - "title": "AgentReasoningRawContentEventMsgType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "AgentReasoningRawContentEventMsg", - "type": "object" - }, - { - "description": "Agent reasoning content delta event from agent.", - "properties": { - "delta": { - "type": "string" - }, - "type": { - "enum": [ - "agent_reasoning_raw_content_delta" - ], - "title": "AgentReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "type" - ], - "title": "AgentReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", - "properties": { - "item_id": { - "default": "", - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "type": { - "enum": [ - "agent_reasoning_section_break" - ], - "title": "AgentReasoningSectionBreakEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "AgentReasoningSectionBreakEventMsg", - "type": "object" - }, - { - "description": "Ack the client's configure message.", - "properties": { - "approval_policy": { - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ], - "description": "When to escalate for approval for execution" - }, - "automation_origin": { - "anyOf": [ - { - "$ref": "#/definitions/AutomationOrigin" - }, - { - "type": "null" - } - ], - "description": "Structured metadata for automated sessions, if the launcher provided it." - }, - "cwd": { - "description": "Working directory that should be treated as the *root* of the session.", - "type": "string" - }, - "forked_from_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ] - }, - "history_entry_count": { - "description": "Current number of entries in the history log.", - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "history_log_id": { - "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initial_messages": { - "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "description": "Tell the client what model is being queried.", - "type": "string" - }, - "model_provider_id": { - "type": "string" - }, - "reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ], - "description": "The effort the model is putting into reasoning about the user's request." - }, - "rollout_path": { - "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", - "type": [ - "string", - "null" - ] - }, - "sandbox_policy": { - "allOf": [ - { - "$ref": "#/definitions/SandboxPolicy" - } - ], - "description": "How to sandbox commands executed in the system" - }, - "session_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "description": "Optional user-facing thread name (may be unset).", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "session_configured" - ], - "title": "SessionConfiguredEventMsgType", - "type": "string" - } - }, - "required": [ - "approval_policy", - "cwd", - "history_entry_count", - "history_log_id", - "model", - "model_provider_id", - "sandbox_policy", - "session_id", - "type" - ], - "title": "SessionConfiguredEventMsg", - "type": "object" - }, - { - "description": "Updated session metadata (e.g., thread name changes).", - "properties": { - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "thread_name": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "thread_name_updated" - ], - "title": "ThreadNameUpdatedEventMsgType", - "type": "string" - } - }, - "required": [ - "thread_id", - "type" - ], - "title": "ThreadNameUpdatedEventMsg", - "type": "object" - }, - { - "description": "Incremental MCP startup progress updates.", - "properties": { - "server": { - "description": "Server name being started.", - "type": "string" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/McpStartupStatus" - } - ], - "description": "Current startup status." - }, - "type": { - "enum": [ - "mcp_startup_update" - ], - "title": "McpStartupUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "server", - "status", - "type" - ], - "title": "McpStartupUpdateEventMsg", - "type": "object" - }, - { - "description": "Aggregate MCP startup completion summary.", - "properties": { - "cancelled": { - "items": { - "type": "string" - }, - "type": "array" - }, - "failed": { - "items": { - "$ref": "#/definitions/McpStartupFailure" - }, - "type": "array" - }, - "ready": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "mcp_startup_complete" - ], - "title": "McpStartupCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "cancelled", - "failed", - "ready", - "type" - ], - "title": "McpStartupCompleteEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the McpToolCallEnd event.", - "type": "string" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "type": { - "enum": [ - "mcp_tool_call_begin" - ], - "title": "McpToolCallBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "invocation", - "type" - ], - "title": "McpToolCallBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Identifier for the corresponding McpToolCallBegin that finished.", - "type": "string" - }, - "duration": { - "$ref": "#/definitions/Duration" - }, - "invocation": { - "$ref": "#/definitions/McpInvocation" - }, - "result": { - "allOf": [ - { - "$ref": "#/definitions/Result_of_CallToolResult_or_String" - } - ], - "description": "Result of the tool call. Note this could be an error." - }, - "type": { - "enum": [ - "mcp_tool_call_end" - ], - "title": "McpToolCallEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "duration", - "invocation", - "result", - "type" - ], - "title": "McpToolCallEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_begin" - ], - "title": "WebSearchBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "WebSearchBeginEventMsg", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "call_id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "web_search_end" - ], - "title": "WebSearchEndEventMsgType", - "type": "string" - } - }, - "required": [ - "action", - "call_id", - "query", - "type" - ], - "title": "WebSearchEndEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_begin" - ], - "title": "ImageGenerationBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "type" - ], - "title": "ImageGenerationBeginEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_end" - ], - "title": "ImageGenerationEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "result", - "status", - "type" - ], - "title": "ImageGenerationEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the server is about to execute a command.", - "properties": { - "call_id": { - "description": "Identifier so this can be paired with the ExecCommandEnd event.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_begin" - ], - "title": "ExecCommandBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "turn_id", - "type" - ], - "title": "ExecCommandBeginEventMsg", - "type": "object" - }, - { - "description": "Incremental chunk of output from a running command.", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "chunk": { - "description": "Raw bytes from the stream (may not be valid UTF-8).", - "type": "string" - }, - "stream": { - "allOf": [ - { - "$ref": "#/definitions/ExecOutputStream" - } - ], - "description": "Which stream produced this chunk." - }, - "type": { - "enum": [ - "exec_command_output_delta" - ], - "title": "ExecCommandOutputDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "chunk", - "stream", - "type" - ], - "title": "ExecCommandOutputDeltaEventMsg", - "type": "object" - }, - { - "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", - "properties": { - "call_id": { - "description": "Identifier for the ExecCommandBegin that produced this chunk.", - "type": "string" - }, - "process_id": { - "description": "Process id associated with the running command.", - "type": "string" - }, - "stdin": { - "description": "Stdin sent to the running session.", - "type": "string" - }, - "type": { - "enum": [ - "terminal_interaction" - ], - "title": "TerminalInteractionEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "process_id", - "stdin", - "type" - ], - "title": "TerminalInteractionEventMsg", - "type": "object" - }, - { - "properties": { - "aggregated_output": { - "default": "", - "description": "Captured aggregated output", - "type": "string" - }, - "call_id": { - "description": "Identifier for the ExecCommandBegin that finished.", - "type": "string" - }, - "command": { - "description": "The command that was executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory if not the default cwd for the agent.", - "type": "string" - }, - "duration": { - "allOf": [ - { - "$ref": "#/definitions/Duration" - } - ], - "description": "The duration of the command execution." - }, - "exit_code": { - "description": "The command's exit code.", - "format": "int32", - "type": "integer" - }, - "formatted_output": { - "description": "Formatted output from the command, as seen by the model.", - "type": "string" - }, - "interaction_input": { - "description": "Raw input sent to a unified exec session (if this is an interaction event).", - "type": [ - "string", - "null" - ] - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "process_id": { - "description": "Identifier for the underlying PTY process (when available).", - "type": [ - "string", - "null" - ] - }, - "source": { - "allOf": [ - { - "$ref": "#/definitions/ExecCommandSource" - } - ], - "default": "agent", - "description": "Where the command originated. Defaults to Agent for backward compatibility." - }, - "stderr": { - "description": "Captured stderr", - "type": "string" - }, - "stdout": { - "description": "Captured stdout", - "type": "string" - }, - "turn_id": { - "description": "Turn ID that this command belongs to.", - "type": "string" - }, - "type": { - "enum": [ - "exec_command_end" - ], - "title": "ExecCommandEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "duration", - "exit_code", - "formatted_output", - "parsed_cmd", - "stderr", - "stdout", - "turn_id", - "type" - ], - "title": "ExecCommandEndEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent attached a local image via the view_image tool.", - "properties": { - "call_id": { - "description": "Identifier for the originating tool call.", - "type": "string" - }, - "path": { - "description": "Local filesystem path provided to the tool.", - "type": "string" - }, - "type": { - "enum": [ - "view_image_tool_call" - ], - "title": "ViewImageToolCallEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "path", - "type" - ], - "title": "ViewImageToolCallEventMsg", - "type": "object" - }, - { - "properties": { - "approval_id": { - "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", - "type": [ - "string", - "null" - ] - }, - "call_id": { - "description": "Identifier for the associated command execution item.", - "type": "string" - }, - "command": { - "description": "The command to be executed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "cwd": { - "description": "The command's working directory.", - "type": "string" - }, - "network_approval_context": { - "anyOf": [ - { - "$ref": "#/definitions/NetworkApprovalContext" - }, - { - "type": "null" - } - ], - "description": "Optional network context for a blocked request that can be approved." - }, - "parsed_cmd": { - "items": { - "$ref": "#/definitions/ParsedCommand" - }, - "type": "array" - }, - "proposed_execpolicy_amendment": { - "description": "Proposed execpolicy amendment that can be applied to allow future runs.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "reason": { - "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "exec_approval_request" - ], - "title": "ExecApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "command", - "cwd", - "parsed_cmd", - "type" - ], - "title": "ExecApprovalRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated tool call, if available.", - "type": "string" - }, - "questions": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestion" - }, - "type": "array" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "request_user_input" - ], - "title": "RequestUserInputEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "questions", - "type" - ], - "title": "RequestUserInputEventMsg", - "type": "object" - }, - { - "properties": { - "arguments": true, - "callId": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "tool": { - "type": "string" - }, - "turnId": { - "type": "string" - }, - "type": { - "enum": [ - "dynamic_tool_call_request" - ], - "title": "DynamicToolCallRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "arguments", - "callId", - "tool", - "turnId", - "type" - ], - "title": "DynamicToolCallRequestEventMsg", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "message": { - "type": "string" - }, - "server_name": { - "type": "string" - }, - "type": { - "enum": [ - "elicitation_request" - ], - "title": "ElicitationRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "message", - "server_name", - "type" - ], - "title": "ElicitationRequestEventMsg", - "type": "object" - }, - { - "properties": { - "call_id": { - "description": "Responses API call id for the associated patch apply call, if available.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "type": "object" - }, - "grant_root": { - "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", - "type": [ - "string", - "null" - ] - }, - "reason": { - "description": "Optional explanatory reason (e.g. request for extra write access).", - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", - "type": "string" - }, - "type": { - "enum": [ - "apply_patch_approval_request" - ], - "title": "ApplyPatchApprovalRequestEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "changes", - "type" - ], - "title": "ApplyPatchApprovalRequestEventMsg", - "type": "object" - }, - { - "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", - "properties": { - "details": { - "description": "Optional extra guidance, such as migration steps or rationale.", - "type": [ - "string", - "null" - ] - }, - "summary": { - "description": "Concise summary of what is deprecated.", - "type": "string" - }, - "type": { - "enum": [ - "deprecation_notice" - ], - "title": "DeprecationNoticeEventMsgType", - "type": "string" - } - }, - "required": [ - "summary", - "type" - ], - "title": "DeprecationNoticeEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "type": { - "enum": [ - "background_event" - ], - "title": "BackgroundEventEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "BackgroundEventEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "undo_started" - ], - "title": "UndoStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UndoStartedEventMsg", - "type": "object" - }, - { - "properties": { - "message": { - "type": [ - "string", - "null" - ] - }, - "success": { - "type": "boolean" - }, - "type": { - "enum": [ - "undo_completed" - ], - "title": "UndoCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "success", - "type" - ], - "title": "UndoCompletedEventMsg", - "type": "object" - }, - { - "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", - "properties": { - "additional_details": { - "default": null, - "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", - "type": [ - "string", - "null" - ] - }, - "codex_error_info": { - "anyOf": [ - { - "$ref": "#/definitions/CodexErrorInfo" - }, - { - "type": "null" - } - ], - "default": null - }, - "message": { - "type": "string" - }, - "type": { - "enum": [ - "stream_error" - ], - "title": "StreamErrorEventMsgType", - "type": "string" - } - }, - "required": [ - "message", - "type" - ], - "title": "StreamErrorEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", - "properties": { - "auto_approved": { - "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", - "type": "boolean" - }, - "call_id": { - "description": "Identifier so this can be paired with the PatchApplyEnd event.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "description": "The changes to be applied.", - "type": "object" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_begin" - ], - "title": "PatchApplyBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "auto_approved", - "call_id", - "changes", - "type" - ], - "title": "PatchApplyBeginEventMsg", - "type": "object" - }, - { - "description": "Notification that a patch application has finished.", - "properties": { - "call_id": { - "description": "Identifier for the PatchApplyBegin that finished.", - "type": "string" - }, - "changes": { - "additionalProperties": { - "$ref": "#/definitions/FileChange" - }, - "default": {}, - "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", - "type": "object" - }, - "stderr": { - "description": "Captured stderr (parser errors, IO failures, etc.).", - "type": "string" - }, - "stdout": { - "description": "Captured stdout (summary printed by apply_patch).", - "type": "string" - }, - "success": { - "description": "Whether the patch was applied successfully.", - "type": "boolean" - }, - "turn_id": { - "default": "", - "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", - "type": "string" - }, - "type": { - "enum": [ - "patch_apply_end" - ], - "title": "PatchApplyEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "stderr", - "stdout", - "success", - "type" - ], - "title": "PatchApplyEndEventMsg", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "turn_diff" - ], - "title": "TurnDiffEventMsgType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "TurnDiffEventMsg", - "type": "object" - }, - { - "description": "Response to GetHistoryEntryRequest.", - "properties": { - "entry": { - "anyOf": [ - { - "$ref": "#/definitions/HistoryEntry" - }, - { - "type": "null" - } - ], - "description": "The entry at the requested offset, if available and parseable." - }, - "log_id": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "offset": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "type": { - "enum": [ - "get_history_entry_response" - ], - "title": "GetHistoryEntryResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "log_id", - "offset", - "type" - ], - "title": "GetHistoryEntryResponseEventMsg", - "type": "object" - }, - { - "description": "List of MCP tools available to the agent.", - "properties": { - "auth_statuses": { - "additionalProperties": { - "$ref": "#/definitions/McpAuthStatus" - }, - "default": {}, - "description": "Authentication status for each configured MCP server.", - "type": "object" - }, - "resource_templates": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/ResourceTemplate" - }, - "type": "array" - }, - "default": {}, - "description": "Known resource templates grouped by server name.", - "type": "object" - }, - "resources": { - "additionalProperties": { - "items": { - "$ref": "#/definitions/Resource" - }, - "type": "array" - }, - "default": {}, - "description": "Known resources grouped by server name.", - "type": "object" - }, - "server_failures": { - "additionalProperties": { - "$ref": "#/definitions/McpServerFailure" - }, - "description": "Legacy server failure map keyed by server name.", - "type": [ - "object", - "null" - ] - }, - "server_tools": { - "additionalProperties": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": "Legacy server -> tool names map used by existing UI surfaces.", - "type": [ - "object", - "null" - ] - }, - "tools": { - "additionalProperties": { - "$ref": "#/definitions/Tool" - }, - "description": "Fully qualified tool name -> tool definition.", - "type": "object" - }, - "type": { - "enum": [ - "mcp_list_tools_response" - ], - "title": "McpListToolsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "tools", - "type" - ], - "title": "McpListToolsResponseEventMsg", - "type": "object" - }, - { - "description": "List of custom prompts available to the agent.", - "properties": { - "custom_prompts": { - "items": { - "$ref": "#/definitions/CustomPrompt" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_custom_prompts_response" - ], - "title": "ListCustomPromptsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "custom_prompts", - "type" - ], - "title": "ListCustomPromptsResponseEventMsg", - "type": "object" - }, - { - "description": "List of skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/SkillsListEntry" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_skills_response" - ], - "title": "ListSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "List of remote skills available to the agent.", - "properties": { - "skills": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "list_remote_skills_response" - ], - "title": "ListRemoteSkillsResponseEventMsgType", - "type": "string" - } - }, - "required": [ - "skills", - "type" - ], - "title": "ListRemoteSkillsResponseEventMsg", - "type": "object" - }, - { - "description": "Remote skill downloaded to local cache.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "remote_skill_downloaded" - ], - "title": "RemoteSkillDownloadedEventMsgType", - "type": "string" - } - }, - "required": [ - "id", - "name", - "path", - "type" - ], - "title": "RemoteSkillDownloadedEventMsg", - "type": "object" - }, - { - "description": "Notification that skill data may have been updated and clients may want to reload.", - "properties": { - "type": { - "enum": [ - "skills_update_available" - ], - "title": "SkillsUpdateAvailableEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SkillsUpdateAvailableEventMsg", - "type": "object" - }, - { - "properties": { - "explanation": { - "default": null, - "description": "Optional note used by newer clients; when provided it supersedes `name`.", - "type": [ - "string", - "null" - ] - }, - "name": { - "default": null, - "description": "Legacy field name used by existing clients.", - "type": [ - "string", - "null" - ] - }, - "plan": { - "items": { - "$ref": "#/definitions/PlanItemArg" - }, - "type": "array" - }, - "type": { - "enum": [ - "plan_update" - ], - "title": "PlanUpdateEventMsgType", - "type": "string" - } - }, - "required": [ - "plan", - "type" - ], - "title": "PlanUpdateEventMsg", - "type": "object" - }, - { - "properties": { - "reason": { - "$ref": "#/definitions/TurnAbortReason" - }, - "type": { - "enum": [ - "turn_aborted" - ], - "title": "TurnAbortedEventMsgType", - "type": "string" - } - }, - "required": [ - "reason", - "type" - ], - "title": "TurnAbortedEventMsg", - "type": "object" - }, - { - "description": "Notification that the agent is shutting down.", - "properties": { - "type": { - "enum": [ - "shutdown_complete" - ], - "title": "ShutdownCompleteEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ShutdownCompleteEventMsg", - "type": "object" - }, - { - "description": "Entered review mode.", - "properties": { - "prompt": { - "default": "", - "description": "Legacy plain-text prompt retained for compatibility with older review flows.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/ReviewTarget" - }, - "type": { - "enum": [ - "entered_review_mode" - ], - "title": "EnteredReviewModeEventMsgType", - "type": "string" - }, - "user_facing_hint": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "target", - "type" - ], - "title": "EnteredReviewModeEventMsg", - "type": "object" - }, - { - "description": "Exited review mode with an optional final result to apply.", - "properties": { - "review_output": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewOutputEvent" - }, - { - "type": "null" - } - ] - }, - "snapshot": { - "anyOf": [ - { - "$ref": "#/definitions/ReviewSnapshotInfo" - }, - { - "type": "null" - } - ] - }, - "type": { - "enum": [ - "exited_review_mode" - ], - "title": "ExitedReviewModeEventMsgType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExitedReviewModeEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/ResponseItem" - }, - "type": { - "enum": [ - "raw_response_item" - ], - "title": "RawResponseItemEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "type" - ], - "title": "RawResponseItemEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_started" - ], - "title": "ItemStartedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemStartedEventMsg", - "type": "object" - }, - { - "properties": { - "item": { - "$ref": "#/definitions/TurnItem" - }, - "thread_id": { - "$ref": "#/definitions/ThreadId" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "item_completed" - ], - "title": "ItemCompletedEventMsgType", - "type": "string" - } - }, - "required": [ - "item", - "thread_id", - "turn_id", - "type" - ], - "title": "ItemCompletedEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "agent_message_content_delta" - ], - "title": "AgentMessageContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "AgentMessageContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "plan_delta" - ], - "title": "PlanDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "PlanDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "summary_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_content_delta" - ], - "title": "ReasoningContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningContentDeltaEventMsg", - "type": "object" - }, - { - "properties": { - "content_index": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "delta": { - "type": "string" - }, - "item_id": { - "type": "string" - }, - "thread_id": { - "type": "string" - }, - "turn_id": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_raw_content_delta" - ], - "title": "ReasoningRawContentDeltaEventMsgType", - "type": "string" - } - }, - "required": [ - "delta", - "item_id", - "thread_id", - "turn_id", - "type" - ], - "title": "ReasoningRawContentDeltaEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_spawn_begin" - ], - "title": "CollabAgentSpawnBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "type" - ], - "title": "CollabAgentSpawnBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent spawn end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "new_thread_id": { - "anyOf": [ - { - "$ref": "#/definitions/ThreadId" - }, - { - "type": "null" - } - ], - "description": "Thread ID of the newly spawned agent, if it was created." - }, - "prompt": { - "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the new agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_spawn_end" - ], - "title": "CollabAgentSpawnEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentSpawnEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_agent_interaction_begin" - ], - "title": "CollabAgentInteractionBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabAgentInteractionBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: agent interaction end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "prompt": { - "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent." - }, - "type": { - "enum": [ - "collab_agent_interaction_end" - ], - "title": "CollabAgentInteractionEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "prompt", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabAgentInteractionEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting begin.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "receiver_thread_ids": { - "description": "Thread ID of the receivers.", - "items": { - "$ref": "#/definitions/ThreadId" - }, - "type": "array" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_waiting_begin" - ], - "title": "CollabWaitingBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_ids", - "sender_thread_id", - "type" - ], - "title": "CollabWaitingBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: waiting end.", - "properties": { - "call_id": { - "description": "ID of the waiting call.", - "type": "string" - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "statuses": { - "additionalProperties": { - "$ref": "#/definitions/AgentStatus" - }, - "description": "Last known status of the receiver agents reported to the sender agent.", - "type": "object" - }, - "type": { - "enum": [ - "collab_waiting_end" - ], - "title": "CollabWaitingEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "sender_thread_id", - "statuses", - "type" - ], - "title": "CollabWaitingEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_close_begin" - ], - "title": "CollabCloseBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabCloseBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: close end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent before the close." - }, - "type": { - "enum": [ - "collab_close_end" - ], - "title": "CollabCloseEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabCloseEndEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume begin.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "type": { - "enum": [ - "collab_resume_begin" - ], - "title": "CollabResumeBeginEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "type" - ], - "title": "CollabResumeBeginEventMsg", - "type": "object" - }, - { - "description": "Collab interaction: resume end.", - "properties": { - "call_id": { - "description": "Identifier for the collab tool call.", - "type": "string" - }, - "receiver_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the receiver." - }, - "sender_thread_id": { - "allOf": [ - { - "$ref": "#/definitions/ThreadId" - } - ], - "description": "Thread ID of the sender." - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/AgentStatus" - } - ], - "description": "Last known status of the receiver agent reported to the sender agent after resume." - }, - "type": { - "enum": [ - "collab_resume_end" - ], - "title": "CollabResumeEndEventMsgType", - "type": "string" - } - }, - "required": [ - "call_id", - "receiver_thread_id", - "sender_thread_id", - "status", - "type" - ], - "title": "CollabResumeEndEventMsg", - "type": "object" - } - ] - }, - "ExecCommandSource": { - "enum": [ - "agent", - "user_shell", - "unified_exec_startup", - "unified_exec_interaction" - ], - "type": "string" - }, - "ExecOutputStream": { - "enum": [ - "stdout", - "stderr" - ], - "type": "string" - }, - "FileChange": { - "oneOf": [ - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "add" - ], - "title": "AddFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "AddFileChange", - "type": "object" - }, - { - "properties": { - "content": { - "type": "string" - }, - "type": { - "enum": [ - "delete" - ], - "title": "DeleteFileChangeType", - "type": "string" - } - }, - "required": [ - "content", - "type" - ], - "title": "DeleteFileChange", - "type": "object" - }, - { - "properties": { - "move_path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "update" - ], - "title": "UpdateFileChangeType", - "type": "string" - }, - "unified_diff": { - "type": "string" - } - }, - "required": [ - "type", - "unified_diff" - ], - "title": "UpdateFileChange", - "type": "object" - } - ] - }, - "FunctionCallOutputBody": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "$ref": "#/definitions/FunctionCallOutputContentItem" - }, - "type": "array" - } - ] - }, - "FunctionCallOutputContentItem": { - "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "input_text" - ], - "title": "InputTextFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "InputTextFunctionCallOutputContentItem", - "type": "object" - }, - { - "properties": { - "detail": { - "anyOf": [ - { - "$ref": "#/definitions/ImageDetail" - }, - { - "type": "null" - } - ] - }, - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "input_image" - ], - "title": "InputImageFunctionCallOutputContentItemType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "InputImageFunctionCallOutputContentItem", - "type": "object" - } - ] - }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, - "HistoryEntry": { - "properties": { - "conversation_id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "ts": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "conversation_id", - "text", - "ts" - ], - "type": "object" - }, - "ImageDetail": { - "enum": [ - "auto", - "low", - "high", - "original" - ], - "type": "string" - }, - "LocalShellAction": { - "oneOf": [ - { - "properties": { - "command": { - "items": { - "type": "string" - }, - "type": "array" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": [ - "object", - "null" - ] - }, - "timeout_ms": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "exec" - ], - "title": "ExecLocalShellActionType", - "type": "string" - }, - "user": { - "type": [ - "string", - "null" - ] - }, - "working_directory": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "command", - "type" - ], - "title": "ExecLocalShellAction", - "type": "object" - } - ] - }, - "LocalShellStatus": { - "enum": [ - "completed", - "in_progress", - "incomplete" - ], - "type": "string" - }, - "McpAuthStatus": { - "enum": [ - "unsupported", - "not_logged_in", - "bearer_token", - "o_auth" - ], - "type": "string" - }, - "McpInvocation": { - "properties": { - "arguments": { - "description": "Arguments to the tool call." - }, - "server": { - "description": "Name of the MCP server as defined in the config.", - "type": "string" - }, - "tool": { - "description": "Name of the tool as given by the MCP server.", - "type": "string" - } - }, - "required": [ - "server", - "tool" - ], - "type": "object" - }, - "McpServerFailure": { - "properties": { - "message": { - "type": "string" - }, - "phase": { - "$ref": "#/definitions/McpServerFailurePhase" - } - }, - "required": [ - "message", - "phase" - ], - "type": "object" - }, - "McpServerFailurePhase": { - "enum": [ - "start", - "list_tools" - ], - "type": "string" - }, - "McpStartupFailure": { - "properties": { - "error": { - "type": "string" - }, - "server": { - "type": "string" - } - }, - "required": [ - "error", - "server" - ], - "type": "object" - }, - "McpStartupStatus": { - "oneOf": [ - { - "properties": { - "state": { - "enum": [ - "starting" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus", - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "ready" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus2", - "type": "object" - }, - { - "properties": { - "error": { - "type": "string" - }, - "state": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "error", - "state" - ], - "type": "object" - }, - { - "properties": { - "state": { - "enum": [ - "cancelled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "title": "StateMcpStartupStatus3", - "type": "object" - } - ] - }, - "MessagePhase": { - "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", - "oneOf": [ - { - "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", - "enum": [ - "commentary" - ], - "type": "string" - }, - { - "description": "The assistant's terminal answer text for the current turn.", - "enum": [ - "final_answer" - ], - "type": "string" - } - ] - }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "NetworkAccess": { - "description": "Represents whether outbound network access is available to the agent.", - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "NetworkApprovalContext": { - "properties": { - "host": { - "type": "string" - }, - "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" - } - }, - "required": [ - "host", - "protocol" - ], - "type": "object" - }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5_tcp", - "socks5_udp" - ], - "type": "string" - }, - "ParsedCommand": { - "oneOf": [ - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "read_command" - ], - "title": "ReadCommandParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ReadCommandParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", - "type": "string" - }, - "type": { - "enum": [ - "read" - ], - "title": "ReadParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "name", - "path", - "type" - ], - "title": "ReadParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "list_files" - ], - "title": "ListFilesParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "ListFilesParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "path": { - "type": [ - "string", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "SearchParsedCommand", - "type": "object" - }, - { - "properties": { - "cmd": { - "type": "string" - }, - "type": { - "enum": [ - "unknown" - ], - "title": "UnknownParsedCommandType", - "type": "string" - } - }, - "required": [ - "cmd", - "type" - ], - "title": "UnknownParsedCommand", - "type": "object" - } - ] - }, - "PlanItemArg": { - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/definitions/StepStatus" - }, - "step": { - "type": "string" - } - }, - "required": [ - "status", - "step" - ], - "type": "object" - }, - "PlanType": { - "enum": [ - "free", - "go", - "plus", - "pro", - "team", - "business", - "enterprise", - "edu", - "unknown" - ], - "type": "string" - }, - "RateLimitReachedType": { - "enum": [ - "rate_limit_reached", - "workspace_owner_credits_depleted", - "workspace_member_credits_depleted", - "workspace_owner_usage_limit_reached", - "workspace_member_usage_limit_reached" - ], - "type": "string" - }, - "RateLimitSnapshot": { - "properties": { - "credits": { - "anyOf": [ - { - "$ref": "#/definitions/CreditsSnapshot" - }, - { - "type": "null" - } - ] - }, - "limit_id": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "limit_name": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "plan_type": { - "anyOf": [ - { - "$ref": "#/definitions/PlanType" - }, - { - "type": "null" - } - ] - }, - "primary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - }, - "rate_limit_reached_type": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitReachedType" - }, - { - "type": "null" - } - ], - "default": null - }, - "secondary": { - "anyOf": [ - { - "$ref": "#/definitions/RateLimitWindow" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, - "RateLimitWindow": { - "properties": { - "resets_at": { - "description": "Unix timestamp (seconds since epoch) when the window resets.", - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "resets_in_seconds": { - "description": "Legacy relative reset in seconds.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - }, - "used_percent": { - "description": "Percentage (0-100) of the window that has been consumed.", - "format": "double", - "type": "number" - }, - "window_minutes": { - "description": "Rolling window duration, in minutes.", - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "used_percent" - ], - "type": "object" - }, - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - }, - "ReasoningItemContent": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "reasoning_text" - ], - "title": "ReasoningTextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "ReasoningTextReasoningItemContent", - "type": "object" - }, - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextReasoningItemContentType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextReasoningItemContent", - "type": "object" - } - ] - }, - "ReasoningItemReasoningSummary": { - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "type": { - "enum": [ - "summary_text" - ], - "title": "SummaryTextReasoningItemReasoningSummaryType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "SummaryTextReasoningItemReasoningSummary", - "type": "object" - } - ] - }, - "RejectConfig": { - "properties": { - "mcp_elicitations": { - "description": "Reject MCP elicitation prompts.", - "type": "boolean" - }, - "request_permissions": { - "default": false, - "description": "Reject approval prompts related to built-in permission requests.", - "type": "boolean" - }, - "rules": { - "description": "Reject prompts triggered by execpolicy `prompt` rules.", - "type": "boolean" - }, - "sandbox_approval": { - "description": "Reject approval prompts related to sandbox escalation.", - "type": "boolean" - }, - "skill_approval": { - "default": false, - "description": "Reject approval prompts triggered by skill script execution.", - "type": "boolean" - } - }, - "required": [ - "mcp_elicitations", - "rules", - "sandbox_approval" - ], - "type": "object" - }, - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - }, - "RequestId": { - "anyOf": [ - { - "type": "string" - }, - { - "format": "int64", - "type": "integer" - } - ], - "description": "ID of a request, which can be either a string or an integer." - }, - "RequestUserInputQuestion": { - "properties": { - "header": { - "type": "string" - }, - "id": { - "type": "string" - }, - "isOther": { - "default": false, - "type": "boolean" - }, - "isSecret": { - "default": false, - "type": "boolean" - }, - "options": { - "items": { - "$ref": "#/definitions/RequestUserInputQuestionOption" - }, - "type": [ - "array", - "null" - ] - }, - "question": { - "type": "string" - } - }, - "required": [ - "header", - "id", - "question" - ], - "type": "object" - }, - "RequestUserInputQuestionOption": { - "properties": { - "description": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "description", - "label" - ], - "type": "object" - }, - "Resource": { - "description": "A known resource that the server is capable of reading.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, - "ResourceTemplate": { - "description": "A template description for resources available on the server.", - "properties": { - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uriTemplate": { - "type": "string" - } - }, - "required": [ - "name", - "uriTemplate" - ], - "type": "object" - }, - "ResponseItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/ContentItem" - }, - "type": "array" - }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ] - }, - "role": { - "type": "string" - }, - "type": { - "enum": [ - "message" - ], - "title": "MessageResponseItemType", - "type": "string" - } - }, - "required": [ - "content", - "role", - "type" - ], - "title": "MessageResponseItem", - "type": "object" - }, - { - "properties": { - "content": { - "default": null, - "items": { - "$ref": "#/definitions/ReasoningItemContent" - }, - "type": [ - "array", - "null" - ] - }, - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string", - "writeOnly": true - }, - "summary": { - "items": { - "$ref": "#/definitions/ReasoningItemReasoningSummary" - }, - "type": "array" - }, - "type": { - "enum": [ - "reasoning" - ], - "title": "ReasoningResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary", - "type" - ], - "title": "ReasoningResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/LocalShellAction" - }, - "call_id": { - "description": "Set when using the Responses API.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "$ref": "#/definitions/LocalShellStatus" - }, - "type": { - "enum": [ - "local_shell_call" - ], - "title": "LocalShellCallResponseItemType", - "type": "string" - } - }, - "required": [ - "action", - "status", - "type" - ], - "title": "LocalShellCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "name": { - "type": "string" - }, - "namespace": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "function_call" - ], - "title": "FunctionCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "call_id", - "name", - "type" - ], - "title": "FunctionCallResponseItem", - "type": "object" - }, - { - "properties": { - "arguments": true, - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "tool_search_call" - ], - "title": "ToolSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "arguments", - "execution", - "type" - ], - "title": "ToolSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "function_call_output" - ], - "title": "FunctionCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "FunctionCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "input": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "custom_tool_call" - ], - "title": "CustomToolCallResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "input", - "name", - "type" - ], - "title": "CustomToolCallResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": "string" - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" - }, - "type": { - "enum": [ - "custom_tool_call_output" - ], - "title": "CustomToolCallOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "call_id", - "output", - "type" - ], - "title": "CustomToolCallOutputResponseItem", - "type": "object" - }, - { - "properties": { - "call_id": { - "type": [ - "string", - "null" - ] - }, - "execution": { - "type": "string" - }, - "status": { - "type": "string" - }, - "tools": { - "items": true, - "type": "array" - }, - "type": { - "enum": [ - "tool_search_output" - ], - "title": "ToolSearchOutputResponseItemType", - "type": "string" - } - }, - "required": [ - "execution", - "status", - "tools", - "type" - ], - "title": "ToolSearchOutputResponseItem", - "type": "object" - }, - { - "properties": { - "action": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchAction" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, - "status": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "web_search_call" - ], - "title": "WebSearchCallResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "WebSearchCallResponseItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "image_generation_call" - ], - "title": "ImageGenerationCallResponseItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationCallResponseItem", - "type": "object" - }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": "string" - }, - "type": { - "enum": [ - "compaction_summary" - ], - "title": "CompactionSummaryResponseItemType", - "type": "string" - } - }, - "required": [ - "encrypted_content", - "type" - ], - "title": "CompactionSummaryResponseItem", - "type": "object" - }, - { - "properties": { - "encrypted_content": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "context_compaction" - ], - "title": "ContextCompactionResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ContextCompactionResponseItem", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherResponseItemType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherResponseItem", - "type": "object" - } - ] - }, - "Result_of_CallToolResult_or_String": { - "oneOf": [ - { - "properties": { - "Ok": { - "$ref": "#/definitions/CallToolResult" - } - }, - "required": [ - "Ok" - ], - "title": "OkResult_of_CallToolResult_or_String", - "type": "object" - }, - { - "properties": { - "Err": { - "type": "string" - } - }, - "required": [ - "Err" - ], - "title": "ErrResult_of_CallToolResult_or_String", - "type": "object" - } - ] - }, - "ReviewCodeLocation": { - "description": "Location of the code related to a review finding.", - "properties": { - "absolute_file_path": { - "type": "string" - }, - "line_range": { - "$ref": "#/definitions/ReviewLineRange" - } - }, - "required": [ - "absolute_file_path", - "line_range" - ], - "type": "object" - }, - "ReviewFinding": { - "description": "A single review finding describing an observed issue or recommendation.", - "properties": { - "body": { - "type": "string" - }, - "code_location": { - "$ref": "#/definitions/ReviewCodeLocation" - }, - "confidence_score": { - "format": "float", - "type": "number" - }, - "priority": { - "format": "int32", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "body", - "code_location", - "confidence_score", - "priority", - "title" - ], - "type": "object" - }, - "ReviewLineRange": { - "description": "Inclusive line range in a file associated with the finding.", - "properties": { - "end": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "start": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - } - }, - "required": [ - "end", - "start" - ], - "type": "object" - }, - "ReviewOutputEvent": { - "description": "Structured review result produced by a child review session.", - "properties": { - "findings": { - "items": { - "$ref": "#/definitions/ReviewFinding" - }, - "type": "array" - }, - "overall_confidence_score": { - "format": "float", - "type": "number" - }, - "overall_correctness": { - "type": "string" - }, - "overall_explanation": { - "type": "string" - } - }, - "required": [ - "findings", - "overall_confidence_score", - "overall_correctness", - "overall_explanation" - ], - "type": "object" - }, - "ReviewSnapshotInfo": { - "properties": { - "branch": { - "type": [ - "string", - "null" - ] - }, - "repo_root": { - "type": [ - "string", - "null" - ] - }, - "snapshot_commit": { - "type": [ - "string", - "null" - ] - }, - "worktree_path": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "ReviewTarget": { - "oneOf": [ - { - "description": "Review the working tree: staged, unstaged, and untracked files.", - "properties": { - "type": { - "enum": [ - "uncommittedChanges" - ], - "title": "UncommittedChangesReviewTargetType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UncommittedChangesReviewTarget", - "type": "object" - }, - { - "description": "Review changes between the current branch and the given base branch.", - "properties": { - "branch": { - "type": "string" - }, - "type": { - "enum": [ - "baseBranch" - ], - "title": "BaseBranchReviewTargetType", - "type": "string" - } - }, - "required": [ - "branch", - "type" - ], - "title": "BaseBranchReviewTarget", - "type": "object" - }, - { - "description": "Review the changes introduced by a specific commit.", - "properties": { - "sha": { - "type": "string" - }, - "title": { - "description": "Optional human-readable label (e.g., commit subject) for UIs.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "commit" - ], - "title": "CommitReviewTargetType", - "type": "string" - } - }, - "required": [ - "sha", - "type" - ], - "title": "CommitReviewTarget", - "type": "object" - }, - { - "description": "Arbitrary instructions provided by the user.", - "properties": { - "instructions": { - "type": "string" - }, - "type": { - "enum": [ - "custom" - ], - "title": "CustomReviewTargetType", - "type": "string" - } - }, - "required": [ - "instructions", - "type" - ], - "title": "CustomReviewTarget", - "type": "object" - } - ] - }, - "SandboxPolicy": { - "description": "Determines execution restrictions for model shell commands.", - "oneOf": [ - { - "description": "No restrictions whatsoever. Use with caution.", - "properties": { - "type": { - "enum": [ - "danger-full-access" - ], - "title": "DangerFullAccessSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DangerFullAccessSandboxPolicy", - "type": "object" - }, - { - "description": "Read-only access to the entire file-system.", - "properties": { - "type": { - "enum": [ - "read-only" - ], - "title": "ReadOnlySandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ReadOnlySandboxPolicy", - "type": "object" - }, - { - "description": "Indicates the process is already in an external sandbox. Allows full disk access while honoring the provided network setting.", - "properties": { - "network_access": { - "allOf": [ - { - "$ref": "#/definitions/NetworkAccess" - } - ], - "default": "restricted", - "description": "Whether the external sandbox permits outbound network traffic." - }, - "type": { - "enum": [ - "external-sandbox" - ], - "title": "ExternalSandboxSandboxPolicyType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "ExternalSandboxSandboxPolicy", - "type": "object" - }, - { - "description": "Same as `ReadOnly` but additionally grants write access to the current working directory (\"workspace\").", - "properties": { - "allow_git_writes": { - "default": false, - "description": "Whether sandboxed commands may perform write operations via Git.", - "type": "boolean" - }, - "exclude_slash_tmp": { - "default": false, - "description": "When set to `true`, will NOT include the `/tmp` among the default writable roots on UNIX. Defaults to `false`.", - "type": "boolean" - }, - "exclude_tmpdir_env_var": { - "default": false, - "description": "When set to `true`, will NOT include the per-user `TMPDIR` environment variable among the default writable roots. Defaults to `false`.", - "type": "boolean" - }, - "network_access": { - "default": false, - "description": "When set to `true`, outbound network access is allowed. `false` by default.", - "type": "boolean" - }, - "type": { - "enum": [ - "workspace-write" - ], - "title": "WorkspaceWriteSandboxPolicyType", - "type": "string" - }, - "writable_roots": { - "description": "Additional folders (beyond cwd and possibly TMPDIR) that should be writable from within the sandbox.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - } - }, - "required": [ - "type" - ], - "title": "WorkspaceWriteSandboxPolicy", - "type": "object" - } - ] - }, - "SkillDependencies": { - "properties": { - "tools": { - "items": { - "$ref": "#/definitions/SkillToolDependency" - }, - "type": "array" - } - }, - "required": [ - "tools" - ], - "type": "object" - }, - "SkillErrorInfo": { - "properties": { - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "message", - "path" - ], - "type": "object" - }, - "SkillInterface": { - "properties": { - "brand_color": { - "type": [ - "string", - "null" - ] - }, - "default_prompt": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_large": { - "type": [ - "string", - "null" - ] - }, - "icon_small": { - "type": [ - "string", - "null" - ] - }, - "short_description": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, - "SkillMetadata": { - "properties": { - "allow_implicit_invocation": { - "default": true, - "type": "boolean" - }, - "dependencies": { - "anyOf": [ - { - "$ref": "#/definitions/SkillDependencies" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "interface": { - "anyOf": [ - { - "$ref": "#/definitions/SkillInterface" - }, - { - "type": "null" - } - ] - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "scope": { - "$ref": "#/definitions/SkillScope" - }, - "short_description": { - "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "description", - "enabled", - "name", - "path", - "scope" - ], - "type": "object" - }, - "SkillScope": { - "enum": [ - "user", - "repo", - "system", - "admin" - ], - "type": "string" - }, - "SkillToolDependency": { - "properties": { - "command": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "transport": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "value" - ], - "type": "object" - }, - "SkillsListEntry": { - "properties": { - "cwd": { - "type": "string" - }, - "errors": { - "items": { - "$ref": "#/definitions/SkillErrorInfo" - }, - "type": "array" - }, - "skills": { - "items": { - "$ref": "#/definitions/SkillMetadata" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "errors", - "skills" - ], - "type": "object" - }, - "StepStatus": { - "enum": [ - "pending", - "in_progress", - "completed" - ], - "type": "string" - }, - "TextElement": { - "properties": { - "byte_range": { - "allOf": [ - { - "$ref": "#/definitions/ByteRange" - } - ], - "description": "Byte range in the parent `text` buffer that this element occupies." - }, - "placeholder": { - "description": "Optional human-readable placeholder for the element, displayed in the UI.", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "byte_range" - ], - "type": "object" - }, - "ThreadId": { - "type": "string" - }, - "TokenUsage": { - "properties": { - "cached_input_tokens": { - "format": "int64", - "type": "integer" - }, - "cached_input_tokens_reported": { - "default": false, - "type": "boolean" - }, - "input_tokens": { - "format": "int64", - "type": "integer" - }, - "output_tokens": { - "format": "int64", - "type": "integer" - }, - "reasoning_output_tokens": { - "format": "int64", - "type": "integer" - }, - "total_tokens": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cached_input_tokens", - "input_tokens", - "output_tokens", - "reasoning_output_tokens", - "total_tokens" - ], - "type": "object" - }, - "TokenUsageInfo": { - "properties": { - "last_token_usage": { - "$ref": "#/definitions/TokenUsage" - }, - "latest_response_model": { - "type": [ - "string", - "null" - ] - }, - "model_context_window": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "requested_model": { - "type": [ - "string", - "null" - ] - }, - "total_token_usage": { - "$ref": "#/definitions/TokenUsage" - } - }, - "required": [ - "last_token_usage", - "total_token_usage" - ], - "type": "object" - }, - "Tool": { - "description": "Definition for a tool the client can call.", - "properties": { - "_meta": true, - "annotations": true, - "description": { - "type": [ - "string", - "null" - ] - }, - "icons": { - "items": true, - "type": [ - "array", - "null" - ] - }, - "inputSchema": true, - "name": { - "type": "string" - }, - "outputSchema": true, - "title": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "inputSchema", - "name" - ], - "type": "object" - }, - "TurnAbortReason": { - "enum": [ - "interrupted", - "replaced", - "review_ended" - ], - "type": "string" - }, - "TurnItem": { - "oneOf": [ - { - "properties": { - "content": { - "items": { - "$ref": "#/definitions/UserInput" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "type": { - "enum": [ - "UserMessage" - ], - "title": "UserMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "UserMessageTurnItem", - "type": "object" - }, - { - "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", - "properties": { - "content": { - "items": { - "$ref": "#/definitions/AgentMessageContent" - }, - "type": "array" - }, - "id": { - "type": "string" - }, - "phase": { - "anyOf": [ - { - "$ref": "#/definitions/MessagePhase" - }, - { - "type": "null" - } - ], - "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." - }, - "type": { - "enum": [ - "AgentMessage" - ], - "title": "AgentMessageTurnItemType", - "type": "string" - } - }, - "required": [ - "content", - "id", - "type" - ], - "title": "AgentMessageTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "type": { - "enum": [ - "Plan" - ], - "title": "PlanTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "text", - "type" - ], - "title": "PlanTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "raw_content": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - }, - "summary_text": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "enum": [ - "Reasoning" - ], - "title": "ReasoningTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "summary_text", - "type" - ], - "title": "ReasoningTurnItem", - "type": "object" - }, - { - "properties": { - "action": { - "$ref": "#/definitions/WebSearchAction" - }, - "id": { - "type": "string" - }, - "query": { - "type": "string" - }, - "type": { - "enum": [ - "WebSearch" - ], - "title": "WebSearchTurnItemType", - "type": "string" - } - }, - "required": [ - "action", - "id", - "query", - "type" - ], - "title": "WebSearchTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "revised_prompt": { - "type": [ - "string", - "null" - ] - }, - "saved_path": { - "anyOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - }, - { - "type": "null" - } - ] - }, - "status": { - "type": "string" - }, - "type": { - "enum": [ - "ImageGeneration" - ], - "title": "ImageGenerationTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "result", - "status", - "type" - ], - "title": "ImageGenerationTurnItem", - "type": "object" - }, - { - "properties": { - "id": { - "type": "string" - }, - "type": { - "enum": [ - "ContextCompaction" - ], - "title": "ContextCompactionTurnItemType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ContextCompactionTurnItem", - "type": "object" - } - ] - }, - "UserInput": { - "description": "User input", - "oneOf": [ - { - "properties": { - "text": { - "type": "string" - }, - "text_elements": { - "default": [], - "description": "UI-defined spans within `text` that should be treated as special elements. These are byte ranges into the UTF-8 `text` buffer and are used to render or persist rich input markers (e.g., image placeholders) across history and resume without mutating the literal text.", - "items": { - "$ref": "#/definitions/TextElement" - }, - "type": "array" - }, - "type": { - "enum": [ - "text" - ], - "title": "TextUserInputType", - "type": "string" - } - }, - "required": [ - "text", - "type" - ], - "title": "TextUserInput", - "type": "object" - }, - { - "description": "Pre‑encoded data: URI image.", - "properties": { - "image_url": { - "type": "string" - }, - "type": { - "enum": [ - "image" - ], - "title": "ImageUserInputType", - "type": "string" - } - }, - "required": [ - "image_url", - "type" - ], - "title": "ImageUserInput", - "type": "object" - }, - { - "description": "Local image path provided by the user. This will be converted to an `Image` variant (base64 data URL) during request serialization.", - "properties": { - "path": { - "type": "string" - }, - "type": { - "enum": [ - "local_image" - ], - "title": "LocalImageUserInputType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "LocalImageUserInput", - "type": "object" - }, - { - "description": "Skill selected by the user (name + path to SKILL.md).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "skill" - ], - "title": "SkillUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "SkillUserInput", - "type": "object" - }, - { - "description": "Explicit mention selected by the user (name + app://connector id).", - "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "type": { - "enum": [ - "mention" - ], - "title": "MentionUserInputType", - "type": "string" - } - }, - "required": [ - "name", - "path", - "type" - ], - "title": "MentionUserInput", - "type": "object" - } - ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - } - }, - "properties": { - "historyEntryCount": { - "format": "uint", - "minimum": 0.0, - "type": "integer" - }, - "historyLogId": { - "format": "uint64", - "minimum": 0.0, - "type": "integer" - }, - "initialMessages": { - "items": { - "$ref": "#/definitions/EventMsg" - }, - "type": [ - "array", - "null" - ] - }, - "model": { - "type": "string" - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "rolloutPath": { - "type": "string" - }, - "sessionId": { - "$ref": "#/definitions/ThreadId" - } - }, - "required": [ - "historyEntryCount", - "historyLogId", - "model", - "rolloutPath", - "sessionId" - ], - "title": "SessionConfiguredNotification", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/SetDefaultModelParams.json b/code-rs/app-server-protocol/schema/json/v1/SetDefaultModelParams.json deleted file mode 100644 index b39e1af3829..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/SetDefaultModelParams.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], - "type": "string" - } - }, - "properties": { - "model": { - "type": [ - "string", - "null" - ] - }, - "reasoningEffort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - } - }, - "title": "SetDefaultModelParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/SetDefaultModelResponse.json b/code-rs/app-server-protocol/schema/json/v1/SetDefaultModelResponse.json deleted file mode 100644 index be964f2103a..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/SetDefaultModelResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SetDefaultModelResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v1/UserInfoResponse.json b/code-rs/app-server-protocol/schema/json/v1/UserInfoResponse.json deleted file mode 100644 index 0baf4a056a5..00000000000 --- a/code-rs/app-server-protocol/schema/json/v1/UserInfoResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "allegedUserEmail": { - "type": [ - "string", - "null" - ] - } - }, - "title": "UserInfoResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v2/AccountLoginCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/AccountLoginCompletedNotification.json index 089018ca4e8..128cb643abe 100644 --- a/code-rs/app-server-protocol/schema/json/v2/AccountLoginCompletedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/AccountLoginCompletedNotification.json @@ -22,4 +22,4 @@ ], "title": "AccountLoginCompletedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index c4e92d2f380..96fefb228b8 100644 --- a/code-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -28,8 +28,11 @@ "go", "plus", "pro", + "prolite", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" @@ -98,8 +101,7 @@ { "type": "null" } - ], - "default": null + ] }, "secondary": { "anyOf": [ @@ -151,4 +153,4 @@ ], "title": "AccountRateLimitsUpdatedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index e178919b91e..e7546f5570e 100644 --- a/code-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -24,8 +24,32 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] + }, + "PlanType": { + "enum": [ + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_usage_based", + "business", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "unknown" + ], + "type": "string" } }, "properties": { @@ -38,8 +62,18 @@ "type": "null" } ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] } }, "title": "AccountUpdatedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/AgentMessageDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/AgentMessageDeltaNotification.json index b24912e5ece..09510d95cf5 100644 --- a/code-rs/app-server-protocol/schema/json/v2/AgentMessageDeltaNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/AgentMessageDeltaNotification.json @@ -22,4 +22,4 @@ ], "title": "AgentMessageDeltaNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index f58059986ec..d4e99f5086e 100644 --- a/code-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -1,9 +1,71 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -34,6 +96,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -48,6 +119,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ @@ -55,6 +133,130 @@ "name" ], "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" } }, "description": "EXPERIMENTAL - notification emitted when the app list changes.", @@ -71,4 +273,4 @@ ], "title": "AppListUpdatedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/AppsListParams.json b/code-rs/app-server-protocol/schema/json/v2/AppsListParams.json index 722d21d7026..385e5ba2985 100644 --- a/code-rs/app-server-protocol/schema/json/v2/AppsListParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/AppsListParams.json @@ -32,4 +32,4 @@ }, "title": "AppsListParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/code-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index 703c828cf13..2fb9092cb06 100644 --- a/code-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -1,9 +1,71 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, "description": { "type": [ "string", @@ -34,6 +96,15 @@ "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "logoUrl": { "type": [ "string", @@ -48,6 +119,13 @@ }, "name": { "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ @@ -55,6 +133,130 @@ "name" ], "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" } }, "description": "EXPERIMENTAL - app list response.", @@ -78,4 +280,4 @@ ], "title": "AppsListResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountParams.json b/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountParams.json index 3c0c2bd9247..22c9a2ac3ce 100644 --- a/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountParams.json @@ -10,4 +10,4 @@ ], "title": "CancelLoginAccountParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountResponse.json b/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountResponse.json index 230ec9555cb..23df186da4f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/CancelLoginAccountResponse.json @@ -19,4 +19,4 @@ ], "title": "CancelLoginAccountResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json new file mode 100644 index 00000000000..fff7e57d50d --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + } + }, + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index b148c86bab0..f29483862cd 100644 --- a/code-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,6 +5,224 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -12,6 +230,135 @@ ], "type": "string" }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "SandboxPolicy": { "oneOf": [ { @@ -32,6 +379,10 @@ }, { "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" @@ -108,14 +459,54 @@ ] } }, + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -129,14 +520,39 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" + }, + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" } }, "required": [ @@ -144,4 +560,4 @@ ], "title": "CommandExecParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json new file mode 100644 index 00000000000..57d3b6a30c6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + } + }, + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "size": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json new file mode 100644 index 00000000000..def86b66d09 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", + "title": "CommandExecResizeResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json index bbf8cd5ae5c..1bbc5192380 100644 --- a/code-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json @@ -1,14 +1,18 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", "properties": { "exitCode": { + "description": "Process exit code.", "format": "int32", "type": "integer" }, "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", "type": "string" }, "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", "type": "string" } }, @@ -19,4 +23,4 @@ ], "title": "CommandExecResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json new file mode 100644 index 00000000000..1f848770517 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json new file mode 100644 index 00000000000..59bdb0cb304 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", + "title": "CommandExecTerminateResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json new file mode 100644 index 00000000000..440f2410bf1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json new file mode 100644 index 00000000000..dff8301ebbd --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", + "title": "CommandExecWriteResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/CommandExecutionOutputDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/CommandExecutionOutputDeltaNotification.json index 4f31def75d2..e4cb64a9dde 100644 --- a/code-rs/app-server-protocol/schema/json/v2/CommandExecutionOutputDeltaNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/CommandExecutionOutputDeltaNotification.json @@ -22,4 +22,4 @@ ], "title": "CommandExecutionOutputDeltaNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json b/code-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json index bd7bb958dd8..03cf2edb7ab 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ConfigBatchWriteParams.json @@ -45,6 +45,10 @@ "string", "null" ] + }, + "reloadUserConfig": { + "description": "When true, hot-reload the updated user config into all loaded threads after writing.", + "type": "boolean" } }, "required": [ @@ -52,4 +56,4 @@ ], "title": "ConfigBatchWriteParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json b/code-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json index 65e90e48e22..b173d2ba953 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json @@ -15,4 +15,4 @@ }, "title": "ConfigReadParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 199cda63e85..87a826e5af1 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -19,31 +19,123 @@ }, "AppConfig": { "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "anyOf": [ { - "$ref": "#/definitions/AppDisabledReason" + "$ref": "#/definitions/AppToolApproval" }, { "type": "null" } ] }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, "enabled": { "default": true, "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" }, - "AppDisabledReason": { + "AppToolsConfig": { + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ - "unknown", - "user" + "user", + "auto_review", + "guardian_subagent" ], "type": "string" }, "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, "type": "object" }, "AskForApproval": { @@ -60,7 +152,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -89,9 +181,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -119,6 +211,17 @@ } ] }, + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "[UNSTABLE] Optional default for where approval requests are routed for review." + }, "compact_prompt": { "type": [ "string", @@ -248,6 +351,12 @@ } ] }, + "service_tier": { + "type": [ + "string", + "null" + ] + }, "tools": { "anyOf": [ { @@ -485,6 +594,17 @@ } ] }, + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." + }, "chatgpt_base_url": { "type": [ "string", @@ -533,6 +653,22 @@ } ] }, + "service_tier": { + "type": [ + "string", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -748,4 +884,4 @@ ], "title": "ConfigReadResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index bd9221ff4cb..14a8d572d61 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -1,6 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -15,7 +24,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -44,9 +53,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -89,10 +98,195 @@ "type": "null" } ] + }, + "featureRequirements": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, + "ConfiguredHookHandler": { + "oneOf": [ + { + "properties": { + "async": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "async", + "command", + "type" + ], + "title": "CommandConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "title": "PromptConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "PromptConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "title": "AgentConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AgentConfiguredHookHandler", + "type": "object" + } + ] + }, + "ConfiguredHookMatcherGroup": { + "properties": { + "hooks": { + "items": { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + "type": "array" + }, + "matcher": { + "type": [ + "string", + "null" + ] } }, + "required": [ + "hooks" + ], "type": "object" }, + "ManagedHooksRequirements": { + "properties": { + "PermissionRequest": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "Stop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "managedDir": { + "type": [ + "string", + "null" + ] + }, + "windowsManagedDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "PermissionRequest", + "PostCompact", + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionStart", + "Stop", + "UserPromptSubmit" + ], + "type": "object" + }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -102,6 +296,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -117,6 +312,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -125,7 +321,7 @@ "null" ] }, - "dangerouslyAllowNonLoopbackAdmin": { + "dangerouslyAllowAllUnixSockets": { "type": [ "boolean", "null" @@ -138,6 +334,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -146,6 +343,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -160,6 +367,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -167,10 +381,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "ResidencyRequirement": { "enum": [ "us" @@ -209,4 +440,4 @@ }, "title": "ConfigRequirementsReadResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ConfigValueWriteParams.json b/code-rs/app-server-protocol/schema/json/v2/ConfigValueWriteParams.json index 36faedb2de5..000c55a830d 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ConfigValueWriteParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ConfigValueWriteParams.json @@ -38,4 +38,4 @@ ], "title": "ConfigValueWriteParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ConfigWarningNotification.json b/code-rs/app-server-protocol/schema/json/v2/ConfigWarningNotification.json index 4926c9b5986..c89e42a2b45 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ConfigWarningNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ConfigWarningNotification.json @@ -74,4 +74,4 @@ ], "title": "ConfigWarningNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json b/code-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json index e8f0fa78d56..631318a8bd5 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -234,4 +234,4 @@ ], "title": "ConfigWriteResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ContextCompactedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ContextCompactedNotification.json index 74d609d34fd..8d2d4b12619 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ContextCompactedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ContextCompactedNotification.json @@ -15,4 +15,4 @@ ], "title": "ContextCompactedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/DeprecationNoticeNotification.json b/code-rs/app-server-protocol/schema/json/v2/DeprecationNoticeNotification.json index c8a6c70b283..7e6c73b9c7b 100644 --- a/code-rs/app-server-protocol/schema/json/v2/DeprecationNoticeNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/DeprecationNoticeNotification.json @@ -18,4 +18,4 @@ ], "title": "DeprecationNoticeNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/code-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 47b6e1d2fc3..fd55d08764d 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -8,6 +8,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -18,35 +19,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -141,9 +113,38 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "TurnError": { "properties": { "additionalDetails": { @@ -195,4 +196,4 @@ ], "title": "ErrorNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json new file mode 100644 index 00000000000..9d6bcec932e --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json new file mode 100644 index 00000000000..9cdbf0691f0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json index 2d347061933..ab562edbf2a 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json @@ -20,4 +20,4 @@ }, "title": "ExperimentalFeatureListParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json index 46000bac513..25398fc0eac 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListResponse.json @@ -113,4 +113,4 @@ ], "title": "ExperimentalFeatureListResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 2cba743c600..20ddd6e48aa 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -18,4 +18,4 @@ }, "title": "ExternalAgentConfigDetectParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json index 022926d177f..b61b7064ac9 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json @@ -1,6 +1,17 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "CommandMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "ExternalAgentConfigMigrationItem": { "properties": { "cwd": { @@ -13,6 +24,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" } @@ -28,9 +49,133 @@ "AGENTS_MD", "CONFIG", "SKILLS", - "MCP_SERVER_CONFIG" + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS" ], "type": "string" + }, + "HookMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "McpServerMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "MigrationDetails": { + "properties": { + "commands": { + "default": [], + "items": { + "$ref": "#/definitions/CommandMigration" + }, + "type": "array" + }, + "hooks": { + "default": [], + "items": { + "$ref": "#/definitions/HookMigration" + }, + "type": "array" + }, + "mcpServers": { + "default": [], + "items": { + "$ref": "#/definitions/McpServerMigration" + }, + "type": "array" + }, + "plugins": { + "default": [], + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + }, + "sessions": { + "default": [], + "items": { + "$ref": "#/definitions/SessionMigration" + }, + "type": "array" + }, + "subagents": { + "default": [], + "items": { + "$ref": "#/definitions/SubagentMigration" + }, + "type": "array" + } + }, + "type": "object" + }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, + "SessionMigration": { + "properties": { + "cwd": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "cwd", + "path" + ], + "type": "object" + }, + "SubagentMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" } }, "properties": { @@ -46,4 +191,4 @@ ], "title": "ExternalAgentConfigDetectResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportCompletedNotification.json new file mode 100644 index 00000000000..b1a57704ea1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportCompletedNotification.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportCompletedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index c37f476af9b..b26e9d187aa 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -1,6 +1,17 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "CommandMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "ExternalAgentConfigMigrationItem": { "properties": { "cwd": { @@ -13,6 +24,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" } @@ -28,9 +49,133 @@ "AGENTS_MD", "CONFIG", "SKILLS", - "MCP_SERVER_CONFIG" + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS" ], "type": "string" + }, + "HookMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "McpServerMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "MigrationDetails": { + "properties": { + "commands": { + "default": [], + "items": { + "$ref": "#/definitions/CommandMigration" + }, + "type": "array" + }, + "hooks": { + "default": [], + "items": { + "$ref": "#/definitions/HookMigration" + }, + "type": "array" + }, + "mcpServers": { + "default": [], + "items": { + "$ref": "#/definitions/McpServerMigration" + }, + "type": "array" + }, + "plugins": { + "default": [], + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + }, + "sessions": { + "default": [], + "items": { + "$ref": "#/definitions/SessionMigration" + }, + "type": "array" + }, + "subagents": { + "default": [], + "items": { + "$ref": "#/definitions/SubagentMigration" + }, + "type": "array" + } + }, + "type": "object" + }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, + "SessionMigration": { + "properties": { + "cwd": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "cwd", + "path" + ], + "type": "object" + }, + "SubagentMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" } }, "properties": { @@ -46,4 +191,4 @@ ], "title": "ExternalAgentConfigImportParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportResponse.json b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportResponse.json index 17fe3440821..6823495d3cf 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportResponse.json @@ -2,4 +2,4 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "ExternalAgentConfigImportResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index 95d35260bbe..07c20986067 100644 --- a/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -4,6 +4,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, @@ -13,6 +22,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", @@ -26,4 +44,4 @@ ], "title": "FeedbackUploadParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadResponse.json b/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadResponse.json index 2efe5c854df..647b613f0b1 100644 --- a/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/FeedbackUploadResponse.json @@ -10,4 +10,4 @@ ], "title": "FeedbackUploadResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FileChangeOutputDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/FileChangeOutputDeltaNotification.json index 10880603993..97d617ea4c4 100644 --- a/code-rs/app-server-protocol/schema/json/v2/FileChangeOutputDeltaNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/FileChangeOutputDeltaNotification.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.", "properties": { "delta": { "type": "string" @@ -22,4 +23,4 @@ ], "title": "FileChangeOutputDeltaNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FileChangePatchUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/FileChangePatchUpdatedNotification.json new file mode 100644 index 00000000000..0ae44aa9612 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FileChangePatchUpdatedNotification.json @@ -0,0 +1,107 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + } + }, + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "changes", + "itemId", + "threadId", + "turnId" + ], + "title": "FileChangePatchUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json b/code-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json new file mode 100644 index 00000000000..cfb9f4e5ba4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsCopyParams.json b/code-rs/app-server-protocol/schema/json/v2/FsCopyParams.json new file mode 100644 index 00000000000..2994fcac812 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsCopyParams.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Copy a file or directory tree on the host filesystem.", + "properties": { + "destinationPath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute destination path." + }, + "recursive": { + "description": "Required for directory copies; ignored for file copies.", + "type": "boolean" + }, + "sourcePath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute source path." + } + }, + "required": [ + "destinationPath", + "sourcePath" + ], + "title": "FsCopyParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsCopyResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsCopyResponse.json new file mode 100644 index 00000000000..b1088b3a31b --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsCopyResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/copy`.", + "title": "FsCopyResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsCreateDirectoryParams.json b/code-rs/app-server-protocol/schema/json/v2/FsCreateDirectoryParams.json new file mode 100644 index 00000000000..a1ac4a8dc51 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsCreateDirectoryParams.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Create a directory on the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to create." + }, + "recursive": { + "description": "Whether parent directories should also be created. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "path" + ], + "title": "FsCreateDirectoryParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsCreateDirectoryResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsCreateDirectoryResponse.json new file mode 100644 index 00000000000..d07e118954c --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsCreateDirectoryResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/createDirectory`.", + "title": "FsCreateDirectoryResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsGetMetadataParams.json b/code-rs/app-server-protocol/schema/json/v2/FsGetMetadataParams.json new file mode 100644 index 00000000000..c70287493c1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsGetMetadataParams.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Request metadata for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to inspect." + } + }, + "required": [ + "path" + ], + "title": "FsGetMetadataParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsGetMetadataResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsGetMetadataResponse.json new file mode 100644 index 00000000000..82481f579eb --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsGetMetadataResponse.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Metadata returned by `fs/getMetadata`.", + "properties": { + "createdAtMs": { + "description": "File creation time in Unix milliseconds when available, otherwise `0`.", + "format": "int64", + "type": "integer" + }, + "isDirectory": { + "description": "Whether the path resolves to a directory.", + "type": "boolean" + }, + "isFile": { + "description": "Whether the path resolves to a regular file.", + "type": "boolean" + }, + "isSymlink": { + "description": "Whether the path itself is a symbolic link.", + "type": "boolean" + }, + "modifiedAtMs": { + "description": "File modification time in Unix milliseconds when available, otherwise `0`.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAtMs", + "isDirectory", + "isFile", + "isSymlink", + "modifiedAtMs" + ], + "title": "FsGetMetadataResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsReadDirectoryParams.json b/code-rs/app-server-protocol/schema/json/v2/FsReadDirectoryParams.json new file mode 100644 index 00000000000..e531fe9f030 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsReadDirectoryParams.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "List direct child names for a directory.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute directory path to read." + } + }, + "required": [ + "path" + ], + "title": "FsReadDirectoryParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsReadDirectoryResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsReadDirectoryResponse.json new file mode 100644 index 00000000000..61f7a3e6475 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsReadDirectoryResponse.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "FsReadDirectoryEntry": { + "description": "A directory entry returned by `fs/readDirectory`.", + "properties": { + "fileName": { + "description": "Direct child entry name only, not an absolute or relative path.", + "type": "string" + }, + "isDirectory": { + "description": "Whether this entry resolves to a directory.", + "type": "boolean" + }, + "isFile": { + "description": "Whether this entry resolves to a regular file.", + "type": "boolean" + } + }, + "required": [ + "fileName", + "isDirectory", + "isFile" + ], + "type": "object" + } + }, + "description": "Directory entries returned by `fs/readDirectory`.", + "properties": { + "entries": { + "description": "Direct child entries in the requested directory.", + "items": { + "$ref": "#/definitions/FsReadDirectoryEntry" + }, + "type": "array" + } + }, + "required": [ + "entries" + ], + "title": "FsReadDirectoryResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsReadFileParams.json b/code-rs/app-server-protocol/schema/json/v2/FsReadFileParams.json new file mode 100644 index 00000000000..e1df6018c15 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsReadFileParams.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Read a file from the host filesystem.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to read." + } + }, + "required": [ + "path" + ], + "title": "FsReadFileParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsReadFileResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsReadFileResponse.json new file mode 100644 index 00000000000..c746cf9357c --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsReadFileResponse.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded file contents returned by `fs/readFile`.", + "properties": { + "dataBase64": { + "description": "File contents encoded as base64.", + "type": "string" + } + }, + "required": [ + "dataBase64" + ], + "title": "FsReadFileResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsRemoveParams.json b/code-rs/app-server-protocol/schema/json/v2/FsRemoveParams.json new file mode 100644 index 00000000000..d6289d46d08 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsRemoveParams.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Remove a file or directory tree from the host filesystem.", + "properties": { + "force": { + "description": "Whether missing paths should be ignored. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + }, + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to remove." + }, + "recursive": { + "description": "Whether directory removal should recurse. Defaults to `true`.", + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "path" + ], + "title": "FsRemoveParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsRemoveResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsRemoveResponse.json new file mode 100644 index 00000000000..d1ec5d11bd0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsRemoveResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/remove`.", + "title": "FsRemoveResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json b/code-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json new file mode 100644 index 00000000000..f46800e9975 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json new file mode 100644 index 00000000000..daa80ad656f --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsWatchParams.json b/code-rs/app-server-protocol/schema/json/v2/FsWatchParams.json new file mode 100644 index 00000000000..29a1ceea148 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsWatchParams.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json new file mode 100644 index 00000000000..abc7d466bb6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Successful response for `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsWriteFileParams.json b/code-rs/app-server-protocol/schema/json/v2/FsWriteFileParams.json new file mode 100644 index 00000000000..e1b5eabd98b --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsWriteFileParams.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Write a file on the host filesystem.", + "properties": { + "dataBase64": { + "description": "File contents encoded as base64.", + "type": "string" + }, + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to write." + } + }, + "required": [ + "dataBase64", + "path" + ], + "title": "FsWriteFileParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/FsWriteFileResponse.json b/code-rs/app-server-protocol/schema/json/v2/FsWriteFileResponse.json new file mode 100644 index 00000000000..07ba35cdf97 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/FsWriteFileResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/writeFile`.", + "title": "FsWriteFileResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/GetAccountParams.json b/code-rs/app-server-protocol/schema/json/v2/GetAccountParams.json index db97d3c4416..ca18a451e94 100644 --- a/code-rs/app-server-protocol/schema/json/v2/GetAccountParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/GetAccountParams.json @@ -9,4 +9,4 @@ }, "title": "GetAccountParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/code-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 713b2002b83..8f7db24e7fe 100644 --- a/code-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -28,8 +28,11 @@ "go", "plus", "pro", + "prolite", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" @@ -98,8 +101,7 @@ { "type": "null" } - ], - "default": null + ] }, "secondary": { "anyOf": [ @@ -148,13 +150,13 @@ "$ref": "#/definitions/RateLimitSnapshot" } ], - "description": "Backward-compatible single-bucket view." + "description": "Backward-compatible single-bucket view; mirrors the historical payload." }, "rateLimitsByLimitId": { "additionalProperties": { "$ref": "#/definitions/RateLimitSnapshot" }, - "description": "Multi-bucket view keyed by metered `limit_id`.", + "description": "Multi-bucket view keyed by metered `limit_id` (for example, `codex`).", "type": [ "object", "null" @@ -166,4 +168,4 @@ ], "title": "GetAccountRateLimitsResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/code-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 5621fdcfbc3..ec333708b76 100644 --- a/code-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -42,6 +42,22 @@ ], "title": "ChatgptAccount", "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "amazonBedrock" + ], + "title": "AmazonBedrockAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AmazonBedrockAccount", + "type": "object" } ] }, @@ -51,8 +67,11 @@ "go", "plus", "pro", + "prolite", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" @@ -80,4 +99,4 @@ ], "title": "GetAccountResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json b/code-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json new file mode 100644 index 00000000000..5a4ef82f0a4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json new file mode 100644 index 00000000000..f63e6a5cfee --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -0,0 +1,194 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookExecutionMode": { + "enum": [ + "sync", + "async" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookOutputEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/HookOutputEntryKind" + }, + "text": { + "type": "string" + } + }, + "required": [ + "kind", + "text" + ], + "type": "object" + }, + "HookOutputEntryKind": { + "enum": [ + "warning", + "stop", + "feedback", + "context", + "error" + ], + "type": "string" + }, + "HookRunStatus": { + "enum": [ + "running", + "completed", + "failed", + "blocked", + "stopped" + ], + "type": "string" + }, + "HookRunSummary": { + "properties": { + "completedAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "durationMs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "entries": { + "items": { + "$ref": "#/definitions/HookOutputEntry" + }, + "type": "array" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "executionMode": { + "$ref": "#/definitions/HookExecutionMode" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "id": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/HookScope" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "startedAt": { + "format": "int64", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/HookRunStatus" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "displayOrder", + "entries", + "eventName", + "executionMode", + "handlerType", + "id", + "scope", + "sourcePath", + "startedAt", + "status" + ], + "type": "object" + }, + "HookScope": { + "enum": [ + "thread", + "turn" + ], + "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "cloudRequirements", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + } + }, + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "run", + "threadId" + ], + "title": "HookCompletedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/code-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json new file mode 100644 index 00000000000..f8eeecfe4de --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -0,0 +1,194 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookExecutionMode": { + "enum": [ + "sync", + "async" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookOutputEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/HookOutputEntryKind" + }, + "text": { + "type": "string" + } + }, + "required": [ + "kind", + "text" + ], + "type": "object" + }, + "HookOutputEntryKind": { + "enum": [ + "warning", + "stop", + "feedback", + "context", + "error" + ], + "type": "string" + }, + "HookRunStatus": { + "enum": [ + "running", + "completed", + "failed", + "blocked", + "stopped" + ], + "type": "string" + }, + "HookRunSummary": { + "properties": { + "completedAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "durationMs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "entries": { + "items": { + "$ref": "#/definitions/HookOutputEntry" + }, + "type": "array" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "executionMode": { + "$ref": "#/definitions/HookExecutionMode" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "id": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/HookScope" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "startedAt": { + "format": "int64", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/HookRunStatus" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "displayOrder", + "entries", + "eventName", + "executionMode", + "handlerType", + "id", + "scope", + "sourcePath", + "startedAt", + "status" + ], + "type": "object" + }, + "HookScope": { + "enum": [ + "thread", + "turn" + ], + "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "cloudRequirements", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + } + }, + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "run", + "threadId" + ], + "title": "HookStartedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/HooksListParams.json b/code-rs/app-server-protocol/schema/json/v2/HooksListParams.json new file mode 100644 index 00000000000..858d415f4f1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/HooksListParams.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/code-rs/app-server-protocol/schema/json/v2/HooksListResponse.json new file mode 100644 index 00000000000..f2a7c80cf00 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -0,0 +1,192 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "currentHash": { + "type": "string" + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "isManaged": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/HookTrustStatus" + } + }, + "required": [ + "currentHash", + "displayOrder", + "enabled", + "eventName", + "handlerType", + "isManaged", + "key", + "source", + "sourcePath", + "timeoutSec", + "trustStatus" + ], + "type": "object" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "cloudRequirements", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 62949a244a3..6909415c2a9 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -45,6 +45,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -72,26 +73,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -101,7 +82,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -200,6 +181,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -209,6 +199,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -228,6 +270,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -241,6 +298,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -260,6 +318,73 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -327,6 +452,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -379,11 +516,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -483,8 +669,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -512,6 +702,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -589,6 +787,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -627,6 +831,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -640,6 +903,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -647,6 +917,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -734,7 +1015,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1089,6 +1370,11 @@ } }, "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -1100,10 +1386,11 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" ], "title": "ItemCompletedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json new file mode 100644 index 00000000000..991d4de0504 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -0,0 +1,623 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "GuardianApprovalReview": { + "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", + "properties": { + "rationale": { + "type": [ + "string", + "null" + ] + }, + "riskLevel": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianRiskLevel" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/GuardianApprovalReviewStatus" + }, + "userAuthorization": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianUserAuthorization" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "files": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" + } + ] + }, + "GuardianApprovalReviewStatus": { + "description": "[UNSTABLE] Lifecycle state for an approval auto-review.", + "enum": [ + "inProgress", + "approved", + "denied", + "timedOut", + "aborted" + ], + "type": "string" + }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, + "GuardianRiskLevel": { + "description": "[UNSTABLE] Risk level assigned by approval auto-review.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "GuardianUserAuthorization": { + "description": "[UNSTABLE] Authorization level assigned by approval auto-review.", + "enum": [ + "unknown", + "low", + "medium", + "high" + ], + "type": "string" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, + "decisionSource": { + "$ref": "#/definitions/AutoReviewDecisionSource" + }, + "review": { + "$ref": "#/definitions/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "action", + "completedAtMs", + "decisionSource", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "ItemGuardianApprovalReviewCompletedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json new file mode 100644 index 00000000000..75ffeb753af --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -0,0 +1,606 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "GuardianApprovalReview": { + "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", + "properties": { + "rationale": { + "type": [ + "string", + "null" + ] + }, + "riskLevel": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianRiskLevel" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/GuardianApprovalReviewStatus" + }, + "userAuthorization": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianUserAuthorization" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "files": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" + } + ] + }, + "GuardianApprovalReviewStatus": { + "description": "[UNSTABLE] Lifecycle state for an approval auto-review.", + "enum": [ + "inProgress", + "approved", + "denied", + "timedOut", + "aborted" + ], + "type": "string" + }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, + "GuardianRiskLevel": { + "description": "[UNSTABLE] Risk level assigned by approval auto-review.", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "GuardianUserAuthorization": { + "description": "[UNSTABLE] Authorization level assigned by approval auto-review.", + "enum": [ + "unknown", + "low", + "medium", + "high" + ], + "type": "string" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, + "review": { + "$ref": "#/definitions/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "action", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "title": "ItemGuardianApprovalReviewStartedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 5abb0799668..758ceba32d5 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -45,6 +45,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -72,26 +73,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -101,7 +82,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -200,6 +181,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -209,6 +199,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -228,6 +270,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -241,6 +298,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -260,6 +318,73 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -327,6 +452,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -379,11 +516,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -483,8 +669,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -512,6 +702,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -589,6 +787,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -627,6 +831,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -640,6 +903,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -647,6 +917,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -734,7 +1015,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1092,6 +1373,11 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1101,9 +1387,10 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], "title": "ItemStartedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json b/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json index ec5dffbdbe0..52149d9fb89 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json @@ -1,5 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + } + }, "properties": { "cursor": { "description": "Opaque pagination cursor returned by a previous call.", @@ -8,6 +17,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -20,4 +40,4 @@ }, "title": "ListMcpServerStatusParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json b/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json index 5a9db4090e6..fc181c2702e 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -188,4 +188,4 @@ ], "title": "ListMcpServerStatusResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/code-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index 8e2d38fd6f3..ab7b852c918 100644 --- a/code-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -23,6 +23,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" @@ -37,6 +40,22 @@ "title": "Chatgptv2::LoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { @@ -73,4 +92,4 @@ } ], "title": "LoginAccountParams" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json b/code-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json index 8dd2bd1e3a1..a800bffccd9 100644 --- a/code-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json @@ -42,6 +42,36 @@ "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, { "properties": { "type": { @@ -60,4 +90,4 @@ } ], "title": "LoginAccountResponse" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/LogoutAccountResponse.json b/code-rs/app-server-protocol/schema/json/v2/LogoutAccountResponse.json index 016bed3789b..56415a03113 100644 --- a/code-rs/app-server-protocol/schema/json/v2/LogoutAccountResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/LogoutAccountResponse.json @@ -2,4 +2,4 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "LogoutAccountResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/MarketplaceAddParams.json b/code-rs/app-server-protocol/schema/json/v2/MarketplaceAddParams.json new file mode 100644 index 00000000000..704e5bbc2a7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/MarketplaceAddParams.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refName": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source" + ], + "title": "MarketplaceAddParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/MarketplaceAddResponse.json b/code-rs/app-server-protocol/schema/json/v2/MarketplaceAddResponse.json new file mode 100644 index 00000000000..d00db0d6be2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/MarketplaceAddResponse.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "alreadyAdded": { + "type": "boolean" + }, + "installedRoot": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "alreadyAdded", + "installedRoot", + "marketplaceName" + ], + "title": "MarketplaceAddResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/MarketplaceRemoveParams.json b/code-rs/app-server-protocol/schema/json/v2/MarketplaceRemoveParams.json new file mode 100644 index 00000000000..2c145686cdb --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/MarketplaceRemoveParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "marketplaceName" + ], + "title": "MarketplaceRemoveParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/MarketplaceRemoveResponse.json b/code-rs/app-server-protocol/schema/json/v2/MarketplaceRemoveResponse.json new file mode 100644 index 00000000000..ae4945078a9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/MarketplaceRemoveResponse.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "installedRoot": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "marketplaceName" + ], + "title": "MarketplaceRemoveResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeParams.json b/code-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeParams.json new file mode 100644 index 00000000000..684d134cb67 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "title": "MarketplaceUpgradeParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeResponse.json b/code-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeResponse.json new file mode 100644 index 00000000000..67882416322 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeResponse.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "MarketplaceUpgradeErrorInfo": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "message" + ], + "type": "object" + } + }, + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/MarketplaceUpgradeErrorInfo" + }, + "type": "array" + }, + "selectedMarketplaces": { + "items": { + "type": "string" + }, + "type": "array" + }, + "upgradedRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "errors", + "selectedMarketplaces", + "upgradedRoots" + ], + "title": "MarketplaceUpgradeResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json b/code-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json new file mode 100644 index 00000000000..2fe58155d28 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json new file mode 100644 index 00000000000..b1a40123440 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + } + }, + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json index 8652ad0f69e..35efd2baf2c 100644 --- a/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginCompletedNotification.json @@ -20,4 +20,4 @@ ], "title": "McpServerOauthLoginCompletedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json b/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json index 443ef534719..4370f444b91 100644 --- a/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json @@ -26,4 +26,4 @@ ], "title": "McpServerOauthLoginParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginResponse.json b/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginResponse.json index fbbff3ef7ea..efeb612dd39 100644 --- a/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginResponse.json @@ -10,4 +10,4 @@ ], "title": "McpServerOauthLoginResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpServerRefreshResponse.json b/code-rs/app-server-protocol/schema/json/v2/McpServerRefreshResponse.json index 2244aecfda8..779192e779e 100644 --- a/code-rs/app-server-protocol/schema/json/v2/McpServerRefreshResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/McpServerRefreshResponse.json @@ -2,4 +2,4 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "McpServerRefreshResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json new file mode 100644 index 00000000000..b0e2cd5a072 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/McpServerStatusUpdatedNotification.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpServerStartupState": { + "enum": [ + "starting", + "ready", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpServerStartupState" + } + }, + "required": [ + "name", + "status" + ], + "title": "McpServerStatusUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpServerToolCallParams.json b/code-rs/app-server-protocol/schema/json/v2/McpServerToolCallParams.json new file mode 100644 index 00000000000..3465e60c83b --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/McpServerToolCallParams.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "_meta": true, + "arguments": true, + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "tool": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "tool" + ], + "title": "McpServerToolCallParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpServerToolCallResponse.json b/code-rs/app-server-protocol/schema/json/v2/McpServerToolCallResponse.json new file mode 100644 index 00000000000..0e5ecdf78de --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/McpServerToolCallResponse.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "title": "McpServerToolCallResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/McpToolCallProgressNotification.json b/code-rs/app-server-protocol/schema/json/v2/McpToolCallProgressNotification.json index 96c21a34d4c..419cab74a3b 100644 --- a/code-rs/app-server-protocol/schema/json/v2/McpToolCallProgressNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/McpToolCallProgressNotification.json @@ -22,4 +22,4 @@ ], "title": "McpToolCallProgressNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ModelListParams.json b/code-rs/app-server-protocol/schema/json/v2/ModelListParams.json index ffa1c8ea566..11a3476240c 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ModelListParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ModelListParams.json @@ -27,4 +27,4 @@ }, "title": "ModelListParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/code-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index c139ba83f81..c0221805eb0 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -22,6 +22,14 @@ }, "Model": { "properties": { + "additionalSpeedTiers": { + "default": [], + "description": "Deprecated: use `serviceTiers` instead.", + "items": { + "type": "string" + }, + "type": "array" + }, "availabilityNux": { "anyOf": [ { @@ -63,6 +71,13 @@ "model": { "type": "string" }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/ModelServiceTier" + }, + "type": "array" + }, "supportedReasoningEfforts": { "items": { "$ref": "#/definitions/ReasoningEffortOption" @@ -113,6 +128,25 @@ ], "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { @@ -190,4 +224,4 @@ ], "title": "ModelListResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ModelProviderCapabilitiesReadParams.json b/code-rs/app-server-protocol/schema/json/v2/ModelProviderCapabilitiesReadParams.json new file mode 100644 index 00000000000..2996bca0f61 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ModelProviderCapabilitiesReadParams.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ModelProviderCapabilitiesReadParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ModelProviderCapabilitiesReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/ModelProviderCapabilitiesReadResponse.json new file mode 100644 index 00000000000..08e4c2ada0f --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ModelProviderCapabilitiesReadResponse.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "imageGeneration": { + "type": "boolean" + }, + "namespaceTools": { + "type": "boolean" + }, + "webSearch": { + "type": "boolean" + } + }, + "required": [ + "imageGeneration", + "namespaceTools", + "webSearch" + ], + "title": "ModelProviderCapabilitiesReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json new file mode 100644 index 00000000000..b9bcc491b01 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ModelReroutedNotification.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + } + }, + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json b/code-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json new file mode 100644 index 00000000000..aea6b628eb3 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + } + }, + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PlanDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/PlanDeltaNotification.json index 74b78a1f7b1..6446392626d 100644 --- a/code-rs/app-server-protocol/schema/json/v2/PlanDeltaNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/PlanDeltaNotification.json @@ -23,4 +23,4 @@ ], "title": "PlanDeltaNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json new file mode 100644 index 00000000000..ad3c0c1079a --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginName" + ], + "title": "PluginInstallParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json new file mode 100644 index 00000000000..2ca7fda4613 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AppSummary": { + "description": "EXPERIMENTAL - app metadata summary for plugin responses.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "needsAuth": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "needsAuth" + ], + "type": "object" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + } + }, + "properties": { + "appsNeedingAuth": { + "items": { + "$ref": "#/definitions/AppSummary" + }, + "type": "array" + }, + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + } + }, + "required": [ + "appsNeedingAuth", + "authPolicy" + ], + "title": "PluginInstallResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginListParams.json new file mode 100644 index 00000000000..65b1b4e88d2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + } + }, + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginListParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginListResponse.json new file mode 100644 index 00000000000..b759d7a3fe6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -0,0 +1,479 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "MarketplaceInterface": { + "properties": { + "displayName": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginMarketplaceEntry": { + "properties": { + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/MarketplaceInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." + }, + "plugins": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "plugins" + ], + "type": "object" + }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + } + }, + "properties": { + "featuredPluginIds": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginListResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginReadParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginReadParams.json new file mode 100644 index 00000000000..5cc3e5cab5f --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginReadParams.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "pluginName": { + "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginName" + ], + "title": "PluginReadParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json new file mode 100644 index 00000000000..fe468884f1e --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -0,0 +1,610 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AppSummary": { + "description": "EXPERIMENTAL - app metadata summary for plugin responses.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "needsAuth": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "needsAuth" + ], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginDetail": { + "properties": { + "apps": { + "items": { + "$ref": "#/definitions/AppSummary" + }, + "type": "array" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "mcpServers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/definitions/SkillSummary" + }, + "type": "array" + }, + "summary": { + "$ref": "#/definitions/PluginSummary" + } + }, + "required": [ + "apps", + "hooks", + "marketplaceName", + "mcpServers", + "skills", + "summary" + ], + "type": "object" + }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "eventName", + "key" + ], + "type": "object" + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + }, + "SkillInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "iconLarge": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "iconSmall": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "SkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/SkillInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "enabled", + "name" + ], + "type": "object" + } + }, + "properties": { + "plugin": { + "$ref": "#/definitions/PluginDetail" + } + }, + "required": [ + "plugin" + ], + "title": "PluginReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareDeleteParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareDeleteParams.json new file mode 100644 index 00000000000..2dbdab8ee66 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareDeleteParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareDeleteParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareDeleteResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareDeleteResponse.json new file mode 100644 index 00000000000..95068869aa9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareDeleteResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareDeleteResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareListParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareListParams.json new file mode 100644 index 00000000000..101136d9053 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareListParams.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareListParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json new file mode 100644 index 00000000000..96818dfead7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -0,0 +1,425 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareListItem": { + "properties": { + "localPluginPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "plugin": { + "$ref": "#/definitions/PluginSummary" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "plugin", + "shareUrl" + ], + "type": "object" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PluginShareListItem" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PluginShareListResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json new file mode 100644 index 00000000000..c2692230681 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + } + }, + "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "pluginPath" + ], + "title": "PluginShareSaveParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareSaveResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareSaveResponse.json new file mode 100644 index 00000000000..dbfe091b7ac --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareSaveResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "remotePluginId", + "shareUrl" + ], + "title": "PluginShareSaveResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json new file mode 100644 index 00000000000..f6b44c92eb5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + } + }, + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareUpdateDiscoverability" + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json new file mode 100644 index 00000000000..fe47f1f4afa --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + } + }, + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginSkillReadParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginSkillReadParams.json new file mode 100644 index 00000000000..12d2d3781bb --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginSkillReadParams.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remoteMarketplaceName": { + "type": "string" + }, + "remotePluginId": { + "type": "string" + }, + "skillName": { + "type": "string" + } + }, + "required": [ + "remoteMarketplaceName", + "remotePluginId", + "skillName" + ], + "title": "PluginSkillReadParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginSkillReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginSkillReadResponse.json new file mode 100644 index 00000000000..a1d53bc8e85 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginSkillReadResponse.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "type": [ + "string", + "null" + ] + } + }, + "title": "PluginSkillReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json b/code-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json new file mode 100644 index 00000000000..5b7e0a592f0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json b/code-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json new file mode 100644 index 00000000000..5c0e37bd9da --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ProcessExitedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ProcessExitedNotification.json new file mode 100644 index 00000000000..3a0a81d316e --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ProcessExitedNotification.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", + "type": "string" + }, + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", + "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" + } + }, + "required": [ + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" + ], + "title": "ProcessExitedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ProcessOutputDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/ProcessOutputDeltaNotification.json new file mode 100644 index 00000000000..1800833f2e0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ProcessOutputDeltaNotification.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + } + }, + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" + ], + "title": "ProcessOutputDeltaNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 0d7c6fb6c07..6973d15baa6 100644 --- a/code-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -25,6 +25,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -133,42 +143,6 @@ } ] }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -339,12 +313,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -399,10 +367,6 @@ "null" ] }, - "id": { - "type": "string", - "writeOnly": true - }, "summary": { "items": { "$ref": "#/definitions/ReasoningItemReasoningSummary" @@ -418,7 +382,6 @@ } }, "required": [ - "id", "summary", "type" ], @@ -552,7 +515,7 @@ "type": "string" }, "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" + "$ref": "#/definitions/FunctionCallOutputBody" }, "type": { "enum": [ @@ -623,7 +586,7 @@ ] }, "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" + "$ref": "#/definitions/FunctionCallOutputBody" }, "type": { "enum": [ @@ -681,7 +644,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -749,26 +712,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { @@ -776,9 +719,9 @@ }, "type": { "enum": [ - "compaction_summary" + "compaction" ], - "title": "CompactionSummaryResponseItemType", + "title": "CompactionResponseItemType", "type": "string" } }, @@ -786,7 +729,7 @@ "encrypted_content", "type" ], - "title": "CompactionSummaryResponseItem", + "title": "CompactionResponseItem", "type": "object" }, { @@ -829,7 +772,7 @@ } ] }, - "WebSearchAction": { + "ResponsesApiWebSearchAction": { "oneOf": [ { "properties": { @@ -852,14 +795,14 @@ "enum": [ "search" ], - "title": "SearchWebSearchActionType", + "title": "SearchResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "SearchWebSearchAction", + "title": "SearchResponsesApiWebSearchAction", "type": "object" }, { @@ -868,7 +811,7 @@ "enum": [ "open_page" ], - "title": "OpenPageWebSearchActionType", + "title": "OpenPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -881,7 +824,7 @@ "required": [ "type" ], - "title": "OpenPageWebSearchAction", + "title": "OpenPageResponsesApiWebSearchAction", "type": "object" }, { @@ -896,7 +839,7 @@ "enum": [ "find_in_page" ], - "title": "FindInPageWebSearchActionType", + "title": "FindInPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -909,7 +852,7 @@ "required": [ "type" ], - "title": "FindInPageWebSearchAction", + "title": "FindInPageResponsesApiWebSearchAction", "type": "object" }, { @@ -918,14 +861,14 @@ "enum": [ "other" ], - "title": "OtherWebSearchActionType", + "title": "OtherResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "OtherWebSearchAction", + "title": "OtherResponsesApiWebSearchAction", "type": "object" } ] @@ -949,4 +892,4 @@ ], "title": "RawResponseItemCompletedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryPartAddedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryPartAddedNotification.json index 2dfafd9c173..33debf2a2e4 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryPartAddedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryPartAddedNotification.json @@ -23,4 +23,4 @@ ], "title": "ReasoningSummaryPartAddedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryTextDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryTextDeltaNotification.json index 49e7e313c98..6f50a8403a3 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryTextDeltaNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ReasoningSummaryTextDeltaNotification.json @@ -27,4 +27,4 @@ ], "title": "ReasoningSummaryTextDeltaNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ReasoningTextDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/ReasoningTextDeltaNotification.json index 1ff90ff594c..ebfd5dc8543 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ReasoningTextDeltaNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ReasoningTextDeltaNotification.json @@ -27,4 +27,4 @@ ], "title": "ReasoningTextDeltaNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json b/code-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json new file mode 100644 index 00000000000..8286815ff46 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "RemoteControlConnectionStatus": { + "enum": [ + "disabled", + "connecting", + "connected", + "errored" + ], + "type": "string" + } + }, + "description": "Current remote-control connection status and environment id exposed to clients.", + "properties": { + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/RemoteControlConnectionStatus" + } + }, + "required": [ + "status" + ], + "title": "RemoteControlStatusChangedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ReviewStartParams.json b/code-rs/app-server-protocol/schema/json/v2/ReviewStartParams.json index fe3481c5461..0089d46491a 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ReviewStartParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ReviewStartParams.json @@ -126,4 +126,4 @@ ], "title": "ReviewStartParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/code-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 1e1b9e3c33e..9afd1ae5149 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -31,6 +31,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +42,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +136,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +182,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +210,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +219,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +318,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +336,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -371,6 +407,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -384,6 +435,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -403,6 +455,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -470,6 +596,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -522,11 +660,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -626,8 +813,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -655,6 +846,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -732,6 +931,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -770,6 +975,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -783,6 +1047,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -790,6 +1061,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -877,7 +1159,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1011,6 +1293,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1026,12 +1324,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1071,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1317,4 +1657,4 @@ ], "title": "ReviewStartResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json b/code-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json new file mode 100644 index 00000000000..c3c63edef09 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + } + }, + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json b/code-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json new file mode 100644 index 00000000000..bfeba322b2b --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ServerRequestResolvedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ServerRequestResolvedNotification.json new file mode 100644 index 00000000000..18a5e760798 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ServerRequestResolvedNotification.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ] + } + }, + "properties": { + "requestId": { + "$ref": "#/definitions/RequestId" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "requestId", + "threadId" + ], + "title": "ServerRequestResolvedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsChangedNotification.json b/code-rs/app-server-protocol/schema/json/v2/SkillsChangedNotification.json new file mode 100644 index 00000000000..cb67d816020 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/SkillsChangedNotification.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "title": "SkillsChangedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json b/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json index b6eb2bba031..696226a50c6 100644 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json @@ -1,17 +1,37 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, "properties": { "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteResponse.json b/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteResponse.json index 760ed353c01..09d73b44c32 100644 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteResponse.json @@ -10,4 +10,4 @@ ], "title": "SkillsConfigWriteResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsListParams.json b/code-rs/app-server-protocol/schema/json/v2/SkillsListParams.json index 6e26be1a3cc..a9a8a9ef8d4 100644 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsListParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/SkillsListParams.json @@ -1,25 +1,5 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" - }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "extraUserRoots" - ], - "type": "object" - } - }, "properties": { "cwds": { "description": "When empty, defaults to the current session working directory.", @@ -31,19 +11,8 @@ "forceReload": { "description": "When true, bypass the skills cache and re-scan skills from disk.", "type": "boolean" - }, - "perCwdExtraUserRoots": { - "default": null, - "description": "Optional per-cwd extra roots to scan as user-scoped skills.", - "items": { - "$ref": "#/definitions/SkillsListExtraRootsForCwd" - }, - "type": [ - "array", - "null" - ] } }, "title": "SkillsListParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json b/code-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json index b21165f14fb..6c72bfbb689 100644 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "SkillDependencies": { "properties": { "tools": { @@ -51,15 +55,23 @@ ] }, "iconLarge": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } ] }, "iconSmall": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } ] }, "shortDescription": { @@ -103,7 +115,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "scope": { "$ref": "#/definitions/SkillScope" @@ -212,4 +224,4 @@ ], "title": "SkillsListResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json b/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json deleted file mode 100644 index 0648a943eae..00000000000 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadParams.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "SkillsRemoteReadParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json deleted file mode 100644 index efdd4ad2660..00000000000 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteReadResponse.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "RemoteSkillSummary": { - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "description", - "id", - "name" - ], - "type": "object" - } - }, - "properties": { - "data": { - "items": { - "$ref": "#/definitions/RemoteSkillSummary" - }, - "type": "array" - } - }, - "required": [ - "data" - ], - "title": "SkillsRemoteReadResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json b/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json deleted file mode 100644 index 51d064cb0e8..00000000000 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteParams.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "hazelnutId": { - "type": "string" - }, - "isPreload": { - "type": "boolean" - } - }, - "required": [ - "hazelnutId", - "isPreload" - ], - "title": "SkillsRemoteWriteParams", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json b/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json deleted file mode 100644 index 94595c2d636..00000000000 --- a/code-rs/app-server-protocol/schema/json/v2/SkillsRemoteWriteResponse.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "path" - ], - "title": "SkillsRemoteWriteResponse", - "type": "object" -} diff --git a/code-rs/app-server-protocol/schema/json/v2/TerminalInteractionNotification.json b/code-rs/app-server-protocol/schema/json/v2/TerminalInteractionNotification.json index 4ca84aab757..ca2648a313d 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TerminalInteractionNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/TerminalInteractionNotification.json @@ -26,4 +26,4 @@ ], "title": "TerminalInteractionNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionParams.json new file mode 100644 index 00000000000..3938815ecd3 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "title": "ThreadApproveGuardianDeniedActionParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionResponse.json new file mode 100644 index 00000000000..b173819cc30 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadApproveGuardianDeniedActionResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveParams.json index bcb57ef843e..49322b60a45 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveParams.json @@ -10,4 +10,4 @@ ], "title": "ThreadArchiveParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveResponse.json index 6927d39e1f4..bfd853e59e1 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadArchiveResponse.json @@ -2,4 +2,4 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "ThreadArchiveResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadArchivedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadArchivedNotification.json new file mode 100644 index 00000000000..cd24f957d47 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadArchivedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchivedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadClosedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadClosedNotification.json new file mode 100644 index 00000000000..13e7f577487 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadClosedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadClosedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartParams.json index 2565f78a72c..a174ff95d9d 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartParams.json @@ -10,4 +10,4 @@ ], "title": "ThreadCompactStartParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartResponse.json index c6b20db1ede..bb372b6ddd7 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadCompactStartResponse.json @@ -2,4 +2,4 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "ThreadCompactStartResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index fccd712686d..29d67403cd5 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,6 +1,19 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -15,7 +28,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -44,9 +57,68 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] @@ -58,6 +130,14 @@ "danger-full-access" ], "type": "string" + }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" } }, "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -72,6 +152,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -97,6 +188,9 @@ "null" ] }, + "ephemeral": { + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ @@ -120,8 +214,25 @@ } ] }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -129,4 +240,4 @@ ], "title": "ThreadForkParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index fad395fc5cb..6e74ab4ac8f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -5,6 +5,71 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -19,7 +84,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -48,9 +113,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -81,6 +146,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -91,35 +157,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -214,6 +251,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -238,6 +297,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -265,26 +325,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -294,7 +334,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -393,6 +433,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -402,6 +451,254 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -444,6 +741,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -457,6 +769,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -476,6 +789,73 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -483,6 +863,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -550,6 +937,135 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -582,6 +1098,10 @@ }, { "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" @@ -669,6 +1189,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -699,6 +1232,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -760,6 +1318,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -770,8 +1342,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -791,6 +1378,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -802,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -810,6 +1408,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -827,15 +1444,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -868,11 +1495,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -972,8 +1648,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -1001,6 +1681,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1078,6 +1766,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1116,6 +1810,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -1129,6 +1882,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -1136,6 +1896,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1223,7 +1994,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1355,8 +2126,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1372,12 +2242,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1417,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1652,8 +2564,24 @@ "approvalPolicy": { "$ref": "#/definitions/AskForApproval" }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, "cwd": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" }, "model": { "type": "string" @@ -1672,7 +2600,18 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] }, "thread": { "$ref": "#/definitions/Thread" @@ -1680,6 +2619,7 @@ }, "required": [ "approvalPolicy", + "approvalsReviewer", "cwd", "model", "modelProvider", @@ -1688,4 +2628,4 @@ ], "title": "ThreadForkResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json new file mode 100644 index 00000000000..c1fe94b9101 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json new file mode 100644 index 00000000000..52a2e905a2d --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + } + }, + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadInjectItemsParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadInjectItemsParams.json new file mode 100644 index 00000000000..d117f3ae0e6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadInjectItemsParams.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "items": { + "description": "Raw Responses API items to append to the thread's model-visible history.", + "items": true, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "items", + "threadId" + ], + "title": "ThreadInjectItemsParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadInjectItemsResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadInjectItemsResponse.json new file mode 100644 index 00000000000..2ba62b22149 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadInjectItemsResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadInjectItemsResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 4db9cb24801..789d9b61fcd 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -1,6 +1,26 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadSortKey": { "enum": [ "created_at", @@ -40,11 +60,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -65,6 +89,24 @@ "null" ] }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional sort direction; defaults to descending (newest first)." + }, "sortKey": { "anyOf": [ { @@ -85,8 +127,12 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index cc36c930fb6..f78fbaf27e9 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -31,6 +34,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +45,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +139,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +185,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +213,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +222,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +321,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +339,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -394,6 +433,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -407,6 +461,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -426,6 +481,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -493,6 +622,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -505,6 +646,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -535,6 +689,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -596,6 +775,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -606,8 +799,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -627,6 +835,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -638,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -646,6 +865,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -663,15 +901,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -706,9 +954,58 @@ }, { "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, "id": { "type": "string" }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -808,8 +1105,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -837,6 +1138,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -914,6 +1223,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -952,6 +1267,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -965,6 +1339,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -972,6 +1353,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1059,7 +1451,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1191,8 +1583,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1208,12 +1699,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1253,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1485,6 +2018,13 @@ } }, "properties": { + "backwardsCursor": { + "description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.", + "type": [ + "string", + "null" + ] + }, "data": { "items": { "$ref": "#/definitions/Thread" @@ -1504,4 +2044,4 @@ ], "title": "ThreadListResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json index 581a446f9b0..d10ee7ed96c 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json @@ -20,4 +20,4 @@ }, "title": "ThreadLoadedListParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json index 582abfdb9d4..cfd90fb813f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json @@ -21,4 +21,4 @@ ], "title": "ThreadLoadedListResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json new file mode 100644 index 00000000000..c6679568ea5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMetadataUpdateParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json new file mode 100644 index 00000000000..4268ad203a0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -0,0 +1,2030 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AgentPath": { + "type": "string" + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "name", + "path", + "type" + ], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "GitInfo": { + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "SessionSource": { + "oneOf": [ + { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": [ + "subAgent" + ], + "title": "SubAgentSessionSource", + "type": "object" + } + ] + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": [ + "review", + "compact", + "memory_consolidation" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": [ + "depth", + "parent_thread_id" + ], + "type": "object" + } + }, + "required": [ + "thread_spawn" + ], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "byteRange" + ], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "id": { + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "plan" + ], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": [ + "changes", + "id", + "status", + "type" + ], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "query", + "type" + ], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "path", + "type" + ], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "Turn": { + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": [ + "id", + "items", + "status" + ], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, + "TurnStatus": { + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ], + "type": "string" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "image" + ], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "skill" + ], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "mention" + ], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + } + }, + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadMetadataUpdateResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadNameUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadNameUpdatedNotification.json index 9d1be76a174..8c3b2095f5f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadNameUpdatedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadNameUpdatedNotification.json @@ -16,4 +16,4 @@ ], "title": "ThreadNameUpdatedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json index 299006d8e3d..f5e5503cc0b 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json @@ -15,4 +15,4 @@ ], "title": "ThreadReadParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8bb04c0e731..fb0d80a047f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -31,6 +34,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +45,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +139,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +185,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +213,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +222,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +321,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +339,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -394,6 +433,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -407,6 +461,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -426,6 +481,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -493,6 +622,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -505,6 +646,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -535,6 +689,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -596,6 +775,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -606,8 +799,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -627,6 +835,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -638,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -646,6 +865,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -663,15 +901,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -704,11 +952,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -808,8 +1105,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -837,6 +1138,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -914,6 +1223,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -952,6 +1267,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -965,6 +1339,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -972,6 +1353,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1059,7 +1451,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1191,8 +1583,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1208,12 +1699,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1253,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1494,4 +2027,4 @@ ], "title": "ThreadReadResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeClosedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeClosedNotification.json new file mode 100644 index 00000000000..edfba83b7bc --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeClosedNotification.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime transport closes.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadRealtimeClosedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeErrorNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeErrorNotification.json new file mode 100644 index 00000000000..e7ec760302d --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeErrorNotification.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime encounters an error.", + "properties": { + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "ThreadRealtimeErrorNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeItemAddedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeItemAddedNotification.json new file mode 100644 index 00000000000..06de7e00e72 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeItemAddedNotification.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId" + ], + "title": "ThreadRealtimeItemAddedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeOutputAudioDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeOutputAudioDeltaNotification.json new file mode 100644 index 00000000000..6c75f675539 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeOutputAudioDeltaNotification.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": [ + "string", + "null" + ] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + } + }, + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" + ], + "title": "ThreadRealtimeOutputAudioDeltaNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeSdpNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeSdpNotification.json new file mode 100644 index 00000000000..907dc8568f4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeSdpNotification.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session.", + "properties": { + "sdp": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "sdp", + "threadId" + ], + "title": "ThreadRealtimeSdpNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json new file mode 100644 index 00000000000..0beb774e763 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + } + }, + "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", + "properties": { + "realtimeSessionId": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" + } + }, + "required": [ + "threadId", + "version" + ], + "title": "ThreadRealtimeStartedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptDeltaNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptDeltaNotification.json new file mode 100644 index 00000000000..22ad778eb2a --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptDeltaNotification.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "delta": { + "description": "Live transcript delta from the realtime event.", + "type": "string" + }, + "role": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "delta", + "role", + "threadId" + ], + "title": "ThreadRealtimeTranscriptDeltaNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptDoneNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptDoneNotification.json new file mode 100644 index 00000000000..2f4199fdb9e --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRealtimeTranscriptDoneNotification.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - final transcript text emitted when realtime completes a transcript part.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "description": "Final complete text for the transcript part.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId" + ], + "title": "ThreadRealtimeTranscriptDoneNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index a686eac8331..5f07fe0149d 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,19 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -15,7 +28,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -44,9 +57,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -75,6 +88,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -183,42 +206,6 @@ } ] }, - "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", - "properties": { - "body": { - "$ref": "#/definitions/FunctionCallOutputBody" - }, - "success": { - "type": [ - "boolean", - "null" - ] - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -311,6 +298,65 @@ } ] }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -397,12 +443,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -457,10 +497,6 @@ "null" ] }, - "id": { - "type": "string", - "writeOnly": true - }, "summary": { "items": { "$ref": "#/definitions/ReasoningItemReasoningSummary" @@ -476,7 +512,6 @@ } }, "required": [ - "id", "summary", "type" ], @@ -610,7 +645,7 @@ "type": "string" }, "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" + "$ref": "#/definitions/FunctionCallOutputBody" }, "type": { "enum": [ @@ -681,7 +716,7 @@ ] }, "output": { - "$ref": "#/definitions/FunctionCallOutputPayload" + "$ref": "#/definitions/FunctionCallOutputBody" }, "type": { "enum": [ @@ -739,7 +774,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -807,26 +842,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { @@ -834,9 +849,9 @@ }, "type": { "enum": [ - "compaction_summary" + "compaction" ], - "title": "CompactionSummaryResponseItemType", + "title": "CompactionResponseItemType", "type": "string" } }, @@ -844,7 +859,7 @@ "encrypted_content", "type" ], - "title": "CompactionSummaryResponseItem", + "title": "CompactionResponseItem", "type": "object" }, { @@ -887,15 +902,7 @@ } ] }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "WebSearchAction": { + "ResponsesApiWebSearchAction": { "oneOf": [ { "properties": { @@ -918,14 +925,14 @@ "enum": [ "search" ], - "title": "SearchWebSearchActionType", + "title": "SearchResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "SearchWebSearchAction", + "title": "SearchResponsesApiWebSearchAction", "type": "object" }, { @@ -934,7 +941,7 @@ "enum": [ "open_page" ], - "title": "OpenPageWebSearchActionType", + "title": "OpenPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -947,7 +954,7 @@ "required": [ "type" ], - "title": "OpenPageWebSearchAction", + "title": "OpenPageResponsesApiWebSearchAction", "type": "object" }, { @@ -962,7 +969,7 @@ "enum": [ "find_in_page" ], - "title": "FindInPageWebSearchActionType", + "title": "FindInPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -975,7 +982,7 @@ "required": [ "type" ], - "title": "FindInPageWebSearchAction", + "title": "FindInPageResponsesApiWebSearchAction", "type": "object" }, { @@ -984,17 +991,25 @@ "enum": [ "other" ], - "title": "OtherWebSearchActionType", + "title": "OtherResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "OtherWebSearchAction", + "title": "OtherResponsesApiWebSearchAction", "type": "object" } ] + }, + "SandboxMode": { + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ], + "type": "string" } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -1009,6 +1024,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -1067,6 +1093,12 @@ } ] }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" } @@ -1076,4 +1108,4 @@ ], "title": "ThreadResumeParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 2013f0b9204..727b7a3fb2f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -5,6 +5,71 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -19,7 +84,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -48,9 +113,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -81,6 +146,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -91,35 +157,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -214,6 +251,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -238,6 +297,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -265,26 +325,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -294,7 +334,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -393,6 +433,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -402,6 +451,254 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -444,6 +741,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -457,6 +769,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -476,6 +789,73 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -483,6 +863,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -550,6 +937,135 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -582,6 +1098,10 @@ }, { "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" @@ -669,6 +1189,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -699,6 +1232,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -760,6 +1318,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -770,8 +1342,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -791,6 +1378,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -802,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -810,6 +1408,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -827,15 +1444,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -868,11 +1495,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -972,8 +1648,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -1001,6 +1681,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1078,6 +1766,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1116,6 +1810,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -1129,6 +1882,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -1136,6 +1896,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1223,7 +1994,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1355,8 +2126,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1372,12 +2242,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1417,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1652,8 +2564,24 @@ "approvalPolicy": { "$ref": "#/definitions/AskForApproval" }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, "cwd": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" }, "model": { "type": "string" @@ -1672,7 +2600,18 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] }, "thread": { "$ref": "#/definitions/Thread" @@ -1680,6 +2619,7 @@ }, "required": [ "approvalPolicy", + "approvalsReviewer", "cwd", "model", "modelProvider", @@ -1688,4 +2628,4 @@ ], "title": "ThreadResumeResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackParams.json index ebf585e873f..cb3ba0db391 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackParams.json @@ -17,4 +17,4 @@ ], "title": "ThreadRollbackParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 0c9fe78b06c..204828c732c 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -31,6 +34,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +45,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +139,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +185,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +213,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +222,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +321,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +339,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -394,6 +433,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -407,6 +461,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -426,6 +481,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -493,6 +622,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -505,6 +646,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -535,6 +689,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -596,6 +775,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -606,8 +799,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -627,6 +835,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -638,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -646,6 +865,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -663,15 +901,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -704,11 +952,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -808,8 +1105,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -837,6 +1138,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -914,6 +1223,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -952,6 +1267,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -965,6 +1339,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -972,6 +1353,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1059,7 +1451,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1191,8 +1583,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1208,12 +1699,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1253,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1499,4 +2032,4 @@ ], "title": "ThreadRollbackResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameParams.json index c128184ac68..9381c7cb127 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameParams.json @@ -14,4 +14,4 @@ ], "title": "ThreadSetNameParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameResponse.json index 65e984434ac..3d25712ff05 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadSetNameResponse.json @@ -2,4 +2,4 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "ThreadSetNameResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadShellCommandParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadShellCommandParams.json new file mode 100644 index 00000000000..13ef468a519 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadShellCommandParams.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "title": "ThreadShellCommandParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadShellCommandResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadShellCommandResponse.json new file mode 100644 index 00000000000..06e9d81a3a7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadShellCommandResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadShellCommandResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 2e9dab29d5e..9a60049a61f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -1,6 +1,19 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -15,7 +28,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -44,9 +57,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -77,6 +90,65 @@ ], "type": "object" }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -92,6 +164,36 @@ "danger-full-access" ], "type": "string" + }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" + }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" } }, "properties": { @@ -105,6 +207,17 @@ } ] }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this thread and subsequent turns." + }, "baseInstructions": { "type": [ "string", @@ -167,8 +280,41 @@ "type": "null" } ] + }, + "serviceName": { + "type": [ + "string", + "null" + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadStartSource" + }, + { + "type": "null" + } + ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 0acd3f7a15a..bf03f0fb557 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -5,6 +5,71 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -19,7 +84,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -48,9 +113,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -81,6 +146,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -91,35 +157,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -214,6 +251,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -238,6 +297,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -265,26 +325,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -294,7 +334,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -393,6 +433,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -402,6 +451,254 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -444,6 +741,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -457,6 +769,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -476,6 +789,73 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -483,6 +863,13 @@ ], "type": "string" }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -550,6 +937,135 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -582,6 +1098,10 @@ }, { "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" @@ -669,6 +1189,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -699,6 +1232,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -760,6 +1318,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -770,8 +1342,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -791,6 +1378,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -802,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -810,6 +1408,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -827,15 +1444,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -868,11 +1495,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -972,8 +1648,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -1001,6 +1681,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -1078,6 +1766,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1116,6 +1810,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -1129,6 +1882,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -1136,6 +1896,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1223,7 +1994,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1355,8 +2126,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1372,12 +2242,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1417,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1652,8 +2564,24 @@ "approvalPolicy": { "$ref": "#/definitions/AskForApproval" }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, "cwd": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" }, "model": { "type": "string" @@ -1672,7 +2600,18 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + }, + "serviceTier": { + "type": [ + "string", + "null" + ] }, "thread": { "$ref": "#/definitions/Thread" @@ -1680,6 +2619,7 @@ }, "required": [ "approvalPolicy", + "approvalsReviewer", "cwd", "model", "modelProvider", @@ -1688,4 +2628,4 @@ ], "title": "ThreadStartResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 9a7fda0514a..759b5990be4 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -31,6 +34,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +45,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +139,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +185,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +213,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +222,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +321,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +339,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -394,6 +433,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -407,6 +461,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -426,6 +481,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -493,6 +622,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -505,6 +646,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -535,6 +689,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -596,6 +775,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -606,8 +799,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -627,6 +835,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -638,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -646,6 +865,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -663,15 +901,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -704,11 +952,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -808,8 +1105,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -837,6 +1138,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -914,6 +1223,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -952,6 +1267,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -965,6 +1339,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -972,6 +1353,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1059,7 +1451,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1191,8 +1583,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1208,12 +1699,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1253,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1494,4 +2027,4 @@ ], "title": "ThreadStartedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadStatusChangedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadStatusChangedNotification.json new file mode 100644 index 00000000000..bd6585042ff --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadStatusChangedNotification.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + } + }, + "properties": { + "status": { + "$ref": "#/definitions/ThreadStatus" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "status", + "threadId" + ], + "title": "ThreadStatusChangedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadTokenUsageUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadTokenUsageUpdatedNotification.json index 00e3b0222dc..111de85c62f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadTokenUsageUpdatedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadTokenUsageUpdatedNotification.json @@ -74,4 +74,4 @@ ], "title": "ThreadTokenUsageUpdatedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveParams.json index 9f5e036c3bf..fd62a96cc3a 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveParams.json @@ -10,4 +10,4 @@ ], "title": "ThreadUnarchiveParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 4922c835e8a..f64400129a1 100644 --- a/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentPath": { + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -31,6 +34,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +45,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +139,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +185,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +213,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +222,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +321,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +339,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -394,6 +433,21 @@ }, "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -407,6 +461,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -426,6 +481,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -493,6 +622,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "SessionSource": { "oneOf": [ { @@ -505,6 +646,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -535,6 +689,31 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -596,6 +775,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" @@ -606,8 +799,23 @@ "type": "integer" }, "cwd": { - "description": "Working directory captured for the thread.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] }, "gitInfo": { "anyOf": [ @@ -627,6 +835,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -638,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -646,6 +865,25 @@ ], "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -663,15 +901,25 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", + "sessionId", "source", + "status", "turns", "updatedAt" ], "type": "object" }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, "ThreadId": { "type": "string" }, @@ -704,11 +952,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -808,8 +1105,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -837,6 +1138,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -914,6 +1223,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -952,6 +1267,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -965,6 +1339,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -972,6 +1353,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -1059,7 +1451,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1191,8 +1583,107 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1208,12 +1699,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1253,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1494,4 +2027,4 @@ ], "title": "ThreadUnarchiveResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchivedNotification.json b/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchivedNotification.json new file mode 100644 index 00000000000..7e4bcd566e4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadUnarchivedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchivedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadUnsubscribeParams.json b/code-rs/app-server-protocol/schema/json/v2/ThreadUnsubscribeParams.json new file mode 100644 index 00000000000..dec3670cc20 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadUnsubscribeParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnsubscribeParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/ThreadUnsubscribeResponse.json b/code-rs/app-server-protocol/schema/json/v2/ThreadUnsubscribeResponse.json new file mode 100644 index 00000000000..2e545dbf9f8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/ThreadUnsubscribeResponse.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadUnsubscribeStatus": { + "enum": [ + "notLoaded", + "notSubscribed", + "unsubscribed" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/ThreadUnsubscribeStatus" + } + }, + "required": [ + "status" + ], + "title": "ThreadUnsubscribeResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 1d31d47efee..e5e2558e9c5 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -31,6 +31,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +42,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +136,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +182,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +210,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +219,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +318,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +336,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -371,6 +407,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -384,6 +435,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -403,6 +455,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -470,6 +596,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -522,11 +660,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -626,8 +813,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -655,6 +846,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -732,6 +931,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -770,6 +975,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -783,6 +1047,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -790,6 +1061,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -877,7 +1159,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1011,6 +1293,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1026,12 +1324,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1071,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1316,4 +1656,4 @@ ], "title": "TurnCompletedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnDiffUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/TurnDiffUpdatedNotification.json index 51ebd883b92..b694ce254ce 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnDiffUpdatedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnDiffUpdatedNotification.json @@ -19,4 +19,4 @@ ], "title": "TurnDiffUpdatedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnInterruptParams.json b/code-rs/app-server-protocol/schema/json/v2/TurnInterruptParams.json index bf390ed8329..9181428a10e 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnInterruptParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnInterruptParams.json @@ -14,4 +14,4 @@ ], "title": "TurnInterruptParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnInterruptResponse.json b/code-rs/app-server-protocol/schema/json/v2/TurnInterruptResponse.json index d4b1ebb5cea..5d8a0f9ce22 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnInterruptResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnInterruptResponse.json @@ -2,4 +2,4 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "TurnInterruptResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json b/code-rs/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json index 345fa3f6705..5a28ffbf17d 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnPlanUpdatedNotification.json @@ -52,4 +52,4 @@ ], "title": "TurnPlanUpdatedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/code-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index c55b84473e5..1ef33d4301b 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -5,6 +5,15 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -19,7 +28,7 @@ { "additionalProperties": false, "properties": { - "reject": { + "granular": { "properties": { "mcp_elicitations": { "type": "boolean" @@ -48,9 +57,9 @@ } }, "required": [ - "reject" + "granular" ], - "title": "RejectAskForApproval", + "title": "GranularAskForApproval", "type": "object" } ] @@ -105,6 +114,65 @@ ], "type": "string" }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -165,6 +233,10 @@ }, { "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" @@ -291,6 +363,21 @@ ], "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { @@ -424,6 +511,17 @@ ], "description": "Override the approval policy for this turn and subsequent turns." }, + "approvalsReviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ], + "description": "Override where approval requests are routed for review on this turn and subsequent turns." + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -480,6 +578,13 @@ ], "description": "Override the sandbox policy for this turn and subsequent turns." }, + "serviceTier": { + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, "summary": { "anyOf": [ { @@ -501,4 +606,4 @@ ], "title": "TurnStartParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/code-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 989829bffae..a2eff7fdd81 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -31,6 +31,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +42,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +136,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +182,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +210,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +219,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +318,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +336,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -371,6 +407,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -384,6 +435,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -403,6 +455,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -470,6 +596,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -522,11 +660,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -626,8 +813,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -655,6 +846,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -732,6 +931,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -770,6 +975,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -783,6 +1047,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -790,6 +1061,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -877,7 +1159,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1011,6 +1293,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1026,12 +1324,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1071,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1312,4 +1652,4 @@ ], "title": "TurnStartResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/code-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index a65936a6604..0952db2acaa 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -31,6 +31,7 @@ "enum": [ "contextWindowExceeded", "usageLimitExceeded", + "serverOverloaded", "cyberPolicy", "internalServerError", "unauthorized", @@ -41,35 +42,6 @@ ], "type": "string" }, - { - "additionalProperties": false, - "properties": { - "modelCap": { - "properties": { - "model": { - "type": "string" - }, - "reset_after_seconds": { - "format": "uint64", - "minimum": 0.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "model" - ], - "type": "object" - } - }, - "required": [ - "modelCap" - ], - "title": "ModelCapCodexErrorInfo", - "type": "object" - }, { "additionalProperties": false, "properties": { @@ -164,6 +136,28 @@ ], "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": [ + "turnKind" + ], + "type": "object" + } + }, + "required": [ + "activeTurnNotSteerable" + ], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" } ] }, @@ -188,6 +182,7 @@ "enum": [ "pendingInit", "running", + "interrupted", "completed", "errored", "shutdown", @@ -215,26 +210,6 @@ }, "CommandAction": { "oneOf": [ - { - "properties": { - "command": { - "type": "string" - }, - "type": { - "enum": [ - "readCommand" - ], - "title": "ReadCommandCommandActionType", - "type": "string" - } - }, - "required": [ - "command", - "type" - ], - "title": "ReadCommandCommandAction", - "type": "object" - }, { "properties": { "command": { @@ -244,7 +219,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -343,6 +318,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -352,6 +336,58 @@ ], "type": "string" }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, "FileUpdateChange": { "properties": { "diff": { @@ -371,6 +407,21 @@ ], "type": "object" }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "hookRunId", + "text" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -384,6 +435,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -403,6 +455,80 @@ ], "type": "string" }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "entries", + "threadIds" + ], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "NonSteerableTurnKind": { + "enum": [ + "review", + "compact" + ], + "type": "string" + }, "PatchApplyStatus": { "enum": [ "inProgress", @@ -470,6 +596,18 @@ } ] }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -522,11 +660,60 @@ "title": "UserMessageThreadItem", "type": "object" }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": [ + "fragments", + "id", + "type" + ], + "title": "HookPromptThreadItem", + "type": "object" + }, { "properties": { "id": { "type": "string" }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "text": { "type": "string" }, @@ -626,8 +813,12 @@ "type": "array" }, "cwd": { - "description": "The command's working directory.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "The command's working directory." }, "durationMs": { "description": "The duration of the command execution in milliseconds.", @@ -655,6 +846,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -732,6 +931,12 @@ "id": { "type": "string" }, + "mcpAppResourceUri": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -770,6 +975,65 @@ "title": "McpToolCallThreadItem", "type": "object" }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, { "properties": { "agentsStates": { @@ -783,6 +1047,13 @@ "description": "Unique identifier for this collab tool call.", "type": "string" }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, "prompt": { "description": "Prompt text sent as part of the collab tool call, when available.", "type": [ @@ -790,6 +1061,17 @@ "null" ] }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, "receiverThreadIds": { "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", "items": { @@ -877,7 +1159,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -1011,6 +1293,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1026,12 +1324,29 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } @@ -1071,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -1316,4 +1656,4 @@ ], "title": "TurnStartedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/code-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index 20c0f238a65..a064d9e7e3f 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -186,4 +186,4 @@ ], "title": "TurnSteerParams", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/TurnSteerResponse.json b/code-rs/app-server-protocol/schema/json/v2/TurnSteerResponse.json index 3a036464b91..d801a3613c6 100644 --- a/code-rs/app-server-protocol/schema/json/v2/TurnSteerResponse.json +++ b/code-rs/app-server-protocol/schema/json/v2/TurnSteerResponse.json @@ -10,4 +10,4 @@ ], "title": "TurnSteerResponse", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/WarningNotification.json b/code-rs/app-server-protocol/schema/json/v2/WarningNotification.json new file mode 100644 index 00000000000..460486896df --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/WarningNotification.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "message" + ], + "title": "WarningNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json new file mode 100644 index 00000000000..de5ee264cb8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupCompletedNotification.json b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupCompletedNotification.json new file mode 100644 index 00000000000..9ed9632fd30 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupCompletedNotification.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + } + }, + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "mode", + "success" + ], + "title": "WindowsSandboxSetupCompletedNotification", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json new file mode 100644 index 00000000000..ed93913cb39 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartParams.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + } + }, + "properties": { + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + } + }, + "required": [ + "mode" + ], + "title": "WindowsSandboxSetupStartParams", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartResponse.json b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartResponse.json new file mode 100644 index 00000000000..ce35665bca4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/json/v2/WindowsSandboxSetupStartResponse.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "started": { + "type": "boolean" + } + }, + "required": [ + "started" + ], + "title": "WindowsSandboxSetupStartResponse", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/json/v2/WindowsWorldWritableWarningNotification.json b/code-rs/app-server-protocol/schema/json/v2/WindowsWorldWritableWarningNotification.json index 3083d961b94..893dbbaf107 100644 --- a/code-rs/app-server-protocol/schema/json/v2/WindowsWorldWritableWarningNotification.json +++ b/code-rs/app-server-protocol/schema/json/v2/WindowsWorldWritableWarningNotification.json @@ -23,4 +23,4 @@ ], "title": "WindowsWorldWritableWarningNotification", "type": "object" -} +} \ No newline at end of file diff --git a/code-rs/app-server-protocol/schema/typescript/AddConversationListenerParams.ts b/code-rs/app-server-protocol/schema/typescript/AddConversationListenerParams.ts deleted file mode 100644 index 6441bed68a5..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AddConversationListenerParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type AddConversationListenerParams = { conversationId: ThreadId, experimentalRawEvents: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AddConversationSubscriptionResponse.ts b/code-rs/app-server-protocol/schema/typescript/AddConversationSubscriptionResponse.ts deleted file mode 100644 index f7e34ef658a..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AddConversationSubscriptionResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AddConversationSubscriptionResponse = { subscriptionId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentMessageContent.ts b/code-rs/app-server-protocol/schema/typescript/AgentMessageContent.ts deleted file mode 100644 index dc2cfb77e38..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentMessageContent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentMessageContent = { "type": "Text", text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentMessageContentDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentMessageContentDeltaEvent.ts deleted file mode 100644 index 1473a4f2bc2..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentMessageContentDeltaEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentMessageContentDeltaEvent = { thread_id: string, turn_id: string, item_id: string, delta: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentMessageDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentMessageDeltaEvent.ts deleted file mode 100644 index 1e12d85fbbb..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentMessageDeltaEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentMessageDeltaEvent = { delta: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentMessageEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentMessageEvent.ts deleted file mode 100644 index ee436566e0c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentMessageEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentMessageEvent = { message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentMessageItem.ts b/code-rs/app-server-protocol/schema/typescript/AgentMessageItem.ts deleted file mode 100644 index c3d022630cc..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentMessageItem.ts +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentMessageContent } from "./AgentMessageContent"; -import type { MessagePhase } from "./MessagePhase"; - -/** - * Assistant-authored message payload used in turn-item streams. - * - * `phase` is optional because not all providers/models emit it. Consumers - * should use it when present, but retain legacy completion semantics when it - * is `None`. - */ -export type AgentMessageItem = { id: string, content: Array, -/** - * Optional phase metadata carried through from `ResponseItem::Message`. - * - * This is currently used by TUI rendering to distinguish mid-turn - * commentary from a final answer and avoid status-indicator jitter. - */ -phase?: MessagePhase, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentPath.ts b/code-rs/app-server-protocol/schema/typescript/AgentPath.ts new file mode 100644 index 00000000000..6e55ce69e20 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/AgentPath.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AgentPath = string; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentReasoningDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentReasoningDeltaEvent.ts deleted file mode 100644 index fc2c221937b..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentReasoningDeltaEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentReasoningDeltaEvent = { delta: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentReasoningEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentReasoningEvent.ts deleted file mode 100644 index bf0062cd431..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentReasoningEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentReasoningEvent = { text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentReasoningRawContentDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentReasoningRawContentDeltaEvent.ts deleted file mode 100644 index fcfa816f5dd..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentReasoningRawContentDeltaEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentReasoningRawContentDeltaEvent = { delta: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentReasoningRawContentEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentReasoningRawContentEvent.ts deleted file mode 100644 index 364c278229d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentReasoningRawContentEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentReasoningRawContentEvent = { text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentReasoningSectionBreakEvent.ts b/code-rs/app-server-protocol/schema/typescript/AgentReasoningSectionBreakEvent.ts deleted file mode 100644 index 604aceed933..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentReasoningSectionBreakEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AgentReasoningSectionBreakEvent = { item_id: string, summary_index: bigint, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AgentStatus.ts b/code-rs/app-server-protocol/schema/typescript/AgentStatus.ts deleted file mode 100644 index ddf6789c78d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AgentStatus.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Agent lifecycle status, derived from emitted events. - */ -export type AgentStatus = "pending_init" | "running" | { "completed": string | null } | { "errored": string } | "shutdown" | "not_found"; diff --git a/code-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts b/code-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts index fafa29ee425..34060772564 100644 --- a/code-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalParams.ts @@ -6,8 +6,8 @@ import type { ThreadId } from "./ThreadId"; export type ApplyPatchApprovalParams = { conversationId: ThreadId, /** - * Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] - * and [codex_core::protocol::PatchApplyEndEvent]. + * Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] + * and [codex_protocol::protocol::PatchApplyEndEvent]. */ callId: string, fileChanges: { [key in string]?: FileChange }, /** diff --git a/code-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalRequestEvent.ts b/code-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalRequestEvent.ts deleted file mode 100644 index 72f89b8a461..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ApplyPatchApprovalRequestEvent.ts +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileChange } from "./FileChange"; - -export type ApplyPatchApprovalRequestEvent = { -/** - * Responses API call id for the associated patch apply call, if available. - */ -call_id: string, -/** - * Turn ID that this patch belongs to. - * Uses `#[serde(default)]` for backwards compatibility with older senders. - */ -turn_id: string, changes: { [key in string]?: FileChange }, -/** - * Optional explanatory reason (e.g. request for extra write access). - */ -reason: string | null, -/** - * When set, the agent is asking the user to allow writes under this root for the remainder of the session. - */ -grant_root: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ArchiveConversationParams.ts b/code-rs/app-server-protocol/schema/typescript/ArchiveConversationParams.ts deleted file mode 100644 index 61fbcc9fc84..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ArchiveConversationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type ArchiveConversationParams = { conversationId: ThreadId, rolloutPath: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ArchiveConversationResponse.ts b/code-rs/app-server-protocol/schema/typescript/ArchiveConversationResponse.ts deleted file mode 100644 index 24900592b2e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ArchiveConversationResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ArchiveConversationResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/AskForApproval.ts b/code-rs/app-server-protocol/schema/typescript/AskForApproval.ts deleted file mode 100644 index 227eb44e77d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AskForApproval.ts +++ /dev/null @@ -1,10 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RejectConfig } from "./RejectConfig"; - -/** - * Determines the conditions under which the user is consulted to approve - * running the command proposed by Codex. - */ -export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "reject": RejectConfig } | "never"; diff --git a/code-rs/app-server-protocol/schema/typescript/AuthMode.ts b/code-rs/app-server-protocol/schema/typescript/AuthMode.ts index 5e0cad8864d..210e54c4a5f 100644 --- a/code-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/code-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity"; diff --git a/code-rs/app-server-protocol/schema/typescript/AuthStatusChangeNotification.ts b/code-rs/app-server-protocol/schema/typescript/AuthStatusChangeNotification.ts deleted file mode 100644 index 17cb442fe09..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AuthStatusChangeNotification.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthMode } from "./AuthMode"; - -/** - * Deprecated notification. Use AccountUpdatedNotification instead. - */ -export type AuthStatusChangeNotification = { authMethod: AuthMode | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AutoContextCheckEvent.ts b/code-rs/app-server-protocol/schema/typescript/AutoContextCheckEvent.ts deleted file mode 100644 index 8de7981b38d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AutoContextCheckEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AutoContextPhase } from "./AutoContextPhase"; - -export type AutoContextCheckEvent = { phase: AutoContextPhase | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AutoContextPhase.ts b/code-rs/app-server-protocol/schema/typescript/AutoContextPhase.ts deleted file mode 100644 index ab2b84cbbfb..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AutoContextPhase.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AutoContextPhase = "checking" | "compacting"; diff --git a/code-rs/app-server-protocol/schema/typescript/AutomationOrigin.ts b/code-rs/app-server-protocol/schema/typescript/AutomationOrigin.ts deleted file mode 100644 index c0029b47a15..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AutomationOrigin.ts +++ /dev/null @@ -1,34 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AutomationTriggerKind } from "./AutomationTriggerKind"; - -export type AutomationOrigin = { kind: AutomationTriggerKind, -/** - * Tool, worker, or integration that launched this automated session. - */ -source?: string, -/** - * Repository name in `owner/repo` form, when the trigger came from GitHub. - */ -repository?: string, -/** - * GitHub issue or pull request number associated with the trigger. - */ -issue_number?: bigint, -/** - * Label that triggered automation, such as `every-code`. - */ -label?: string, -/** - * GitHub event delivery id, webhook id, or local request id. - */ -event_id?: string, -/** - * Actor reported by the source system as applying the trigger. - */ -actor?: string, -/** - * Direct URL to the triggering issue, PR, event, or worker record. - */ -url?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/AutomationTriggerKind.ts b/code-rs/app-server-protocol/schema/typescript/AutomationTriggerKind.ts deleted file mode 100644 index 807f519f7da..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/AutomationTriggerKind.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AutomationTriggerKind = "github_label" | "other"; diff --git a/code-rs/app-server-protocol/schema/typescript/BackgroundEventEvent.ts b/code-rs/app-server-protocol/schema/typescript/BackgroundEventEvent.ts deleted file mode 100644 index 236b1dd888e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/BackgroundEventEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type BackgroundEventEvent = { message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ByteRange.ts b/code-rs/app-server-protocol/schema/typescript/ByteRange.ts deleted file mode 100644 index ba05db20335..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ByteRange.ts +++ /dev/null @@ -1,13 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ByteRange = { -/** - * Start byte offset (inclusive) within the UTF-8 text buffer. - */ -start: number, -/** - * End byte offset (exclusive) within the UTF-8 text buffer. - */ -end: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CallToolResult.ts b/code-rs/app-server-protocol/schema/typescript/CallToolResult.ts deleted file mode 100644 index e7a471d465d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CallToolResult.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The server's response to a tool call. - */ -export type CallToolResult = { content: Array, structuredContent?: JsonValue, isError?: boolean, _meta?: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CancelLoginChatGptParams.ts b/code-rs/app-server-protocol/schema/typescript/CancelLoginChatGptParams.ts deleted file mode 100644 index dae8e8c7840..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CancelLoginChatGptParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CancelLoginChatGptParams = { loginId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CancelLoginChatGptResponse.ts b/code-rs/app-server-protocol/schema/typescript/CancelLoginChatGptResponse.ts deleted file mode 100644 index 004e6f8ea21..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CancelLoginChatGptResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CancelLoginChatGptResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/code-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 18bbc734ca2..a12185b5010 100644 --- a/code-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/code-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -1,62 +1,82 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AddConversationListenerParams } from "./AddConversationListenerParams"; -import type { ArchiveConversationParams } from "./ArchiveConversationParams"; -import type { CancelLoginChatGptParams } from "./CancelLoginChatGptParams"; -import type { ExecOneOffCommandParams } from "./ExecOneOffCommandParams"; -import type { ForkConversationParams } from "./ForkConversationParams"; import type { FuzzyFileSearchParams } from "./FuzzyFileSearchParams"; import type { GetAuthStatusParams } from "./GetAuthStatusParams"; import type { GetConversationSummaryParams } from "./GetConversationSummaryParams"; import type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; import type { InitializeParams } from "./InitializeParams"; -import type { InterruptConversationParams } from "./InterruptConversationParams"; -import type { ListConversationsParams } from "./ListConversationsParams"; -import type { LoginApiKeyParams } from "./LoginApiKeyParams"; -import type { NewConversationParams } from "./NewConversationParams"; -import type { RemoveConversationListenerParams } from "./RemoveConversationListenerParams"; import type { RequestId } from "./RequestId"; -import type { ResumeConversationParams } from "./ResumeConversationParams"; -import type { SendUserMessageParams } from "./SendUserMessageParams"; -import type { SendUserTurnParams } from "./SendUserTurnParams"; -import type { SetDefaultModelParams } from "./SetDefaultModelParams"; import type { AppsListParams } from "./v2/AppsListParams"; import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams"; import type { CommandExecParams } from "./v2/CommandExecParams"; +import type { CommandExecResizeParams } from "./v2/CommandExecResizeParams"; +import type { CommandExecTerminateParams } from "./v2/CommandExecTerminateParams"; +import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; +import type { ExperimentalFeatureEnablementSetParams } from "./v2/ExperimentalFeatureEnablementSetParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; import type { FeedbackUploadParams } from "./v2/FeedbackUploadParams"; +import type { FsCopyParams } from "./v2/FsCopyParams"; +import type { FsCreateDirectoryParams } from "./v2/FsCreateDirectoryParams"; +import type { FsGetMetadataParams } from "./v2/FsGetMetadataParams"; +import type { FsReadDirectoryParams } from "./v2/FsReadDirectoryParams"; +import type { FsReadFileParams } from "./v2/FsReadFileParams"; +import type { FsRemoveParams } from "./v2/FsRemoveParams"; +import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; +import type { FsWatchParams } from "./v2/FsWatchParams"; +import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; +import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; +import type { MarketplaceAddParams } from "./v2/MarketplaceAddParams"; +import type { MarketplaceRemoveParams } from "./v2/MarketplaceRemoveParams"; +import type { MarketplaceUpgradeParams } from "./v2/MarketplaceUpgradeParams"; +import type { McpResourceReadParams } from "./v2/McpResourceReadParams"; import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; +import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; import type { ModelListParams } from "./v2/ModelListParams"; +import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapabilitiesReadParams"; +import type { PluginInstallParams } from "./v2/PluginInstallParams"; +import type { PluginListParams } from "./v2/PluginListParams"; +import type { PluginReadParams } from "./v2/PluginReadParams"; +import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; +import type { PluginShareListParams } from "./v2/PluginShareListParams"; +import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; +import type { PluginShareUpdateTargetsParams } from "./v2/PluginShareUpdateTargetsParams"; +import type { PluginSkillReadParams } from "./v2/PluginSkillReadParams"; +import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; +import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; -import type { SkillsRemoteReadParams } from "./v2/SkillsRemoteReadParams"; -import type { SkillsRemoteWriteParams } from "./v2/SkillsRemoteWriteParams"; +import type { ThreadApproveGuardianDeniedActionParams } from "./v2/ThreadApproveGuardianDeniedActionParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; +import type { ThreadInjectItemsParams } from "./v2/ThreadInjectItemsParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams"; +import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams"; import type { ThreadReadParams } from "./v2/ThreadReadParams"; import type { ThreadResumeParams } from "./v2/ThreadResumeParams"; import type { ThreadRollbackParams } from "./v2/ThreadRollbackParams"; import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams"; +import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams"; import type { ThreadStartParams } from "./v2/ThreadStartParams"; import type { ThreadUnarchiveParams } from "./v2/ThreadUnarchiveParams"; +import type { ThreadUnsubscribeParams } from "./v2/ThreadUnsubscribeParams"; import type { TurnInterruptParams } from "./v2/TurnInterruptParams"; import type { TurnStartParams } from "./v2/TurnStartParams"; import type { TurnSteerParams } from "./v2/TurnSteerParams"; +import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupStartParams"; /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CodexErrorInfo.ts b/code-rs/app-server-protocol/schema/typescript/CodexErrorInfo.ts deleted file mode 100644 index 95545601cbe..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CodexErrorInfo.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Codex errors that we expose to clients. - */ -export type CodexErrorInfo = "context_window_exceeded" | "usage_limit_exceeded" | "cyber_policy" | { "model_cap": { model: string, reset_after_seconds: bigint | null, } } | { "http_connection_failed": { http_status_code: number | null, } } | { "response_stream_connection_failed": { http_status_code: number | null, } } | "internal_server_error" | "unauthorized" | "bad_request" | "sandbox_error" | { "response_stream_disconnected": { http_status_code: number | null, } } | { "response_too_many_failed_attempts": { http_status_code: number | null, } } | "thread_rollback_failed" | "other"; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabAgentInteractionBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabAgentInteractionBeginEvent.ts deleted file mode 100644 index ebad8c89f3c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabAgentInteractionBeginEvent.ts +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type CollabAgentInteractionBeginEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the receiver. - */ -receiver_thread_id: ThreadId, -/** - * Prompt sent from the sender to the receiver. Can be empty to prevent CoT - * leaking at the beginning. - */ -prompt: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts deleted file mode 100644 index e4ac2aba5e0..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentStatus } from "./AgentStatus"; -import type { ThreadId } from "./ThreadId"; - -export type CollabAgentInteractionEndEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the receiver. - */ -receiver_thread_id: ThreadId, -/** - * Prompt sent from the sender to the receiver. Can be empty to prevent CoT - * leaking at the beginning. - */ -prompt: string, -/** - * Last known status of the receiver agent reported to the sender agent. - */ -status: AgentStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabAgentSpawnBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabAgentSpawnBeginEvent.ts deleted file mode 100644 index f76c783b278..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabAgentSpawnBeginEvent.ts +++ /dev/null @@ -1,19 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type CollabAgentSpawnBeginEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the - * beginning. - */ -prompt: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts deleted file mode 100644 index 36494ca1b41..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentStatus } from "./AgentStatus"; -import type { ThreadId } from "./ThreadId"; - -export type CollabAgentSpawnEndEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the newly spawned agent, if it was created. - */ -new_thread_id: ThreadId | null, -/** - * Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the - * beginning. - */ -prompt: string, -/** - * Last known status of the new agent reported to the sender agent. - */ -status: AgentStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabCloseBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabCloseBeginEvent.ts deleted file mode 100644 index 7166ceef76d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabCloseBeginEvent.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type CollabCloseBeginEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the receiver. - */ -receiver_thread_id: ThreadId, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts deleted file mode 100644 index e36f1495880..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentStatus } from "./AgentStatus"; -import type { ThreadId } from "./ThreadId"; - -export type CollabCloseEndEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the receiver. - */ -receiver_thread_id: ThreadId, -/** - * Last known status of the receiver agent reported to the sender agent before - * the close. - */ -status: AgentStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts deleted file mode 100644 index ed6ef539cef..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type CollabResumeBeginEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the receiver. - */ -receiver_thread_id: ThreadId, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts deleted file mode 100644 index 1a624ac61d6..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentStatus } from "./AgentStatus"; -import type { ThreadId } from "./ThreadId"; - -export type CollabResumeEndEvent = { -/** - * Identifier for the collab tool call. - */ -call_id: string, -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the receiver. - */ -receiver_thread_id: ThreadId, -/** - * Last known status of the receiver agent reported to the sender agent after - * resume. - */ -status: AgentStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts deleted file mode 100644 index 2b5b353f7eb..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type CollabWaitingBeginEvent = { -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * Thread ID of the receivers. - */ -receiver_thread_ids: Array, -/** - * ID of the waiting call. - */ -call_id: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts deleted file mode 100644 index b964a71676e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts +++ /dev/null @@ -1,19 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentStatus } from "./AgentStatus"; -import type { ThreadId } from "./ThreadId"; - -export type CollabWaitingEndEvent = { -/** - * Thread ID of the sender. - */ -sender_thread_id: ThreadId, -/** - * ID of the waiting call. - */ -call_id: string, -/** - * Last known status of the receiver agents reported to the sender agent. - */ -statuses: { [key in ThreadId]?: AgentStatus }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CollaborationModeMask.ts b/code-rs/app-server-protocol/schema/typescript/CollaborationModeMask.ts deleted file mode 100644 index 05902676d7d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CollaborationModeMask.ts +++ /dev/null @@ -1,11 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ModeKind } from "./ModeKind"; -import type { ReasoningEffort } from "./ReasoningEffort"; - -/** - * A mask for collaboration mode settings, allowing partial updates. - * All fields except `name` are optional, enabling selective updates. - */ -export type CollaborationModeMask = { name: string, mode: ModeKind | null, model: string | null, reasoning_effort: ReasoningEffort | null | null, developer_instructions: string | null | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ContentItem.ts b/code-rs/app-server-protocol/schema/typescript/ContentItem.ts index c89b9d78a45..21cd8d02f3f 100644 --- a/code-rs/app-server-protocol/schema/typescript/ContentItem.ts +++ b/code-rs/app-server-protocol/schema/typescript/ContentItem.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageDetail } from "./ImageDetail"; -export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, } | { "type": "output_text", text: string, }; +export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "output_text", text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ContextCompactedEvent.ts b/code-rs/app-server-protocol/schema/typescript/ContextCompactedEvent.ts deleted file mode 100644 index 538ca7a1bcc..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ContextCompactedEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ContextCompactedEvent = null; diff --git a/code-rs/app-server-protocol/schema/typescript/ContextCompactionItem.ts b/code-rs/app-server-protocol/schema/typescript/ContextCompactionItem.ts deleted file mode 100644 index dc3ab6388e7..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ContextCompactionItem.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ContextCompactionItem = { id: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CreditsSnapshot.ts b/code-rs/app-server-protocol/schema/typescript/CreditsSnapshot.ts deleted file mode 100644 index 737bf99bef4..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CreditsSnapshot.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CreditsSnapshot = { has_credits: boolean, unlimited: boolean, balance: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/CustomPrompt.ts b/code-rs/app-server-protocol/schema/typescript/CustomPrompt.ts deleted file mode 100644 index 96fe75e9695..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/CustomPrompt.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CustomPrompt = { name: string, path: string, content: string, description: string | null, argument_hint: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/DeprecationNoticeEvent.ts b/code-rs/app-server-protocol/schema/typescript/DeprecationNoticeEvent.ts deleted file mode 100644 index 74c6ead7a87..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/DeprecationNoticeEvent.ts +++ /dev/null @@ -1,13 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type DeprecationNoticeEvent = { -/** - * Concise summary of what is deprecated. - */ -summary: string, -/** - * Optional extra guidance, such as migration steps or rationale. - */ -details: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/DynamicToolCallRequest.ts b/code-rs/app-server-protocol/schema/typescript/DynamicToolCallRequest.ts deleted file mode 100644 index 062661cd4b4..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/DynamicToolCallRequest.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; - -export type DynamicToolCallRequest = { callId: string, turnId: string, namespace?: string, tool: string, arguments: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts b/code-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts deleted file mode 100644 index 045e304bdc5..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ElicitationRequestEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ElicitationRequestEvent = { server_name: string, id: string | number, message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ErrorEvent.ts b/code-rs/app-server-protocol/schema/typescript/ErrorEvent.ts deleted file mode 100644 index fafde767e08..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ErrorEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CodexErrorInfo } from "./CodexErrorInfo"; - -export type ErrorEvent = { message: string, codex_error_info: CodexErrorInfo | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/EventMsg.ts b/code-rs/app-server-protocol/schema/typescript/EventMsg.ts deleted file mode 100644 index 9c1a4296592..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ /dev/null @@ -1,80 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentMessageContentDeltaEvent } from "./AgentMessageContentDeltaEvent"; -import type { AgentMessageDeltaEvent } from "./AgentMessageDeltaEvent"; -import type { AgentMessageEvent } from "./AgentMessageEvent"; -import type { AgentReasoningDeltaEvent } from "./AgentReasoningDeltaEvent"; -import type { AgentReasoningEvent } from "./AgentReasoningEvent"; -import type { AgentReasoningRawContentDeltaEvent } from "./AgentReasoningRawContentDeltaEvent"; -import type { AgentReasoningRawContentEvent } from "./AgentReasoningRawContentEvent"; -import type { AgentReasoningSectionBreakEvent } from "./AgentReasoningSectionBreakEvent"; -import type { ApplyPatchApprovalRequestEvent } from "./ApplyPatchApprovalRequestEvent"; -import type { AutoContextCheckEvent } from "./AutoContextCheckEvent"; -import type { BackgroundEventEvent } from "./BackgroundEventEvent"; -import type { CollabAgentInteractionBeginEvent } from "./CollabAgentInteractionBeginEvent"; -import type { CollabAgentInteractionEndEvent } from "./CollabAgentInteractionEndEvent"; -import type { CollabAgentSpawnBeginEvent } from "./CollabAgentSpawnBeginEvent"; -import type { CollabAgentSpawnEndEvent } from "./CollabAgentSpawnEndEvent"; -import type { CollabCloseBeginEvent } from "./CollabCloseBeginEvent"; -import type { CollabCloseEndEvent } from "./CollabCloseEndEvent"; -import type { CollabResumeBeginEvent } from "./CollabResumeBeginEvent"; -import type { CollabResumeEndEvent } from "./CollabResumeEndEvent"; -import type { CollabWaitingBeginEvent } from "./CollabWaitingBeginEvent"; -import type { CollabWaitingEndEvent } from "./CollabWaitingEndEvent"; -import type { ContextCompactedEvent } from "./ContextCompactedEvent"; -import type { DeprecationNoticeEvent } from "./DeprecationNoticeEvent"; -import type { DynamicToolCallRequest } from "./DynamicToolCallRequest"; -import type { ElicitationRequestEvent } from "./ElicitationRequestEvent"; -import type { ErrorEvent } from "./ErrorEvent"; -import type { ExecApprovalRequestEvent } from "./ExecApprovalRequestEvent"; -import type { ExecCommandBeginEvent } from "./ExecCommandBeginEvent"; -import type { ExecCommandEndEvent } from "./ExecCommandEndEvent"; -import type { ExecCommandOutputDeltaEvent } from "./ExecCommandOutputDeltaEvent"; -import type { ExitedReviewModeEvent } from "./ExitedReviewModeEvent"; -import type { GetHistoryEntryResponseEvent } from "./GetHistoryEntryResponseEvent"; -import type { ImageGenerationBeginEvent } from "./ImageGenerationBeginEvent"; -import type { ImageGenerationEndEvent } from "./ImageGenerationEndEvent"; -import type { ItemCompletedEvent } from "./ItemCompletedEvent"; -import type { ItemStartedEvent } from "./ItemStartedEvent"; -import type { ListCustomPromptsResponseEvent } from "./ListCustomPromptsResponseEvent"; -import type { ListRemoteSkillsResponseEvent } from "./ListRemoteSkillsResponseEvent"; -import type { ListSkillsResponseEvent } from "./ListSkillsResponseEvent"; -import type { McpListToolsResponseEvent } from "./McpListToolsResponseEvent"; -import type { McpStartupCompleteEvent } from "./McpStartupCompleteEvent"; -import type { McpStartupUpdateEvent } from "./McpStartupUpdateEvent"; -import type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; -import type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; -import type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent"; -import type { PatchApplyEndEvent } from "./PatchApplyEndEvent"; -import type { PlanDeltaEvent } from "./PlanDeltaEvent"; -import type { RawResponseItemEvent } from "./RawResponseItemEvent"; -import type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; -import type { ReasoningRawContentDeltaEvent } from "./ReasoningRawContentDeltaEvent"; -import type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; -import type { RequestUserInputEvent } from "./RequestUserInputEvent"; -import type { ReviewRequest } from "./ReviewRequest"; -import type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; -import type { StreamErrorEvent } from "./StreamErrorEvent"; -import type { TerminalInteractionEvent } from "./TerminalInteractionEvent"; -import type { ThreadNameUpdatedEvent } from "./ThreadNameUpdatedEvent"; -import type { ThreadRolledBackEvent } from "./ThreadRolledBackEvent"; -import type { TokenCountEvent } from "./TokenCountEvent"; -import type { TurnAbortedEvent } from "./TurnAbortedEvent"; -import type { TurnCompleteEvent } from "./TurnCompleteEvent"; -import type { TurnDiffEvent } from "./TurnDiffEvent"; -import type { TurnStartedEvent } from "./TurnStartedEvent"; -import type { UndoCompletedEvent } from "./UndoCompletedEvent"; -import type { UndoStartedEvent } from "./UndoStartedEvent"; -import type { UpdatePlanArgs } from "./UpdatePlanArgs"; -import type { UserMessageEvent } from "./UserMessageEvent"; -import type { ViewImageToolCallEvent } from "./ViewImageToolCallEvent"; -import type { WarningEvent } from "./WarningEvent"; -import type { WebSearchBeginEvent } from "./WebSearchBeginEvent"; -import type { WebSearchEndEvent } from "./WebSearchEndEvent"; - -/** - * Response event from the agent - * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. - */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "auto_context_check" } & AutoContextCheckEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts b/code-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts deleted file mode 100644 index 356090a9f00..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecApprovalRequestEvent.ts +++ /dev/null @@ -1,44 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; -import type { NetworkApprovalContext } from "./NetworkApprovalContext"; -import type { ParsedCommand } from "./ParsedCommand"; - -export type ExecApprovalRequestEvent = { -/** - * Identifier for the associated command execution item. - */ -call_id: string, -/** - * Identifier for this specific approval callback. - * - * When absent, the approval is for the command item itself (`call_id`). - * This is present for subcommand approvals (via execve intercept). - */ -approval_id?: string, -/** - * Turn ID that this command belongs to. - * Uses `#[serde(default)]` for backwards compatibility. - */ -turn_id: string, -/** - * The command to be executed. - */ -command: Array, -/** - * The command's working directory. - */ -cwd: string, -/** - * Optional human-readable reason for the approval (e.g. retry without sandbox). - */ -reason: string | null, -/** - * Optional network context for a blocked request that can be approved. - */ -network_approval_context?: NetworkApprovalContext, -/** - * Proposed execpolicy amendment that can be applied to allow future runs. - */ -proposed_execpolicy_amendment?: ExecPolicyAmendment, parsed_cmd: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts b/code-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts index 7ae028a73f9..b7470c15b7e 100644 --- a/code-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/ExecCommandApprovalParams.ts @@ -6,11 +6,11 @@ import type { ThreadId } from "./ThreadId"; export type ExecCommandApprovalParams = { conversationId: ThreadId, /** - * Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] - * and [codex_core::protocol::ExecCommandEndEvent]. + * Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] + * and [codex_protocol::protocol::ExecCommandEndEvent]. */ callId: string, /** * Identifier for this specific approval callback. */ -approvalId?: string, command: Array, cwd: string, reason: string | null, parsedCmd: Array, }; +approvalId: string | null, command: Array, cwd: string, reason: string | null, parsedCmd: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecCommandBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/ExecCommandBeginEvent.ts deleted file mode 100644 index 9bd648251cc..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecCommandBeginEvent.ts +++ /dev/null @@ -1,35 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ExecCommandSource } from "./ExecCommandSource"; -import type { ParsedCommand } from "./ParsedCommand"; - -export type ExecCommandBeginEvent = { -/** - * Identifier so this can be paired with the ExecCommandEnd event. - */ -call_id: string, -/** - * Identifier for the underlying PTY process (when available). - */ -process_id?: string, -/** - * Turn ID that this command belongs to. - */ -turn_id: string, -/** - * The command to be executed. - */ -command: Array, -/** - * The command's working directory if not the default cwd for the agent. - */ -cwd: string, parsed_cmd: Array, -/** - * Where the command originated. Defaults to Agent for backward compatibility. - */ -source: ExecCommandSource, -/** - * Raw input sent to a unified exec session (if this is an interaction event). - */ -interaction_input?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecCommandEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/ExecCommandEndEvent.ts deleted file mode 100644 index 8bd0e198ea7..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecCommandEndEvent.ts +++ /dev/null @@ -1,59 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ExecCommandSource } from "./ExecCommandSource"; -import type { ParsedCommand } from "./ParsedCommand"; - -export type ExecCommandEndEvent = { -/** - * Identifier for the ExecCommandBegin that finished. - */ -call_id: string, -/** - * Identifier for the underlying PTY process (when available). - */ -process_id?: string, -/** - * Turn ID that this command belongs to. - */ -turn_id: string, -/** - * The command that was executed. - */ -command: Array, -/** - * The command's working directory if not the default cwd for the agent. - */ -cwd: string, parsed_cmd: Array, -/** - * Where the command originated. Defaults to Agent for backward compatibility. - */ -source: ExecCommandSource, -/** - * Raw input sent to a unified exec session (if this is an interaction event). - */ -interaction_input?: string, -/** - * Captured stdout - */ -stdout: string, -/** - * Captured stderr - */ -stderr: string, -/** - * Captured aggregated output - */ -aggregated_output: string, -/** - * The command's exit code. - */ -exit_code: number, -/** - * The duration of the command execution. - */ -duration: string, -/** - * Formatted output from the command, as seen by the model. - */ -formatted_output: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecCommandOutputDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/ExecCommandOutputDeltaEvent.ts deleted file mode 100644 index 795e10b6cb7..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecCommandOutputDeltaEvent.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ExecOutputStream } from "./ExecOutputStream"; - -export type ExecCommandOutputDeltaEvent = { -/** - * Identifier for the ExecCommandBegin that produced this chunk. - */ -call_id: string, -/** - * Which stream produced this chunk. - */ -stream: ExecOutputStream, -/** - * Raw bytes from the stream (may not be valid UTF-8). - */ -chunk: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecCommandSource.ts b/code-rs/app-server-protocol/schema/typescript/ExecCommandSource.ts deleted file mode 100644 index b665441bc2e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecCommandSource.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ExecCommandSource = "agent" | "user_shell" | "unified_exec_startup" | "unified_exec_interaction"; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecOneOffCommandParams.ts b/code-rs/app-server-protocol/schema/typescript/ExecOneOffCommandParams.ts deleted file mode 100644 index ca28ad775c5..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecOneOffCommandParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SandboxPolicy } from "./SandboxPolicy"; - -export type ExecOneOffCommandParams = { command: Array, timeoutMs: bigint | null, cwd: string | null, sandboxPolicy: SandboxPolicy | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecOneOffCommandResponse.ts b/code-rs/app-server-protocol/schema/typescript/ExecOneOffCommandResponse.ts deleted file mode 100644 index ff43ec4ca25..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecOneOffCommandResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ExecOneOffCommandResponse = { exitCode: number, stdout: string, stderr: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ExecOutputStream.ts b/code-rs/app-server-protocol/schema/typescript/ExecOutputStream.ts deleted file mode 100644 index 96aa74483d7..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExecOutputStream.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ExecOutputStream = "stdout" | "stderr"; diff --git a/code-rs/app-server-protocol/schema/typescript/ExitedReviewModeEvent.ts b/code-rs/app-server-protocol/schema/typescript/ExitedReviewModeEvent.ts deleted file mode 100644 index 74e5355ed9b..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ExitedReviewModeEvent.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReviewOutputEvent } from "./ReviewOutputEvent"; -import type { ReviewSnapshotInfo } from "./ReviewSnapshotInfo"; - -export type ExitedReviewModeEvent = { review_output: ReviewOutputEvent | null, snapshot?: ReviewSnapshotInfo, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ForkConversationParams.ts b/code-rs/app-server-protocol/schema/typescript/ForkConversationParams.ts deleted file mode 100644 index 4ca548fbff1..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ForkConversationParams.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { NewConversationParams } from "./NewConversationParams"; -import type { ThreadId } from "./ThreadId"; - -export type ForkConversationParams = { path: string | null, conversationId: ThreadId | null, overrides: NewConversationParams | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ForkConversationResponse.ts b/code-rs/app-server-protocol/schema/typescript/ForkConversationResponse.ts deleted file mode 100644 index 80d6e7947c3..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ForkConversationResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EventMsg } from "./EventMsg"; -import type { ThreadId } from "./ThreadId"; - -export type ForkConversationResponse = { conversationId: ThreadId, model: string, initialMessages: Array | null, rolloutPath: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/FunctionCallOutputPayload.ts b/code-rs/app-server-protocol/schema/typescript/FunctionCallOutputPayload.ts deleted file mode 100644 index 6376c5b8eb0..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/FunctionCallOutputPayload.ts +++ /dev/null @@ -1,12 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; - -/** - * The payload we send back to OpenAI when reporting a tool call result. - * - * `body` serializes directly as the wire value for `function_call_output.output`. - * `success` remains internal metadata for downstream handling. - */ -export type FunctionCallOutputPayload = { body: FunctionCallOutputBody, success: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchMatchType.ts b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchMatchType.ts new file mode 100644 index 00000000000..60e92f925ea --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchMatchType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FuzzyFileSearchMatchType = "file" | "directory"; diff --git a/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts index e841dbfa04e..0ff6bf4516f 100644 --- a/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts +++ b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts @@ -1,8 +1,9 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FuzzyFileSearchMatchType } from "./FuzzyFileSearchMatchType"; /** * Superset of [`codex_file_search::FileMatch`] */ -export type FuzzyFileSearchResult = { root: string, path: string, file_name: string, score: number, indices: Array | null, }; +export type FuzzyFileSearchResult = { root: string, path: string, match_type: FuzzyFileSearchMatchType, file_name: string, score: number, indices: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts new file mode 100644 index 00000000000..f4dc7fac11a --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionCompletedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FuzzyFileSearchSessionCompletedNotification = { sessionId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionUpdatedNotification.ts b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionUpdatedNotification.ts new file mode 100644 index 00000000000..ba9caa768ea --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/FuzzyFileSearchSessionUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult"; + +export type FuzzyFileSearchSessionUpdatedNotification = { sessionId: string, query: string, files: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/GetHistoryEntryResponseEvent.ts b/code-rs/app-server-protocol/schema/typescript/GetHistoryEntryResponseEvent.ts deleted file mode 100644 index ca997abbee1..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/GetHistoryEntryResponseEvent.ts +++ /dev/null @@ -1,10 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { HistoryEntry } from "./HistoryEntry"; - -export type GetHistoryEntryResponseEvent = { offset: number, log_id: bigint, -/** - * The entry at the requested offset, if available and parseable. - */ -entry: HistoryEntry | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/GetUserAgentResponse.ts b/code-rs/app-server-protocol/schema/typescript/GetUserAgentResponse.ts deleted file mode 100644 index a74aba5da60..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/GetUserAgentResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type GetUserAgentResponse = { userAgent: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/GetUserSavedConfigResponse.ts b/code-rs/app-server-protocol/schema/typescript/GetUserSavedConfigResponse.ts deleted file mode 100644 index f8dcf2e67cc..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/GetUserSavedConfigResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { UserSavedConfig } from "./UserSavedConfig"; - -export type GetUserSavedConfigResponse = { config: UserSavedConfig, }; diff --git a/code-rs/app-server-protocol/schema/typescript/GhostCommit.ts b/code-rs/app-server-protocol/schema/typescript/GhostCommit.ts deleted file mode 100644 index 780d473006a..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/GhostCommit.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Details of a ghost commit created from a repository state. - */ -export type GhostCommit = { id: string, parent: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/HistoryEntry.ts b/code-rs/app-server-protocol/schema/typescript/HistoryEntry.ts deleted file mode 100644 index da5bc37c21f..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/HistoryEntry.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type HistoryEntry = { conversation_id: string, ts: bigint, text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ImageGenerationBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/ImageGenerationBeginEvent.ts deleted file mode 100644 index 3e424dbd05d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ImageGenerationBeginEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ImageGenerationBeginEvent = { call_id: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ImageGenerationEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/ImageGenerationEndEvent.ts deleted file mode 100644 index b6b4f665577..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ImageGenerationEndEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "./AbsolutePathBuf"; - -export type ImageGenerationEndEvent = { call_id: string, status: string, revised_prompt?: string, result: string, saved_path?: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts b/code-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts deleted file mode 100644 index 5941007b0f0..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "./AbsolutePathBuf"; - -export type ImageGenerationItem = { id: string, status: string, revised_prompt?: string, result: string, saved_path?: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/code-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index 3a845b850be..5d42cc4852d 100644 --- a/code-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/code-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -9,4 +9,9 @@ export type InitializeCapabilities = { /** * Opt into receiving experimental API methods and fields. */ -experimentalApi: boolean, }; +experimentalApi: boolean, +/** + * Exact notification method names that should be suppressed for this + * connection (for example `thread/started`). + */ +optOutNotificationMethods?: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/InitializeResponse.ts b/code-rs/app-server-protocol/schema/typescript/InitializeResponse.ts index 8a6bec66ef1..f1f79d173c7 100644 --- a/code-rs/app-server-protocol/schema/typescript/InitializeResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/InitializeResponse.ts @@ -1,5 +1,20 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "./AbsolutePathBuf"; -export type InitializeResponse = { userAgent: string, }; +export type InitializeResponse = { userAgent: string, +/** + * Absolute path to the server's $CODEX_HOME directory. + */ +codexHome: AbsolutePathBuf, +/** + * Platform family for the running app-server target, for example + * `"unix"` or `"windows"`. + */ +platformFamily: string, +/** + * Operating system for the running app-server target, for example + * `"macos"`, `"linux"`, or `"windows"`. + */ +platformOs: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/InputItem.ts b/code-rs/app-server-protocol/schema/typescript/InputItem.ts deleted file mode 100644 index 4960e3c4ac0..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/InputItem.ts +++ /dev/null @@ -1,10 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TextElement } from "./TextElement"; - -export type InputItem = { "type": "text", "data": { text: string, -/** - * UI-defined spans within `text` used to render or persist special elements. - */ -text_elements: Array, } } | { "type": "image", "data": { image_url: string, } } | { "type": "localImage", "data": { path: string, } }; diff --git a/code-rs/app-server-protocol/schema/typescript/InternalSessionSource.ts b/code-rs/app-server-protocol/schema/typescript/InternalSessionSource.ts new file mode 100644 index 00000000000..47417c51679 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/InternalSessionSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InternalSessionSource = "memory_consolidation"; diff --git a/code-rs/app-server-protocol/schema/typescript/InterruptConversationParams.ts b/code-rs/app-server-protocol/schema/typescript/InterruptConversationParams.ts deleted file mode 100644 index 8db162c97c1..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/InterruptConversationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type InterruptConversationParams = { conversationId: ThreadId, }; diff --git a/code-rs/app-server-protocol/schema/typescript/InterruptConversationResponse.ts b/code-rs/app-server-protocol/schema/typescript/InterruptConversationResponse.ts deleted file mode 100644 index 375604eef31..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/InterruptConversationResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TurnAbortReason } from "./TurnAbortReason"; - -export type InterruptConversationResponse = { abortReason: TurnAbortReason, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ItemCompletedEvent.ts b/code-rs/app-server-protocol/schema/typescript/ItemCompletedEvent.ts deleted file mode 100644 index 97de348dff9..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ItemCompletedEvent.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; -import type { TurnItem } from "./TurnItem"; - -export type ItemCompletedEvent = { thread_id: ThreadId, turn_id: string, item: TurnItem, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ItemStartedEvent.ts b/code-rs/app-server-protocol/schema/typescript/ItemStartedEvent.ts deleted file mode 100644 index e82f78f9652..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ItemStartedEvent.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; -import type { TurnItem } from "./TurnItem"; - -export type ItemStartedEvent = { thread_id: ThreadId, turn_id: string, item: TurnItem, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ListConversationsParams.ts b/code-rs/app-server-protocol/schema/typescript/ListConversationsParams.ts deleted file mode 100644 index 27c9f3172ac..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ListConversationsParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ListConversationsParams = { pageSize: number | null, cursor: string | null, modelProviders: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ListConversationsResponse.ts b/code-rs/app-server-protocol/schema/typescript/ListConversationsResponse.ts deleted file mode 100644 index 0e26443a5fb..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ListConversationsResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ConversationSummary } from "./ConversationSummary"; - -export type ListConversationsResponse = { items: Array, nextCursor: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ListCustomPromptsResponseEvent.ts b/code-rs/app-server-protocol/schema/typescript/ListCustomPromptsResponseEvent.ts deleted file mode 100644 index 9ebb43afb74..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ListCustomPromptsResponseEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CustomPrompt } from "./CustomPrompt"; - -/** - * Response payload for `Op::ListCustomPrompts`. - */ -export type ListCustomPromptsResponseEvent = { custom_prompts: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ListRemoteSkillsResponseEvent.ts b/code-rs/app-server-protocol/schema/typescript/ListRemoteSkillsResponseEvent.ts deleted file mode 100644 index e3b277f4d64..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ListRemoteSkillsResponseEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RemoteSkillSummary } from "./RemoteSkillSummary"; - -/** - * Response payload for `Op::ListRemoteSkills`. - */ -export type ListRemoteSkillsResponseEvent = { skills: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ListSkillsResponseEvent.ts b/code-rs/app-server-protocol/schema/typescript/ListSkillsResponseEvent.ts deleted file mode 100644 index efdd547596d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ListSkillsResponseEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SkillsListEntry } from "./SkillsListEntry"; - -/** - * Response payload for `Op::ListSkills`. - */ -export type ListSkillsResponseEvent = { skills: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/LoginApiKeyParams.ts b/code-rs/app-server-protocol/schema/typescript/LoginApiKeyParams.ts deleted file mode 100644 index 3638553d3ea..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/LoginApiKeyParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LoginApiKeyParams = { apiKey: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/LoginApiKeyResponse.ts b/code-rs/app-server-protocol/schema/typescript/LoginApiKeyResponse.ts deleted file mode 100644 index a67347aeb74..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/LoginApiKeyResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LoginApiKeyResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/LoginChatGptCompleteNotification.ts b/code-rs/app-server-protocol/schema/typescript/LoginChatGptCompleteNotification.ts deleted file mode 100644 index 82c07bfa2dd..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/LoginChatGptCompleteNotification.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Deprecated in favor of AccountLoginCompletedNotification. - */ -export type LoginChatGptCompleteNotification = { loginId: string, success: boolean, error: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/LoginChatGptResponse.ts b/code-rs/app-server-protocol/schema/typescript/LoginChatGptResponse.ts deleted file mode 100644 index 41472801172..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/LoginChatGptResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LoginChatGptResponse = { loginId: string, authUrl: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/LogoutChatGptResponse.ts b/code-rs/app-server-protocol/schema/typescript/LogoutChatGptResponse.ts deleted file mode 100644 index ad5dbd91057..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/LogoutChatGptResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LogoutChatGptResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/MacOsAutomationValue.ts b/code-rs/app-server-protocol/schema/typescript/MacOsAutomationValue.ts deleted file mode 100644 index e351c319ddd..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/MacOsAutomationValue.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type MacOsAutomationValue = boolean | Array; diff --git a/code-rs/app-server-protocol/schema/typescript/MacOsPreferencesValue.ts b/code-rs/app-server-protocol/schema/typescript/MacOsPreferencesValue.ts deleted file mode 100644 index 74a67ca1cb8..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/MacOsPreferencesValue.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type MacOsPreferencesValue = boolean | string; diff --git a/code-rs/app-server-protocol/schema/typescript/McpAuthStatus.ts b/code-rs/app-server-protocol/schema/typescript/McpAuthStatus.ts deleted file mode 100644 index 919ae85fd09..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpAuthStatus.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type McpAuthStatus = "unsupported" | "not_logged_in" | "bearer_token" | "o_auth"; diff --git a/code-rs/app-server-protocol/schema/typescript/McpInvocation.ts b/code-rs/app-server-protocol/schema/typescript/McpInvocation.ts deleted file mode 100644 index 2a64feeb647..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpInvocation.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; - -export type McpInvocation = { -/** - * Name of the MCP server as defined in the config. - */ -server: string, -/** - * Name of the tool as given by the MCP server. - */ -tool: string, -/** - * Arguments to the tool call. - */ -arguments: JsonValue | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpListToolsResponseEvent.ts b/code-rs/app-server-protocol/schema/typescript/McpListToolsResponseEvent.ts deleted file mode 100644 index 2ef5c8f2b67..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpListToolsResponseEvent.ts +++ /dev/null @@ -1,34 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { McpAuthStatus } from "./McpAuthStatus"; -import type { McpServerFailure } from "./McpServerFailure"; -import type { Resource } from "./Resource"; -import type { ResourceTemplate } from "./ResourceTemplate"; -import type { Tool } from "./Tool"; - -export type McpListToolsResponseEvent = { -/** - * Fully qualified tool name -> tool definition. - */ -tools: { [key in string]?: Tool }, -/** - * Legacy server -> tool names map used by existing UI surfaces. - */ -server_tools?: { [key in string]?: Array } | null, -/** - * Legacy server failure map keyed by server name. - */ -server_failures?: { [key in string]?: McpServerFailure } | null, -/** - * Known resources grouped by server name. - */ -resources: { [key in string]?: Array }, -/** - * Known resource templates grouped by server name. - */ -resource_templates: { [key in string]?: Array }, -/** - * Authentication status for each configured MCP server. - */ -auth_statuses: { [key in string]?: McpAuthStatus }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpServerFailure.ts b/code-rs/app-server-protocol/schema/typescript/McpServerFailure.ts deleted file mode 100644 index 558b5af793e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpServerFailure.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { McpServerFailurePhase } from "./McpServerFailurePhase"; - -export type McpServerFailure = { phase: McpServerFailurePhase, message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpServerFailurePhase.ts b/code-rs/app-server-protocol/schema/typescript/McpServerFailurePhase.ts deleted file mode 100644 index 171bccd107c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpServerFailurePhase.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type McpServerFailurePhase = "start" | "list_tools"; diff --git a/code-rs/app-server-protocol/schema/typescript/McpStartupCompleteEvent.ts b/code-rs/app-server-protocol/schema/typescript/McpStartupCompleteEvent.ts deleted file mode 100644 index 67354adfbe4..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpStartupCompleteEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { McpStartupFailure } from "./McpStartupFailure"; - -export type McpStartupCompleteEvent = { ready: Array, failed: Array, cancelled: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpStartupFailure.ts b/code-rs/app-server-protocol/schema/typescript/McpStartupFailure.ts deleted file mode 100644 index b12009b15bd..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpStartupFailure.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type McpStartupFailure = { server: string, error: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpStartupStatus.ts b/code-rs/app-server-protocol/schema/typescript/McpStartupStatus.ts deleted file mode 100644 index 48c08226f4e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpStartupStatus.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type McpStartupStatus = { "state": "starting" } | { "state": "ready" } | { "state": "failed", error: string, } | { "state": "cancelled" }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpStartupUpdateEvent.ts b/code-rs/app-server-protocol/schema/typescript/McpStartupUpdateEvent.ts deleted file mode 100644 index 3a1d0ad62bd..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpStartupUpdateEvent.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { McpStartupStatus } from "./McpStartupStatus"; - -export type McpStartupUpdateEvent = { -/** - * Server name being started. - */ -server: string, -/** - * Current startup status. - */ -status: McpStartupStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpToolCallBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/McpToolCallBeginEvent.ts deleted file mode 100644 index 8110d872d96..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpToolCallBeginEvent.ts +++ /dev/null @@ -1,10 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { McpInvocation } from "./McpInvocation"; - -export type McpToolCallBeginEvent = { -/** - * Identifier so this can be paired with the McpToolCallEnd event. - */ -call_id: string, invocation: McpInvocation, }; diff --git a/code-rs/app-server-protocol/schema/typescript/McpToolCallEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/McpToolCallEndEvent.ts deleted file mode 100644 index 6b9c20695fb..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/McpToolCallEndEvent.ts +++ /dev/null @@ -1,15 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CallToolResult } from "./CallToolResult"; -import type { McpInvocation } from "./McpInvocation"; - -export type McpToolCallEndEvent = { -/** - * Identifier for the corresponding McpToolCallBegin that finished. - */ -call_id: string, invocation: McpInvocation, duration: string, -/** - * Result of the tool call. Note this could be an error. - */ -result: { Ok : CallToolResult } | { Err : string }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/NetworkAccess.ts b/code-rs/app-server-protocol/schema/typescript/NetworkAccess.ts deleted file mode 100644 index f259e67b99f..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/NetworkAccess.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Represents whether outbound network access is available to the agent. - */ -export type NetworkAccess = "restricted" | "enabled"; diff --git a/code-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts b/code-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts deleted file mode 100644 index b4b78e473cc..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/NetworkApprovalContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; - -export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, }; diff --git a/code-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts b/code-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts deleted file mode 100644 index a33eab566fb..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/NetworkApprovalProtocol.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type NetworkApprovalProtocol = "http" | "https" | "socks5_tcp" | "socks5_udp"; diff --git a/code-rs/app-server-protocol/schema/typescript/NetworkPolicyAmendment.ts b/code-rs/app-server-protocol/schema/typescript/NetworkPolicyAmendment.ts new file mode 100644 index 00000000000..4e5092e4d0c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/NetworkPolicyAmendment.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; + +export type NetworkPolicyAmendment = { host: string, action: NetworkPolicyRuleAction, }; diff --git a/code-rs/app-server-protocol/schema/typescript/NetworkPolicyRuleAction.ts b/code-rs/app-server-protocol/schema/typescript/NetworkPolicyRuleAction.ts new file mode 100644 index 00000000000..55ec70032a6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/NetworkPolicyRuleAction.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkPolicyRuleAction = "allow" | "deny"; diff --git a/code-rs/app-server-protocol/schema/typescript/NewConversationParams.ts b/code-rs/app-server-protocol/schema/typescript/NewConversationParams.ts deleted file mode 100644 index e1113c27e23..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/NewConversationParams.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { SandboxMode } from "./SandboxMode"; -import type { JsonValue } from "./serde_json/JsonValue"; - -export type NewConversationParams = { model: string | null, modelProvider: string | null, profile: string | null, cwd: string | null, approvalPolicy: AskForApproval | null, sandbox: SandboxMode | null, config: { [key in string]?: JsonValue } | null, baseInstructions: string | null, developerInstructions: string | null, compactPrompt: string | null, includeApplyPatchTool: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/NewConversationResponse.ts b/code-rs/app-server-protocol/schema/typescript/NewConversationResponse.ts deleted file mode 100644 index 608c2ac1101..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/NewConversationResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ThreadId } from "./ThreadId"; - -export type NewConversationResponse = { conversationId: ThreadId, model: string, reasoningEffort: ReasoningEffort | null, rolloutPath: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ParsedCommand.ts b/code-rs/app-server-protocol/schema/typescript/ParsedCommand.ts index 92c8f86cba9..092476e9ac2 100644 --- a/code-rs/app-server-protocol/schema/typescript/ParsedCommand.ts +++ b/code-rs/app-server-protocol/schema/typescript/ParsedCommand.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ParsedCommand = { "type": "read_command", cmd: string, } | { "type": "read", cmd: string, name: string, +export type ParsedCommand = { "type": "read", cmd: string, name: string, /** * (Best effort) Path to the file being read by the command. When * possible, this is an absolute path, though when relative, it should diff --git a/code-rs/app-server-protocol/schema/typescript/PatchApplyBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/PatchApplyBeginEvent.ts deleted file mode 100644 index 73e992244eb..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/PatchApplyBeginEvent.ts +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileChange } from "./FileChange"; - -export type PatchApplyBeginEvent = { -/** - * Identifier so this can be paired with the PatchApplyEnd event. - */ -call_id: string, -/** - * Turn ID that this patch belongs to. - * Uses `#[serde(default)]` for backwards compatibility. - */ -turn_id: string, -/** - * If true, there was no ApplyPatchApprovalRequest for this patch. - */ -auto_approved: boolean, -/** - * The changes to be applied. - */ -changes: { [key in string]?: FileChange }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/PatchApplyEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/PatchApplyEndEvent.ts deleted file mode 100644 index 2dbdb0f6863..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/PatchApplyEndEvent.ts +++ /dev/null @@ -1,31 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileChange } from "./FileChange"; - -export type PatchApplyEndEvent = { -/** - * Identifier for the PatchApplyBegin that finished. - */ -call_id: string, -/** - * Turn ID that this patch belongs to. - * Uses `#[serde(default)]` for backwards compatibility. - */ -turn_id: string, -/** - * Captured stdout (summary printed by apply_patch). - */ -stdout: string, -/** - * Captured stderr (parser errors, IO failures, etc.). - */ -stderr: string, -/** - * Whether the patch was applied successfully. - */ -success: boolean, -/** - * The changes that were applied (mirrors PatchApplyBeginEvent::changes). - */ -changes: { [key in string]?: FileChange }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/PlanDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/PlanDeltaEvent.ts deleted file mode 100644 index f2ff5884429..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/PlanDeltaEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type PlanDeltaEvent = { thread_id: string, turn_id: string, item_id: string, delta: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/PlanItem.ts b/code-rs/app-server-protocol/schema/typescript/PlanItem.ts deleted file mode 100644 index 909ab40e64b..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/PlanItem.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type PlanItem = { id: string, text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/PlanItemArg.ts b/code-rs/app-server-protocol/schema/typescript/PlanItemArg.ts deleted file mode 100644 index a9c8acfa75e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/PlanItemArg.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { StepStatus } from "./StepStatus"; - -export type PlanItemArg = { step: string, status: StepStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/PlanType.ts b/code-rs/app-server-protocol/schema/typescript/PlanType.ts index 9f622d0f1be..44891467e92 100644 --- a/code-rs/app-server-protocol/schema/typescript/PlanType.ts +++ b/code-rs/app-server-protocol/schema/typescript/PlanType.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "business" | "enterprise" | "edu" | "unknown"; +export type PlanType = "free" | "go" | "plus" | "pro" | "prolite" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown"; diff --git a/code-rs/app-server-protocol/schema/typescript/Profile.ts b/code-rs/app-server-protocol/schema/typescript/Profile.ts deleted file mode 100644 index 53d16e4a331..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/Profile.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ReasoningSummary } from "./ReasoningSummary"; -import type { Verbosity } from "./Verbosity"; - -export type Profile = { model: string | null, modelProvider: string | null, approvalPolicy: AskForApproval | null, modelReasoningEffort: ReasoningEffort | null, modelReasoningSummary: ReasoningSummary | null, modelVerbosity: Verbosity | null, chatgptBaseUrl: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RateLimitReachedType.ts b/code-rs/app-server-protocol/schema/typescript/RateLimitReachedType.ts deleted file mode 100644 index 78f106c905d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RateLimitReachedType.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RateLimitReachedType = "rate_limit_reached" | "workspace_owner_credits_depleted" | "workspace_member_credits_depleted" | "workspace_owner_usage_limit_reached" | "workspace_member_usage_limit_reached"; diff --git a/code-rs/app-server-protocol/schema/typescript/RateLimitSnapshot.ts b/code-rs/app-server-protocol/schema/typescript/RateLimitSnapshot.ts deleted file mode 100644 index 5da995e0273..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RateLimitSnapshot.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CreditsSnapshot } from "./CreditsSnapshot"; -import type { PlanType } from "./PlanType"; -import type { RateLimitReachedType } from "./RateLimitReachedType"; -import type { RateLimitWindow } from "./RateLimitWindow"; - -export type RateLimitSnapshot = { limit_id: string | null, limit_name: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, plan_type: PlanType | null, rate_limit_reached_type: RateLimitReachedType | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RateLimitWindow.ts b/code-rs/app-server-protocol/schema/typescript/RateLimitWindow.ts deleted file mode 100644 index ca746dbd2ae..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RateLimitWindow.ts +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RateLimitWindow = { -/** - * Percentage (0-100) of the window that has been consumed. - */ -used_percent: number, -/** - * Rolling window duration, in minutes. - */ -window_minutes: number | null, -/** - * Legacy relative reset in seconds. - */ -resets_in_seconds?: number, -/** - * Unix timestamp (seconds since epoch) when the window resets. - */ -resets_at?: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RawResponseItemEvent.ts b/code-rs/app-server-protocol/schema/typescript/RawResponseItemEvent.ts deleted file mode 100644 index 62dd4f0018e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RawResponseItemEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ResponseItem } from "./ResponseItem"; - -export type RawResponseItemEvent = { item: ResponseItem, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts b/code-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts new file mode 100644 index 00000000000..cedc4bbe525 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RealtimeConversationVersion = "v1" | "v2"; diff --git a/code-rs/app-server-protocol/schema/typescript/RealtimeOutputModality.ts b/code-rs/app-server-protocol/schema/typescript/RealtimeOutputModality.ts new file mode 100644 index 00000000000..78e00e7143d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/RealtimeOutputModality.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RealtimeOutputModality = "text" | "audio"; diff --git a/code-rs/app-server-protocol/schema/typescript/RealtimeVoice.ts b/code-rs/app-server-protocol/schema/typescript/RealtimeVoice.ts new file mode 100644 index 00000000000..c3a434e9449 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/RealtimeVoice.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RealtimeVoice = "alloy" | "arbor" | "ash" | "ballad" | "breeze" | "cedar" | "coral" | "cove" | "echo" | "ember" | "juniper" | "maple" | "marin" | "sage" | "shimmer" | "sol" | "spruce" | "vale" | "verse"; diff --git a/code-rs/app-server-protocol/schema/typescript/RealtimeVoicesList.ts b/code-rs/app-server-protocol/schema/typescript/RealtimeVoicesList.ts new file mode 100644 index 00000000000..b81cbc0a0cf --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/RealtimeVoicesList.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RealtimeVoice } from "./RealtimeVoice"; + +export type RealtimeVoicesList = { v1: Array, v2: Array, defaultV1: RealtimeVoice, defaultV2: RealtimeVoice, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReasoningContentDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/ReasoningContentDeltaEvent.ts deleted file mode 100644 index 70dfc01d24d..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReasoningContentDeltaEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ReasoningContentDeltaEvent = { thread_id: string, turn_id: string, item_id: string, delta: string, summary_index: bigint, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReasoningItem.ts b/code-rs/app-server-protocol/schema/typescript/ReasoningItem.ts deleted file mode 100644 index 80bcb65fd17..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReasoningItem.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ReasoningItem = { id: string, summary_text: Array, raw_content: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReasoningRawContentDeltaEvent.ts b/code-rs/app-server-protocol/schema/typescript/ReasoningRawContentDeltaEvent.ts deleted file mode 100644 index ef3a792caf9..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReasoningRawContentDeltaEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ReasoningRawContentDeltaEvent = { thread_id: string, turn_id: string, item_id: string, delta: string, content_index: bigint, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RejectConfig.ts b/code-rs/app-server-protocol/schema/typescript/RejectConfig.ts deleted file mode 100644 index b4902f77dc5..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RejectConfig.ts +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RejectConfig = { -/** - * Reject approval prompts related to sandbox escalation. - */ -sandbox_approval: boolean, -/** - * Reject prompts triggered by execpolicy `prompt` rules. - */ -rules: boolean, -/** - * Reject approval prompts triggered by skill script execution. - */ -skill_approval: boolean, -/** - * Reject approval prompts related to built-in permission requests. - */ -request_permissions: boolean, -/** - * Reject MCP elicitation prompts. - */ -mcp_elicitations: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RemoteSkillDownloadedEvent.ts b/code-rs/app-server-protocol/schema/typescript/RemoteSkillDownloadedEvent.ts deleted file mode 100644 index 83082f2a57a..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RemoteSkillDownloadedEvent.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Response payload for `Op::DownloadRemoteSkill`. - */ -export type RemoteSkillDownloadedEvent = { id: string, name: string, path: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RemoteSkillSummary.ts b/code-rs/app-server-protocol/schema/typescript/RemoteSkillSummary.ts deleted file mode 100644 index 7bf57b3b094..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RemoteSkillSummary.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RemoteSkillSummary = { id: string, name: string, description: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RemoveConversationListenerParams.ts b/code-rs/app-server-protocol/schema/typescript/RemoveConversationListenerParams.ts deleted file mode 100644 index e9628b63416..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RemoveConversationListenerParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RemoveConversationListenerParams = { subscriptionId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RemoveConversationSubscriptionResponse.ts b/code-rs/app-server-protocol/schema/typescript/RemoveConversationSubscriptionResponse.ts deleted file mode 100644 index 8053d7e4b46..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RemoveConversationSubscriptionResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RemoveConversationSubscriptionResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/RequestUserInputEvent.ts b/code-rs/app-server-protocol/schema/typescript/RequestUserInputEvent.ts deleted file mode 100644 index c385aa82521..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RequestUserInputEvent.ts +++ /dev/null @@ -1,15 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RequestUserInputQuestion } from "./RequestUserInputQuestion"; - -export type RequestUserInputEvent = { -/** - * Responses API call id for the associated tool call, if available. - */ -call_id: string, -/** - * Turn ID that this request belongs to. - * Uses `#[serde(default)]` for backwards compatibility. - */ -turn_id: string, questions: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RequestUserInputQuestion.ts b/code-rs/app-server-protocol/schema/typescript/RequestUserInputQuestion.ts deleted file mode 100644 index 2a68f7b4c88..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RequestUserInputQuestion.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RequestUserInputQuestionOption } from "./RequestUserInputQuestionOption"; - -export type RequestUserInputQuestion = { id: string, header: string, question: string, isOther: boolean, isSecret: boolean, options: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/RequestUserInputQuestionOption.ts b/code-rs/app-server-protocol/schema/typescript/RequestUserInputQuestionOption.ts deleted file mode 100644 index b2d2a0db48c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/RequestUserInputQuestionOption.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RequestUserInputQuestionOption = { label: string, description: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ResourceContent.ts b/code-rs/app-server-protocol/schema/typescript/ResourceContent.ts new file mode 100644 index 00000000000..f5bcf2d5126 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/ResourceContent.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Contents returned when reading a resource from an MCP server. + */ +export type ResourceContent = { +/** + * The URI of this resource. + */ +uri: string, mimeType?: string, text: string, _meta?: JsonValue, } | { +/** + * The URI of this resource. + */ +uri: string, mimeType?: string, blob: string, _meta?: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/code-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 47947febca7..6fa9beee253 100644 --- a/code-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/code-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -2,8 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ContentItem } from "./ContentItem"; -import type { FunctionCallOutputPayload } from "./FunctionCallOutputPayload"; -import type { GhostCommit } from "./GhostCommit"; +import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; import type { LocalShellAction } from "./LocalShellAction"; import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; @@ -11,8 +10,8 @@ import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, end_turn?: boolean, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputPayload, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction_summary", encrypted_content: string, } | { "type": "context_compaction", encrypted_content?: string, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "compaction", encrypted_content: string, } | { "type": "context_compaction", encrypted_content?: string, } | { "type": "other" }; diff --git a/code-rs/app-server-protocol/schema/typescript/ResumeConversationParams.ts b/code-rs/app-server-protocol/schema/typescript/ResumeConversationParams.ts deleted file mode 100644 index f2fe9d47c8a..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ResumeConversationParams.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { NewConversationParams } from "./NewConversationParams"; -import type { ResponseItem } from "./ResponseItem"; -import type { ThreadId } from "./ThreadId"; - -export type ResumeConversationParams = { path: string | null, conversationId: ThreadId | null, history: Array | null, overrides: NewConversationParams | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ResumeConversationResponse.ts b/code-rs/app-server-protocol/schema/typescript/ResumeConversationResponse.ts deleted file mode 100644 index 1af5b685999..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ResumeConversationResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EventMsg } from "./EventMsg"; -import type { ThreadId } from "./ThreadId"; - -export type ResumeConversationResponse = { conversationId: ThreadId, model: string, initialMessages: Array | null, rolloutPath: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewCodeLocation.ts b/code-rs/app-server-protocol/schema/typescript/ReviewCodeLocation.ts deleted file mode 100644 index 752589fe559..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReviewCodeLocation.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReviewLineRange } from "./ReviewLineRange"; - -/** - * Location of the code related to a review finding. - */ -export type ReviewCodeLocation = { absolute_file_path: string, line_range: ReviewLineRange, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewDecision.ts b/code-rs/app-server-protocol/schema/typescript/ReviewDecision.ts index 662fae625a7..109f72929ca 100644 --- a/code-rs/app-server-protocol/schema/typescript/ReviewDecision.ts +++ b/code-rs/app-server-protocol/schema/typescript/ReviewDecision.ts @@ -2,8 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; +import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; /** * User's decision in response to an ExecApprovalRequest. */ -export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | "denied" | "abort"; +export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "timed_out" | "abort"; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewFinding.ts b/code-rs/app-server-protocol/schema/typescript/ReviewFinding.ts deleted file mode 100644 index e7c96bd170e..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReviewFinding.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReviewCodeLocation } from "./ReviewCodeLocation"; - -/** - * A single review finding describing an observed issue or recommendation. - */ -export type ReviewFinding = { title: string, body: string, confidence_score: number, priority: number, code_location: ReviewCodeLocation, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewLineRange.ts b/code-rs/app-server-protocol/schema/typescript/ReviewLineRange.ts deleted file mode 100644 index c57ec6ed603..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReviewLineRange.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Inclusive line range in a file associated with the finding. - */ -export type ReviewLineRange = { start: number, end: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewOutputEvent.ts b/code-rs/app-server-protocol/schema/typescript/ReviewOutputEvent.ts deleted file mode 100644 index c45747424ba..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReviewOutputEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReviewFinding } from "./ReviewFinding"; - -/** - * Structured review result produced by a child review session. - */ -export type ReviewOutputEvent = { findings: Array, overall_correctness: string, overall_explanation: string, overall_confidence_score: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewRequest.ts b/code-rs/app-server-protocol/schema/typescript/ReviewRequest.ts deleted file mode 100644 index dd83ec56333..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReviewRequest.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReviewTarget } from "./ReviewTarget"; - -/** - * Review request sent to the review session. - */ -export type ReviewRequest = { target: ReviewTarget, user_facing_hint?: string, -/** - * Legacy plain-text prompt retained for compatibility with older review - * flows. - */ -prompt: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewSnapshotInfo.ts b/code-rs/app-server-protocol/schema/typescript/ReviewSnapshotInfo.ts deleted file mode 100644 index 573e73de354..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReviewSnapshotInfo.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ReviewSnapshotInfo = { snapshot_commit: string | null, branch: string | null, worktree_path: string | null, repo_root: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ReviewTarget.ts b/code-rs/app-server-protocol/schema/typescript/ReviewTarget.ts deleted file mode 100644 index a69b68c056c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ReviewTarget.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ReviewTarget = { "type": "uncommittedChanges" } | { "type": "baseBranch", branch: string, } | { "type": "commit", sha: string, -/** - * Optional human-readable label (e.g., commit subject) for UIs. - */ -title: string | null, } | { "type": "custom", instructions: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SandboxMode.ts b/code-rs/app-server-protocol/schema/typescript/SandboxMode.ts deleted file mode 100644 index b8cf4326b98..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SandboxMode.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; diff --git a/code-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts b/code-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts deleted file mode 100644 index 8af727948a4..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts +++ /dev/null @@ -1,39 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "./AbsolutePathBuf"; -import type { NetworkAccess } from "./NetworkAccess"; - -/** - * Determines execution restrictions for model shell commands. - */ -export type SandboxPolicy = { "type": "danger-full-access" } | { "type": "read-only" } | { "type": "external-sandbox", -/** - * Whether the external sandbox permits outbound network traffic. - */ -network_access: NetworkAccess, } | { "type": "workspace-write", -/** - * Additional folders (beyond cwd and possibly TMPDIR) that should be - * writable from within the sandbox. - */ -writable_roots?: Array, -/** - * When set to `true`, outbound network access is allowed. `false` by - * default. - */ -network_access: boolean, -/** - * When set to `true`, will NOT include the per-user `TMPDIR` - * environment variable among the default writable roots. Defaults to - * `false`. - */ -exclude_tmpdir_env_var: boolean, -/** - * When set to `true`, will NOT include the `/tmp` among the default - * writable roots on UNIX. Defaults to `false`. - */ -exclude_slash_tmp: boolean, -/** - * Whether sandboxed commands may perform write operations via Git. - */ -allow_git_writes: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SandboxSettings.ts b/code-rs/app-server-protocol/schema/typescript/SandboxSettings.ts deleted file mode 100644 index 94139b0e5dd..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SandboxSettings.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "./AbsolutePathBuf"; - -export type SandboxSettings = { writableRoots: Array, networkAccess: boolean | null, excludeTmpdirEnvVar: boolean | null, excludeSlashTmp: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SendUserMessageParams.ts b/code-rs/app-server-protocol/schema/typescript/SendUserMessageParams.ts deleted file mode 100644 index 6aee538eb04..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SendUserMessageParams.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { InputItem } from "./InputItem"; -import type { ThreadId } from "./ThreadId"; - -export type SendUserMessageParams = { conversationId: ThreadId, items: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SendUserMessageResponse.ts b/code-rs/app-server-protocol/schema/typescript/SendUserMessageResponse.ts deleted file mode 100644 index 1a03e043a65..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SendUserMessageResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SendUserMessageResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts b/code-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts deleted file mode 100644 index e303c8795f4..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts +++ /dev/null @@ -1,16 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { InputItem } from "./InputItem"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ReasoningSummary } from "./ReasoningSummary"; -import type { SandboxPolicy } from "./SandboxPolicy"; -import type { ThreadId } from "./ThreadId"; -import type { JsonValue } from "./serde_json/JsonValue"; - -export type SendUserTurnParams = { conversationId: ThreadId, items: Array, cwd: string, approvalPolicy: AskForApproval, sandboxPolicy: SandboxPolicy, model: string, effort: ReasoningEffort | null, summary: ReasoningSummary, -/** - * Optional JSON Schema used to constrain the final assistant message for this turn. - */ -outputSchema: JsonValue | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SendUserTurnResponse.ts b/code-rs/app-server-protocol/schema/typescript/SendUserTurnResponse.ts deleted file mode 100644 index cffd0ac3983..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SendUserTurnResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SendUserTurnResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/code-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 2e1b1e963a1..f4dd0e1864c 100644 --- a/code-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/code-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -1,40 +1,72 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthStatusChangeNotification } from "./AuthStatusChangeNotification"; -import type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification"; -import type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; +import type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; +import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification"; import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimitsUpdatedNotification"; import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification"; import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification"; import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification"; +import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification"; import type { CommandExecutionOutputDeltaNotification } from "./v2/CommandExecutionOutputDeltaNotification"; import type { ConfigWarningNotification } from "./v2/ConfigWarningNotification"; import type { ContextCompactedNotification } from "./v2/ContextCompactedNotification"; import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification"; import type { ErrorNotification } from "./v2/ErrorNotification"; +import type { ExternalAgentConfigImportCompletedNotification } from "./v2/ExternalAgentConfigImportCompletedNotification"; import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification"; +import type { FileChangePatchUpdatedNotification } from "./v2/FileChangePatchUpdatedNotification"; +import type { FsChangedNotification } from "./v2/FsChangedNotification"; +import type { GuardianWarningNotification } from "./v2/GuardianWarningNotification"; +import type { HookCompletedNotification } from "./v2/HookCompletedNotification"; +import type { HookStartedNotification } from "./v2/HookStartedNotification"; import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; +import type { ItemGuardianApprovalReviewCompletedNotification } from "./v2/ItemGuardianApprovalReviewCompletedNotification"; +import type { ItemGuardianApprovalReviewStartedNotification } from "./v2/ItemGuardianApprovalReviewStartedNotification"; import type { ItemStartedNotification } from "./v2/ItemStartedNotification"; import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification"; +import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; +import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; +import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; +import type { ProcessExitedNotification } from "./v2/ProcessExitedNotification"; +import type { ProcessOutputDeltaNotification } from "./v2/ProcessOutputDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; import type { ReasoningSummaryTextDeltaNotification } from "./v2/ReasoningSummaryTextDeltaNotification"; import type { ReasoningTextDeltaNotification } from "./v2/ReasoningTextDeltaNotification"; +import type { RemoteControlStatusChangedNotification } from "./v2/RemoteControlStatusChangedNotification"; +import type { ServerRequestResolvedNotification } from "./v2/ServerRequestResolvedNotification"; +import type { SkillsChangedNotification } from "./v2/SkillsChangedNotification"; import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification"; +import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification"; +import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification"; +import type { ThreadGoalClearedNotification } from "./v2/ThreadGoalClearedNotification"; +import type { ThreadGoalUpdatedNotification } from "./v2/ThreadGoalUpdatedNotification"; import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification"; +import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification"; +import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNotification"; +import type { ThreadRealtimeItemAddedNotification } from "./v2/ThreadRealtimeItemAddedNotification"; +import type { ThreadRealtimeOutputAudioDeltaNotification } from "./v2/ThreadRealtimeOutputAudioDeltaNotification"; +import type { ThreadRealtimeSdpNotification } from "./v2/ThreadRealtimeSdpNotification"; +import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification"; +import type { ThreadRealtimeTranscriptDeltaNotification } from "./v2/ThreadRealtimeTranscriptDeltaNotification"; +import type { ThreadRealtimeTranscriptDoneNotification } from "./v2/ThreadRealtimeTranscriptDoneNotification"; import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification"; +import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification"; import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification"; +import type { ThreadUnarchivedNotification } from "./v2/ThreadUnarchivedNotification"; import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification"; import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification"; import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification"; import type { TurnStartedNotification } from "./v2/TurnStartedNotification"; +import type { WarningNotification } from "./v2/WarningNotification"; +import type { WindowsSandboxSetupCompletedNotification } from "./v2/WindowsSandboxSetupCompletedNotification"; import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldWritableWarningNotification"; /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/code-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/code-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 17c66959aa9..13d04b0be70 100644 --- a/code-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/code-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -8,9 +8,11 @@ import type { ChatgptAuthTokensRefreshParams } from "./v2/ChatgptAuthTokensRefre import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutionRequestApprovalParams"; import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams"; import type { FileChangeRequestApprovalParams } from "./v2/FileChangeRequestApprovalParams"; +import type { McpServerElicitationRequestParams } from "./v2/McpServerElicitationRequestParams"; +import type { PermissionsRequestApprovalParams } from "./v2/PermissionsRequestApprovalParams"; import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams"; /** * Request initiated from the server and sent to the client. */ -export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; +export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts b/code-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts deleted file mode 100644 index 9f26d783bb6..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SessionConfiguredEvent.ts +++ /dev/null @@ -1,57 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { AutomationOrigin } from "./AutomationOrigin"; -import type { EventMsg } from "./EventMsg"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { SandboxPolicy } from "./SandboxPolicy"; -import type { ThreadId } from "./ThreadId"; - -export type SessionConfiguredEvent = { session_id: ThreadId, forked_from_id: ThreadId | null, -/** - * Optional user-facing thread name (may be unset). - */ -thread_name?: string, -/** - * Tell the client what model is being queried. - */ -model: string, model_provider_id: string, -/** - * When to escalate for approval for execution - */ -approval_policy: AskForApproval, -/** - * How to sandbox commands executed in the system - */ -sandbox_policy: SandboxPolicy, -/** - * Working directory that should be treated as the *root* of the - * session. - */ -cwd: string, -/** - * The effort the model is putting into reasoning about the user's request. - */ -reasoning_effort: ReasoningEffort | null, -/** - * Identifier of the history log file (inode on Unix, 0 otherwise). - */ -history_log_id: bigint, -/** - * Current number of entries in the history log. - */ -history_entry_count: number, -/** - * Structured metadata for automated sessions, if the launcher provided it. - */ -automation_origin?: AutomationOrigin, -/** - * Optional initial messages (as events) for resumed sessions. - * When present, UIs can use these to seed the history. - */ -initial_messages: Array | null, -/** - * Path in which the rollout is stored. Can be `None` for ephemeral threads - */ -rollout_path: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts b/code-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts deleted file mode 100644 index 3dee74aa3a9..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EventMsg } from "./EventMsg"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ThreadId } from "./ThreadId"; - -export type SessionConfiguredNotification = { sessionId: ThreadId, model: string, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array | null, rolloutPath: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SessionSource.ts b/code-rs/app-server-protocol/schema/typescript/SessionSource.ts index e5e746e3844..3317c228b0d 100644 --- a/code-rs/app-server-protocol/schema/typescript/SessionSource.ts +++ b/code-rs/app-server-protocol/schema/typescript/SessionSource.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InternalSessionSource } from "./InternalSessionSource"; import type { SubAgentSource } from "./SubAgentSource"; -export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "subagent": SubAgentSource } | "unknown"; +export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "custom": string } | { "internal": InternalSessionSource } | { "subagent": SubAgentSource } | "unknown"; diff --git a/code-rs/app-server-protocol/schema/typescript/SetDefaultModelParams.ts b/code-rs/app-server-protocol/schema/typescript/SetDefaultModelParams.ts deleted file mode 100644 index b9e4e7d901c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SetDefaultModelParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningEffort } from "./ReasoningEffort"; - -export type SetDefaultModelParams = { model: string | null, reasoningEffort: ReasoningEffort | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SetDefaultModelResponse.ts b/code-rs/app-server-protocol/schema/typescript/SetDefaultModelResponse.ts deleted file mode 100644 index 1639601e0c5..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SetDefaultModelResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SetDefaultModelResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/SkillDependencies.ts b/code-rs/app-server-protocol/schema/typescript/SkillDependencies.ts deleted file mode 100644 index e2dd4f42415..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SkillDependencies.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SkillToolDependency } from "./SkillToolDependency"; - -export type SkillDependencies = { tools: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SkillErrorInfo.ts b/code-rs/app-server-protocol/schema/typescript/SkillErrorInfo.ts deleted file mode 100644 index 6eaf035d8cc..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SkillErrorInfo.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillErrorInfo = { path: string, message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SkillInterface.ts b/code-rs/app-server-protocol/schema/typescript/SkillInterface.ts deleted file mode 100644 index 30250b93831..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SkillInterface.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillInterface = { display_name?: string, short_description?: string, icon_small?: string, icon_large?: string, brand_color?: string, default_prompt?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SkillMetadata.ts b/code-rs/app-server-protocol/schema/typescript/SkillMetadata.ts deleted file mode 100644 index a5b6ea73e77..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SkillMetadata.ts +++ /dev/null @@ -1,12 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SkillDependencies } from "./SkillDependencies"; -import type { SkillInterface } from "./SkillInterface"; -import type { SkillScope } from "./SkillScope"; - -export type SkillMetadata = { name: string, description: string, -/** - * Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. - */ -short_description?: string, interface?: SkillInterface, dependencies?: SkillDependencies, path: string, scope: SkillScope, allow_implicit_invocation: boolean, enabled: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SkillScope.ts b/code-rs/app-server-protocol/schema/typescript/SkillScope.ts deleted file mode 100644 index 997006f5b83..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SkillScope.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillScope = "user" | "repo" | "system" | "admin"; diff --git a/code-rs/app-server-protocol/schema/typescript/SkillToolDependency.ts b/code-rs/app-server-protocol/schema/typescript/SkillToolDependency.ts deleted file mode 100644 index a5da45e1785..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SkillToolDependency.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillToolDependency = { type: string, value: string, description?: string, transport?: string, command?: string, url?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SkillsListEntry.ts b/code-rs/app-server-protocol/schema/typescript/SkillsListEntry.ts deleted file mode 100644 index 3f46c98a4a0..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/SkillsListEntry.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SkillErrorInfo } from "./SkillErrorInfo"; -import type { SkillMetadata } from "./SkillMetadata"; - -export type SkillsListEntry = { cwd: string, skills: Array, errors: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/StepStatus.ts b/code-rs/app-server-protocol/schema/typescript/StepStatus.ts deleted file mode 100644 index 8494a76e0b7..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/StepStatus.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type StepStatus = "pending" | "in_progress" | "completed"; diff --git a/code-rs/app-server-protocol/schema/typescript/StreamErrorEvent.ts b/code-rs/app-server-protocol/schema/typescript/StreamErrorEvent.ts deleted file mode 100644 index 0b08fa7efa8..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/StreamErrorEvent.ts +++ /dev/null @@ -1,12 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CodexErrorInfo } from "./CodexErrorInfo"; - -export type StreamErrorEvent = { message: string, codex_error_info: CodexErrorInfo | null, -/** - * Optional details about the underlying stream failure (often the same - * human-readable message that is surfaced as the terminal error if retries - * are exhausted). - */ -additional_details: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/SubAgentSource.ts b/code-rs/app-server-protocol/schema/typescript/SubAgentSource.ts index 269a411be93..669e5802b13 100644 --- a/code-rs/app-server-protocol/schema/typescript/SubAgentSource.ts +++ b/code-rs/app-server-protocol/schema/typescript/SubAgentSource.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentPath } from "./AgentPath"; import type { ThreadId } from "./ThreadId"; -export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, } } | "memory_consolidation" | { "other": string }; +export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_path: AgentPath | null, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string }; diff --git a/code-rs/app-server-protocol/schema/typescript/TerminalInteractionEvent.ts b/code-rs/app-server-protocol/schema/typescript/TerminalInteractionEvent.ts deleted file mode 100644 index e05de284a3f..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TerminalInteractionEvent.ts +++ /dev/null @@ -1,17 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type TerminalInteractionEvent = { -/** - * Identifier for the ExecCommandBegin that produced this chunk. - */ -call_id: string, -/** - * Process id associated with the running command. - */ -process_id: string, -/** - * Stdin sent to the running session. - */ -stdin: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TextElement.ts b/code-rs/app-server-protocol/schema/typescript/TextElement.ts deleted file mode 100644 index 535e0a1dde8..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TextElement.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ByteRange } from "./ByteRange"; - -export type TextElement = { -/** - * Byte range in the parent `text` buffer that this element occupies. - */ -byteRange: ByteRange, -/** - * Optional human-readable placeholder for the element, displayed in the UI. - */ -placeholder: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ThreadMemoryMode.ts b/code-rs/app-server-protocol/schema/typescript/ThreadMemoryMode.ts new file mode 100644 index 00000000000..74a7e759e73 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/ThreadMemoryMode.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryMode = "enabled" | "disabled"; diff --git a/code-rs/app-server-protocol/schema/typescript/ThreadNameUpdatedEvent.ts b/code-rs/app-server-protocol/schema/typescript/ThreadNameUpdatedEvent.ts deleted file mode 100644 index 639e29f9d77..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ThreadNameUpdatedEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type ThreadNameUpdatedEvent = { thread_id: ThreadId, thread_name?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ThreadRolledBackEvent.ts b/code-rs/app-server-protocol/schema/typescript/ThreadRolledBackEvent.ts deleted file mode 100644 index fae4100ec9f..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ThreadRolledBackEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ThreadRolledBackEvent = { -/** - * Number of user turns that were removed from context. - */ -num_turns: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TokenCountEvent.ts b/code-rs/app-server-protocol/schema/typescript/TokenCountEvent.ts deleted file mode 100644 index f58b5746414..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TokenCountEvent.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RateLimitSnapshot } from "./RateLimitSnapshot"; -import type { TokenUsageInfo } from "./TokenUsageInfo"; - -export type TokenCountEvent = { info: TokenUsageInfo | null, rate_limits: RateLimitSnapshot | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TokenUsage.ts b/code-rs/app-server-protocol/schema/typescript/TokenUsage.ts deleted file mode 100644 index 4e33790b3f7..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TokenUsage.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type TokenUsage = { input_tokens: number, cached_input_tokens: number, cached_input_tokens_reported: boolean, output_tokens: number, reasoning_output_tokens: number, total_tokens: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TokenUsageInfo.ts b/code-rs/app-server-protocol/schema/typescript/TokenUsageInfo.ts deleted file mode 100644 index ac611546a33..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TokenUsageInfo.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TokenUsage } from "./TokenUsage"; - -export type TokenUsageInfo = { total_token_usage: TokenUsage, last_token_usage: TokenUsage, requested_model?: string, latest_response_model?: string, model_context_window: number | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/Tools.ts b/code-rs/app-server-protocol/schema/typescript/Tools.ts deleted file mode 100644 index 03870229660..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/Tools.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type Tools = { webSearch: boolean | null, viewImage: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TurnAbortReason.ts b/code-rs/app-server-protocol/schema/typescript/TurnAbortReason.ts deleted file mode 100644 index f07cde6292c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TurnAbortReason.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type TurnAbortReason = "interrupted" | "replaced" | "review_ended"; diff --git a/code-rs/app-server-protocol/schema/typescript/TurnAbortedEvent.ts b/code-rs/app-server-protocol/schema/typescript/TurnAbortedEvent.ts deleted file mode 100644 index eb0bf24c188..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TurnAbortedEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TurnAbortReason } from "./TurnAbortReason"; - -export type TurnAbortedEvent = { reason: TurnAbortReason, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TurnCompleteEvent.ts b/code-rs/app-server-protocol/schema/typescript/TurnCompleteEvent.ts deleted file mode 100644 index ab271ba9e39..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TurnCompleteEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type TurnCompleteEvent = { last_agent_message: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TurnDiffEvent.ts b/code-rs/app-server-protocol/schema/typescript/TurnDiffEvent.ts deleted file mode 100644 index 52e3df09b08..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TurnDiffEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type TurnDiffEvent = { unified_diff: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/TurnItem.ts b/code-rs/app-server-protocol/schema/typescript/TurnItem.ts deleted file mode 100644 index 965fb184812..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TurnItem.ts +++ /dev/null @@ -1,12 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AgentMessageItem } from "./AgentMessageItem"; -import type { ContextCompactionItem } from "./ContextCompactionItem"; -import type { ImageGenerationItem } from "./ImageGenerationItem"; -import type { PlanItem } from "./PlanItem"; -import type { ReasoningItem } from "./ReasoningItem"; -import type { UserMessageItem } from "./UserMessageItem"; -import type { WebSearchItem } from "./WebSearchItem"; - -export type TurnItem = { "type": "UserMessage" } & UserMessageItem | { "type": "AgentMessage" } & AgentMessageItem | { "type": "Plan" } & PlanItem | { "type": "Reasoning" } & ReasoningItem | { "type": "WebSearch" } & WebSearchItem | { "type": "ImageGeneration" } & ImageGenerationItem | { "type": "ContextCompaction" } & ContextCompactionItem; diff --git a/code-rs/app-server-protocol/schema/typescript/TurnStartedEvent.ts b/code-rs/app-server-protocol/schema/typescript/TurnStartedEvent.ts deleted file mode 100644 index 91598aa7896..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/TurnStartedEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ModeKind } from "./ModeKind"; - -export type TurnStartedEvent = { model_context_window: bigint | null, collaboration_mode_kind: ModeKind, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UndoCompletedEvent.ts b/code-rs/app-server-protocol/schema/typescript/UndoCompletedEvent.ts deleted file mode 100644 index 2d94e2e18d2..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UndoCompletedEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type UndoCompletedEvent = { success: boolean, message: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UndoStartedEvent.ts b/code-rs/app-server-protocol/schema/typescript/UndoStartedEvent.ts deleted file mode 100644 index 712082adff4..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UndoStartedEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type UndoStartedEvent = { message: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UpdatePlanArgs.ts b/code-rs/app-server-protocol/schema/typescript/UpdatePlanArgs.ts deleted file mode 100644 index 1c8b705fe70..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UpdatePlanArgs.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PlanItemArg } from "./PlanItemArg"; - -export type UpdatePlanArgs = { -/** - * Legacy field name used by existing clients. - */ -name: string | null, -/** - * Optional note used by newer clients; when provided it supersedes `name`. - */ -explanation: string | null, plan: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UserInfoResponse.ts b/code-rs/app-server-protocol/schema/typescript/UserInfoResponse.ts deleted file mode 100644 index 3d257a1c5e4..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UserInfoResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type UserInfoResponse = { allegedUserEmail: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UserInput.ts b/code-rs/app-server-protocol/schema/typescript/UserInput.ts deleted file mode 100644 index 77dab3a38b2..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UserInput.ts +++ /dev/null @@ -1,16 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TextElement } from "./TextElement"; - -/** - * User input - */ -export type UserInput = { "type": "text", text: string, -/** - * UI-defined spans within `text` that should be treated as special elements. - * These are byte ranges into the UTF-8 `text` buffer and are used to render - * or persist rich input markers (e.g., image placeholders) across history - * and resume without mutating the literal text. - */ -text_elements: Array, } | { "type": "image", image_url: string, } | { "type": "local_image", path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UserMessageEvent.ts b/code-rs/app-server-protocol/schema/typescript/UserMessageEvent.ts deleted file mode 100644 index e7984540093..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UserMessageEvent.ts +++ /dev/null @@ -1,22 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TextElement } from "./TextElement"; - -export type UserMessageEvent = { message: string, -/** - * Image URLs sourced from `UserInput::Image`. These are safe - * to replay in legacy UI history events and correspond to images sent to - * the model. - */ -images: Array | null, -/** - * Local file paths sourced from `UserInput::LocalImage`. These are kept so - * the UI can reattach images when editing history, and should not be sent - * to the model or treated as API-ready URLs. - */ -local_images: Array, -/** - * UI-defined spans within `message` used to render or persist special elements. - */ -text_elements: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UserMessageItem.ts b/code-rs/app-server-protocol/schema/typescript/UserMessageItem.ts deleted file mode 100644 index df856287a5a..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UserMessageItem.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { UserInput } from "./UserInput"; - -export type UserMessageItem = { id: string, content: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/UserSavedConfig.ts b/code-rs/app-server-protocol/schema/typescript/UserSavedConfig.ts deleted file mode 100644 index e70107f31e8..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/UserSavedConfig.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { ForcedLoginMethod } from "./ForcedLoginMethod"; -import type { Profile } from "./Profile"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ReasoningSummary } from "./ReasoningSummary"; -import type { SandboxMode } from "./SandboxMode"; -import type { SandboxSettings } from "./SandboxSettings"; -import type { Tools } from "./Tools"; -import type { Verbosity } from "./Verbosity"; - -export type UserSavedConfig = { approvalPolicy: AskForApproval | null, sandboxMode: SandboxMode | null, sandboxSettings: SandboxSettings | null, forcedChatgptWorkspaceId: string | null, forcedLoginMethod: ForcedLoginMethod | null, model: string | null, modelReasoningEffort: ReasoningEffort | null, modelReasoningSummary: ReasoningSummary | null, modelVerbosity: Verbosity | null, tools: Tools | null, profile: string | null, profiles: { [key in string]?: Profile }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/ViewImageToolCallEvent.ts b/code-rs/app-server-protocol/schema/typescript/ViewImageToolCallEvent.ts deleted file mode 100644 index d9842c10d12..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/ViewImageToolCallEvent.ts +++ /dev/null @@ -1,13 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ViewImageToolCallEvent = { -/** - * Identifier for the originating tool call. - */ -call_id: string, -/** - * Local filesystem path provided to the tool. - */ -path: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/WarningEvent.ts b/code-rs/app-server-protocol/schema/typescript/WarningEvent.ts deleted file mode 100644 index 35ec40f7cd0..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/WarningEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type WarningEvent = { message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/WebSearchBeginEvent.ts b/code-rs/app-server-protocol/schema/typescript/WebSearchBeginEvent.ts deleted file mode 100644 index 4a8d881914b..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/WebSearchBeginEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type WebSearchBeginEvent = { call_id: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/WebSearchEndEvent.ts b/code-rs/app-server-protocol/schema/typescript/WebSearchEndEvent.ts deleted file mode 100644 index 5b8b67c28b6..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/WebSearchEndEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { WebSearchAction } from "./WebSearchAction"; - -export type WebSearchEndEvent = { call_id: string, query: string, action: WebSearchAction, }; diff --git a/code-rs/app-server-protocol/schema/typescript/WebSearchItem.ts b/code-rs/app-server-protocol/schema/typescript/WebSearchItem.ts deleted file mode 100644 index 46b14065193..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/WebSearchItem.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { WebSearchAction } from "./WebSearchAction"; - -export type WebSearchItem = { id: string, query: string, action: WebSearchAction, }; diff --git a/code-rs/app-server-protocol/schema/typescript/index.ts b/code-rs/app-server-protocol/schema/typescript/index.ts index 2c19833c0a7..97ea4356019 100644 --- a/code-rs/app-server-protocol/schema/typescript/index.ts +++ b/code-rs/app-server-protocol/schema/typescript/index.ts @@ -1,243 +1,78 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; -export type { AddConversationListenerParams } from "./AddConversationListenerParams"; -export type { AddConversationSubscriptionResponse } from "./AddConversationSubscriptionResponse"; -export type { AgentMessageContent } from "./AgentMessageContent"; -export type { AgentMessageContentDeltaEvent } from "./AgentMessageContentDeltaEvent"; -export type { AgentMessageDeltaEvent } from "./AgentMessageDeltaEvent"; -export type { AgentMessageEvent } from "./AgentMessageEvent"; -export type { AgentMessageItem } from "./AgentMessageItem"; -export type { AgentReasoningDeltaEvent } from "./AgentReasoningDeltaEvent"; -export type { AgentReasoningEvent } from "./AgentReasoningEvent"; -export type { AgentReasoningRawContentDeltaEvent } from "./AgentReasoningRawContentDeltaEvent"; -export type { AgentReasoningRawContentEvent } from "./AgentReasoningRawContentEvent"; -export type { AgentReasoningSectionBreakEvent } from "./AgentReasoningSectionBreakEvent"; -export type { AgentStatus } from "./AgentStatus"; +export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; -export type { ApplyPatchApprovalRequestEvent } from "./ApplyPatchApprovalRequestEvent"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; -export type { ArchiveConversationParams } from "./ArchiveConversationParams"; -export type { ArchiveConversationResponse } from "./ArchiveConversationResponse"; -export type { AskForApproval } from "./AskForApproval"; export type { AuthMode } from "./AuthMode"; -export type { AuthStatusChangeNotification } from "./AuthStatusChangeNotification"; -export type { AutoContextCheckEvent } from "./AutoContextCheckEvent"; -export type { AutoContextPhase } from "./AutoContextPhase"; -export type { AutomationOrigin } from "./AutomationOrigin"; -export type { AutomationTriggerKind } from "./AutomationTriggerKind"; -export type { BackgroundEventEvent } from "./BackgroundEventEvent"; -export type { ByteRange } from "./ByteRange"; -export type { CallToolResult } from "./CallToolResult"; -export type { CancelLoginChatGptParams } from "./CancelLoginChatGptParams"; -export type { CancelLoginChatGptResponse } from "./CancelLoginChatGptResponse"; export type { ClientInfo } from "./ClientInfo"; export type { ClientNotification } from "./ClientNotification"; export type { ClientRequest } from "./ClientRequest"; -export type { CodexErrorInfo } from "./CodexErrorInfo"; -export type { CollabAgentInteractionBeginEvent } from "./CollabAgentInteractionBeginEvent"; -export type { CollabAgentInteractionEndEvent } from "./CollabAgentInteractionEndEvent"; -export type { CollabAgentSpawnBeginEvent } from "./CollabAgentSpawnBeginEvent"; -export type { CollabAgentSpawnEndEvent } from "./CollabAgentSpawnEndEvent"; -export type { CollabCloseBeginEvent } from "./CollabCloseBeginEvent"; -export type { CollabCloseEndEvent } from "./CollabCloseEndEvent"; -export type { CollabResumeBeginEvent } from "./CollabResumeBeginEvent"; -export type { CollabResumeEndEvent } from "./CollabResumeEndEvent"; -export type { CollabWaitingBeginEvent } from "./CollabWaitingBeginEvent"; -export type { CollabWaitingEndEvent } from "./CollabWaitingEndEvent"; export type { CollaborationMode } from "./CollaborationMode"; -export type { CollaborationModeMask } from "./CollaborationModeMask"; export type { ContentItem } from "./ContentItem"; -export type { ContextCompactedEvent } from "./ContextCompactedEvent"; -export type { ContextCompactionItem } from "./ContextCompactionItem"; export type { ConversationGitInfo } from "./ConversationGitInfo"; export type { ConversationSummary } from "./ConversationSummary"; -export type { CreditsSnapshot } from "./CreditsSnapshot"; -export type { CustomPrompt } from "./CustomPrompt"; -export type { DeprecationNoticeEvent } from "./DeprecationNoticeEvent"; -export type { DynamicToolCallRequest } from "./DynamicToolCallRequest"; -export type { ElicitationRequestEvent } from "./ElicitationRequestEvent"; -export type { ErrorEvent } from "./ErrorEvent"; -export type { EventMsg } from "./EventMsg"; -export type { ExecApprovalRequestEvent } from "./ExecApprovalRequestEvent"; export type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams"; export type { ExecCommandApprovalResponse } from "./ExecCommandApprovalResponse"; -export type { ExecCommandBeginEvent } from "./ExecCommandBeginEvent"; -export type { ExecCommandEndEvent } from "./ExecCommandEndEvent"; -export type { ExecCommandOutputDeltaEvent } from "./ExecCommandOutputDeltaEvent"; -export type { ExecCommandSource } from "./ExecCommandSource"; -export type { ExecOneOffCommandParams } from "./ExecOneOffCommandParams"; -export type { ExecOneOffCommandResponse } from "./ExecOneOffCommandResponse"; -export type { ExecOutputStream } from "./ExecOutputStream"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; -export type { ExitedReviewModeEvent } from "./ExitedReviewModeEvent"; export type { FileChange } from "./FileChange"; export type { ForcedLoginMethod } from "./ForcedLoginMethod"; -export type { ForkConversationParams } from "./ForkConversationParams"; -export type { ForkConversationResponse } from "./ForkConversationResponse"; export type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; export type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentItem"; -export type { FunctionCallOutputPayload } from "./FunctionCallOutputPayload"; +export type { FuzzyFileSearchMatchType } from "./FuzzyFileSearchMatchType"; export type { FuzzyFileSearchParams } from "./FuzzyFileSearchParams"; export type { FuzzyFileSearchResponse } from "./FuzzyFileSearchResponse"; export type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult"; +export type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; +export type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; export type { GetAuthStatusParams } from "./GetAuthStatusParams"; export type { GetAuthStatusResponse } from "./GetAuthStatusResponse"; export type { GetConversationSummaryParams } from "./GetConversationSummaryParams"; export type { GetConversationSummaryResponse } from "./GetConversationSummaryResponse"; -export type { GetHistoryEntryResponseEvent } from "./GetHistoryEntryResponseEvent"; -export type { GetUserAgentResponse } from "./GetUserAgentResponse"; -export type { GetUserSavedConfigResponse } from "./GetUserSavedConfigResponse"; -export type { GhostCommit } from "./GhostCommit"; export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse"; export type { GitSha } from "./GitSha"; -export type { HistoryEntry } from "./HistoryEntry"; export type { ImageDetail } from "./ImageDetail"; -export type { ImageGenerationBeginEvent } from "./ImageGenerationBeginEvent"; -export type { ImageGenerationEndEvent } from "./ImageGenerationEndEvent"; -export type { ImageGenerationItem } from "./ImageGenerationItem"; export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; -export type { InputItem } from "./InputItem"; export type { InputModality } from "./InputModality"; -export type { InterruptConversationParams } from "./InterruptConversationParams"; -export type { InterruptConversationResponse } from "./InterruptConversationResponse"; -export type { ItemCompletedEvent } from "./ItemCompletedEvent"; -export type { ItemStartedEvent } from "./ItemStartedEvent"; -export type { ListConversationsParams } from "./ListConversationsParams"; -export type { ListConversationsResponse } from "./ListConversationsResponse"; -export type { ListCustomPromptsResponseEvent } from "./ListCustomPromptsResponseEvent"; -export type { ListRemoteSkillsResponseEvent } from "./ListRemoteSkillsResponseEvent"; -export type { ListSkillsResponseEvent } from "./ListSkillsResponseEvent"; +export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; -export type { LoginApiKeyParams } from "./LoginApiKeyParams"; -export type { LoginApiKeyResponse } from "./LoginApiKeyResponse"; -export type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification"; -export type { LoginChatGptResponse } from "./LoginChatGptResponse"; -export type { LogoutChatGptResponse } from "./LogoutChatGptResponse"; -export type { MacOsAutomationValue } from "./MacOsAutomationValue"; -export type { MacOsPreferencesValue } from "./MacOsPreferencesValue"; -export type { McpAuthStatus } from "./McpAuthStatus"; -export type { McpInvocation } from "./McpInvocation"; -export type { McpListToolsResponseEvent } from "./McpListToolsResponseEvent"; -export type { McpServerFailure } from "./McpServerFailure"; -export type { McpServerFailurePhase } from "./McpServerFailurePhase"; -export type { McpStartupCompleteEvent } from "./McpStartupCompleteEvent"; -export type { McpStartupFailure } from "./McpStartupFailure"; -export type { McpStartupStatus } from "./McpStartupStatus"; -export type { McpStartupUpdateEvent } from "./McpStartupUpdateEvent"; -export type { McpToolCallBeginEvent } from "./McpToolCallBeginEvent"; -export type { McpToolCallEndEvent } from "./McpToolCallEndEvent"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; -export type { NetworkAccess } from "./NetworkAccess"; -export type { NetworkApprovalContext } from "./NetworkApprovalContext"; -export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; -export type { NewConversationParams } from "./NewConversationParams"; -export type { NewConversationResponse } from "./NewConversationResponse"; +export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; +export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; -export type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent"; -export type { PatchApplyEndEvent } from "./PatchApplyEndEvent"; export type { Personality } from "./Personality"; -export type { PlanDeltaEvent } from "./PlanDeltaEvent"; -export type { PlanItem } from "./PlanItem"; -export type { PlanItemArg } from "./PlanItemArg"; export type { PlanType } from "./PlanType"; -export type { Profile } from "./Profile"; -export type { RateLimitReachedType } from "./RateLimitReachedType"; -export type { RateLimitSnapshot } from "./RateLimitSnapshot"; -export type { RateLimitWindow } from "./RateLimitWindow"; -export type { RawResponseItemEvent } from "./RawResponseItemEvent"; -export type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; +export type { RealtimeConversationVersion } from "./RealtimeConversationVersion"; +export type { RealtimeOutputModality } from "./RealtimeOutputModality"; +export type { RealtimeVoice } from "./RealtimeVoice"; +export type { RealtimeVoicesList } from "./RealtimeVoicesList"; export type { ReasoningEffort } from "./ReasoningEffort"; -export type { ReasoningItem } from "./ReasoningItem"; export type { ReasoningItemContent } from "./ReasoningItemContent"; export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; -export type { ReasoningRawContentDeltaEvent } from "./ReasoningRawContentDeltaEvent"; export type { ReasoningSummary } from "./ReasoningSummary"; -export type { RejectConfig } from "./RejectConfig"; -export type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; -export type { RemoteSkillSummary } from "./RemoteSkillSummary"; -export type { RemoveConversationListenerParams } from "./RemoveConversationListenerParams"; -export type { RemoveConversationSubscriptionResponse } from "./RemoveConversationSubscriptionResponse"; export type { RequestId } from "./RequestId"; -export type { RequestUserInputEvent } from "./RequestUserInputEvent"; -export type { RequestUserInputQuestion } from "./RequestUserInputQuestion"; -export type { RequestUserInputQuestionOption } from "./RequestUserInputQuestionOption"; export type { Resource } from "./Resource"; +export type { ResourceContent } from "./ResourceContent"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; -export type { ResumeConversationParams } from "./ResumeConversationParams"; -export type { ResumeConversationResponse } from "./ResumeConversationResponse"; -export type { ReviewCodeLocation } from "./ReviewCodeLocation"; export type { ReviewDecision } from "./ReviewDecision"; -export type { ReviewFinding } from "./ReviewFinding"; -export type { ReviewLineRange } from "./ReviewLineRange"; -export type { ReviewOutputEvent } from "./ReviewOutputEvent"; -export type { ReviewRequest } from "./ReviewRequest"; -export type { ReviewSnapshotInfo } from "./ReviewSnapshotInfo"; -export type { ReviewTarget } from "./ReviewTarget"; -export type { SandboxMode } from "./SandboxMode"; -export type { SandboxPolicy } from "./SandboxPolicy"; -export type { SandboxSettings } from "./SandboxSettings"; -export type { SendUserMessageParams } from "./SendUserMessageParams"; -export type { SendUserMessageResponse } from "./SendUserMessageResponse"; -export type { SendUserTurnParams } from "./SendUserTurnParams"; -export type { SendUserTurnResponse } from "./SendUserTurnResponse"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; -export type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; -export type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; export type { SessionSource } from "./SessionSource"; -export type { SetDefaultModelParams } from "./SetDefaultModelParams"; -export type { SetDefaultModelResponse } from "./SetDefaultModelResponse"; export type { Settings } from "./Settings"; -export type { SkillDependencies } from "./SkillDependencies"; -export type { SkillErrorInfo } from "./SkillErrorInfo"; -export type { SkillInterface } from "./SkillInterface"; -export type { SkillMetadata } from "./SkillMetadata"; -export type { SkillScope } from "./SkillScope"; -export type { SkillToolDependency } from "./SkillToolDependency"; -export type { SkillsListEntry } from "./SkillsListEntry"; -export type { StepStatus } from "./StepStatus"; -export type { StreamErrorEvent } from "./StreamErrorEvent"; export type { SubAgentSource } from "./SubAgentSource"; -export type { TerminalInteractionEvent } from "./TerminalInteractionEvent"; -export type { TextElement } from "./TextElement"; export type { ThreadId } from "./ThreadId"; -export type { ThreadNameUpdatedEvent } from "./ThreadNameUpdatedEvent"; -export type { ThreadRolledBackEvent } from "./ThreadRolledBackEvent"; -export type { TokenCountEvent } from "./TokenCountEvent"; -export type { TokenUsage } from "./TokenUsage"; -export type { TokenUsageInfo } from "./TokenUsageInfo"; +export type { ThreadMemoryMode } from "./ThreadMemoryMode"; export type { Tool } from "./Tool"; -export type { Tools } from "./Tools"; -export type { TurnAbortReason } from "./TurnAbortReason"; -export type { TurnAbortedEvent } from "./TurnAbortedEvent"; -export type { TurnCompleteEvent } from "./TurnCompleteEvent"; -export type { TurnDiffEvent } from "./TurnDiffEvent"; -export type { TurnItem } from "./TurnItem"; -export type { TurnStartedEvent } from "./TurnStartedEvent"; -export type { UndoCompletedEvent } from "./UndoCompletedEvent"; -export type { UndoStartedEvent } from "./UndoStartedEvent"; -export type { UpdatePlanArgs } from "./UpdatePlanArgs"; -export type { UserInfoResponse } from "./UserInfoResponse"; -export type { UserInput } from "./UserInput"; -export type { UserMessageEvent } from "./UserMessageEvent"; -export type { UserMessageItem } from "./UserMessageItem"; -export type { UserSavedConfig } from "./UserSavedConfig"; export type { Verbosity } from "./Verbosity"; -export type { ViewImageToolCallEvent } from "./ViewImageToolCallEvent"; -export type { WarningEvent } from "./WarningEvent"; export type { WebSearchAction } from "./WebSearchAction"; -export type { WebSearchBeginEvent } from "./WebSearchBeginEvent"; export type { WebSearchContextSize } from "./WebSearchContextSize"; -export type { WebSearchEndEvent } from "./WebSearchEndEvent"; -export type { WebSearchItem } from "./WebSearchItem"; export type { WebSearchLocation } from "./WebSearchLocation"; export type { WebSearchMode } from "./WebSearchMode"; export type { WebSearchToolConfig } from "./WebSearchToolConfig"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/Account.ts b/code-rs/app-server-protocol/schema/typescript/v2/Account.ts index f91677499e7..4c3a58e8d6a 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts index eacb8154129..84bf626e0d0 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AuthMode } from "../AuthMode"; +import type { PlanType } from "../PlanType"; -export type AccountUpdatedNotification = { authMode: AuthMode | null, }; +export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/code-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts new file mode 100644 index 00000000000..cbc8c6ef0a7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -0,0 +1,21 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; + +export type ActivePermissionProfile = { +/** + * Identifier from `default_permissions` or the implicit built-in default, + * such as `:workspace` or a user-defined `[permissions.]` profile. + */ +id: string, +/** + * Parent profile identifier once permissions profiles support + * inheritance. This is currently always `null`. + */ +extends: string | null, +/** + * Bounded user-requested modifications applied on top of the named + * profile, if any. + */ +modifications: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts new file mode 100644 index 00000000000..1cbee6868a2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts b/code-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts new file mode 100644 index 00000000000..70498d6a67a --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AddCreditsNudgeCreditType = "credits" | "usage_limit"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts new file mode 100644 index 00000000000..2b62da68eaf --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AddCreditsNudgeEmailStatus = "sent" | "cooldown_active"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts b/code-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts index fb45464f993..e29263b95fa 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts @@ -1,5 +1,15 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; -export type AdditionalFileSystemPermissions = { read: Array | null, write: Array | null, }; +export type AdditionalFileSystemPermissions = { +/** + * This will be removed in favor of `entries`. + */ +read: Array | null, +/** + * This will be removed in favor of `entries`. + */ +write: Array | null, globScanMaxDepth?: number, entries?: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts b/code-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts deleted file mode 100644 index eae1ad810cc..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { MacOsAutomationValue } from "../MacOsAutomationValue"; -import type { MacOsPreferencesValue } from "../MacOsPreferencesValue"; - -export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesValue | null, automations: MacOsAutomationValue | null, accessibility: boolean | null, calendar: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AdditionalNetworkPermissions.ts b/code-rs/app-server-protocol/schema/typescript/v2/AdditionalNetworkPermissions.ts new file mode 100644 index 00000000000..823de26ca36 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AdditionalNetworkPermissions.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AdditionalNetworkPermissions = { enabled: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts b/code-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts index 4ca4dca120b..5120ec3135d 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts @@ -2,6 +2,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; -import type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; +import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; -export type AdditionalPermissionProfile = { network: boolean | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, }; +export type AdditionalPermissionProfile = { +/** + * Partial overlay used for per-command permission requests. + */ +network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts new file mode 100644 index 00000000000..873398db670 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppBranding.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - app metadata returned by app-list APIs. + */ +export type AppBranding = { category: string | null, developer: string | null, website: string | null, privacyPolicy: string | null, termsOfService: string | null, isDiscoverableApp: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts deleted file mode 100644 index bc44835a762..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AppDisabledReason = "unknown" | "user"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index fd533b24ea3..ef1f54aa682 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -1,11 +1,13 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppBranding } from "./AppBranding"; +import type { AppMetadata } from "./AppMetadata"; /** * EXPERIMENTAL - app metadata returned by app-list APIs. */ -export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, +export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, branding: AppBranding | null, appMetadata: AppMetadata | null, labels: { [key in string]?: string } | null, installUrl: string | null, isAccessible: boolean, /** * Whether this app is enabled in config.toml. * Example: @@ -14,4 +16,4 @@ export type AppInfo = { id: string, name: string, description: string | null, lo * enabled = false * ``` */ -isEnabled: boolean, }; +isEnabled: boolean, pluginDisplayNames: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts new file mode 100644 index 00000000000..f1a5001eb1b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppMetadata.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppReview } from "./AppReview"; +import type { AppScreenshot } from "./AppScreenshot"; + +export type AppMetadata = { review: AppReview | null, categories: Array | null, subCategories: Array | null, seoDescription: string | null, screenshots: Array | null, developer: string | null, version: string | null, versionId: string | null, versionNotes: string | null, firstPartyType: string | null, firstPartyRequiresInstall: boolean | null, showInComposerWhenUnlinked: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppReview.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppReview.ts new file mode 100644 index 00000000000..10fd95f09de --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppReview.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppReview = { status: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts new file mode 100644 index 00000000000..0d264246f3f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppScreenshot.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppScreenshot = { url: string | null, fileId: string | null, userPrompt: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppSummary.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppSummary.ts new file mode 100644 index 00000000000..586c76f8f78 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppSummary.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - app metadata summary for plugin responses. + */ +export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, needsAuth: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts new file mode 100644 index 00000000000..e92cd8e28b2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppToolApproval = "auto" | "prompt" | "approve"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts new file mode 100644 index 00000000000..16a1c22c6d8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppToolApproval } from "./AppToolApproval"; + +export type AppToolsConfig = { [key in string]?: { enabled: boolean | null, approval_mode: AppToolApproval | null, } }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts b/code-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts new file mode 100644 index 00000000000..1d932946cc5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Configures who approval requests are routed to for review. Examples + * include sandbox escapes, blocked network access, MCP approval prompts, and + * ARC escalations. Defaults to `user`. `auto_review` uses a carefully + * prompted subagent to gather relevant context and apply a risk-based + * decision framework before approving or denying the request. + */ +export type ApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index ae3e455201a..b22997de97f 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -1,6 +1,8 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AppDisabledReason } from "./AppDisabledReason"; +import type { AppToolApproval } from "./AppToolApproval"; +import type { AppToolsConfig } from "./AppToolsConfig"; +import type { AppsDefaultConfig } from "./AppsDefaultConfig"; -export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } }; +export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts b/code-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts new file mode 100644 index 00000000000..e73386027e0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppsDefaultConfig = { enabled: boolean, destructive_enabled: boolean, open_world_enabled: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts b/code-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts index 55415eaea43..8d41214e013 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/AskForApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "reject": { sandbox_approval: boolean, rules: boolean, skill_approval: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never"; +export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "granular": { sandbox_approval: boolean, rules: boolean, skill_approval: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/AutoReviewDecisionSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/AutoReviewDecisionSource.ts new file mode 100644 index 00000000000..8806981237f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/AutoReviewDecisionSource.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * [UNSTABLE] Source that produced a terminal approval auto-review decision. + */ +export type AutoReviewDecisionSource = "agent"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/code-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 962356e27f4..6e975abf413 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; /** * This translation layer make sure that we expose codex error code in camel case. @@ -8,4 +9,4 @@ * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "cyberPolicy" | { "modelCap": { model: string, reset_after_seconds: bigint | null, } } | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CollabAgentStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/CollabAgentStatus.ts index 3672d19dac0..66d3119ba68 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/CollabAgentStatus.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/CollabAgentStatus.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CollabAgentStatus = "pendingInit" | "running" | "completed" | "errored" | "shutdown" | "notFound"; +export type CollabAgentStatus = "pendingInit" | "running" | "interrupted" | "completed" | "errored" | "shutdown" | "notFound"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CollaborationModeMask.ts b/code-rs/app-server-protocol/schema/typescript/v2/CollaborationModeMask.ts new file mode 100644 index 00000000000..83adc6446fd --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CollaborationModeMask.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModeKind } from "../ModeKind"; +import type { ReasoningEffort } from "../ReasoningEffort"; + +/** + * EXPERIMENTAL - collaboration mode preset metadata for clients. + */ +export type CollaborationModeMask = { name: string, mode: ModeKind | null, model: string | null, reasoning_effort: ReasoningEffort | null | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts index 8fd8163633e..a17fb06a0c0 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandAction.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type CommandAction = { "type": "readCommand", command: string, } | { "type": "read", command: string, name: string, path: string, } | { "type": "listFiles", command: string, path: string | null, } | { "type": "search", command: string, query: string | null, path: string | null, } | { "type": "unknown", command: string, }; +export type CommandAction = { "type": "read", command: string, name: string, path: AbsolutePathBuf, } | { "type": "listFiles", command: string, path: string | null, } | { "type": "search", command: string, query: string | null, path: string | null, } | { "type": "unknown", command: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts new file mode 100644 index 00000000000..a6c2ea45dc9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts @@ -0,0 +1,30 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommandExecOutputStream } from "./CommandExecOutputStream"; + +/** + * Base64-encoded output chunk emitted for a streaming `command/exec` request. + * + * These notifications are connection-scoped. If the originating connection + * closes, the server terminates the process. + */ +export type CommandExecOutputDeltaNotification = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * Output stream for this chunk. + */ +stream: CommandExecOutputStream, +/** + * Base64-encoded output bytes. + */ +deltaBase64: string, +/** + * `true` on the final streamed chunk for a stream when `outputBytesCap` + * truncated later output on that stream. + */ +capReached: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts new file mode 100644 index 00000000000..a8c5b66711d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stream label for `command/exec/outputDelta` notifications. + */ +export type CommandExecOutputStream = "stdout" | "stderr"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 847e19d6939..221a2399c15 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -1,6 +1,85 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type CommandExecParams = { command: Array, timeoutMs?: number | null, cwd?: string | null, sandboxPolicy?: SandboxPolicy | null, }; +/** + * Run a standalone command (argv vector) in the server sandbox without + * creating a thread or turn. + * + * The final `command/exec` response is deferred until the process exits and is + * sent only after all `command/exec/outputDelta` notifications for that + * connection have been emitted. + */ +export type CommandExecParams = {/** + * Command argv vector. Empty arrays are rejected. + */ +command: Array, /** + * Optional client-supplied, connection-scoped process id. + * + * Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + * `command/exec/write`, `command/exec/resize`, and + * `command/exec/terminate` calls. When omitted, buffered execution gets an + * internal id that is not exposed to the client. + */ +processId?: string | null, /** + * Enable PTY mode. + * + * This implies `streamStdin` and `streamStdoutStderr`. + */ +tty?: boolean, /** + * Allow follow-up `command/exec/write` requests to write stdin bytes. + * + * Requires a client-supplied `processId`. + */ +streamStdin?: boolean, /** + * Stream stdout/stderr via `command/exec/outputDelta` notifications. + * + * Streamed bytes are not duplicated into the final response and require a + * client-supplied `processId`. + */ +streamStdoutStderr?: boolean, /** + * Optional per-stream stdout/stderr capture cap in bytes. + * + * When omitted, the server default applies. Cannot be combined with + * `disableOutputCap`. + */ +outputBytesCap?: number | null, /** + * Disable stdout/stderr capture truncation for this request. + * + * Cannot be combined with `outputBytesCap`. + */ +disableOutputCap?: boolean, /** + * Disable the timeout entirely for this request. + * + * Cannot be combined with `timeoutMs`. + */ +disableTimeout?: boolean, /** + * Optional timeout in milliseconds. + * + * When omitted, the server default applies. Cannot be combined with + * `disableTimeout`. + */ +timeoutMs?: number | null, /** + * Optional working directory. Defaults to the server cwd. + */ +cwd?: string | null, /** + * Optional environment overrides merged into the server-computed + * environment. + * + * Matching names override inherited values. Set a key to `null` to unset + * an inherited variable. + */ +env?: { [key in string]?: string | null } | null, /** + * Optional initial PTY size in character cells. Only valid when `tty` is + * true. + */ +size?: CommandExecTerminalSize | null, /** + * Optional sandbox policy for this command. + * + * Uses the same shape as thread/turn execution sandbox configuration and + * defaults to the user's configured policy when omitted. Cannot be + * combined with `permissionProfile`. + */ +sandboxPolicy?: SandboxPolicy | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts new file mode 100644 index 00000000000..40a05dc780d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts @@ -0,0 +1,18 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; + +/** + * Resize a running PTY-backed `command/exec` session. + */ +export type CommandExecResizeParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * New PTY size in character cells. + */ +size: CommandExecTerminalSize, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts new file mode 100644 index 00000000000..7b7f2be7006 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Empty success response for `command/exec/resize`. + */ +export type CommandExecResizeResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts index 6887a3e3c2c..25e01eb53bf 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts @@ -2,4 +2,23 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandExecResponse = { exitCode: number, stdout: string, stderr: string, }; +/** + * Final buffered result for `command/exec`. + */ +export type CommandExecResponse = { +/** + * Process exit code. + */ +exitCode: number, +/** + * Buffered stdout capture. + * + * Empty when stdout was streamed via `command/exec/outputDelta`. + */ +stdout: string, +/** + * Buffered stderr capture. + * + * Empty when stderr was streamed via `command/exec/outputDelta`. + */ +stderr: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts new file mode 100644 index 00000000000..0bfacb62c6d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts @@ -0,0 +1,16 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * PTY size in character cells for `command/exec` PTY sessions. + */ +export type CommandExecTerminalSize = { +/** + * Terminal height in character cells. + */ +rows: number, +/** + * Terminal width in character cells. + */ +cols: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts new file mode 100644 index 00000000000..cae97057523 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Terminate a running `command/exec` session. + */ +export type CommandExecTerminateParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts new file mode 100644 index 00000000000..dc6371fbdd6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Empty success response for `command/exec/terminate`. + */ +export type CommandExecTerminateResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts new file mode 100644 index 00000000000..2092c793817 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts @@ -0,0 +1,22 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Write stdin bytes to a running `command/exec` session, close stdin, or + * both. + */ +export type CommandExecWriteParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * Optional base64-encoded stdin bytes to write. + */ +deltaBase64?: string | null, +/** + * Close stdin after writing `deltaBase64`, if present. + */ +closeStdin?: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts new file mode 100644 index 00000000000..6dbbddf4dd2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Empty success response for `command/exec/write`. + */ +export type CommandExecWriteResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts index 80df9bd02ce..c022030a1e2 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; +import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; -export type CommandExecutionApprovalDecision = "accept" | "acceptForSession" | { "acceptWithExecpolicyAmendment": { execpolicy_amendment: ExecPolicyAmendment, } } | "decline" | "cancel"; +export type CommandExecutionApprovalDecision = "accept" | "acceptForSession" | { "acceptWithExecpolicyAmendment": { execpolicy_amendment: ExecPolicyAmendment, } } | { "applyNetworkPolicyAmendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "decline" | "cancel"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index dd1b57ad405..0e9100836a6 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -1,41 +1,43 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { CommandAction } from "./CommandAction"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; +import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; -export type CommandExecutionRequestApprovalParams = { threadId: string, turnId: string, itemId: string, -/** - * Identifier for this specific approval callback. - */ -approvalId?: string | null, -/** +export type CommandExecutionRequestApprovalParams = {threadId: string, turnId: string, itemId: string, /** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, /** + * Unique identifier for this specific approval callback. + * + * For regular shell/unified_exec approvals, this is null. + * + * For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to + * one parent `itemId`, so `approvalId` is a distinct opaque callback id + * (a UUID) used to disambiguate routing. + */ +approvalId?: string | null, /** * Optional explanatory reason (e.g. request for network access). */ -reason?: string | null, -/** +reason?: string | null, /** * Optional context for a managed-network approval prompt. */ -networkApprovalContext?: NetworkApprovalContext | null, -/** +networkApprovalContext?: NetworkApprovalContext | null, /** * The command to be executed. */ -command?: string | null, -/** +command?: string | null, /** * The command's working directory. */ -cwd?: string | null, -/** +cwd?: AbsolutePathBuf | null, /** * Best-effort parsed command actions for friendly display. */ -commandActions?: Array | null, -/** - * Optional additional permissions requested for this command. - */ -additionalPermissions?: AdditionalPermissionProfile | null, -/** +commandActions?: Array | null, /** * Optional proposed execpolicy amendment to allow similar commands without prompting. */ -proposedExecpolicyAmendment?: ExecPolicyAmendment | null, }; +proposedExecpolicyAmendment?: ExecPolicyAmendment | null, /** + * Optional proposed network policy amendments (allow/deny host) for future requests. + */ +proposedNetworkPolicyAmendments?: Array | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionSource.ts new file mode 100644 index 00000000000..9432841fb7c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandExecutionSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecutionSource = "agent" | "userShell" | "unifiedExecStartup" | "unifiedExecInteraction"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/CommandMigration.ts b/code-rs/app-server-protocol/schema/typescript/v2/CommandMigration.ts new file mode 100644 index 00000000000..fdf28f318e9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/CommandMigration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandMigration = { name: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/Config.ts b/code-rs/app-server-protocol/schema/typescript/v2/Config.ts index aee0ac33838..cc7e340ea33 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -8,10 +8,15 @@ import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AnalyticsConfig } from "./AnalyticsConfig"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { ProfileV2 } from "./ProfileV2"; import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; import type { ToolsV2 } from "./ToolsV2"; -export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, /** + * [UNSTABLE] Optional default for where approval requests are routed for + * review. + */ +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts index 9ce330647e6..352eac28e34 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ConfigBatchWriteParams.ts @@ -7,4 +7,8 @@ export type ConfigBatchWriteParams = { edits: Array, /** * Path to the config file to write; defaults to the user's `config.toml` when omitted. */ -filePath?: string | null, expectedVersion?: string | null, }; +filePath?: string | null, expectedVersion?: string | null, +/** + * When true, hot-reload the updated user config into all loaded threads after writing. + */ +reloadUserConfig?: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/code-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index f99c880697c..47a99453fe3 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts b/code-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts new file mode 100644 index 00000000000..a81ce61f6e9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfiguredHookHandler = { "type": "command", command: string, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts b/code-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts new file mode 100644 index 00000000000..2c00fc166c2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfiguredHookHandler } from "./ConfiguredHookHandler"; + +export type ConfiguredHookMatcherGroup = { matcher: string | null, hooks: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts index e0be3e37bd2..0823ac66cd0 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "../serde_json/JsonValue"; -export type DynamicToolCallParams = { threadId: string, turnId: string, callId: string, namespace?: string | null, tool: string, arguments: JsonValue, }; +export type DynamicToolCallParams = { threadId: string, turnId: string, callId: string, namespace: string | null, tool: string, arguments: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallStatus.ts new file mode 100644 index 00000000000..04f44ec0a8b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DynamicToolCallStatus = "inProgress" | "completed" | "failed"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts new file mode 100644 index 00000000000..d96955bf3dc --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExperimentalFeatureEnablementSetParams = { +/** + * Process-wide runtime feature enablement keyed by canonical feature name. + * + * Only named features are updated. Omitted features are left unchanged. + * Send an empty map for a no-op. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts new file mode 100644 index 00000000000..d0a8975b817 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExperimentalFeatureEnablementSetResponse = { +/** + * Feature enablement entries updated by this request. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportCompletedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportCompletedNotification.ts new file mode 100644 index 00000000000..edb8f191621 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportCompletedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExternalAgentConfigImportCompletedNotification = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts b/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts index 45e5585a778..c9921ccbc6b 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts @@ -2,9 +2,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; +import type { MigrationDetails } from "./MigrationDetails"; export type ExternalAgentConfigMigrationItem = { itemType: ExternalAgentConfigMigrationItemType, description: string, /** * Null or empty means home-scoped migration; non-empty means repo-scoped migration. */ -cwd: string | null, }; +cwd: string | null, details: MigrationDetails | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts b/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts index c9bd160b1c6..d8576937fdc 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "MCP_SERVER_CONFIG"; +export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG" | "SUBAGENTS" | "HOOKS" | "COMMANDS" | "SESSIONS"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index 3066e654061..86d9de2f0da 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FileChangeOutputDeltaNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/FileChangeOutputDeltaNotification.ts index 1018bd8a2b8..c11f626cd45 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/FileChangeOutputDeltaNotification.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/FileChangeOutputDeltaNotification.ts @@ -2,4 +2,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Deprecated legacy notification for `apply_patch` textual output. + * + * The server no longer emits this notification. + */ export type FileChangeOutputDeltaNotification = { threadId: string, turnId: string, itemId: string, delta: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FileChangePatchUpdatedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/FileChangePatchUpdatedNotification.ts new file mode 100644 index 00000000000..4a4ed92753d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FileChangePatchUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileUpdateChange } from "./FileUpdateChange"; + +export type FileChangePatchUpdatedNotification = { threadId: string, turnId: string, itemId: string, changes: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts index c514ed62195..2db7be9ec49 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts @@ -3,6 +3,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type FileChangeRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +/** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, /** * Optional explanatory reason (e.g. request for extra write access). */ diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts new file mode 100644 index 00000000000..b1d801fe416 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FileSystemAccessMode = "read" | "write" | "none"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts new file mode 100644 index 00000000000..2efc7eab3f1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemPath.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; + +export type FileSystemPath = { "type": "path", path: AbsolutePathBuf, } | { "type": "glob_pattern", pattern: string, } | { "type": "special", value: FileSystemSpecialPath, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FileSystemSandboxEntry.ts b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemSandboxEntry.ts new file mode 100644 index 00000000000..f37cd0d63ee --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemSandboxEntry.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileSystemAccessMode } from "./FileSystemAccessMode"; +import type { FileSystemPath } from "./FileSystemPath"; + +export type FileSystemSandboxEntry = { path: FileSystemPath, access: FileSystemAccessMode, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FileSystemSpecialPath.ts b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemSpecialPath.ts new file mode 100644 index 00000000000..f4dc2b01e61 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FileSystemSpecialPath.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "project_roots", subpath: string | null, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts new file mode 100644 index 00000000000..3f3be8ff3d1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Filesystem watch notification emitted for `fs/watch` subscribers. + */ +export type FsChangedNotification = { +/** + * Watch identifier previously provided to `fs/watch`. + */ +watchId: string, +/** + * File or directory paths associated with this event. + */ +changedPaths: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsCopyParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsCopyParams.ts new file mode 100644 index 00000000000..d19aca92680 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsCopyParams.ts @@ -0,0 +1,21 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Copy a file or directory tree on the host filesystem. + */ +export type FsCopyParams = { +/** + * Absolute source path. + */ +sourcePath: AbsolutePathBuf, +/** + * Absolute destination path. + */ +destinationPath: AbsolutePathBuf, +/** + * Required for directory copies; ignored for file copies. + */ +recursive?: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsCopyResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsCopyResponse.ts new file mode 100644 index 00000000000..3e3061a8ab5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsCopyResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Successful response for `fs/copy`. + */ +export type FsCopyResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsCreateDirectoryParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsCreateDirectoryParams.ts new file mode 100644 index 00000000000..b648d350292 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsCreateDirectoryParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Create a directory on the host filesystem. + */ +export type FsCreateDirectoryParams = { +/** + * Absolute directory path to create. + */ +path: AbsolutePathBuf, +/** + * Whether parent directories should also be created. Defaults to `true`. + */ +recursive?: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsCreateDirectoryResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsCreateDirectoryResponse.ts new file mode 100644 index 00000000000..5d251b71564 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsCreateDirectoryResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Successful response for `fs/createDirectory`. + */ +export type FsCreateDirectoryResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataParams.ts new file mode 100644 index 00000000000..4ea0445cd68 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataParams.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Request metadata for an absolute path. + */ +export type FsGetMetadataParams = { +/** + * Absolute path to inspect. + */ +path: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts new file mode 100644 index 00000000000..a1a127e192f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts @@ -0,0 +1,28 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Metadata returned by `fs/getMetadata`. + */ +export type FsGetMetadataResponse = { +/** + * Whether the path resolves to a directory. + */ +isDirectory: boolean, +/** + * Whether the path resolves to a regular file. + */ +isFile: boolean, +/** + * Whether the path itself is a symbolic link. + */ +isSymlink: boolean, +/** + * File creation time in Unix milliseconds when available, otherwise `0`. + */ +createdAtMs: number, +/** + * File modification time in Unix milliseconds when available, otherwise `0`. + */ +modifiedAtMs: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryEntry.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryEntry.ts new file mode 100644 index 00000000000..197673d2bb2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryEntry.ts @@ -0,0 +1,20 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A directory entry returned by `fs/readDirectory`. + */ +export type FsReadDirectoryEntry = { +/** + * Direct child entry name only, not an absolute or relative path. + */ +fileName: string, +/** + * Whether this entry resolves to a directory. + */ +isDirectory: boolean, +/** + * Whether this entry resolves to a regular file. + */ +isFile: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryParams.ts new file mode 100644 index 00000000000..94eaae43559 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryParams.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * List direct child names for a directory. + */ +export type FsReadDirectoryParams = { +/** + * Absolute directory path to read. + */ +path: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryResponse.ts new file mode 100644 index 00000000000..0ffb8acd4c8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsReadDirectoryResponse.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FsReadDirectoryEntry } from "./FsReadDirectoryEntry"; + +/** + * Directory entries returned by `fs/readDirectory`. + */ +export type FsReadDirectoryResponse = { +/** + * Direct child entries in the requested directory. + */ +entries: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsReadFileParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsReadFileParams.ts new file mode 100644 index 00000000000..d5bf22e33a7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsReadFileParams.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Read a file from the host filesystem. + */ +export type FsReadFileParams = { +/** + * Absolute path to read. + */ +path: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsReadFileResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsReadFileResponse.ts new file mode 100644 index 00000000000..26b6126970f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsReadFileResponse.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Base64-encoded file contents returned by `fs/readFile`. + */ +export type FsReadFileResponse = { +/** + * File contents encoded as base64. + */ +dataBase64: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsRemoveParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsRemoveParams.ts new file mode 100644 index 00000000000..c95b860acac --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsRemoveParams.ts @@ -0,0 +1,21 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Remove a file or directory tree from the host filesystem. + */ +export type FsRemoveParams = { +/** + * Absolute path to remove. + */ +path: AbsolutePathBuf, +/** + * Whether directory removal should recurse. Defaults to `true`. + */ +recursive?: boolean | null, +/** + * Whether missing paths should be ignored. Defaults to `true`. + */ +force?: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsRemoveResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsRemoveResponse.ts new file mode 100644 index 00000000000..981c28fa1e4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsRemoveResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Successful response for `fs/remove`. + */ +export type FsRemoveResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts new file mode 100644 index 00000000000..ff314814f03 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stop filesystem watch notifications for a prior `fs/watch`. + */ +export type FsUnwatchParams = { +/** + * Watch identifier previously provided to `fs/watch`. + */ +watchId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts new file mode 100644 index 00000000000..02507d2c008 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Successful response for `fs/unwatch`. + */ +export type FsUnwatchResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts new file mode 100644 index 00000000000..b990b8e0ec2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Start filesystem watch notifications for an absolute path. + */ +export type FsWatchParams = { +/** + * Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + */ +watchId: string, +/** + * Absolute file or directory path to watch. + */ +path: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts new file mode 100644 index 00000000000..82e6c7e9b31 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Successful response for `fs/watch`. + */ +export type FsWatchResponse = { +/** + * Canonicalized path associated with the watch. + */ +path: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsWriteFileParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsWriteFileParams.ts new file mode 100644 index 00000000000..1e8672b5296 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsWriteFileParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Write a file on the host filesystem. + */ +export type FsWriteFileParams = { +/** + * Absolute path to write. + */ +path: AbsolutePathBuf, +/** + * File contents encoded as base64. + */ +dataBase64: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/FsWriteFileResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/FsWriteFileResponse.ts new file mode 100644 index 00000000000..ad0ce283801 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/FsWriteFileResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Successful response for `fs/writeFile`. + */ +export type FsWriteFileResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts index 25c0b5c3868..02cc7779343 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts @@ -5,10 +5,10 @@ import type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type GetAccountRateLimitsResponse = { /** - * Backward-compatible single-bucket view. + * Backward-compatible single-bucket view; mirrors the historical payload. */ rateLimits: RateLimitSnapshot, /** - * Multi-bucket view keyed by metered `limit_id`. + * Multi-bucket view keyed by metered `limit_id` (for example, `codex`). */ -rateLimitsByLimitId?: { [key in string]?: RateLimitSnapshot } | null, }; +rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts b/code-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts new file mode 100644 index 00000000000..3ae6c605112 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; +import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; + +export type GrantedPermissionProfile = { network?: AdditionalNetworkPermissions, fileSystem?: AdditionalFileSystemPermissions, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReview.ts b/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReview.ts new file mode 100644 index 00000000000..11d797eb194 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReview.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus"; +import type { GuardianRiskLevel } from "./GuardianRiskLevel"; +import type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; + +/** + * [UNSTABLE] Temporary approval auto-review payload used by + * `item/autoApprovalReview/*` notifications. This shape is expected to change + * soon. + */ +export type GuardianApprovalReview = { status: GuardianApprovalReviewStatus, riskLevel: GuardianRiskLevel | null, userAuthorization: GuardianUserAuthorization | null, rationale: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts b/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts new file mode 100644 index 00000000000..4f00e37d20e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { GuardianCommandSource } from "./GuardianCommandSource"; +import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; +import type { RequestPermissionProfile } from "./RequestPermissionProfile"; + +export type GuardianApprovalReviewAction = { "type": "command", source: GuardianCommandSource, command: string, cwd: AbsolutePathBuf, } | { "type": "execve", source: GuardianCommandSource, program: string, argv: Array, cwd: AbsolutePathBuf, } | { "type": "applyPatch", cwd: AbsolutePathBuf, files: Array, } | { "type": "networkAccess", target: string, host: string, protocol: NetworkApprovalProtocol, port: number, } | { "type": "mcpToolCall", server: string, toolName: string, connectorId: string | null, connectorName: string | null, toolTitle: string | null, } | { "type": "requestPermissions", reason: string | null, permissions: RequestPermissionProfile, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewStatus.ts new file mode 100644 index 00000000000..ae892572f5d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewStatus.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * [UNSTABLE] Lifecycle state for an approval auto-review. + */ +export type GuardianApprovalReviewStatus = "inProgress" | "approved" | "denied" | "timedOut" | "aborted"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.ts new file mode 100644 index 00000000000..b48e9b08261 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GuardianCommandSource = "shell" | "unifiedExec"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GuardianRiskLevel.ts b/code-rs/app-server-protocol/schema/typescript/v2/GuardianRiskLevel.ts new file mode 100644 index 00000000000..7734016aa87 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GuardianRiskLevel.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * [UNSTABLE] Risk level assigned by approval auto-review. + */ +export type GuardianRiskLevel = "low" | "medium" | "high" | "critical"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GuardianUserAuthorization.ts b/code-rs/app-server-protocol/schema/typescript/v2/GuardianUserAuthorization.ts new file mode 100644 index 00000000000..936611f7849 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GuardianUserAuthorization.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * [UNSTABLE] Authorization level assigned by approval auto-review. + */ +export type GuardianUserAuthorization = "unknown" | "low" | "medium" | "high"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts new file mode 100644 index 00000000000..1659f62f1bf --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GuardianWarningNotification = { +/** + * Thread target for the guardian warning. + */ +threadId: string, +/** + * Concise guardian warning message for the user. + */ +message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookCompletedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookCompletedNotification.ts new file mode 100644 index 00000000000..fe4dbfb5134 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookCompletedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HookRunSummary } from "./HookRunSummary"; + +export type HookCompletedNotification = { threadId: string, turnId: string | null, run: HookRunSummary, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts new file mode 100644 index 00000000000..75c259b0c0c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookErrorInfo = { path: string, message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts new file mode 100644 index 00000000000..91c2def7098 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookExecutionMode.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookExecutionMode.ts new file mode 100644 index 00000000000..61f98564cad --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookExecutionMode.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookExecutionMode = "sync" | "async"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookHandlerType.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookHandlerType.ts new file mode 100644 index 00000000000..dc3f087bff9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookHandlerType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookHandlerType = "command" | "prompt" | "agent"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts new file mode 100644 index 00000000000..94e3c30c92d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { HookEventName } from "./HookEventName"; +import type { HookHandlerType } from "./HookHandlerType"; +import type { HookSource } from "./HookSource"; +import type { HookTrustStatus } from "./HookTrustStatus"; + +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookMigration.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookMigration.ts new file mode 100644 index 00000000000..92ec2d3da4a --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookMigration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookMigration = { name: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookOutputEntry.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookOutputEntry.ts new file mode 100644 index 00000000000..834f0c4e0cb --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookOutputEntry.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HookOutputEntryKind } from "./HookOutputEntryKind"; + +export type HookOutputEntry = { kind: HookOutputEntryKind, text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookOutputEntryKind.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookOutputEntryKind.ts new file mode 100644 index 00000000000..090dfe38740 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookOutputEntryKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookOutputEntryKind = "warning" | "stop" | "feedback" | "context" | "error"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts new file mode 100644 index 00000000000..2c6b18acba2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookPromptFragment.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookPromptFragment = { text: string, hookRunId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookRunStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookRunStatus.ts new file mode 100644 index 00000000000..ffca7e0e2c9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookRunStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookRunStatus = "running" | "completed" | "failed" | "blocked" | "stopped"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts new file mode 100644 index 00000000000..75ab780b93f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookRunSummary.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { HookEventName } from "./HookEventName"; +import type { HookExecutionMode } from "./HookExecutionMode"; +import type { HookHandlerType } from "./HookHandlerType"; +import type { HookOutputEntry } from "./HookOutputEntry"; +import type { HookRunStatus } from "./HookRunStatus"; +import type { HookScope } from "./HookScope"; +import type { HookSource } from "./HookSource"; + +export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookScope.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookScope.ts new file mode 100644 index 00000000000..ff6f8bfee44 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookScope.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookScope = "thread" | "turn"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookSource.ts new file mode 100644 index 00000000000..98bbe1e412a --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookStartedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookStartedNotification.ts new file mode 100644 index 00000000000..1f781ed6395 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookStartedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HookRunSummary } from "./HookRunSummary"; + +export type HookStartedNotification = { threadId: string, turnId: string | null, run: HookRunSummary, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts new file mode 100644 index 00000000000..692fdc4c112 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookTrustStatus = "managed" | "untrusted" | "trusted" | "modified"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts b/code-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts new file mode 100644 index 00000000000..256b29bb465 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HookErrorInfo } from "./HookErrorInfo"; +import type { HookMetadata } from "./HookMetadata"; + +export type HooksListEntry = { cwd: string, hooks: Array, warnings: Array, errors: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts new file mode 100644 index 00000000000..db29387d29c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HooksListParams = { +/** + * When empty, defaults to the current session working directory. + */ +cwds?: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts new file mode 100644 index 00000000000..4c2dd1a8dba --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HooksListEntry } from "./HooksListEntry"; + +export type HooksListResponse = { data: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts index 96122204b43..25ced4a0750 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle completed. + */ +completedAtMs: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts new file mode 100644 index 00000000000..32d12be6084 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts @@ -0,0 +1,38 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; +import type { GuardianApprovalReview } from "./GuardianApprovalReview"; +import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; + +/** + * [UNSTABLE] Temporary notification payload for approval auto-review. This + * shape is expected to change soon. + */ +export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this review started. + */ +startedAtMs: number, +/** + * Unix timestamp (in milliseconds) when this review completed. + */ +completedAtMs: number, +/** + * Stable identifier for this review. + */ +reviewId: string, +/** + * Identifier for the reviewed item or tool call when one exists. + * + * In most cases, one review maps to one target item. The exceptions are + * - execve reviews, where a single command may contain multiple execve + * calls to review (only possible when using the shell_zsh_fork feature) + * - network policy reviews, where there is no target item + * + * A network call is triggered by a CommandExecution item, so having a + * target_item_id set to the CommandExecution item would be misleading + * because the review is about the network call, not the command execution. + * Therefore, target_item_id is set to None for network policy reviews. + */ +targetItemId: string | null, decisionSource: AutoReviewDecisionSource, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts new file mode 100644 index 00000000000..92d34fdebc1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts @@ -0,0 +1,33 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GuardianApprovalReview } from "./GuardianApprovalReview"; +import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; + +/** + * [UNSTABLE] Temporary notification payload for approval auto-review. This + * shape is expected to change soon. + */ +export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this review started. + */ +startedAtMs: number, +/** + * Stable identifier for this review. + */ +reviewId: string, +/** + * Identifier for the reviewed item or tool call when one exists. + * + * In most cases, one review maps to one target item. The exceptions are + * - execve reviews, where a single command may contain multiple execve + * calls to review (only possible when using the shell_zsh_fork feature) + * - network policy reviews, where there is no target item + * + * A network call is triggered by a CommandExecution item, so having a + * target_item_id set to the CommandExecution item would be misleading + * because the review is about the network call, not the command execution. + * Therefore, target_item_id is set to None for network policy reviews. + */ +targetItemId: string | null, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts index 5cf1e7b9188..9ec8af09e9f 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle started. + */ +startedAtMs: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts index c8d81ee9421..fa00b8ea010 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpServerStatusDetail } from "./McpServerStatusDetail"; export type ListMcpServerStatusParams = { /** @@ -10,4 +11,9 @@ cursor?: string | null, /** * Optional page size; defaults to a server-defined value. */ -limit?: number | null, }; +limit?: number | null, +/** + * Controls how much MCP inventory data to fetch for each server. + * Defaults to `Full` when omitted. + */ +detail?: McpServerStatusDetail | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index e3e8afb680e..e6f1e2ed436 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptAuthTokens", +export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", /** * Access token (JWT) supplied by the client. * This token is used for backend API requests and email extraction. diff --git a/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts index 2b1bcc5f562..34bccd6578e 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts @@ -6,4 +6,12 @@ export type LoginAccountResponse = { "type": "apiKey", } | { "type": "chatgpt", /** * URL the client should open in a browser to initiate the OAuth flow. */ -authUrl: string, } | { "type": "chatgptAuthTokens", }; +authUrl: string, } | { "type": "chatgptDeviceCode", loginId: string, +/** + * URL the client should open in a browser to complete device code authorization. + */ +verificationUrl: string, +/** + * One-time code the user must enter after signing in. + */ +userCode: string, } | { "type": "chatgptAuthTokens", }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/code-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts new file mode 100644 index 00000000000..cde0e4a5034 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; + +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddParams.ts new file mode 100644 index 00000000000..23d16048124 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MarketplaceAddParams = { source: string, refName?: string | null, sparsePaths?: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddResponse.ts new file mode 100644 index 00000000000..8657d44c3d7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type MarketplaceAddResponse = { marketplaceName: string, installedRoot: AbsolutePathBuf, alreadyAdded: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceInterface.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceInterface.ts new file mode 100644 index 00000000000..f82dc17944e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceInterface.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MarketplaceInterface = { displayName: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts new file mode 100644 index 00000000000..3e60e2149e8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type MarketplaceLoadErrorInfo = { marketplacePath: AbsolutePathBuf, message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceRemoveParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceRemoveParams.ts new file mode 100644 index 00000000000..086dd52a485 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceRemoveParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MarketplaceRemoveParams = { marketplaceName: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceRemoveResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceRemoveResponse.ts new file mode 100644 index 00000000000..68a04ecdf46 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceRemoveResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type MarketplaceRemoveResponse = { marketplaceName: string, installedRoot: AbsolutePathBuf | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeErrorInfo.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeErrorInfo.ts new file mode 100644 index 00000000000..d54f8f592e9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeErrorInfo.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MarketplaceUpgradeErrorInfo = { marketplaceName: string, message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeParams.ts new file mode 100644 index 00000000000..6d2e5f50bd6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MarketplaceUpgradeParams = { marketplaceName?: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeResponse.ts new file mode 100644 index 00000000000..456fbdccab7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { MarketplaceUpgradeErrorInfo } from "./MarketplaceUpgradeErrorInfo"; + +export type MarketplaceUpgradeResponse = { selectedMarketplaces: Array, upgradedRoots: Array, errors: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationArrayType.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationArrayType.ts new file mode 100644 index 00000000000..066b44ea595 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationArrayType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationArrayType = "array"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanSchema.ts new file mode 100644 index 00000000000..ae0f4a4976f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationBooleanType } from "./McpElicitationBooleanType"; + +export type McpElicitationBooleanSchema = { type: McpElicitationBooleanType, title?: string, description?: string, default?: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanType.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanType.ts new file mode 100644 index 00000000000..f2b9ed48df4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationBooleanType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationBooleanType = "boolean"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationConstOption.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationConstOption.ts new file mode 100644 index 00000000000..2031655d8bc --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationConstOption.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationConstOption = { const: string, title: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationEnumSchema.ts new file mode 100644 index 00000000000..e9155db4ad4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationEnumSchema.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationLegacyTitledEnumSchema } from "./McpElicitationLegacyTitledEnumSchema"; +import type { McpElicitationMultiSelectEnumSchema } from "./McpElicitationMultiSelectEnumSchema"; +import type { McpElicitationSingleSelectEnumSchema } from "./McpElicitationSingleSelectEnumSchema"; + +export type McpElicitationEnumSchema = McpElicitationSingleSelectEnumSchema | McpElicitationMultiSelectEnumSchema | McpElicitationLegacyTitledEnumSchema; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationLegacyTitledEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationLegacyTitledEnumSchema.ts new file mode 100644 index 00000000000..8dcec317468 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationLegacyTitledEnumSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationLegacyTitledEnumSchema = { type: McpElicitationStringType, title?: string, description?: string, enum: Array, enumNames?: Array, default?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationMultiSelectEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationMultiSelectEnumSchema.ts new file mode 100644 index 00000000000..48eb25e172e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationMultiSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationTitledMultiSelectEnumSchema } from "./McpElicitationTitledMultiSelectEnumSchema"; +import type { McpElicitationUntitledMultiSelectEnumSchema } from "./McpElicitationUntitledMultiSelectEnumSchema"; + +export type McpElicitationMultiSelectEnumSchema = McpElicitationUntitledMultiSelectEnumSchema | McpElicitationTitledMultiSelectEnumSchema; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberSchema.ts new file mode 100644 index 00000000000..6628db9215c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationNumberType } from "./McpElicitationNumberType"; + +export type McpElicitationNumberSchema = { type: McpElicitationNumberType, title?: string, description?: string, minimum?: number, maximum?: number, default?: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberType.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberType.ts new file mode 100644 index 00000000000..96a9ded7607 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationNumberType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationNumberType = "number" | "integer"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationObjectType.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationObjectType.ts new file mode 100644 index 00000000000..2449a0c1ed2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationObjectType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationObjectType = "object"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationPrimitiveSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationPrimitiveSchema.ts new file mode 100644 index 00000000000..2828ae5895f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationPrimitiveSchema.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema"; +import type { McpElicitationEnumSchema } from "./McpElicitationEnumSchema"; +import type { McpElicitationNumberSchema } from "./McpElicitationNumberSchema"; +import type { McpElicitationStringSchema } from "./McpElicitationStringSchema"; + +export type McpElicitationPrimitiveSchema = McpElicitationEnumSchema | McpElicitationStringSchema | McpElicitationNumberSchema | McpElicitationBooleanSchema; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationSchema.ts new file mode 100644 index 00000000000..1afa533342b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationSchema.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationObjectType } from "./McpElicitationObjectType"; +import type { McpElicitationPrimitiveSchema } from "./McpElicitationPrimitiveSchema"; + +/** + * Typed form schema for MCP `elicitation/create` requests. + * + * This matches the `requestedSchema` shape from the MCP 2025-11-25 + * `ElicitRequestFormParams` schema. + */ +export type McpElicitationSchema = { $schema?: string, type: McpElicitationObjectType, properties: { [key in string]?: McpElicitationPrimitiveSchema }, required?: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationSingleSelectEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationSingleSelectEnumSchema.ts new file mode 100644 index 00000000000..2ba7dadb12b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationSingleSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationTitledSingleSelectEnumSchema } from "./McpElicitationTitledSingleSelectEnumSchema"; +import type { McpElicitationUntitledSingleSelectEnumSchema } from "./McpElicitationUntitledSingleSelectEnumSchema"; + +export type McpElicitationSingleSelectEnumSchema = McpElicitationUntitledSingleSelectEnumSchema | McpElicitationTitledSingleSelectEnumSchema; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringFormat.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringFormat.ts new file mode 100644 index 00000000000..9891d4c7ca7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringFormat.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationStringFormat = "email" | "uri" | "date" | "date-time"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringSchema.ts new file mode 100644 index 00000000000..c2ca1eb83c5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringFormat } from "./McpElicitationStringFormat"; +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationStringSchema = { type: McpElicitationStringType, title?: string, description?: string, minLength?: number, maxLength?: number, format?: McpElicitationStringFormat, default?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringType.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringType.ts new file mode 100644 index 00000000000..bf2ddfab91c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationStringType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpElicitationStringType = "string"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledEnumItems.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledEnumItems.ts new file mode 100644 index 00000000000..44ff2ef2780 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledEnumItems.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationConstOption } from "./McpElicitationConstOption"; + +export type McpElicitationTitledEnumItems = { anyOf: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledMultiSelectEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledMultiSelectEnumSchema.ts new file mode 100644 index 00000000000..75274d34e18 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledMultiSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationArrayType } from "./McpElicitationArrayType"; +import type { McpElicitationTitledEnumItems } from "./McpElicitationTitledEnumItems"; + +export type McpElicitationTitledMultiSelectEnumSchema = { type: McpElicitationArrayType, title?: string, description?: string, minItems?: bigint, maxItems?: bigint, items: McpElicitationTitledEnumItems, default?: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledSingleSelectEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledSingleSelectEnumSchema.ts new file mode 100644 index 00000000000..47b73191419 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationTitledSingleSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationConstOption } from "./McpElicitationConstOption"; +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationTitledSingleSelectEnumSchema = { type: McpElicitationStringType, title?: string, description?: string, oneOf: Array, default?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledEnumItems.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledEnumItems.ts new file mode 100644 index 00000000000..f790881fb4e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledEnumItems.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationUntitledEnumItems = { type: McpElicitationStringType, enum: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledMultiSelectEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledMultiSelectEnumSchema.ts new file mode 100644 index 00000000000..5acf9fee002 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledMultiSelectEnumSchema.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationArrayType } from "./McpElicitationArrayType"; +import type { McpElicitationUntitledEnumItems } from "./McpElicitationUntitledEnumItems"; + +export type McpElicitationUntitledMultiSelectEnumSchema = { type: McpElicitationArrayType, title?: string, description?: string, minItems?: bigint, maxItems?: bigint, items: McpElicitationUntitledEnumItems, default?: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledSingleSelectEnumSchema.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledSingleSelectEnumSchema.ts new file mode 100644 index 00000000000..49be545d53d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpElicitationUntitledSingleSelectEnumSchema.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpElicitationStringType } from "./McpElicitationStringType"; + +export type McpElicitationUntitledSingleSelectEnumSchema = { type: McpElicitationStringType, title?: string, description?: string, enum: Array, default?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts new file mode 100644 index 00000000000..c48795f27e8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpResourceReadParams = { threadId?: string | null, server: string, uri: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts new file mode 100644 index 00000000000..2af1dbcd09d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ResourceContent } from "../ResourceContent"; + +export type McpResourceReadResponse = { contents: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationAction.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationAction.ts new file mode 100644 index 00000000000..7be134c0150 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationAction.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpServerElicitationAction = "accept" | "decline" | "cancel"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts new file mode 100644 index 00000000000..90d60f77c7b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts @@ -0,0 +1,16 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; +import type { McpElicitationSchema } from "./McpElicitationSchema"; + +export type McpServerElicitationRequestParams = { threadId: string, +/** + * Active Codex turn when this elicitation was observed, if app-server could correlate one. + * + * This is nullable because MCP models elicitation as a standalone server-to-client request + * identified by the MCP server request id. It may be triggered during a turn, but turn + * context is app-server correlation rather than part of the protocol identity of the + * elicitation itself. + */ +turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts new file mode 100644 index 00000000000..a3d145744ea --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestResponse.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; +import type { McpServerElicitationAction } from "./McpServerElicitationAction"; + +export type McpServerElicitationRequestResponse = { action: McpServerElicitationAction, +/** + * Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. + * + * This is nullable because decline/cancel responses have no content. + */ +content: JsonValue | null, +/** + * Optional client metadata for form-mode action handling. + */ +_meta: JsonValue | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerMigration.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerMigration.ts new file mode 100644 index 00000000000..03c125109f0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerMigration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpServerMigration = { name: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerStartupState.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerStartupState.ts new file mode 100644 index 00000000000..c62babca66a --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerStartupState.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpServerStartupState = "starting" | "ready" | "failed" | "cancelled"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts new file mode 100644 index 00000000000..ab97cc2f31d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpServerStatusDetail = "full" | "toolsAndAuthOnly"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts new file mode 100644 index 00000000000..42f5881c5dc --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerStatusUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpServerStartupState } from "./McpServerStartupState"; + +export type McpServerStatusUpdatedNotification = { name: string, status: McpServerStartupState, error: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallParams.ts new file mode 100644 index 00000000000..046a3fdc28c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type McpServerToolCallParams = { threadId: string, server: string, tool: string, arguments?: JsonValue, _meta?: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallResponse.ts new file mode 100644 index 00000000000..fe14692ad26 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpServerToolCallResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type McpServerToolCallResponse = { content: Array, structuredContent?: JsonValue, isError?: boolean, _meta?: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts b/code-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts index f493a86094e..916a5f5bb3f 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "../serde_json/JsonValue"; -export type McpToolCallResult = { content: Array, structuredContent: JsonValue | null, }; +export type McpToolCallResult = { content: Array, structuredContent: JsonValue | null, _meta: JsonValue | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MemoryCitation.ts b/code-rs/app-server-protocol/schema/typescript/v2/MemoryCitation.ts new file mode 100644 index 00000000000..7657e29f8bf --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MemoryCitation.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MemoryCitationEntry } from "./MemoryCitationEntry"; + +export type MemoryCitation = { entries: Array, threadIds: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MemoryCitationEntry.ts b/code-rs/app-server-protocol/schema/typescript/v2/MemoryCitationEntry.ts new file mode 100644 index 00000000000..9b9ce17267f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MemoryCitationEntry.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryCitationEntry = { path: string, lineStart: number, lineEnd: number, note: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts b/code-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts new file mode 100644 index 00000000000..4fe87eabdbf --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CommandMigration } from "./CommandMigration"; +import type { HookMigration } from "./HookMigration"; +import type { McpServerMigration } from "./McpServerMigration"; +import type { PluginsMigration } from "./PluginsMigration"; +import type { SessionMigration } from "./SessionMigration"; +import type { SubagentMigration } from "./SubagentMigration"; + +export type MigrationDetails = { plugins: Array, sessions: Array, mcpServers: Array, hooks: Array, subagents: Array, commands: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/Model.ts b/code-rs/app-server-protocol/schema/typescript/v2/Model.ts index cd991081902..2354ffbf9e3 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -4,7 +4,12 @@ import type { InputModality } from "../InputModality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; +import type { ModelServiceTier } from "./ModelServiceTier"; import type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; import type { ReasoningEffortOption } from "./ReasoningEffortOption"; -export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, isDefault: boolean, }; +export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, +/** + * Deprecated: use `serviceTiers` instead. + */ +additionalSpeedTiers: Array, serviceTiers: Array, isDefault: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ModelProviderCapabilitiesReadParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ModelProviderCapabilitiesReadParams.ts new file mode 100644 index 00000000000..00cbe470b3c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ModelProviderCapabilitiesReadParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelProviderCapabilitiesReadParams = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ModelProviderCapabilitiesReadResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ModelProviderCapabilitiesReadResponse.ts new file mode 100644 index 00000000000..043fc30435b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ModelProviderCapabilitiesReadResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelProviderCapabilitiesReadResponse = { namespaceTools: boolean, imageGeneration: boolean, webSearch: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts b/code-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts new file mode 100644 index 00000000000..e780e7f95d7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ModelRerouteReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelRerouteReason = "highRiskCyberActivity"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts new file mode 100644 index 00000000000..9b6b2e524ab --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ModelReroutedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelRerouteReason } from "./ModelRerouteReason"; + +export type ModelReroutedNotification = { threadId: string, turnId: string, fromModel: string, toModel: string, reason: ModelRerouteReason, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts b/code-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts new file mode 100644 index 00000000000..09693d07882 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelServiceTier = { id: string, name: string, description: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts new file mode 100644 index 00000000000..00538c090f0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelVerification = "trustedAccessForCyber"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts new file mode 100644 index 00000000000..3af484d0eaa --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelVerification } from "./ModelVerification"; + +export type ModelVerificationNotification = { threadId: string, turnId: string, verifications: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts b/code-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts new file mode 100644 index 00000000000..2ea44392de9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkDomainPermission = "allow" | "deny"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/NetworkPolicyAmendment.ts b/code-rs/app-server-protocol/schema/typescript/v2/NetworkPolicyAmendment.ts new file mode 100644 index 00000000000..4e5092e4d0c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/NetworkPolicyAmendment.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; + +export type NetworkPolicyAmendment = { host: string, action: NetworkPolicyRuleAction, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/NetworkPolicyRuleAction.ts b/code-rs/app-server-protocol/schema/typescript/v2/NetworkPolicyRuleAction.ts new file mode 100644 index 00000000000..55ec70032a6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/NetworkPolicyRuleAction.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkPolicyRuleAction = "allow" | "deny"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/code-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index b7ac9d2f7a8..04e07ef1de9 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -1,5 +1,32 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkDomainPermission } from "./NetworkDomainPermission"; +import type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; -export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, +/** + * Canonical network permission map for `experimental_network`. + */ +domains: { [key in string]?: NetworkDomainPermission } | null, +/** + * When true, only managed allowlist entries are respected while managed + * network enforcement is active. + */ +managedAllowedDomainsOnly: boolean | null, +/** + * Legacy compatibility view derived from `domains`. + */ +allowedDomains: Array | null, +/** + * Legacy compatibility view derived from `domains`. + */ +deniedDomains: Array | null, +/** + * Canonical unix socket permission map for `experimental_network`. + */ +unixSockets: { [key in string]?: NetworkUnixSocketPermission } | null, +/** + * Legacy compatibility view derived from `unix_sockets`. + */ +allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts b/code-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts new file mode 100644 index 00000000000..466c6e5f8f9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkUnixSocketPermission = "allow" | "none"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts b/code-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts new file mode 100644 index 00000000000..2624df2ba0d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/NonSteerableTurnKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NonSteerableTurnKind = "review" | "compact"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts new file mode 100644 index 00000000000..8ca127ebcb1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionGrantScope.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionGrantScope = "turn" | "session"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts new file mode 100644 index 00000000000..7642c276506 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; +import type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; + +export type PermissionProfile = { "type": "managed", network: PermissionProfileNetworkPermissions, fileSystem: PermissionProfileFileSystemPermissions, } | { "type": "disabled" } | { "type": "external", network: PermissionProfileNetworkPermissions, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts new file mode 100644 index 00000000000..29aeceb433b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; + +export type PermissionProfileFileSystemPermissions = { "type": "restricted", entries: Array, globScanMaxDepth?: number, } | { "type": "unrestricted" }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts new file mode 100644 index 00000000000..c619edcea81 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts new file mode 100644 index 00000000000..0b25a769a9f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionProfileNetworkPermissions = { enabled: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts new file mode 100644 index 00000000000..a415bd0028e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; + +export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts new file mode 100644 index 00000000000..509f60923ba --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { RequestPermissionProfile } from "./RequestPermissionProfile"; + +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +/** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, cwd: AbsolutePathBuf, reason: string | null, permissions: RequestPermissionProfile, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts new file mode 100644 index 00000000000..f42b39560c8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; +import type { PermissionGrantScope } from "./PermissionGrantScope"; + +export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, +/** + * Review every subsequent command in this turn before normal sandboxed execution. + */ +strictAutoReview?: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginAuthPolicy.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginAuthPolicy.ts new file mode 100644 index 00000000000..5b90e9c3136 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginAuthPolicy.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginAuthPolicy = "ON_INSTALL" | "ON_USE"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginAvailability.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginAvailability.ts new file mode 100644 index 00000000000..bec0b88cc20 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginAvailability.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginAvailability = "AVAILABLE" | "DISABLED_BY_ADMIN"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts new file mode 100644 index 00000000000..64836c87f7c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { AppSummary } from "./AppSummary"; +import type { PluginHookSummary } from "./PluginHookSummary"; +import type { PluginSummary } from "./PluginSummary"; +import type { SkillSummary } from "./SkillSummary"; + +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts new file mode 100644 index 00000000000..48046bbd7ad --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HookEventName } from "./HookEventName"; + +export type PluginHookSummary = { key: string, eventName: HookEventName, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts new file mode 100644 index 00000000000..257dc47a1ef --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginInstallParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicy.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicy.ts new file mode 100644 index 00000000000..d624f38ea3f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicy.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginInstallPolicy = "NOT_AVAILABLE" | "AVAILABLE" | "INSTALLED_BY_DEFAULT"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts new file mode 100644 index 00000000000..b88119d44c5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppSummary } from "./AppSummary"; +import type { PluginAuthPolicy } from "./PluginAuthPolicy"; + +export type PluginInstallResponse = { authPolicy: PluginAuthPolicy, appsNeedingAuth: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts new file mode 100644 index 00000000000..4e97ee66f39 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -0,0 +1,35 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, +/** + * Starter prompts for the plugin. Capped at 3 entries with a maximum of + * 128 characters per entry. + */ +defaultPrompt: Array | null, brandColor: string | null, +/** + * Local composer icon path, resolved from the installed plugin package. + */ +composerIcon: AbsolutePathBuf | null, +/** + * Remote composer icon URL from the plugin catalog. + */ +composerIconUrl: string | null, +/** + * Local logo path, resolved from the installed plugin package. + */ +logo: AbsolutePathBuf | null, +/** + * Remote logo URL from the plugin catalog. + */ +logoUrl: string | null, +/** + * Local screenshot paths, resolved from the installed plugin package. + */ +screenshots: Array, +/** + * Remote screenshot URLs from the plugin catalog. + */ +screenshotUrls: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts new file mode 100644 index 00000000000..6ff6161f340 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts new file mode 100644 index 00000000000..6dd86b8a412 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; + +export type PluginListParams = { +/** + * Optional working directories used to discover repo marketplaces. When omitted, + * only home-scoped marketplaces and the official curated marketplace are considered. + */ +cwds?: Array | null, +/** + * Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus + * the default remote catalog when enabled by feature flag. + */ +marketplaceKinds?: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts new file mode 100644 index 00000000000..d50200c905f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; +import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; + +export type PluginListResponse = { marketplaces: Array, marketplaceLoadErrors: Array, featuredPluginIds: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts new file mode 100644 index 00000000000..f9dcee27df6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { MarketplaceInterface } from "./MarketplaceInterface"; +import type { PluginSummary } from "./PluginSummary"; + +export type PluginMarketplaceEntry = { name: string, +/** + * Local marketplace file path when the marketplace is backed by a local file. + * Remote-only catalog marketplaces do not have a local path. + */ +path: AbsolutePathBuf | null, interface: MarketplaceInterface | null, plugins: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts new file mode 100644 index 00000000000..8c4394f0da6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginReadParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginReadResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginReadResponse.ts new file mode 100644 index 00000000000..841b916ebe9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginReadResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginDetail } from "./PluginDetail"; + +export type PluginReadResponse = { plugin: PluginDetail, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts new file mode 100644 index 00000000000..f1c5c958d73 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipal } from "./PluginSharePrincipal"; + +export type PluginShareContext = { remotePluginId: string, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, shareTargets: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteParams.ts new file mode 100644 index 00000000000..b0adaf2da85 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareDeleteParams = { remotePluginId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteResponse.ts new file mode 100644 index 00000000000..23102683645 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareDeleteResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts new file mode 100644 index 00000000000..8c2242163b6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts new file mode 100644 index 00000000000..b63738aacd9 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginSummary } from "./PluginSummary"; + +export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListParams.ts new file mode 100644 index 00000000000..167ace7ac2c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareListParams = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts new file mode 100644 index 00000000000..50b324f5ab0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginShareListItem } from "./PluginShareListItem"; + +export type PluginShareListResponse = { data: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts new file mode 100644 index 00000000000..9e0ecc48e75 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts new file mode 100644 index 00000000000..e54c129cbfe --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginSharePrincipalType = "user" | "group" | "workspace"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts new file mode 100644 index 00000000000..c8df0d6c1c2 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginShareTarget } from "./PluginShareTarget"; + +export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, discoverability?: PluginShareDiscoverability | null, shareTargets?: Array | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveResponse.ts new file mode 100644 index 00000000000..b53ace0ef9c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareSaveResponse = { remotePluginId: string, shareUrl: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts new file mode 100644 index 00000000000..fd1969087f5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateDiscoverability.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateDiscoverability.ts new file mode 100644 index 00000000000..fd601987af4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateDiscoverability.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareUpdateDiscoverability = "UNLISTED" | "PRIVATE"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts new file mode 100644 index 00000000000..eecd4be82be --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginShareTarget } from "./PluginShareTarget"; +import type { PluginShareUpdateDiscoverability } from "./PluginShareUpdateDiscoverability"; + +export type PluginShareUpdateTargetsParams = { remotePluginId: string, discoverability: PluginShareUpdateDiscoverability, shareTargets: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts new file mode 100644 index 00000000000..0ce722460fa --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginSharePrincipal } from "./PluginSharePrincipal"; + +export type PluginShareUpdateTargetsResponse = { principals: Array, discoverability: PluginShareDiscoverability, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginSkillReadParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginSkillReadParams.ts new file mode 100644 index 00000000000..54a63599cf6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginSkillReadParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginSkillReadParams = { remoteMarketplaceName: string, remotePluginId: string, skillName: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginSkillReadResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginSkillReadResponse.ts new file mode 100644 index 00000000000..0ae37982ba7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginSkillReadResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginSkillReadResponse = { contents: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts new file mode 100644 index 00000000000..f6e867195d6 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts new file mode 100644 index 00000000000..d855f3d31ca --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -0,0 +1,19 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginAuthPolicy } from "./PluginAuthPolicy"; +import type { PluginAvailability } from "./PluginAvailability"; +import type { PluginInstallPolicy } from "./PluginInstallPolicy"; +import type { PluginInterface } from "./PluginInterface"; +import type { PluginShareContext } from "./PluginShareContext"; +import type { PluginSource } from "./PluginSource"; + +export type PluginSummary = { id: string, name: string, +/** + * Remote sharing context associated with this plugin when available. + */ +shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, +/** + * Availability state for installing and using the plugin. + */ +availability: PluginAvailability, interface: PluginInterface | null, keywords: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts new file mode 100644 index 00000000000..e7f52c0eb3c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginUninstallParams = { pluginId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts new file mode 100644 index 00000000000..5d02c2f7167 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginUninstallResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/PluginsMigration.ts b/code-rs/app-server-protocol/schema/typescript/v2/PluginsMigration.ts new file mode 100644 index 00000000000..0dce06d9ab4 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/PluginsMigration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginsMigration = { marketplaceName: string, pluginNames: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ProcessExitedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ProcessExitedNotification.ts new file mode 100644 index 00000000000..0d82633421e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ProcessExitedNotification.ts @@ -0,0 +1,42 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Final process exit notification for `process/spawn`. + */ +export type ProcessExitedNotification = { +/** + * Client-supplied, connection-scoped `processHandle` from `process/spawn`. + */ +processHandle: string, +/** + * Process exit code. + */ +exitCode: number, +/** + * Buffered stdout capture. + * + * Empty when stdout was streamed via `process/outputDelta`. + */ +stdout: string, +/** + * Whether stdout reached `outputBytesCap`. + * + * In streaming mode, stdout is empty and cap state is also reported on the + * final stdout `process/outputDelta` notification. + */ +stdoutCapReached: boolean, +/** + * Buffered stderr capture. + * + * Empty when stderr was streamed via `process/outputDelta`. + */ +stderr: string, +/** + * Whether stderr reached `outputBytesCap`. + * + * In streaming mode, stderr is empty and cap state is also reported on the + * final stderr `process/outputDelta` notification. + */ +stderrCapReached: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ProcessOutputDeltaNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ProcessOutputDeltaNotification.ts new file mode 100644 index 00000000000..46369e396a1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ProcessOutputDeltaNotification.ts @@ -0,0 +1,26 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProcessOutputStream } from "./ProcessOutputStream"; + +/** + * Base64-encoded output chunk emitted for a streaming `process/spawn` request. + */ +export type ProcessOutputDeltaNotification = { +/** + * Client-supplied, connection-scoped `processHandle` from `process/spawn`. + */ +processHandle: string, +/** + * Output stream this chunk belongs to. + */ +stream: ProcessOutputStream, +/** + * Base64-encoded output bytes. + */ +deltaBase64: string, +/** + * True on the final streamed chunk for this stream when output was + * truncated by `outputBytesCap`. + */ +capReached: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ProcessOutputStream.ts b/code-rs/app-server-protocol/schema/typescript/v2/ProcessOutputStream.ts new file mode 100644 index 00000000000..1bb550d90df --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ProcessOutputStream.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stream label for `process/outputDelta` notifications. + */ +export type ProcessOutputStream = "stdout" | "stderr"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ProcessTerminalSize.ts b/code-rs/app-server-protocol/schema/typescript/v2/ProcessTerminalSize.ts new file mode 100644 index 00000000000..1c4b467038a --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ProcessTerminalSize.ts @@ -0,0 +1,16 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * PTY size in character cells for `process/spawn` PTY sessions. + */ +export type ProcessTerminalSize = { +/** + * Terminal height in character cells. + */ +rows: number, +/** + * Terminal width in character cells. + */ +cols: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/code-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 56428ba7abd..d05038701c8 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -6,6 +6,13 @@ import type { ReasoningSummary } from "../ReasoningSummary"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { ToolsV2 } from "./ToolsV2"; -export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +export type ProfileV2 = {model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, /** + * [UNSTABLE] Optional profile-level override for where approval requests + * are routed for review. If omitted, the enclosing config default is + * used. + */ +approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/code-rs/app-server-protocol/schema/typescript/v2/RemoteControlConnectionStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/RemoteControlConnectionStatus.ts new file mode 100644 index 00000000000..3e6197f5b55 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/RemoteControlConnectionStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RemoteControlConnectionStatus = "disabled" | "connecting" | "connected" | "errored"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts new file mode 100644 index 00000000000..16a9138556d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus"; + +/** + * Current remote-control connection status and environment id exposed to clients. + */ +export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, environmentId: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts b/code-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts deleted file mode 100644 index 7bf57b3b094..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/RemoteSkillSummary.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RemoteSkillSummary = { id: string, name: string, description: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/RequestPermissionProfile.ts b/code-rs/app-server-protocol/schema/typescript/v2/RequestPermissionProfile.ts new file mode 100644 index 00000000000..2bf8d8dffef --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/RequestPermissionProfile.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; +import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; + +export type RequestPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/code-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index 199d7f2a522..5575701ff2d 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -4,4 +4,4 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { NetworkAccess } from "./NetworkAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly" } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts new file mode 100644 index 00000000000..383ad4aab3d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; + +export type SendAddCreditsNudgeEmailParams = { creditType: AddCreditsNudgeCreditType, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts new file mode 100644 index 00000000000..71dcb190a63 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; + +export type SendAddCreditsNudgeEmailResponse = { status: AddCreditsNudgeEmailStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ServerRequestResolvedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ServerRequestResolvedNotification.ts new file mode 100644 index 00000000000..56c53cc445e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ServerRequestResolvedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RequestId } from "../RequestId"; + +export type ServerRequestResolvedNotification = { threadId: string, requestId: RequestId, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SessionMigration.ts b/code-rs/app-server-protocol/schema/typescript/v2/SessionMigration.ts new file mode 100644 index 00000000000..526af4dd949 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/SessionMigration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SessionMigration = { path: string, cwd: string, title: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts index b35b421fcd7..852e6ded971 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SubAgentSource } from "../SubAgentSource"; -export type SessionSource = "cli" | "vscode" | "exec" | "appServer" | { "subAgent": SubAgentSource } | "unknown"; +export type SessionSource = "cli" | "vscode" | "exec" | "appServer" | { "custom": string } | { "subAgent": SubAgentSource } | "unknown"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillInterface.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillInterface.ts index 86c37a0bd78..2361afcf0f2 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillInterface.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/SkillInterface.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type SkillInterface = { displayName?: string, shortDescription?: string, iconSmall?: string, iconLarge?: string, brandColor?: string, defaultPrompt?: string, }; +export type SkillInterface = { displayName?: string, shortDescription?: string, iconSmall?: AbsolutePathBuf, iconLarge?: AbsolutePathBuf, brandColor?: string, defaultPrompt?: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts index b620fffbdbb..e43484d1f46 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { SkillDependencies } from "./SkillDependencies"; import type { SkillInterface } from "./SkillInterface"; import type { SkillScope } from "./SkillScope"; @@ -9,4 +10,4 @@ export type SkillMetadata = { name: string, description: string, /** * Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. */ -shortDescription?: string, interface?: SkillInterface, dependencies?: SkillDependencies, path: string, scope: SkillScope, enabled: boolean, }; +shortDescription?: string, interface?: SkillInterface, dependencies?: SkillDependencies, path: AbsolutePathBuf, scope: SkillScope, enabled: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts new file mode 100644 index 00000000000..4999a0728ac --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { SkillInterface } from "./SkillInterface"; + +export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf | null, enabled: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsChangedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsChangedNotification.ts new file mode 100644 index 00000000000..23ed93a5ece --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/SkillsChangedNotification.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Notification emitted when watched local skill files change. + * + * Treat this as an invalidation signal and re-run `skills/list` with the + * client's current parameters when refreshed skill metadata is needed. + */ +export type SkillsChangedNotification = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts index 5a4bcf9bc0d..39192e075e0 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts @@ -1,5 +1,14 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type SkillsConfigWriteParams = { path: string, enabled: boolean, }; +export type SkillsConfigWriteParams = { +/** + * Path-based selector. + */ +path?: AbsolutePathBuf | null, +/** + * Name-based selector. + */ +name?: string | null, enabled: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsListExtraRootsForCwd.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsListExtraRootsForCwd.ts deleted file mode 100644 index c18cd4ba1d8..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillsListExtraRootsForCwd.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillsListExtraRootsForCwd = { cwd: string, extraUserRoots: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts index ad714a32978..4adeb38b3ba 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd"; export type SkillsListParams = { /** @@ -11,8 +10,4 @@ cwds?: Array, /** * When true, bypass the skills cache and re-scan skills from disk. */ -forceReload?: boolean, -/** - * Optional per-cwd extra roots to scan as user-scoped skills. - */ -perCwdExtraUserRoots?: Array | null, }; +forceReload?: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts deleted file mode 100644 index 9f917876861..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillsRemoteReadParams = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts deleted file mode 100644 index c1c7b1cc70c..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteReadResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RemoteSkillSummary } from "./RemoteSkillSummary"; - -export type SkillsRemoteReadResponse = { data: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts deleted file mode 100644 index 857b609ef14..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillsRemoteWriteParams = { hazelnutId: string, isPreload: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts deleted file mode 100644 index cf1665ab974..00000000000 --- a/code-rs/app-server-protocol/schema/typescript/v2/SkillsRemoteWriteResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SkillsRemoteWriteResponse = { id: string, name: string, path: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SortDirection.ts b/code-rs/app-server-protocol/schema/typescript/v2/SortDirection.ts new file mode 100644 index 00000000000..d8597a46ea7 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/SortDirection.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SortDirection = "asc" | "desc"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/SubagentMigration.ts b/code-rs/app-server-protocol/schema/typescript/v2/SubagentMigration.ts new file mode 100644 index 00000000000..aaf6cf0d91e --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/SubagentMigration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubagentMigration = { name: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/code-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 27a8877cfcc..d917094e36b 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -1,15 +1,30 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { GitInfo } from "./GitInfo"; import type { SessionSource } from "./SessionSource"; +import type { ThreadSource } from "./ThreadSource"; +import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; export type Thread = { id: string, +/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, +/** + * Source thread id when this thread was created by forking another thread. + */ +forkedFromId: string | null, /** * Usually the first user message in the thread, if available. */ preview: string, +/** + * Whether the thread is ephemeral and should not be materialized on disk. + */ +ephemeral: boolean, /** * Model provider used for this thread (for example, 'openai'). */ @@ -22,6 +37,10 @@ createdAt: number, * Unix timestamp (in seconds) when the thread was last updated. */ updatedAt: number, +/** + * Current runtime status for the thread. + */ +status: ThreadStatus, /** * [UNSTABLE] Path to the thread on disk. */ @@ -29,7 +48,7 @@ path: string | null, /** * Working directory captured for the thread. */ -cwd: string, +cwd: AbsolutePathBuf, /** * Version of the CLI that created the thread. */ @@ -38,10 +57,26 @@ cliVersion: string, * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ source: SessionSource, +/** + * Optional analytics source classification for this thread. + */ +threadSource: ThreadSource | null, +/** + * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + */ +agentNickname: string | null, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agentRole: string | null, /** * Optional Git metadata captured when the thread was created. */ gitInfo: GitInfo | null, +/** + * Optional user-facing thread title. + */ +name: string | null, /** * Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` * (when `includeTurns` is true) responses. diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadActiveFlag.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadActiveFlag.ts new file mode 100644 index 00000000000..73c875a00d8 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadActiveFlag.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadActiveFlag = "waitingOnApproval" | "waitingOnUserInput"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionParams.ts new file mode 100644 index 00000000000..7d1ab0d83f3 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionParams.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type ThreadApproveGuardianDeniedActionParams = { threadId: string, +/** + * Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. + */ +event: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionResponse.ts new file mode 100644 index 00000000000..856bb28cfb1 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadApproveGuardianDeniedActionResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadArchivedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadArchivedNotification.ts new file mode 100644 index 00000000000..cca18907912 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadArchivedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadArchivedNotification = { threadId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadClosedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadClosedNotification.ts new file mode 100644 index 00000000000..ed5bf546caf --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadClosedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadClosedNotification = { threadId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 44c81a24e42..6076a4bb148 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -2,8 +2,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "../serde_json/JsonValue"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; /** * There are two ways to fork a thread: @@ -15,10 +17,13 @@ import type { SandboxMode } from "./SandboxMode"; * Prefer using thread_id whenever possible. */ export type ThreadForkParams = {threadId: string, /** - * [UNSTABLE] Specify the rollout path to fork from. - * If specified, the thread_id param will be ignored. - */ -path?: string | null, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null}; +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** + * Override where approval requests are routed for review on this thread + * and subsequent turns. + */ +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** + * Optional client-supplied analytics source classification for this forked thread. + */ +threadSource?: ThreadSource | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index a46480cb7b7..c44533ec1ab 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -1,9 +1,22 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** + * Instruction source files currently loaded for this thread. + */ +instructionSources: Array, approvalPolicy: AskForApproval, /** + * Reviewer currently used for approval requests on this thread. + */ +approvalsReviewer: ApprovalsReviewer, /** + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. + */ +sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts new file mode 100644 index 00000000000..c68732324fe --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadGoalStatus } from "./ThreadGoalStatus"; + +export type ThreadGoal = { threadId: string, objective: string, status: ThreadGoalStatus, tokenBudget: number | null, tokensUsed: number, timeUsedSeconds: number, createdAt: number, updatedAt: number, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts new file mode 100644 index 00000000000..e8e5a8b6e04 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadGoalClearedNotification = { threadId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts new file mode 100644 index 00000000000..7a4bf332fb0 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadGoalStatus = "active" | "paused" | "budgetLimited" | "complete"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts new file mode 100644 index 00000000000..c9972afa84f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadGoal } from "./ThreadGoal"; + +export type ThreadGoalUpdatedNotification = { threadId: string, turnId: string | null, goal: ThreadGoal, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadInjectItemsParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadInjectItemsParams.ts new file mode 100644 index 00000000000..4a49224a397 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadInjectItemsParams.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type ThreadInjectItemsParams = { threadId: string, +/** + * Raw Responses API items to append to the thread's model-visible history. + */ +items: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadInjectItemsResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadInjectItemsResponse.ts new file mode 100644 index 00000000000..60dcf0d0b3d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadInjectItemsResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadInjectItemsResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 114edf14be5..f7880c9d32c 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -2,21 +2,28 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { MessagePhase } from "../MessagePhase"; +import type { ReasoningEffort } from "../ReasoningEffort"; import type { JsonValue } from "../serde_json/JsonValue"; import type { CollabAgentState } from "./CollabAgentState"; import type { CollabAgentTool } from "./CollabAgentTool"; import type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus"; import type { CommandAction } from "./CommandAction"; +import type { CommandExecutionSource } from "./CommandExecutionSource"; import type { CommandExecutionStatus } from "./CommandExecutionStatus"; +import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; +import type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; import type { FileUpdateChange } from "./FileUpdateChange"; +import type { HookPromptFragment } from "./HookPromptFragment"; import type { McpToolCallError } from "./McpToolCallError"; import type { McpToolCallResult } from "./McpToolCallResult"; import type { McpToolCallStatus } from "./McpToolCallStatus"; +import type { MemoryCitation } from "./MemoryCitation"; import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { UserInput } from "./UserInput"; import type { WebSearchAction } from "./WebSearchAction"; -export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "agentMessage", id: string, text: string, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, +export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** * The command to be executed. */ @@ -24,11 +31,11 @@ command: string, /** * The command's working directory. */ -cwd: string, +cwd: AbsolutePathBuf, /** * Identifier for the underlying PTY process (when available). */ -processId: string | null, status: CommandExecutionStatus, +processId: string | null, source: CommandExecutionSource, status: CommandExecutionStatus, /** * A best-effort parsing of the command to understand the action(s) it will perform. * This returns a list of CommandAction objects because a single shell command may @@ -46,10 +53,14 @@ exitCode: number | null, /** * The duration of the command execution in milliseconds. */ -durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, result: McpToolCallResult | null, error: McpToolCallError | null, +durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null, /** * The duration of the MCP tool call in milliseconds. */ +durationMs: number | null, } | { "type": "dynamicToolCall", id: string, namespace: string | null, tool: string, arguments: JsonValue, status: DynamicToolCallStatus, contentItems: Array | null, success: boolean | null, +/** + * The duration of the dynamic tool call in milliseconds. + */ durationMs: number | null, } | { "type": "collabAgentToolCall", /** * Unique identifier for this collab tool call. @@ -76,7 +87,15 @@ receiverThreadIds: Array, * Prompt text sent as part of the collab tool call, when available. */ prompt: string | null, +/** + * Model requested for the spawned agent, when applicable. + */ +model: string | null, +/** + * Reasoning effort requested for the spawned agent, when applicable. + */ +reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: string, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath: AbsolutePathBuf | null, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index b340ea6b6f4..ce5b6a79bae 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SortDirection } from "./SortDirection"; import type { ThreadSortKey } from "./ThreadSortKey"; import type { ThreadSourceKind } from "./ThreadSourceKind"; @@ -17,6 +18,10 @@ limit?: number | null, * Optional sort key; defaults to created_at. */ sortKey?: ThreadSortKey | null, +/** + * Optional sort direction; defaults to descending (newest first). + */ +sortDirection?: SortDirection | null, /** * Optional provider filter; when set, only sessions recorded under these * providers are returned. When present but empty, includes all providers. @@ -33,7 +38,17 @@ sourceKinds?: Array | null, */ archived?: boolean | null, /** - * Optional cwd filter; when set, only threads whose session cwd exactly - * matches this path are returned. + * Optional cwd filter or filters; when set, only threads whose session cwd + * exactly matches one of these paths are returned. + */ +cwd?: string | Array | null, +/** + * If true, return from the state DB without scanning JSONL rollouts to + * repair thread metadata. Omitted or false preserves scan-and-repair + * behavior. + */ +useStateDbOnly?: boolean, +/** + * Optional substring filter for the extracted thread title. */ -cwd?: string | null, }; +searchTerm?: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts index 3e4d4f57a4d..51757e24c3a 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadListResponse.ts @@ -8,4 +8,11 @@ export type ThreadListResponse = { data: Array, * Opaque cursor to pass to the next call to continue after the last item. * if None, there are no more items to return. */ -nextCursor: string | null, }; +nextCursor: string | null, +/** + * Opaque cursor to pass as `cursor` when reversing `sortDirection`. + * This is only populated when the page contains at least one thread. + * Use it with the opposite `sortDirection`; for timestamp sorts it anchors + * at the start of the page timestamp so same-second updates are not skipped. + */ +backwardsCursor: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataGitInfoUpdateParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataGitInfoUpdateParams.ts new file mode 100644 index 00000000000..865b5346225 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataGitInfoUpdateParams.ts @@ -0,0 +1,20 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMetadataGitInfoUpdateParams = { +/** + * Omit to leave the stored commit unchanged, set to `null` to clear it, + * or provide a non-empty string to replace it. + */ +sha?: string | null, +/** + * Omit to leave the stored branch unchanged, set to `null` to clear it, + * or provide a non-empty string to replace it. + */ +branch?: string | null, +/** + * Omit to leave the stored origin URL unchanged, set to `null` to clear it, + * or provide a non-empty string to replace it. + */ +originUrl?: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts new file mode 100644 index 00000000000..bec4bc1284d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams"; + +export type ThreadMetadataUpdateParams = { threadId: string, +/** + * Patch the stored Git metadata for this thread. + * Omit a field to leave it unchanged, set it to `null` to clear it, or + * provide a string to replace the stored value. + */ +gitInfo?: ThreadMetadataGitInfoUpdateParams | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateResponse.ts new file mode 100644 index 00000000000..d9c09ef2d32 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Thread } from "./Thread"; + +export type ThreadMetadataUpdateResponse = { thread: Thread, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeAudioChunk.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeAudioChunk.ts new file mode 100644 index 00000000000..eefb79dd656 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeAudioChunk.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - thread realtime audio chunk. + */ +export type ThreadRealtimeAudioChunk = { data: string, sampleRate: number, numChannels: number, samplesPerChannel: number | null, itemId: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeClosedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeClosedNotification.ts new file mode 100644 index 00000000000..a39cd71ed73 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeClosedNotification.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - emitted when thread realtime transport closes. + */ +export type ThreadRealtimeClosedNotification = { threadId: string, reason: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeErrorNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeErrorNotification.ts new file mode 100644 index 00000000000..0b24879ecdf --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeErrorNotification.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - emitted when thread realtime encounters an error. + */ +export type ThreadRealtimeErrorNotification = { threadId: string, message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeItemAddedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeItemAddedNotification.ts new file mode 100644 index 00000000000..f996e77cd85 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeItemAddedNotification.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +/** + * EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. + */ +export type ThreadRealtimeItemAddedNotification = { threadId: string, item: JsonValue, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeOutputAudioDeltaNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeOutputAudioDeltaNotification.ts new file mode 100644 index 00000000000..1d03fd89cf5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeOutputAudioDeltaNotification.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadRealtimeAudioChunk } from "./ThreadRealtimeAudioChunk"; + +/** + * EXPERIMENTAL - streamed output audio emitted by thread realtime. + */ +export type ThreadRealtimeOutputAudioDeltaNotification = { threadId: string, audio: ThreadRealtimeAudioChunk, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeSdpNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeSdpNotification.ts new file mode 100644 index 00000000000..16a7fd18c54 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeSdpNotification.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. + */ +export type ThreadRealtimeSdpNotification = { threadId: string, sdp: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartTransport.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartTransport.ts new file mode 100644 index 00000000000..339e1b1b17a --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartTransport.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - transport used by thread realtime. + */ +export type ThreadRealtimeStartTransport = { "type": "websocket" } | { "type": "webrtc", +/** + * SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the + * realtime events data channel. + */ +sdp: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts new file mode 100644 index 00000000000..56763777fca --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RealtimeConversationVersion } from "../RealtimeConversationVersion"; + +/** + * EXPERIMENTAL - emitted when thread realtime startup is accepted. + */ +export type ThreadRealtimeStartedNotification = { threadId: string, realtimeSessionId: string | null, version: RealtimeConversationVersion, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptDeltaNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptDeltaNotification.ts new file mode 100644 index 00000000000..805eeddd768 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptDeltaNotification.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - flat transcript delta emitted whenever realtime + * transcript text changes. + */ +export type ThreadRealtimeTranscriptDeltaNotification = { threadId: string, role: string, +/** + * Live transcript delta from the realtime event. + */ +delta: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptDoneNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptDoneNotification.ts new file mode 100644 index 00000000000..d4667ad039f --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeTranscriptDoneNotification.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - final transcript text emitted when realtime completes + * a transcript part. + */ +export type ThreadRealtimeTranscriptDoneNotification = { threadId: string, role: string, +/** + * Final complete text for the transcript part. + */ +text: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index c868b8f95cf..6d1dbdca4fa 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -2,8 +2,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ResponseItem } from "../ResponseItem"; import type { JsonValue } from "../serde_json/JsonValue"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; @@ -19,15 +19,10 @@ import type { SandboxMode } from "./SandboxMode"; * Prefer using thread_id whenever possible. */ export type ThreadResumeParams = {threadId: string, /** - * [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. - * If specified, the thread will be resumed with the provided history - * instead of loaded from disk. - */ -history?: Array | null, /** - * [UNSTABLE] Specify the rollout path to resume from. - * If specified, the thread_id param will be ignored. - */ -path?: string | null, /** * Configuration overrides for the resumed thread, if any. */ -model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** + * Override where approval requests are routed for review on this thread + * and subsequent turns. + */ +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 6d7a70a6a99..f91756c7c66 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -1,9 +1,22 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** + * Instruction source files currently loaded for this thread. + */ +instructionSources: Array, approvalPolicy: AskForApproval, /** + * Reviewer currently used for approval requests on this thread. + */ +approvalsReviewer: ApprovalsReviewer, /** + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. + */ +sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandParams.ts new file mode 100644 index 00000000000..2761dee2d0b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadShellCommandParams = { threadId: string, +/** + * Shell command string evaluated by the thread's configured shell. + * Unlike `command/exec`, this intentionally preserves shell syntax + * such as pipes, redirects, and quoting. This runs unsandboxed with full + * access rather than inheriting the thread sandbox policy. + */ +command: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandResponse.ts new file mode 100644 index 00000000000..9c54b45839d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadShellCommandResponse = Record; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts new file mode 100644 index 00000000000..8f555248011 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadSource = "user" | "subagent" | "memory_consolidation"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 84ad633d56a..30509ef6cb3 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -3,11 +3,17 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; import type { JsonValue } from "../serde_json/JsonValue"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; +import type { ThreadStartSource } from "./ThreadStartSource"; -export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /** - * If true, opt into emitting raw Responses API items on the event stream. - * This is for internal use only (e.g. Codex Cloud). +export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** + * Override where approval requests are routed for review on this thread + * and subsequent turns. */ -experimentalRawEvents: boolean}; +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /** + * Optional client-supplied analytics source classification for this thread. + */ +threadSource?: ThreadSource | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 4a76f9af204..9573bd7dee2 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -1,9 +1,22 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, cwd: string, approvalPolicy: AskForApproval, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** + * Instruction source files currently loaded for this thread. + */ +instructionSources: Array, approvalPolicy: AskForApproval, /** + * Reviewer currently used for approval requests on this thread. + */ +approvalsReviewer: ApprovalsReviewer, /** + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. + */ +sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartSource.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartSource.ts new file mode 100644 index 00000000000..ea1b839c6ba --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStartSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadStartSource = "startup" | "clear"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStatus.ts new file mode 100644 index 00000000000..7cc6c8a6aad --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStatus.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadActiveFlag } from "./ThreadActiveFlag"; + +export type ThreadStatus = { "type": "notLoaded" } | { "type": "idle" } | { "type": "systemError" } | { "type": "active", activeFlags: Array, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadStatusChangedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStatusChangedNotification.ts new file mode 100644 index 00000000000..3242c892c8c --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadStatusChangedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadStatus } from "./ThreadStatus"; + +export type ThreadStatusChangedNotification = { threadId: string, status: ThreadStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnarchivedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnarchivedNotification.ts new file mode 100644 index 00000000000..e2c16171729 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnarchivedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadUnarchivedNotification = { threadId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeParams.ts new file mode 100644 index 00000000000..3d5f3a04cda --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadUnsubscribeParams = { threadId: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeResponse.ts new file mode 100644 index 00000000000..6f8f66b2262 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadUnsubscribeStatus } from "./ThreadUnsubscribeStatus"; + +export type ThreadUnsubscribeResponse = { status: ThreadUnsubscribeStatus, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeStatus.ts b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeStatus.ts new file mode 100644 index 00000000000..2970598dc1b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/ThreadUnsubscribeStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadUnsubscribeStatus = "notLoaded" | "notSubscribed" | "unsubscribed"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/code-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 93f50b32f58..6505ec345f9 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -3,16 +3,31 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; import type { TurnError } from "./TurnError"; +import type { TurnItemsView } from "./TurnItemsView"; import type { TurnStatus } from "./TurnStatus"; export type Turn = { id: string, /** - * Only populated on a `thread/resume` or `thread/fork` response. - * For all other responses and notifications returning a Turn, - * the items field will be an empty list. + * Thread items currently included in this turn payload. */ -items: Array, status: TurnStatus, +items: Array, +/** + * Describes how much of `items` has been loaded for this turn. + */ +itemsView: TurnItemsView, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts new file mode 100644 index 00000000000..bb981b0ac97 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts b/code-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts new file mode 100644 index 00000000000..9056923065d --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TurnItemsView = "notLoaded" | "summary" | "full"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index d595035ddaf..b04919d86b6 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -1,11 +1,11 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CollaborationMode } from "../CollaborationMode"; import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { JsonValue } from "../serde_json/JsonValue"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { UserInput } from "./UserInput"; @@ -17,12 +17,19 @@ cwd?: string | null, /** * Override the approval policy for this turn and subsequent turns. */ approvalPolicy?: AskForApproval | null, /** + * Override where approval requests are routed for review on this turn and + * subsequent turns. + */ +approvalsReviewer?: ApprovalsReviewer | null, /** * Override the sandbox policy for this turn and subsequent turns. */ sandboxPolicy?: SandboxPolicy | null, /** * Override the model for this turn and subsequent turns. */ model?: string | null, /** + * Override the service tier for this turn and subsequent turns. + */ +serviceTier?: string | null | null, /** * Override the reasoning effort for this turn and subsequent turns. */ effort?: ReasoningEffort | null, /** @@ -32,13 +39,7 @@ summary?: ReasoningSummary | null, /** * Override the personality for this turn and subsequent turns. */ personality?: Personality | null, /** - * Optional JSON Schema used to constrain the final assistant message for this turn. - */ -outputSchema?: JsonValue | null, /** - * EXPERIMENTAL - Set a pre-set collaboration mode. - * Takes precedence over model, reasoning_effort, and developer instructions if set. - * - * For `collaboration_mode.settings.developer_instructions`, `null` means - * "use the built-in instructions for the selected mode". + * Optional JSON Schema used to constrain the final assistant message for + * this turn. */ -collaborationMode?: CollaborationMode | null}; +outputSchema?: JsonValue | null}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts index 4a80ccd2e6e..dae166b4013 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts @@ -3,9 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UserInput } from "./UserInput"; -export type TurnSteerParams = { threadId: string, input: Array, -/** +export type TurnSteerParams = {threadId: string, input: Array, /** * Required active turn id precondition. The request fails when it does not * match the currently active turn. */ -expectedTurnId: string, }; +expectedTurnId: string}; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/WarningNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/WarningNotification.ts new file mode 100644 index 00000000000..bd3433be41b --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/WarningNotification.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WarningNotification = { +/** + * Optional thread target when the warning applies to a specific thread. + */ +threadId: string | null, +/** + * Concise warning message for the user. + */ +message: string, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts new file mode 100644 index 00000000000..41b1161acf5 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WindowsSandboxReadiness = "ready" | "notConfigured" | "updateRequired"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts new file mode 100644 index 00000000000..bc42a1d9626 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness"; + +export type WindowsSandboxReadinessResponse = { status: WindowsSandboxReadiness, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupCompletedNotification.ts b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupCompletedNotification.ts new file mode 100644 index 00000000000..d4c0b6cfaed --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupCompletedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; + +export type WindowsSandboxSetupCompletedNotification = { mode: WindowsSandboxSetupMode, success: boolean, error: string | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupMode.ts b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupMode.ts new file mode 100644 index 00000000000..a74bea42408 --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupMode.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WindowsSandboxSetupMode = "elevated" | "unelevated"; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartParams.ts b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartParams.ts new file mode 100644 index 00000000000..596c9f5b8cd --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartParams.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; + +export type WindowsSandboxSetupStartParams = { mode: WindowsSandboxSetupMode, cwd?: AbsolutePathBuf | null, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartResponse.ts b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartResponse.ts new file mode 100644 index 00000000000..a19004948ef --- /dev/null +++ b/code-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxSetupStartResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WindowsSandboxSetupStartResponse = { started: boolean, }; diff --git a/code-rs/app-server-protocol/schema/typescript/v2/index.ts b/code-rs/app-server-protocol/schema/typescript/v2/index.ts index 08da9a84621..3cd919cb9f4 100644 --- a/code-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/code-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,18 +4,31 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; +export type { ActivePermissionProfile } from "./ActivePermissionProfile"; +export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; +export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; +export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; -export type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; +export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; -export type { AppDisabledReason } from "./AppDisabledReason"; +export type { AppBranding } from "./AppBranding"; export type { AppInfo } from "./AppInfo"; export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; +export type { AppMetadata } from "./AppMetadata"; +export type { AppReview } from "./AppReview"; +export type { AppScreenshot } from "./AppScreenshot"; +export type { AppSummary } from "./AppSummary"; +export type { AppToolApproval } from "./AppToolApproval"; +export type { AppToolsConfig } from "./AppToolsConfig"; +export type { ApprovalsReviewer } from "./ApprovalsReviewer"; export type { AppsConfig } from "./AppsConfig"; +export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; +export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; export type { ByteRange } from "./ByteRange"; export type { CancelLoginAccountParams } from "./CancelLoginAccountParams"; export type { CancelLoginAccountResponse } from "./CancelLoginAccountResponse"; @@ -28,14 +41,26 @@ export type { CollabAgentState } from "./CollabAgentState"; export type { CollabAgentStatus } from "./CollabAgentStatus"; export type { CollabAgentTool } from "./CollabAgentTool"; export type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus"; +export type { CollaborationModeMask } from "./CollaborationModeMask"; export type { CommandAction } from "./CommandAction"; +export type { CommandExecOutputDeltaNotification } from "./CommandExecOutputDeltaNotification"; +export type { CommandExecOutputStream } from "./CommandExecOutputStream"; export type { CommandExecParams } from "./CommandExecParams"; +export type { CommandExecResizeParams } from "./CommandExecResizeParams"; +export type { CommandExecResizeResponse } from "./CommandExecResizeResponse"; export type { CommandExecResponse } from "./CommandExecResponse"; +export type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +export type { CommandExecTerminateParams } from "./CommandExecTerminateParams"; +export type { CommandExecTerminateResponse } from "./CommandExecTerminateResponse"; +export type { CommandExecWriteParams } from "./CommandExecWriteParams"; +export type { CommandExecWriteResponse } from "./CommandExecWriteResponse"; export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision"; export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification"; export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams"; export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse"; +export type { CommandExecutionSource } from "./CommandExecutionSource"; export type { CommandExecutionStatus } from "./CommandExecutionStatus"; +export type { CommandMigration } from "./CommandMigration"; export type { Config } from "./Config"; export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams"; export type { ConfigEdit } from "./ConfigEdit"; @@ -49,21 +74,27 @@ export type { ConfigRequirementsReadResponse } from "./ConfigRequirementsReadRes export type { ConfigValueWriteParams } from "./ConfigValueWriteParams"; export type { ConfigWarningNotification } from "./ConfigWarningNotification"; export type { ConfigWriteResponse } from "./ConfigWriteResponse"; +export type { ConfiguredHookHandler } from "./ConfiguredHookHandler"; +export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; export type { ContextCompactedNotification } from "./ContextCompactedNotification"; export type { CreditsSnapshot } from "./CreditsSnapshot"; export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification"; export type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; export type { DynamicToolCallParams } from "./DynamicToolCallParams"; export type { DynamicToolCallResponse } from "./DynamicToolCallResponse"; +export type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; export type { DynamicToolSpec } from "./DynamicToolSpec"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; +export type { ExperimentalFeatureEnablementSetParams } from "./ExperimentalFeatureEnablementSetParams"; +export type { ExperimentalFeatureEnablementSetResponse } from "./ExperimentalFeatureEnablementSetResponse"; export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; export type { ExternalAgentConfigDetectParams } from "./ExternalAgentConfigDetectParams"; export type { ExternalAgentConfigDetectResponse } from "./ExternalAgentConfigDetectResponse"; +export type { ExternalAgentConfigImportCompletedNotification } from "./ExternalAgentConfigImportCompletedNotification"; export type { ExternalAgentConfigImportParams } from "./ExternalAgentConfigImportParams"; export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImportResponse"; export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; @@ -72,44 +103,204 @@ export type { FeedbackUploadParams } from "./FeedbackUploadParams"; export type { FeedbackUploadResponse } from "./FeedbackUploadResponse"; export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision"; export type { FileChangeOutputDeltaNotification } from "./FileChangeOutputDeltaNotification"; +export type { FileChangePatchUpdatedNotification } from "./FileChangePatchUpdatedNotification"; export type { FileChangeRequestApprovalParams } from "./FileChangeRequestApprovalParams"; export type { FileChangeRequestApprovalResponse } from "./FileChangeRequestApprovalResponse"; +export type { FileSystemAccessMode } from "./FileSystemAccessMode"; +export type { FileSystemPath } from "./FileSystemPath"; +export type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; +export type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; export type { FileUpdateChange } from "./FileUpdateChange"; +export type { FsChangedNotification } from "./FsChangedNotification"; +export type { FsCopyParams } from "./FsCopyParams"; +export type { FsCopyResponse } from "./FsCopyResponse"; +export type { FsCreateDirectoryParams } from "./FsCreateDirectoryParams"; +export type { FsCreateDirectoryResponse } from "./FsCreateDirectoryResponse"; +export type { FsGetMetadataParams } from "./FsGetMetadataParams"; +export type { FsGetMetadataResponse } from "./FsGetMetadataResponse"; +export type { FsReadDirectoryEntry } from "./FsReadDirectoryEntry"; +export type { FsReadDirectoryParams } from "./FsReadDirectoryParams"; +export type { FsReadDirectoryResponse } from "./FsReadDirectoryResponse"; +export type { FsReadFileParams } from "./FsReadFileParams"; +export type { FsReadFileResponse } from "./FsReadFileResponse"; +export type { FsRemoveParams } from "./FsRemoveParams"; +export type { FsRemoveResponse } from "./FsRemoveResponse"; +export type { FsUnwatchParams } from "./FsUnwatchParams"; +export type { FsUnwatchResponse } from "./FsUnwatchResponse"; +export type { FsWatchParams } from "./FsWatchParams"; +export type { FsWatchResponse } from "./FsWatchResponse"; +export type { FsWriteFileParams } from "./FsWriteFileParams"; +export type { FsWriteFileResponse } from "./FsWriteFileResponse"; export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GitInfo } from "./GitInfo"; +export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; +export type { GuardianApprovalReview } from "./GuardianApprovalReview"; +export type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; +export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus"; +export type { GuardianCommandSource } from "./GuardianCommandSource"; +export type { GuardianRiskLevel } from "./GuardianRiskLevel"; +export type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; +export type { GuardianWarningNotification } from "./GuardianWarningNotification"; +export type { HookCompletedNotification } from "./HookCompletedNotification"; +export type { HookErrorInfo } from "./HookErrorInfo"; +export type { HookEventName } from "./HookEventName"; +export type { HookExecutionMode } from "./HookExecutionMode"; +export type { HookHandlerType } from "./HookHandlerType"; +export type { HookMetadata } from "./HookMetadata"; +export type { HookMigration } from "./HookMigration"; +export type { HookOutputEntry } from "./HookOutputEntry"; +export type { HookOutputEntryKind } from "./HookOutputEntryKind"; +export type { HookPromptFragment } from "./HookPromptFragment"; +export type { HookRunStatus } from "./HookRunStatus"; +export type { HookRunSummary } from "./HookRunSummary"; +export type { HookScope } from "./HookScope"; +export type { HookSource } from "./HookSource"; +export type { HookStartedNotification } from "./HookStartedNotification"; +export type { HookTrustStatus } from "./HookTrustStatus"; +export type { HooksListEntry } from "./HooksListEntry"; +export type { HooksListParams } from "./HooksListParams"; +export type { HooksListResponse } from "./HooksListResponse"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; +export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; +export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification"; export type { ItemStartedNotification } from "./ItemStartedNotification"; export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams"; export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse"; export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; +export type { ManagedHooksRequirements } from "./ManagedHooksRequirements"; +export type { MarketplaceAddParams } from "./MarketplaceAddParams"; +export type { MarketplaceAddResponse } from "./MarketplaceAddResponse"; +export type { MarketplaceInterface } from "./MarketplaceInterface"; +export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; +export type { MarketplaceRemoveParams } from "./MarketplaceRemoveParams"; +export type { MarketplaceRemoveResponse } from "./MarketplaceRemoveResponse"; +export type { MarketplaceUpgradeErrorInfo } from "./MarketplaceUpgradeErrorInfo"; +export type { MarketplaceUpgradeParams } from "./MarketplaceUpgradeParams"; +export type { MarketplaceUpgradeResponse } from "./MarketplaceUpgradeResponse"; export type { McpAuthStatus } from "./McpAuthStatus"; +export type { McpElicitationArrayType } from "./McpElicitationArrayType"; +export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema"; +export type { McpElicitationBooleanType } from "./McpElicitationBooleanType"; +export type { McpElicitationConstOption } from "./McpElicitationConstOption"; +export type { McpElicitationEnumSchema } from "./McpElicitationEnumSchema"; +export type { McpElicitationLegacyTitledEnumSchema } from "./McpElicitationLegacyTitledEnumSchema"; +export type { McpElicitationMultiSelectEnumSchema } from "./McpElicitationMultiSelectEnumSchema"; +export type { McpElicitationNumberSchema } from "./McpElicitationNumberSchema"; +export type { McpElicitationNumberType } from "./McpElicitationNumberType"; +export type { McpElicitationObjectType } from "./McpElicitationObjectType"; +export type { McpElicitationPrimitiveSchema } from "./McpElicitationPrimitiveSchema"; +export type { McpElicitationSchema } from "./McpElicitationSchema"; +export type { McpElicitationSingleSelectEnumSchema } from "./McpElicitationSingleSelectEnumSchema"; +export type { McpElicitationStringFormat } from "./McpElicitationStringFormat"; +export type { McpElicitationStringSchema } from "./McpElicitationStringSchema"; +export type { McpElicitationStringType } from "./McpElicitationStringType"; +export type { McpElicitationTitledEnumItems } from "./McpElicitationTitledEnumItems"; +export type { McpElicitationTitledMultiSelectEnumSchema } from "./McpElicitationTitledMultiSelectEnumSchema"; +export type { McpElicitationTitledSingleSelectEnumSchema } from "./McpElicitationTitledSingleSelectEnumSchema"; +export type { McpElicitationUntitledEnumItems } from "./McpElicitationUntitledEnumItems"; +export type { McpElicitationUntitledMultiSelectEnumSchema } from "./McpElicitationUntitledMultiSelectEnumSchema"; +export type { McpElicitationUntitledSingleSelectEnumSchema } from "./McpElicitationUntitledSingleSelectEnumSchema"; +export type { McpResourceReadParams } from "./McpResourceReadParams"; +export type { McpResourceReadResponse } from "./McpResourceReadResponse"; +export type { McpServerElicitationAction } from "./McpServerElicitationAction"; +export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams"; +export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse"; +export type { McpServerMigration } from "./McpServerMigration"; export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthLoginCompletedNotification"; export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams"; export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse"; export type { McpServerRefreshResponse } from "./McpServerRefreshResponse"; +export type { McpServerStartupState } from "./McpServerStartupState"; export type { McpServerStatus } from "./McpServerStatus"; +export type { McpServerStatusDetail } from "./McpServerStatusDetail"; +export type { McpServerStatusUpdatedNotification } from "./McpServerStatusUpdatedNotification"; +export type { McpServerToolCallParams } from "./McpServerToolCallParams"; +export type { McpServerToolCallResponse } from "./McpServerToolCallResponse"; export type { McpToolCallError } from "./McpToolCallError"; export type { McpToolCallProgressNotification } from "./McpToolCallProgressNotification"; export type { McpToolCallResult } from "./McpToolCallResult"; export type { McpToolCallStatus } from "./McpToolCallStatus"; +export type { MemoryCitation } from "./MemoryCitation"; +export type { MemoryCitationEntry } from "./MemoryCitationEntry"; export type { MergeStrategy } from "./MergeStrategy"; +export type { MigrationDetails } from "./MigrationDetails"; export type { Model } from "./Model"; export type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; export type { ModelListParams } from "./ModelListParams"; export type { ModelListResponse } from "./ModelListResponse"; +export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabilitiesReadParams"; +export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse"; +export type { ModelRerouteReason } from "./ModelRerouteReason"; +export type { ModelReroutedNotification } from "./ModelReroutedNotification"; +export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; +export type { ModelVerification } from "./ModelVerification"; +export type { ModelVerificationNotification } from "./ModelVerificationNotification"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; +export type { NetworkDomainPermission } from "./NetworkDomainPermission"; +export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; +export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; +export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; +export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionProfile } from "./PermissionProfile"; +export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; +export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; +export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; +export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; +export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; +export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; +export type { PluginAuthPolicy } from "./PluginAuthPolicy"; +export type { PluginAvailability } from "./PluginAvailability"; +export type { PluginDetail } from "./PluginDetail"; +export type { PluginHookSummary } from "./PluginHookSummary"; +export type { PluginInstallParams } from "./PluginInstallParams"; +export type { PluginInstallPolicy } from "./PluginInstallPolicy"; +export type { PluginInstallResponse } from "./PluginInstallResponse"; +export type { PluginInterface } from "./PluginInterface"; +export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; +export type { PluginListParams } from "./PluginListParams"; +export type { PluginListResponse } from "./PluginListResponse"; +export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; +export type { PluginReadParams } from "./PluginReadParams"; +export type { PluginReadResponse } from "./PluginReadResponse"; +export type { PluginShareContext } from "./PluginShareContext"; +export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; +export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; +export type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +export type { PluginShareListItem } from "./PluginShareListItem"; +export type { PluginShareListParams } from "./PluginShareListParams"; +export type { PluginShareListResponse } from "./PluginShareListResponse"; +export type { PluginSharePrincipal } from "./PluginSharePrincipal"; +export type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; +export type { PluginShareSaveParams } from "./PluginShareSaveParams"; +export type { PluginShareSaveResponse } from "./PluginShareSaveResponse"; +export type { PluginShareTarget } from "./PluginShareTarget"; +export type { PluginShareUpdateDiscoverability } from "./PluginShareUpdateDiscoverability"; +export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams"; +export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse"; +export type { PluginSkillReadParams } from "./PluginSkillReadParams"; +export type { PluginSkillReadResponse } from "./PluginSkillReadResponse"; +export type { PluginSource } from "./PluginSource"; +export type { PluginSummary } from "./PluginSummary"; +export type { PluginUninstallParams } from "./PluginUninstallParams"; +export type { PluginUninstallResponse } from "./PluginUninstallResponse"; +export type { PluginsMigration } from "./PluginsMigration"; +export type { ProcessExitedNotification } from "./ProcessExitedNotification"; +export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification"; +export type { ProcessOutputStream } from "./ProcessOutputStream"; +export type { ProcessTerminalSize } from "./ProcessTerminalSize"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; @@ -119,7 +310,9 @@ export type { ReasoningEffortOption } from "./ReasoningEffortOption"; export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification"; export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification"; export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification"; -export type { RemoteSkillSummary } from "./RemoteSkillSummary"; +export type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus"; +export type { RemoteControlStatusChangedNotification } from "./RemoteControlStatusChangedNotification"; +export type { RequestPermissionProfile } from "./RequestPermissionProfile"; export type { ResidencyRequirement } from "./ResidencyRequirement"; export type { ReviewDelivery } from "./ReviewDelivery"; export type { ReviewStartParams } from "./ReviewStartParams"; @@ -128,57 +321,94 @@ export type { ReviewTarget } from "./ReviewTarget"; export type { SandboxMode } from "./SandboxMode"; export type { SandboxPolicy } from "./SandboxPolicy"; export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; +export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams"; +export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse"; +export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification"; +export type { SessionMigration } from "./SessionMigration"; export type { SessionSource } from "./SessionSource"; export type { SkillDependencies } from "./SkillDependencies"; export type { SkillErrorInfo } from "./SkillErrorInfo"; export type { SkillInterface } from "./SkillInterface"; export type { SkillMetadata } from "./SkillMetadata"; export type { SkillScope } from "./SkillScope"; +export type { SkillSummary } from "./SkillSummary"; export type { SkillToolDependency } from "./SkillToolDependency"; +export type { SkillsChangedNotification } from "./SkillsChangedNotification"; export type { SkillsConfigWriteParams } from "./SkillsConfigWriteParams"; export type { SkillsConfigWriteResponse } from "./SkillsConfigWriteResponse"; export type { SkillsListEntry } from "./SkillsListEntry"; -export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; -export type { SkillsRemoteReadParams } from "./SkillsRemoteReadParams"; -export type { SkillsRemoteReadResponse } from "./SkillsRemoteReadResponse"; -export type { SkillsRemoteWriteParams } from "./SkillsRemoteWriteParams"; -export type { SkillsRemoteWriteResponse } from "./SkillsRemoteWriteResponse"; +export type { SortDirection } from "./SortDirection"; +export type { SubagentMigration } from "./SubagentMigration"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; export type { TextPosition } from "./TextPosition"; export type { TextRange } from "./TextRange"; export type { Thread } from "./Thread"; +export type { ThreadActiveFlag } from "./ThreadActiveFlag"; +export type { ThreadApproveGuardianDeniedActionParams } from "./ThreadApproveGuardianDeniedActionParams"; +export type { ThreadApproveGuardianDeniedActionResponse } from "./ThreadApproveGuardianDeniedActionResponse"; export type { ThreadArchiveParams } from "./ThreadArchiveParams"; export type { ThreadArchiveResponse } from "./ThreadArchiveResponse"; +export type { ThreadArchivedNotification } from "./ThreadArchivedNotification"; +export type { ThreadClosedNotification } from "./ThreadClosedNotification"; export type { ThreadCompactStartParams } from "./ThreadCompactStartParams"; export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; +export type { ThreadGoal } from "./ThreadGoal"; +export type { ThreadGoalClearedNotification } from "./ThreadGoalClearedNotification"; +export type { ThreadGoalStatus } from "./ThreadGoalStatus"; +export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification"; +export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams"; +export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse"; export type { ThreadItem } from "./ThreadItem"; export type { ThreadListParams } from "./ThreadListParams"; export type { ThreadListResponse } from "./ThreadListResponse"; export type { ThreadLoadedListParams } from "./ThreadLoadedListParams"; export type { ThreadLoadedListResponse } from "./ThreadLoadedListResponse"; +export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams"; +export type { ThreadMetadataUpdateParams } from "./ThreadMetadataUpdateParams"; +export type { ThreadMetadataUpdateResponse } from "./ThreadMetadataUpdateResponse"; export type { ThreadNameUpdatedNotification } from "./ThreadNameUpdatedNotification"; export type { ThreadReadParams } from "./ThreadReadParams"; export type { ThreadReadResponse } from "./ThreadReadResponse"; +export type { ThreadRealtimeAudioChunk } from "./ThreadRealtimeAudioChunk"; +export type { ThreadRealtimeClosedNotification } from "./ThreadRealtimeClosedNotification"; +export type { ThreadRealtimeErrorNotification } from "./ThreadRealtimeErrorNotification"; +export type { ThreadRealtimeItemAddedNotification } from "./ThreadRealtimeItemAddedNotification"; +export type { ThreadRealtimeOutputAudioDeltaNotification } from "./ThreadRealtimeOutputAudioDeltaNotification"; +export type { ThreadRealtimeSdpNotification } from "./ThreadRealtimeSdpNotification"; +export type { ThreadRealtimeStartTransport } from "./ThreadRealtimeStartTransport"; +export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification"; +export type { ThreadRealtimeTranscriptDeltaNotification } from "./ThreadRealtimeTranscriptDeltaNotification"; +export type { ThreadRealtimeTranscriptDoneNotification } from "./ThreadRealtimeTranscriptDoneNotification"; export type { ThreadResumeParams } from "./ThreadResumeParams"; export type { ThreadResumeResponse } from "./ThreadResumeResponse"; export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; +export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; +export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; +export type { ThreadSource } from "./ThreadSource"; export type { ThreadSourceKind } from "./ThreadSourceKind"; export type { ThreadStartParams } from "./ThreadStartParams"; export type { ThreadStartResponse } from "./ThreadStartResponse"; +export type { ThreadStartSource } from "./ThreadStartSource"; export type { ThreadStartedNotification } from "./ThreadStartedNotification"; +export type { ThreadStatus } from "./ThreadStatus"; +export type { ThreadStatusChangedNotification } from "./ThreadStatusChangedNotification"; export type { ThreadTokenUsage } from "./ThreadTokenUsage"; export type { ThreadTokenUsageUpdatedNotification } from "./ThreadTokenUsageUpdatedNotification"; export type { ThreadUnarchiveParams } from "./ThreadUnarchiveParams"; export type { ThreadUnarchiveResponse } from "./ThreadUnarchiveResponse"; +export type { ThreadUnarchivedNotification } from "./ThreadUnarchivedNotification"; +export type { ThreadUnsubscribeParams } from "./ThreadUnsubscribeParams"; +export type { ThreadUnsubscribeResponse } from "./ThreadUnsubscribeResponse"; +export type { ThreadUnsubscribeStatus } from "./ThreadUnsubscribeStatus"; export type { TokenUsageBreakdown } from "./TokenUsageBreakdown"; export type { ToolRequestUserInputAnswer } from "./ToolRequestUserInputAnswer"; export type { ToolRequestUserInputOption } from "./ToolRequestUserInputOption"; @@ -189,9 +419,11 @@ export type { ToolsV2 } from "./ToolsV2"; export type { Turn } from "./Turn"; export type { TurnCompletedNotification } from "./TurnCompletedNotification"; export type { TurnDiffUpdatedNotification } from "./TurnDiffUpdatedNotification"; +export type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; +export type { TurnItemsView } from "./TurnItemsView"; export type { TurnPlanStep } from "./TurnPlanStep"; export type { TurnPlanStepStatus } from "./TurnPlanStepStatus"; export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification"; @@ -202,6 +434,13 @@ export type { TurnStatus } from "./TurnStatus"; export type { TurnSteerParams } from "./TurnSteerParams"; export type { TurnSteerResponse } from "./TurnSteerResponse"; export type { UserInput } from "./UserInput"; +export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; +export type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness"; +export type { WindowsSandboxReadinessResponse } from "./WindowsSandboxReadinessResponse"; +export type { WindowsSandboxSetupCompletedNotification } from "./WindowsSandboxSetupCompletedNotification"; +export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; +export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; +export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse"; export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification"; export type { WriteStatus } from "./WriteStatus"; diff --git a/code-rs/app-server-protocol/src/bin/export.rs b/code-rs/app-server-protocol/src/bin/export.rs index 3af8260f382..10541d1fcbc 100644 --- a/code-rs/app-server-protocol/src/bin/export.rs +++ b/code-rs/app-server-protocol/src/bin/export.rs @@ -14,11 +14,21 @@ struct Args { /// Optional Prettier executable path to format generated TypeScript files #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] prettier: Option, + + /// Include experimental API methods and fields in generated output. + #[arg(long = "experimental")] + experimental: bool, } fn main() -> Result<()> { let args = Args::parse(); - code_app_server_protocol::generate_types(&args.out_dir, args.prettier.as_deref()) + codex_app_server_protocol::generate_ts_with_options( + &args.out_dir, + args.prettier.as_deref(), + codex_app_server_protocol::GenerateTsOptions { + experimental_api: args.experimental, + ..codex_app_server_protocol::GenerateTsOptions::default() + }, + )?; + codex_app_server_protocol::generate_json_with_experimental(&args.out_dir, args.experimental) } - - diff --git a/code-rs/app-server-protocol/src/bin/write_schema_fixtures.rs b/code-rs/app-server-protocol/src/bin/write_schema_fixtures.rs index 9bd2852bf37..789d30cea20 100644 --- a/code-rs/app-server-protocol/src/bin/write_schema_fixtures.rs +++ b/code-rs/app-server-protocol/src/bin/write_schema_fixtures.rs @@ -26,10 +26,10 @@ fn main() -> Result<()> { .schema_root .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schema")); - code_app_server_protocol::write_schema_fixtures_with_options( + codex_app_server_protocol::write_schema_fixtures_with_options( &schema_root, args.prettier.as_deref(), - code_app_server_protocol::SchemaFixtureOptions { + codex_app_server_protocol::SchemaFixtureOptions { experimental_api: args.experimental, }, ) @@ -40,5 +40,3 @@ fn main() -> Result<()> { ) }) } - - diff --git a/code-rs/app-server-protocol/src/experimental_api.rs b/code-rs/app-server-protocol/src/experimental_api.rs index eadc2a40ff7..af7a1efbe68 100644 --- a/code-rs/app-server-protocol/src/experimental_api.rs +++ b/code-rs/app-server-protocol/src/experimental_api.rs @@ -1,3 +1,6 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; + /// Marker trait for protocol types that can signal experimental usage. pub trait ExperimentalApi { /// Returns a short reason identifier when an experimental method or field is @@ -28,10 +31,36 @@ pub fn experimental_required_message(reason: &str) -> String { format!("{reason} requires experimentalApi capability") } +impl ExperimentalApi for Option { + fn experimental_reason(&self) -> Option<&'static str> { + self.as_ref().and_then(ExperimentalApi::experimental_reason) + } +} + +impl ExperimentalApi for Vec { + fn experimental_reason(&self) -> Option<&'static str> { + self.iter().find_map(ExperimentalApi::experimental_reason) + } +} + +impl ExperimentalApi for HashMap { + fn experimental_reason(&self) -> Option<&'static str> { + self.values().find_map(ExperimentalApi::experimental_reason) + } +} + +impl ExperimentalApi for BTreeMap { + fn experimental_reason(&self) -> Option<&'static str> { + self.values().find_map(ExperimentalApi::experimental_reason) + } +} + #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::ExperimentalApi as ExperimentalApiTrait; - use code_experimental_api_macros::ExperimentalApi; + use codex_experimental_api_macros::ExperimentalApi; use pretty_assertions::assert_eq; #[allow(dead_code)] @@ -48,6 +77,34 @@ mod tests { StableTuple(u8), } + #[allow(dead_code)] + #[derive(ExperimentalApi)] + struct NestedFieldShape { + #[experimental(nested)] + inner: Option, + } + + #[allow(dead_code)] + #[derive(ExperimentalApi)] + struct NestedCollectionShape { + #[experimental(nested)] + inners: Vec, + } + + #[allow(dead_code)] + #[derive(ExperimentalApi)] + struct NestedMapShape { + #[experimental(nested)] + inners: HashMap, + } + + #[allow(dead_code)] + #[derive(ExperimentalApi)] + struct ExperimentalFieldShape { + #[experimental("field/optionalCollection")] + optional_collection: Option>, + } + #[test] fn derive_supports_all_enum_variant_shapes() { assert_eq!( @@ -67,4 +124,72 @@ mod tests { None ); } + + #[test] + fn derive_supports_nested_experimental_fields() { + assert_eq!( + ExperimentalApiTrait::experimental_reason(&NestedFieldShape { + inner: Some(EnumVariantShapes::Named { value: 1 }), + }), + Some("enum/named") + ); + assert_eq!( + ExperimentalApiTrait::experimental_reason(&NestedFieldShape { inner: None }), + None + ); + } + + #[test] + fn derive_supports_nested_collections() { + assert_eq!( + ExperimentalApiTrait::experimental_reason(&NestedCollectionShape { + inners: vec![ + EnumVariantShapes::StableTuple(1), + EnumVariantShapes::Tuple(2) + ], + }), + Some("enum/tuple") + ); + assert_eq!( + ExperimentalApiTrait::experimental_reason(&NestedCollectionShape { + inners: Vec::new() + }), + None + ); + } + + #[test] + fn derive_supports_nested_maps() { + assert_eq!( + ExperimentalApiTrait::experimental_reason(&NestedMapShape { + inners: HashMap::from([( + "default".to_string(), + EnumVariantShapes::Named { value: 1 }, + )]), + }), + Some("enum/named") + ); + assert_eq!( + ExperimentalApiTrait::experimental_reason(&NestedMapShape { + inners: HashMap::new(), + }), + None + ); + } + + #[test] + fn derive_marks_optional_experimental_fields_when_some() { + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: Some(Vec::new()), + }), + Some("field/optionalCollection") + ); + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: None, + }), + None + ); + } } diff --git a/code-rs/app-server-protocol/src/export.rs b/code-rs/app-server-protocol/src/export.rs index 2221b1d4834..0f9b33671b2 100644 --- a/code-rs/app-server-protocol/src/export.rs +++ b/code-rs/app-server-protocol/src/export.rs @@ -17,12 +17,13 @@ use crate::protocol::common::EXPERIMENTAL_CLIENT_METHODS; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; -use code_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutLine; use schemars::JsonSchema; use schemars::schema_for; use serde::Serialize; use serde_json::Map; use serde_json::Value; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; @@ -32,10 +33,22 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::thread; use ts_rs::TS; -const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; +pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; +const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; +const SPECIAL_DEFINITIONS: &[&str] = &[ + "ClientNotification", + "ClientRequest", + "ServerNotification", + "ServerRequest", +]; +const FLAT_V2_SHARED_DEFINITIONS: &[&str] = &["ClientRequest", "ServerNotification"]; +const V1_CLIENT_REQUEST_METHODS: &[&str] = + &["getConversationSummary", "gitDiffToRemote", "getAuthStatus"]; +const EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON: &[&str] = &["rawResponseItem/completed"]; #[derive(Clone)] pub struct GeneratedSchema { @@ -116,17 +129,32 @@ pub fn generate_ts_with_options( } // Ensure our header is present on all TS files (root + subdirs like v2/). - let mut ts_files = Vec::new(); - let should_collect_ts_files = - options.ensure_headers || (options.run_prettier && prettier.is_some()); - if should_collect_ts_files { - ts_files = ts_files_in_recursive(out_dir)?; - } + let ts_files = ts_files_in_recursive(out_dir)?; if options.ensure_headers { - for file in &ts_files { - prepend_header_if_missing(file)?; - } + let worker_count = thread::available_parallelism() + .map_or(1, usize::from) + .min(ts_files.len().max(1)); + let chunk_size = ts_files.len().div_ceil(worker_count); + thread::scope(|scope| -> Result<()> { + let mut workers = Vec::new(); + for chunk in ts_files.chunks(chunk_size.max(1)) { + workers.push(scope.spawn(move || -> Result<()> { + for file in chunk { + prepend_header_if_missing(file)?; + } + Ok(()) + })); + } + + for worker in workers { + worker + .join() + .map_err(|_| anyhow!("TypeScript header worker panicked"))??; + } + + Ok(()) + })?; } // Optionally run Prettier on all generated TS files. @@ -146,16 +174,19 @@ pub fn generate_ts_with_options( } } - if ts_files.is_empty() { - ts_files = ts_files_in_recursive(out_dir)?; - } - ensure_trailing_newlines(&ts_files)?; + trim_trailing_whitespace_in_ts_files(&ts_files)?; Ok(()) } pub fn generate_json(out_dir: &Path) -> Result<()> { - generate_json_with_experimental(out_dir, false) + generate_json_with_experimental(out_dir, /*experimental_api*/ false) +} + +pub fn generate_internal_json_schema(out_dir: &Path) -> Result<()> { + ensure_dir(out_dir)?; + write_json_schema::(out_dir, "RolloutLine")?; + Ok(()) } pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) -> Result<()> { @@ -172,7 +203,6 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) - |d| write_json_schema_with_return::(d, "ServerRequest"), |d| write_json_schema_with_return::(d, "ClientNotification"), |d| write_json_schema_with_return::(d, "ServerNotification"), - |d| write_json_schema_with_return::(d, "EventMsg"), ]; let mut schemas: Vec = Vec::new(); @@ -186,6 +216,8 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) - schemas.extend(export_server_response_schemas(out_dir)?); schemas.extend(export_client_notification_schemas(out_dir)?); schemas.extend(export_server_notification_schemas(out_dir)?); + schemas + .retain(|schema| !schema.in_v1_dir || JSON_V1_ALLOWLIST.contains(&schema.logical_name())); let mut bundle = build_schema_bundle(schemas)?; if !experimental_api { @@ -195,14 +227,16 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) - out_dir.join("codex_app_server_protocol.schemas.json"), &bundle, )?; + let flat_v2_bundle = build_flat_v2_schema(&bundle)?; + write_pretty_json( + out_dir.join("codex_app_server_protocol.v2.schemas.json"), + &flat_v2_bundle, + )?; if !experimental_api { filter_experimental_json_files(out_dir)?; } - let json_files = json_files_in_recursive(out_dir)?; - ensure_trailing_newlines(&json_files)?; - Ok(()) } @@ -219,6 +253,41 @@ fn filter_experimental_ts(out_dir: &Path) -> Result<()> { Ok(()) } +pub(crate) fn filter_experimental_ts_tree(tree: &mut BTreeMap) -> Result<()> { + let registered_fields = experimental_fields(); + let experimental_method_types = experimental_method_types(); + if let Some(content) = tree.get_mut(Path::new("ClientRequest.ts")) { + let filtered = + filter_client_request_ts_contents(std::mem::take(content), EXPERIMENTAL_CLIENT_METHODS); + *content = filtered; + } + + let mut fields_by_type_name: HashMap> = HashMap::new(); + for field in registered_fields { + fields_by_type_name + .entry(field.type_name.to_string()) + .or_default() + .insert(field.field_name.to_string()); + } + + for (path, content) in tree.iter_mut() { + let Some(type_name) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let Some(experimental_field_names) = fields_by_type_name.get(type_name) else { + continue; + }; + let filtered = filter_experimental_type_fields_ts_contents( + std::mem::take(content), + experimental_field_names, + ); + *content = filtered; + } + + remove_generated_type_entries(tree, &experimental_method_types, "ts"); + Ok(()) +} + /// Removes union arms from `ClientRequest.ts` for methods marked experimental. fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Result<()> { let path = out_dir.join("ClientRequest.ts"); @@ -227,9 +296,15 @@ fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Re } let mut content = fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?; + content = filter_client_request_ts_contents(content, experimental_methods); + + fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} +fn filter_client_request_ts_contents(mut content: String, experimental_methods: &[&str]) -> String { let Some((prefix, body, suffix)) = split_type_alias(&content) else { - return Ok(()); + return content; }; let experimental_methods: HashSet<&str> = experimental_methods .iter() @@ -247,12 +322,9 @@ fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Re let new_body = filtered_arms.join(" | "); content = format!("{prefix}{new_body}{suffix}"); let import_usage_scope = split_type_alias(&content) - .map(|(_, body, _)| body) + .map(|(_, filtered_body, _)| filtered_body) .unwrap_or_else(|| new_body.clone()); - content = prune_unused_type_imports(content, &import_usage_scope); - - fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; - Ok(()) + prune_unused_type_imports(content, &import_usage_scope) } /// Removes experimental properties from generated TypeScript type files. @@ -290,8 +362,17 @@ fn filter_experimental_fields_in_ts_file( ) -> Result<()> { let mut content = fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + content = filter_experimental_type_fields_ts_contents(content, experimental_field_names); + fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +fn filter_experimental_type_fields_ts_contents( + mut content: String, + experimental_field_names: &HashSet, +) -> String { let Some((open_brace, close_brace)) = type_body_brace_span(&content) else { - return Ok(()); + return content; }; let inner = &content[open_brace + 1..close_brace]; let fields = split_top_level_multi(inner, &[',', ';']); @@ -310,9 +391,7 @@ fn filter_experimental_fields_in_ts_file( let import_usage_scope = split_type_alias(&content) .map(|(_, body, _)| body) .unwrap_or_else(|| new_inner.clone()); - content = prune_unused_type_imports(content, &import_usage_scope); - fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?; - Ok(()) + prune_unused_type_imports(content, &import_usage_scope) } fn filter_experimental_schema(bundle: &mut Value) -> Result<()> { @@ -514,6 +593,23 @@ fn remove_generated_type_files( Ok(()) } +fn remove_generated_type_entries( + tree: &mut BTreeMap, + type_names: &HashSet, + extension: &str, +) { + for type_name in type_names { + for subdir in ["", "v1", "v2"] { + let path = if subdir.is_empty() { + PathBuf::from(format!("{type_name}.{extension}")) + } else { + PathBuf::from(subdir).join(format!("{type_name}.{extension}")) + }; + tree.remove(&path); + } + } +} + fn remove_experimental_method_type_definitions(bundle: &mut Value) { let type_names = experimental_method_types(); let Some(definitions) = bundle.get_mut("definitions").and_then(Value::as_object_mut) else { @@ -601,19 +697,6 @@ fn json_files_in_recursive(dir: &Path) -> Result> { Ok(out) } -fn ensure_trailing_newlines(paths: &[PathBuf]) -> Result<()> { - for path in paths { - let mut content = fs::read_to_string(path) - .with_context(|| format!("Failed to read {path}", path = path.display()))?; - if !content.ends_with('\n') { - content.push('\n'); - fs::write(path, content) - .with_context(|| format!("Failed to write {path}", path = path.display()))?; - } - } - Ok(()) -} - fn read_json_value(path: &Path) -> Result { let content = fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; @@ -653,11 +736,11 @@ fn find_top_level_brace_span(input: &str) -> Option<(usize, usize)> { let mut state = ScanState::default(); let mut open_index = None; for (index, ch) in input.char_indices() { - if !state.in_string() && ch == '{' && state.depth.is_top_level() { + if !state.in_ignored_syntax() && ch == '{' && state.depth.is_top_level() { open_index = Some(index); } state.observe(ch); - if !state.in_string() + if !state.in_ignored_syntax() && ch == '}' && state.depth.is_top_level() && let Some(open) = open_index @@ -677,7 +760,7 @@ fn split_top_level_multi(input: &str, delimiters: &[char]) -> Vec { let mut start = 0usize; let mut parts = Vec::new(); for (index, ch) in input.char_indices() { - if !state.in_string() && state.depth.is_top_level() && delimiters.contains(&ch) { + if !state.in_ignored_syntax() && state.depth.is_top_level() && delimiters.contains(&ch) { let part = input[start..index].trim(); if !part.is_empty() { parts.push(part.to_string()); @@ -799,22 +882,58 @@ struct ScanState { depth: Depth, string_delim: Option, escape: bool, + block_comment: bool, + line_comment: bool, + previous_char: Option, } impl ScanState { fn observe(&mut self, ch: char) { + if self.line_comment { + if ch == '\n' { + self.line_comment = false; + } + self.previous_char = Some(ch); + return; + } + + if self.block_comment { + if self.previous_char == Some('*') && ch == '/' { + self.block_comment = false; + self.previous_char = None; + } else { + self.previous_char = Some(ch); + } + return; + } + if let Some(delim) = self.string_delim { if self.escape { self.escape = false; + self.previous_char = Some(ch); return; } if ch == '\\' { self.escape = true; + self.previous_char = Some(ch); return; } if ch == delim { self.string_delim = None; } + self.previous_char = Some(ch); + return; + } + + if self.previous_char == Some('/') && ch == '/' { + self.line_comment = true; + self.previous_char = Some(ch); + return; + } + + if self.previous_char == Some('/') && ch == '*' { + self.block_comment = true; + self.previous_char = Some(ch); return; } @@ -836,10 +955,11 @@ impl ScanState { } _ => {} } + self.previous_char = Some(ch); } - fn in_string(&self) -> bool { - self.string_delim.is_some() + fn in_ignored_syntax(&self) -> bool { + self.string_delim.is_some() || self.block_comment || self.line_comment } } @@ -858,14 +978,6 @@ impl Depth { } fn build_schema_bundle(schemas: Vec) -> Result { - const SPECIAL_DEFINITIONS: &[&str] = &[ - "ClientNotification", - "ClientRequest", - "EventMsg", - "ServerNotification", - "ServerRequest", - ]; - let namespaced_types = collect_namespaced_types(&schemas); let mut definitions = Map::new(); @@ -883,6 +995,8 @@ fn build_schema_bundle(schemas: Vec) -> Result { if let Some(ref ns) = namespace { rewrite_refs_to_namespace(&mut value, ns); + } else { + rewrite_refs_to_known_namespaces(&mut value, &namespaced_types); } let mut forced_namespace_refs: Vec<(String, String)> = Vec::new(); @@ -946,6 +1060,210 @@ fn build_schema_bundle(schemas: Vec) -> Result { Ok(Value::Object(root)) } +/// Build a datamodel-code-generator-friendly v2 bundle from the mixed export. +/// +/// The full bundle keeps v2 schemas nested under `definitions.v2`, plus a few +/// shared root definitions like `ClientRequest` and `ServerNotification`. +/// Python codegen only walks one definitions map level, so +/// a direct feed would treat `v2` itself as a schema and miss unreferenced v2 +/// leaves. This helper flattens all v2 definitions to the root definitions map, +/// then pulls in the shared root schemas and any non-v2 transitive deps they +/// still reference. Keep the shared root unions intact here: some valid +/// request/notification/event variants are inline or only reference shared root +/// helpers, so filtering them by the presence of a `#/definitions/v2/` ref +/// would silently drop real API surface from the flat bundle. +fn build_flat_v2_schema(bundle: &Value) -> Result { + let Value::Object(root) = bundle else { + return Err(anyhow!("expected bundle root to be an object")); + }; + let definitions = root + .get("definitions") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected bundle definitions map"))?; + let v2_definitions = definitions + .get("v2") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected v2 namespace in bundle definitions"))?; + + let mut flat_root = root.clone(); + let title = root + .get("title") + .and_then(Value::as_str) + .unwrap_or("CodexAppServerProtocol"); + let mut flat_definitions = v2_definitions.clone(); + let mut shared_definitions = Map::new(); + let mut non_v2_refs = HashSet::new(); + + for shared in FLAT_V2_SHARED_DEFINITIONS { + let Some(shared_schema) = definitions.get(*shared) else { + continue; + }; + let shared_schema = shared_schema.clone(); + non_v2_refs.extend(collect_non_v2_refs(&shared_schema)); + shared_definitions.insert((*shared).to_string(), shared_schema); + } + + for name in collect_definition_dependencies(definitions, non_v2_refs) { + if name == "v2" || flat_definitions.contains_key(&name) { + continue; + } + if let Some(schema) = definitions.get(&name) { + flat_definitions.insert(name, schema.clone()); + } + } + + flat_definitions.extend(shared_definitions); + flat_root.insert("title".to_string(), Value::String(format!("{title}V2"))); + flat_root.insert("definitions".to_string(), Value::Object(flat_definitions)); + let mut flat_bundle = Value::Object(flat_root); + rewrite_ref_prefix(&mut flat_bundle, "#/definitions/v2/", "#/definitions/"); + ensure_no_ref_prefix(&flat_bundle, "#/definitions/v2/", "flat v2")?; + ensure_referenced_definitions_present(&flat_bundle, "flat v2")?; + Ok(flat_bundle) +} + +fn collect_non_v2_refs(value: &Value) -> HashSet { + let mut refs = HashSet::new(); + collect_non_v2_refs_inner(value, &mut refs); + refs +} + +fn collect_non_v2_refs_inner(value: &Value, refs: &mut HashSet) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && let Some(name) = reference.strip_prefix("#/definitions/") + && !reference.starts_with("#/definitions/v2/") + { + refs.insert(name.to_string()); + } + for child in obj.values() { + collect_non_v2_refs_inner(child, refs); + } + } + Value::Array(items) => { + for child in items { + collect_non_v2_refs_inner(child, refs); + } + } + _ => {} + } +} + +fn collect_definition_dependencies( + definitions: &Map, + names: HashSet, +) -> HashSet { + let mut seen = HashSet::new(); + let mut to_process: Vec = names.into_iter().collect(); + while let Some(name) = to_process.pop() { + if !seen.insert(name.clone()) { + continue; + } + let Some(schema) = definitions.get(&name) else { + continue; + }; + for dep in collect_non_v2_refs(schema) { + if !seen.contains(&dep) { + to_process.push(dep); + } + } + } + seen +} + +fn rewrite_ref_prefix(value: &mut Value, prefix: &str, replacement: &str) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") { + *reference = reference.replace(prefix, replacement); + } + for child in obj.values_mut() { + rewrite_ref_prefix(child, prefix, replacement); + } + } + Value::Array(items) => { + for child in items { + rewrite_ref_prefix(child, prefix, replacement); + } + } + _ => {} + } +} + +fn ensure_no_ref_prefix(value: &Value, prefix: &str, label: &str) -> Result<()> { + if let Some(reference) = first_ref_with_prefix(value, prefix) { + return Err(anyhow!( + "{label} schema still references namespaced definitions; found {reference}" + )); + } + Ok(()) +} + +fn first_ref_with_prefix(value: &Value, prefix: &str) -> Option { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && reference.starts_with(prefix) + { + return Some(reference.clone()); + } + obj.values() + .find_map(|child| first_ref_with_prefix(child, prefix)) + } + Value::Array(items) => items + .iter() + .find_map(|child| first_ref_with_prefix(child, prefix)), + _ => None, + } +} + +fn ensure_referenced_definitions_present(schema: &Value, label: &str) -> Result<()> { + let definitions = schema + .get("definitions") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected definitions map in {label} schema"))?; + let mut missing = HashSet::new(); + collect_missing_definitions(schema, definitions, &mut missing); + if missing.is_empty() { + return Ok(()); + } + let mut missing_names: Vec = missing.into_iter().collect(); + missing_names.sort(); + Err(anyhow!( + "{label} schema missing definitions: {}", + missing_names.join(", ") + )) +} + +fn collect_missing_definitions( + value: &Value, + definitions: &Map, + missing: &mut HashSet, +) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && let Some(name) = reference.strip_prefix("#/definitions/") + { + let name = name.split('/').next().unwrap_or(name); + if !definitions.contains_key(name) { + missing.insert(name.to_string()); + } + } + for child in obj.values() { + collect_missing_definitions(child, definitions, missing); + } + } + Value::Array(items) => { + for child in items { + collect_missing_definitions(child, definitions, missing); + } + } + _ => {} + } +} + fn insert_into_namespace( definitions: &mut Map, namespace: &str, @@ -957,25 +1275,62 @@ fn insert_into_namespace( .or_insert_with(|| Value::Object(Map::new())); match entry { Value::Object(map) => { - map.insert(name, schema); - Ok(()) + insert_definition(map, name, schema, &format!("namespace `{namespace}`")) } _ => Err(anyhow!("expected namespace {namespace} to be an object")), } } +fn insert_definition( + definitions: &mut Map, + name: String, + schema: Value, + location: &str, +) -> Result<()> { + if let Some(existing) = definitions.get(&name) { + if existing == &schema { + return Ok(()); + } + + let existing_title = existing + .get("title") + .and_then(Value::as_str) + .unwrap_or(""); + let new_title = schema + .get("title") + .and_then(Value::as_str) + .unwrap_or(""); + return Err(anyhow!( + "schema definition collision in {location}: {name} (existing title: {existing_title}, new title: {new_title}); use #[schemars(rename = \"...\")] to rename one of the conflicting schema definitions" + )); + } + + definitions.insert(name, schema); + Ok(()) +} + fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result where T: JsonSchema, { let file_stem = name.trim(); + let (raw_namespace, logical_name) = split_namespace(file_stem); + let include_in_json_codegen = + raw_namespace != Some("v1") || JSON_V1_ALLOWLIST.contains(&logical_name); let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; - annotate_schema(&mut schema_value, Some(file_stem)); + if include_in_json_codegen { + if file_stem == "ClientRequest" { + strip_v1_client_request_variants_from_json_schema(&mut schema_value); + } else if file_stem == "ServerNotification" { + strip_v1_server_notification_variants_from_json_schema(&mut schema_value); + } + enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value); + annotate_schema(&mut schema_value, Some(file_stem)); + } // If the name looks like a namespaced path (e.g., "v2::Type"), mirror // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise // write alongside the legacy files. - let (raw_namespace, logical_name) = split_namespace(file_stem); let out_path = if let Some(ns) = raw_namespace { let dir = out_dir.join(ns); ensure_dir(&dir)?; @@ -984,7 +1339,7 @@ where out_dir.join(format!("{file_stem}.json")) }; - if !IGNORED_DEFINITIONS.contains(&logical_name) { + if include_in_json_codegen && !IGNORED_DEFINITIONS.contains(&logical_name) { write_pretty_json(out_path, &schema_value) .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; } @@ -1001,57 +1356,255 @@ where }) } -pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result -where - T: JsonSchema, -{ - write_json_schema_with_return::(out_dir, name) +fn enforce_numbered_definition_collision_overrides(schema_name: &str, schema: &mut Value) { + for defs_key in ["definitions", "$defs"] { + let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else { + continue; + }; + detect_numbered_definition_collisions(schema_name, defs_key, defs); + } } -fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { - let json = serde_json::to_vec_pretty(value) - .with_context(|| format!("Failed to serialize JSON schema to {}", path.display()))?; - fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?; - Ok(()) +fn strip_v1_client_request_variants_from_json_schema(schema: &mut Value) { + let v1_methods: HashSet<&str> = V1_CLIENT_REQUEST_METHODS.iter().copied().collect(); + strip_method_variants_from_json_schema(schema, &v1_methods); } -/// Split a fully-qualified type name like "v2::Type" into its namespace and logical name. -fn split_namespace(name: &str) -> (Option<&str>, &str) { - name.split_once("::") - .map_or((None, name), |(ns, rest)| (Some(ns), rest)) +fn strip_v1_server_notification_variants_from_json_schema(schema: &mut Value) { + let methods: HashSet<&str> = EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON + .iter() + .copied() + .collect(); + strip_method_variants_from_json_schema(schema, &methods); } -/// Recursively rewrite $ref values that point at "#/definitions/..." so that -/// they point to a namespaced location under the bundle. -fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) { - match value { - Value::Object(obj) => { - if let Some(Value::String(r)) = obj.get_mut("$ref") - && let Some(suffix) = r.strip_prefix("#/definitions/") - { - let prefix = format!("{ns}/"); - if !suffix.starts_with(&prefix) { - *r = format!("#/definitions/{ns}/{suffix}"); - } - } - for v in obj.values_mut() { - rewrite_refs_to_namespace(v, ns); - } - } - Value::Array(items) => { - for v in items.iter_mut() { - rewrite_refs_to_namespace(v, ns); - } - } - _ => {} +fn strip_method_variants_from_json_schema(schema: &mut Value, methods_to_remove: &HashSet<&str>) { + { + let Some(root) = schema.as_object_mut() else { + return; + }; + let Some(Value::Array(variants)) = root.get_mut("oneOf") else { + return; + }; + variants.retain(|variant| !is_method_variant_in_set(variant, methods_to_remove)); + } + + let reachable = reachable_local_definitions(schema, "definitions"); + let Some(root) = schema.as_object_mut() else { + return; + }; + if let Some(definitions) = root.get_mut("definitions").and_then(Value::as_object_mut) { + definitions.retain(|name, _| reachable.contains(name)); } } -fn collect_namespaced_types(schemas: &[GeneratedSchema]) -> HashMap { - let mut types = HashMap::new(); - for schema in schemas { - if let Some(ns) = schema.namespace() { - types +fn is_method_variant_in_set(value: &Value, methods: &HashSet<&str>) -> bool { + let Value::Object(map) = value else { + return false; + }; + let Some(properties) = map.get("properties").and_then(Value::as_object) else { + return false; + }; + let Some(method_schema) = properties.get("method") else { + return false; + }; + let Some(method) = string_literal(method_schema) else { + return false; + }; + methods.contains(method) +} + +fn reachable_local_definitions(schema: &Value, defs_key: &str) -> HashSet { + let Some(definitions) = schema.get(defs_key).and_then(Value::as_object) else { + return HashSet::new(); + }; + let mut queue: Vec = Vec::new(); + let mut reachable: HashSet = HashSet::new(); + + collect_local_definition_refs_excluding_maps(schema, defs_key, &mut queue, &mut reachable); + + while let Some(name) = queue.pop() { + if let Some(def_schema) = definitions.get(&name) { + collect_local_definition_refs(def_schema, defs_key, &mut queue, &mut reachable); + } + } + reachable +} + +fn collect_local_definition_refs_excluding_maps( + value: &Value, + defs_key: &str, + queue: &mut Vec, + reachable: &mut HashSet, +) { + match value { + Value::Object(map) => { + for (key, child) in map { + if key == defs_key || key == "$defs" || key == "definitions" { + continue; + } + collect_local_definition_refs_excluding_maps(child, defs_key, queue, reachable); + } + } + Value::Array(items) => { + for child in items { + collect_local_definition_refs_excluding_maps(child, defs_key, queue, reachable); + } + } + _ => {} + } + collect_local_definition_ref_here(value, defs_key, queue, reachable); +} + +fn collect_local_definition_refs( + value: &Value, + defs_key: &str, + queue: &mut Vec, + reachable: &mut HashSet, +) { + collect_local_definition_ref_here(value, defs_key, queue, reachable); + match value { + Value::Object(map) => { + for child in map.values() { + collect_local_definition_refs(child, defs_key, queue, reachable); + } + } + Value::Array(items) => { + for child in items { + collect_local_definition_refs(child, defs_key, queue, reachable); + } + } + _ => {} + } +} + +fn collect_local_definition_ref_here( + value: &Value, + defs_key: &str, + queue: &mut Vec, + reachable: &mut HashSet, +) { + let Some(reference) = value + .as_object() + .and_then(|obj| obj.get("$ref")) + .and_then(Value::as_str) + else { + return; + }; + let Some(name) = reference.strip_prefix(&format!("#/{defs_key}/")) else { + return; + }; + let name = name.split('/').next().unwrap_or(name); + if reachable.insert(name.to_string()) { + queue.push(name.to_string()); + } +} + +fn detect_numbered_definition_collisions( + schema_name: &str, + defs_key: &str, + defs: &Map, +) { + for generated_name in defs.keys() { + let base_name = generated_name.trim_end_matches(|c: char| c.is_ascii_digit()); + if base_name == generated_name || !defs.contains_key(base_name) { + continue; + } + + panic!( + "Numbered definition naming collision detected: schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}" + ); + } +} + +pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result +where + T: JsonSchema, +{ + write_json_schema_with_return::(out_dir, name) +} + +fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { + let json = serde_json::to_vec_pretty(value) + .with_context(|| format!("Failed to serialize JSON schema to {}", path.display()))?; + fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +/// Split a fully-qualified type name like "v2::Type" into its namespace and logical name. +fn split_namespace(name: &str) -> (Option<&str>, &str) { + name.split_once("::") + .map_or((None, name), |(ns, rest)| (Some(ns), rest)) +} + +/// Recursively rewrite $ref values that point at "#/definitions/..." so that +/// they point to a namespaced location under the bundle. +fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) { + match value { + Value::Object(obj) => { + if let Some(Value::String(r)) = obj.get_mut("$ref") + && let Some(suffix) = r.strip_prefix("#/definitions/") + { + let prefix = format!("{ns}/"); + if !suffix.starts_with(&prefix) { + *r = format!("#/definitions/{ns}/{suffix}"); + } + } + for v in obj.values_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + _ => {} + } +} + +/// Recursively rewrite bare root definition refs to the namespace that owns the +/// referenced type in the bundle. +/// +/// The mixed export contains shared root helper schemas that are intentionally +/// left outside the `v2` namespace, but some of their extracted child +/// definitions still contain refs like `#/definitions/ThreadId`. When the real +/// schema only exists under `#/definitions/v2/ThreadId`, those refs become +/// dangling and downstream codegen falls back to placeholder `Any` models. This +/// rewrite keeps the shared helpers at the root while retargeting their refs to +/// the namespaced definitions that actually exist. +fn rewrite_refs_to_known_namespaces(value: &mut Value, types: &HashMap) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") + && let Some(suffix) = reference.strip_prefix("#/definitions/") + { + let (name, tail) = suffix + .split_once('/') + .map_or((suffix, None), |(name, tail)| (name, Some(tail))); + if let Some(ns) = namespace_for_definition(name, types) { + let tail = tail.map_or(String::new(), |rest| format!("/{rest}")); + *reference = format!("#/definitions/{ns}/{name}{tail}"); + } + } + for v in obj.values_mut() { + rewrite_refs_to_known_namespaces(v, types); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + rewrite_refs_to_known_namespaces(v, types); + } + } + _ => {} + } +} + +fn collect_namespaced_types(schemas: &[GeneratedSchema]) -> HashMap { + let mut types = HashMap::new(); + for schema in schemas { + if let Some(ns) = schema.namespace() { + types .entry(schema.logical_name().to_string()) .or_insert_with(|| ns.to_string()); if let Some(Value::Object(defs)) = schema.value().get("definitions") { @@ -1105,7 +1658,11 @@ fn variant_definition_name(base: &str, variant: &Value) -> Option { if props.len() == 1 && let Some(key) = props.keys().next() { - let pascal = to_pascal_case(key); + let pascal = props + .get(key) + .and_then(string_literal) + .map(to_pascal_case) + .unwrap_or_else(|| to_pascal_case(key)); return Some(format!("{pascal}{base}")); } } @@ -1218,11 +1775,12 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { && let Some(base_name) = base && let Some(name) = variant_definition_name(base_name, variant) { - let mut candidate = name.clone(); - let mut index = 2; - while seen.contains(&candidate) { - candidate = format!("{name}{index}"); - index += 1; + let candidate = name.clone(); + if seen.contains(&candidate) { + let collision_key = variant_title_collision_key(base_name, &name, variant); + panic!( + "Variant title naming collision detected: {collision_key} (generated name: {name})" + ); } if let Some(obj) = variant.as_object_mut() { obj.insert("title".into(), Value::String(candidate.clone())); @@ -1242,6 +1800,48 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { } } +fn variant_title_collision_key(base: &str, generated_name: &str, variant: &Value) -> String { + let mut parts = vec![ + format!("base={base}"), + format!("generated={generated_name}"), + ]; + + if let Some(props) = variant.get("properties").and_then(Value::as_object) { + for key in DISCRIMINATOR_KEYS { + if let Some(value) = literal_from_property(props, key) { + parts.push(format!("{key}={value}")); + } + } + for (key, value) in props { + if DISCRIMINATOR_KEYS.contains(&key.as_str()) { + continue; + } + if let Some(literal) = string_literal(value) { + parts.push(format!("literal:{key}={literal}")); + } + } + + if props.len() == 1 + && let Some(key) = props.keys().next() + { + parts.push(format!("only_property={key}")); + } + } + + if let Some(required) = variant.get("required").and_then(Value::as_array) + && required.len() == 1 + && let Some(key) = required[0].as_str() + { + parts.push(format!("required_only={key}")); + } + + if parts.len() == 2 { + parts.push(format!("variant={variant}")); + } + + parts.join("|") +} + const DISCRIMINATOR_KEYS: &[&str] = &["type", "method", "mode", "status", "role", "reason"]; fn set_discriminator_titles(props: &mut Map, owner: &str) { @@ -1328,13 +1928,13 @@ fn prepend_header_if_missing(path: &Path) -> Result<()> { .with_context(|| format!("Failed to read {}", path.display()))?; } - if content.starts_with(HEADER) { + if content.starts_with(GENERATED_TS_HEADER) { return Ok(()); } let mut f = fs::File::create(path) .with_context(|| format!("Failed to open {} for writing", path.display()))?; - f.write_all(HEADER.as_bytes()) + f.write_all(GENERATED_TS_HEADER.as_bytes()) .with_context(|| format!("Failed to write header to {}", path.display()))?; f.write_all(content.as_bytes()) .with_context(|| format!("Failed to write content to {}", path.display()))?; @@ -1376,271 +1976,331 @@ fn ts_files_in_recursive(dir: &Path) -> Result> { Ok(files) } +fn trim_trailing_whitespace_in_ts_files(paths: &[PathBuf]) -> Result<()> { + for path in paths { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let trimmed = trim_trailing_line_whitespace(&content); + if trimmed != content { + fs::write(path, trimmed) + .with_context(|| format!("Failed to write {}", path.display()))?; + } + } + Ok(()) +} + +pub(crate) fn trim_trailing_line_whitespace(content: &str) -> String { + let mut trimmed = String::with_capacity(content.len()); + for line in content.split_inclusive('\n') { + if let Some(line_without_newline) = line.strip_suffix('\n') { + trimmed.push_str(line_without_newline.trim_end_matches([' ', '\t'])); + trimmed.push('\n'); + } else { + trimmed.push_str(line.trim_end_matches([' ', '\t'])); + } + } + trimmed +} + /// Generate an index.ts file that re-exports all generated types. /// This allows consumers to import all types from a single file. fn generate_index_ts(out_dir: &Path) -> Result { - let mut entries: Vec = Vec::new(); - let mut stems: Vec = ts_files_in(out_dir)? - .into_iter() - .filter_map(|p| { - let stem = p.file_stem()?.to_string_lossy().into_owned(); + let content = generated_index_ts_with_header(index_ts_entries( + &ts_files_in(out_dir)? + .iter() + .map(PathBuf::as_path) + .collect::>(), + ts_files_in(&out_dir.join("v2")) + .map(|v| !v.is_empty()) + .unwrap_or(false), + )); + + let index_path = out_dir.join("index.ts"); + let mut f = fs::File::create(&index_path) + .with_context(|| format!("Failed to create {}", index_path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write {}", index_path.display()))?; + Ok(index_path) +} + +pub(crate) fn generate_index_ts_tree(tree: &mut BTreeMap) { + let root_entries = tree + .keys() + .filter(|path| path.components().count() == 1) + .map(PathBuf::as_path) + .collect::>(); + let has_v2_ts = tree.keys().any(|path| { + path.parent() + .is_some_and(|parent| parent == Path::new("v2")) + && path.extension() == Some(OsStr::new("ts")) + && path.file_stem().is_some_and(|stem| stem != "index") + }); + tree.insert( + PathBuf::from("index.ts"), + index_ts_entries(&root_entries, has_v2_ts), + ); + + let v2_entries = tree + .keys() + .filter(|path| { + path.parent() + .is_some_and(|parent| parent == Path::new("v2")) + }) + .map(PathBuf::as_path) + .collect::>(); + if !v2_entries.is_empty() { + tree.insert( + PathBuf::from("v2").join("index.ts"), + index_ts_entries(&v2_entries, /*has_v2_ts*/ false), + ); + } +} + +fn generated_index_ts_with_header(content: String) -> String { + let mut with_header = String::with_capacity(GENERATED_TS_HEADER.len() + content.len()); + with_header.push_str(GENERATED_TS_HEADER); + with_header.push_str(&content); + with_header +} + +fn index_ts_entries(paths: &[&Path], has_v2_ts: bool) -> String { + let mut stems: Vec = paths + .iter() + .filter(|path| path.extension() == Some(OsStr::new("ts"))) + .filter_map(|path| { + let stem = path.file_stem()?.to_string_lossy().into_owned(); if stem == "index" { None } else { Some(stem) } }) + .filter(|stem| stem != "EventMsg") .collect(); stems.sort(); stems.dedup(); + let mut entries = String::new(); for name in stems { - entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); + entries.push_str(&format!("export type {{ {name} }} from \"./{name}\";\n")); } - - // If this is the root out_dir and a ./v2 folder exists with TS files, - // expose it as a namespace to avoid symbol collisions at the root. - let v2_dir = out_dir.join("v2"); - let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false); if has_v2_ts { - entries.push("export * as v2 from \"./v2\";\n".to_string()); - } - - let mut content = - String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); - content.push_str(HEADER); - for line in &entries { - content.push_str(line); + entries.push_str("export * as v2 from \"./v2\";\n"); } - - let index_path = out_dir.join("index.ts"); - let mut f = fs::File::create(&index_path) - .with_context(|| format!("Failed to create {}", index_path.display()))?; - f.write_all(content.as_bytes()) - .with_context(|| format!("Failed to write {}", index_path.display()))?; - Ok(index_path) + entries } #[cfg(test)] mod tests { use super::*; use crate::protocol::v2; + use crate::schema_fixtures::read_schema_fixture_subtree; + use anyhow::Context; use anyhow::Result; use pretty_assertions::assert_eq; use std::collections::BTreeSet; - use std::fs; + use std::path::Path; use std::path::PathBuf; use uuid::Uuid; #[test] fn generated_ts_optional_nullable_fields_only_in_params() -> Result<()> { // Assert that "?: T | null" only appears in generated *Params types. - let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7())); - fs::create_dir(&output_dir)?; - - struct TempDirGuard(PathBuf); - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - let _guard = TempDirGuard(output_dir.clone()); + let fixture_tree = read_schema_fixture_subtree(&schema_root()?, "typescript")?; - // Avoid doing more work than necessary to keep the test from timing out. - let options = GenerateTsOptions { - generate_indices: false, - ensure_headers: false, - run_prettier: false, - experimental_api: false, - }; - generate_ts_with_options(&output_dir, None, options)?; - - let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?; + let client_request_ts = std::str::from_utf8( + fixture_tree + .get(Path::new("ClientRequest.ts")) + .ok_or_else(|| anyhow::anyhow!("missing ClientRequest.ts fixture"))?, + )?; assert_eq!(client_request_ts.contains("mock/experimentalMethod"), false); assert_eq!( client_request_ts.contains("MockExperimentalMethodParams"), false ); - let thread_start_ts = - fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?; + let typescript_index = std::str::from_utf8( + fixture_tree + .get(Path::new("index.ts")) + .ok_or_else(|| anyhow::anyhow!("missing index.ts fixture"))?, + )?; + assert_eq!(typescript_index.contains("export type { EventMsg }"), false); + let thread_start_ts = std::str::from_utf8( + fixture_tree + .get(Path::new("v2/ThreadStartParams.ts")) + .ok_or_else(|| anyhow::anyhow!("missing v2/ThreadStartParams.ts fixture"))?, + )?; assert_eq!(thread_start_ts.contains("mockExperimentalField"), false); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodParams.ts") - .exists(), + fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodParams.ts")), false ); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodResponse.ts") - .exists(), + fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")), false ); let mut undefined_offenders = Vec::new(); let mut optional_nullable_offenders = BTreeSet::new(); - let mut stack = vec![output_dir]; - while let Some(dir) = stack.pop() { - for entry in fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; + for (path, contents) in &fixture_tree { + if !matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts")) { + continue; + } + + // Only allow "?: T | null" in objects representing JSON-RPC requests, + // which we assume are called "*Params". + let allow_optional_nullable = path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| { + stem.ends_with("Params") + || stem == "InitializeCapabilities" + || matches!( + stem, + "CollabAgentRef" + | "CollabAgentStatusEntry" + | "CollabAgentSpawnEndEvent" + | "CollabAgentInteractionEndEvent" + | "CollabCloseEndEvent" + | "CollabResumeBeginEvent" + | "CollabResumeEndEvent" + ) + }); + + let contents = std::str::from_utf8(contents)?; + if contents.contains("| undefined") { + undefined_offenders.push(path.clone()); + } + + const SKIP_PREFIXES: &[&str] = &[ + "const ", + "let ", + "var ", + "export const ", + "export let ", + "export var ", + ]; + + let mut search_start = 0; + while let Some(idx) = contents[search_start..].find("| null") { + let abs_idx = search_start + idx; + // Find the property-colon for this field by scanning forward + // from the start of the segment and ignoring nested braces, + // brackets, and parens. This avoids colons inside nested + // type literals like `{ [k in string]?: string }`. + + let line_start_idx = contents[..abs_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); + + let mut segment_start_idx = line_start_idx; + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind(',') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('{') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('}') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); } - if matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts")) { - // Only allow "?: T | null" in objects representing JSON-RPC requests, - // which we assume are called "*Params". - let allow_optional_nullable = path - .file_stem() - .and_then(|stem| stem.to_str()) - .is_some_and(|stem| stem.ends_with("Params")); - - let contents = fs::read_to_string(&path)?; - if contents.contains("| undefined") { - undefined_offenders.push(path.clone()); + // Scan forward for the colon that separates the field name from its type. + let mut level_brace = 0_i32; + let mut level_brack = 0_i32; + let mut level_paren = 0_i32; + let mut in_single = false; + let mut in_double = false; + let mut escape = false; + let mut prop_colon_idx = None; + for (i, ch) in contents[segment_start_idx..abs_idx].char_indices() { + let idx_abs = segment_start_idx + i; + if escape { + escape = false; + continue; } - - const SKIP_PREFIXES: &[&str] = &[ - "const ", - "let ", - "var ", - "export const ", - "export let ", - "export var ", - ]; - - let mut search_start = 0; - while let Some(idx) = contents[search_start..].find("| null") { - let abs_idx = search_start + idx; - // Find the property-colon for this field by scanning forward - // from the start of the segment and ignoring nested braces, - // brackets, and parens. This avoids colons inside nested - // type literals like `{ [k in string]?: string }`. - - let line_start_idx = - contents[..abs_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); - - let mut segment_start_idx = line_start_idx; - if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind(',') { - segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); - } - if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('{') { - segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); - } - if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('}') { - segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + match ch { + '\\' => { + if in_single || in_double { + escape = true; + } } - - // Scan forward for the colon that separates the field name from its type. - let mut level_brace = 0_i32; - let mut level_brack = 0_i32; - let mut level_paren = 0_i32; - let mut in_single = false; - let mut in_double = false; - let mut escape = false; - let mut prop_colon_idx = None; - for (i, ch) in contents[segment_start_idx..abs_idx].char_indices() { - let idx_abs = segment_start_idx + i; - if escape { - escape = false; - continue; + '\'' => { + if !in_double { + in_single = !in_single; } - match ch { - '\\' => { - // Only treat as escape when inside a string. - if in_single || in_double { - escape = true; - } - } - '\'' => { - if !in_double { - in_single = !in_single; - } - } - '"' => { - if !in_single { - in_double = !in_double; - } - } - '{' if !in_single && !in_double => level_brace += 1, - '}' if !in_single && !in_double => level_brace -= 1, - '[' if !in_single && !in_double => level_brack += 1, - ']' if !in_single && !in_double => level_brack -= 1, - '(' if !in_single && !in_double => level_paren += 1, - ')' if !in_single && !in_double => level_paren -= 1, - ':' if !in_single - && !in_double - && level_brace == 0 - && level_brack == 0 - && level_paren == 0 => - { - prop_colon_idx = Some(idx_abs); - break; - } - _ => {} + } + '"' => { + if !in_single { + in_double = !in_double; } } - - let Some(colon_idx) = prop_colon_idx else { - search_start = abs_idx + 5; - continue; - }; - - let mut field_prefix = contents[segment_start_idx..colon_idx].trim(); - if field_prefix.is_empty() { - search_start = abs_idx + 5; - continue; + '{' if !in_single && !in_double => level_brace += 1, + '}' if !in_single && !in_double => level_brace -= 1, + '[' if !in_single && !in_double => level_brack += 1, + ']' if !in_single && !in_double => level_brack -= 1, + '(' if !in_single && !in_double => level_paren += 1, + ')' if !in_single && !in_double => level_paren -= 1, + ':' if !in_single + && !in_double + && level_brace == 0 + && level_brack == 0 + && level_paren == 0 => + { + prop_colon_idx = Some(idx_abs); + break; } + _ => {} + } + } - if let Some(comment_idx) = field_prefix.rfind("*/") { - field_prefix = field_prefix[comment_idx + 2..].trim_start(); - } + let Some(colon_idx) = prop_colon_idx else { + search_start = abs_idx + 5; + continue; + }; - if field_prefix.is_empty() { - search_start = abs_idx + 5; - continue; - } + let mut field_prefix = contents[segment_start_idx..colon_idx].trim(); + if field_prefix.is_empty() { + search_start = abs_idx + 5; + continue; + } - if SKIP_PREFIXES - .iter() - .any(|prefix| field_prefix.starts_with(prefix)) - { - search_start = abs_idx + 5; - continue; - } + if let Some(comment_idx) = field_prefix.rfind("*/") { + field_prefix = field_prefix[comment_idx + 2..].trim_start(); + } - if field_prefix.contains('(') { - search_start = abs_idx + 5; - continue; - } + if field_prefix.is_empty() { + search_start = abs_idx + 5; + continue; + } - // If the last non-whitespace before ':' is '?', then this is an - // optional field with a nullable type (i.e., "?: T | null"). - // These are only allowed in *Params types. - if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?') - && !allow_optional_nullable - { - let line_number = - contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1; - let offending_line_end = contents[line_start_idx..] - .find('\n') - .map(|i| line_start_idx + i) - .unwrap_or(contents.len()); - let offending_snippet = - contents[line_start_idx..offending_line_end].trim(); - - optional_nullable_offenders.insert(format!( - "{}:{}: {offending_snippet}", - path.display(), - line_number - )); - } + if SKIP_PREFIXES + .iter() + .any(|prefix| field_prefix.starts_with(prefix)) + { + search_start = abs_idx + 5; + continue; + } - search_start = abs_idx + 5; - } + if field_prefix.contains('(') { + search_start = abs_idx + 5; + continue; + } + + // If the last non-whitespace before ':' is '?', then this is an + // optional field with a nullable type (i.e., "?: T | null"). + // These are only allowed in *Params types. + if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?') + && !allow_optional_nullable + { + let line_number = + contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1; + let offending_line_end = contents[line_start_idx..] + .find('\n') + .map(|i| line_start_idx + i) + .unwrap_or(contents.len()); + let offending_snippet = contents[line_start_idx..offending_line_end].trim(); + + optional_nullable_offenders.insert(format!( + "{}:{}: {offending_snippet}", + path.display(), + line_number + )); } + + search_start = abs_idx + 5; } } @@ -1660,50 +2320,44 @@ mod tests { Ok(()) } + fn schema_root() -> Result { + let typescript_index = codex_utils_cargo_bin::find_resource!("schema/typescript/index.ts") + .context("resolve TypeScript schema index.ts")?; + let schema_root = typescript_index + .parent() + .and_then(|parent| parent.parent()) + .context("derive schema root from schema/typescript/index.ts")? + .to_path_buf(); + Ok(schema_root) + } + #[test] fn generate_ts_with_experimental_api_retains_experimental_entries() -> Result<()> { - let output_dir = - std::env::temp_dir().join(format!("codex_ts_types_experimental_{}", Uuid::now_v7())); - fs::create_dir(&output_dir)?; - - struct TempDirGuard(PathBuf); - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - let _guard = TempDirGuard(output_dir.clone()); - - let options = GenerateTsOptions { - generate_indices: false, - ensure_headers: false, - run_prettier: false, - experimental_api: true, - }; - generate_ts_with_options(&output_dir, None, options)?; - - let client_request_ts = fs::read_to_string(output_dir.join("ClientRequest.ts"))?; + let client_request_ts = ClientRequest::export_to_string()?; assert_eq!(client_request_ts.contains("mock/experimentalMethod"), true); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodParams.ts") - .exists(), + client_request_ts.contains("MockExperimentalMethodParams"), true ); assert_eq!( - output_dir - .join("v2") - .join("MockExperimentalMethodResponse.ts") - .exists(), + v2::MockExperimentalMethodParams::export_to_string()? + .contains("MockExperimentalMethodParams"), + true + ); + assert_eq!( + v2::MockExperimentalMethodResponse::export_to_string()? + .contains("MockExperimentalMethodResponse"), true ); - let thread_start_ts = - fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?; + let thread_start_ts = v2::ThreadStartParams::export_to_string()?; assert_eq!(thread_start_ts.contains("mockExperimentalField"), true); + let command_execution_request_approval_ts = + v2::CommandExecutionRequestApprovalParams::export_to_string()?; + assert_eq!( + command_execution_request_approval_ts.contains("additionalPermissions"), + true + ); Ok(()) } @@ -1734,6 +2388,267 @@ mod tests { Ok(()) } + #[test] + fn build_schema_bundle_rewrites_root_helper_refs_to_namespaced_defs() -> Result<()> { + let bundle = build_schema_bundle(vec![ + GeneratedSchema { + namespace: None, + logical_name: "LegacyEnvelope".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "LegacyEnvelope", + "type": "object", + "properties": { + "current_thread": { "$ref": "#/definitions/ThreadId" }, + "turn_item": { "$ref": "#/definitions/TurnItem" } + }, + "definitions": { + "TurnItem": { + "type": "object", + "properties": { + "thread_id": { "$ref": "#/definitions/ThreadId" }, + "phase": { "$ref": "#/definitions/MessagePhase" }, + "content": { + "type": "array", + "items": { "$ref": "#/definitions/UserInput" } + } + } + } + } + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "ThreadId".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "ThreadId", + "type": "string" + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "MessagePhase".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "MessagePhase", + "type": "string" + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "UserInput".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "UserInput", + "type": "string" + }), + }, + ])?; + + assert_eq!( + bundle["definitions"]["LegacyEnvelope"]["properties"]["current_thread"]["$ref"], + serde_json::json!("#/definitions/v2/ThreadId") + ); + assert_eq!( + bundle["definitions"]["LegacyEnvelope"]["properties"]["turn_item"]["$ref"], + serde_json::json!("#/definitions/TurnItem") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["thread_id"]["$ref"], + serde_json::json!("#/definitions/v2/ThreadId") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["phase"]["$ref"], + serde_json::json!("#/definitions/v2/MessagePhase") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["content"]["items"]["$ref"], + serde_json::json!("#/definitions/v2/UserInput") + ); + + Ok(()) + } + + #[test] + fn build_flat_v2_schema_keeps_shared_root_schemas_and_dependencies() -> Result<()> { + let bundle = serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CodexAppServerProtocol", + "type": "object", + "definitions": { + "ClientRequest": { + "oneOf": [ + { + "title": "StartRequest", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/v2/ThreadStartParams" }, + "shared": { "$ref": "#/definitions/SharedHelper" } + } + }, + { + "title": "InitializeRequest", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/InitializeParams" } + } + }, + { + "title": "LogoutRequest", + "type": "object", + "properties": { + "params": { "type": "null" } + } + } + ] + }, + "EventMsg": { + "oneOf": [ + { "$ref": "#/definitions/v2/ThreadStartedEventMsg" }, + { + "title": "WarningEventMsg", + "type": "object", + "properties": { + "message": { "type": "string" }, + "type": { + "enum": ["warning"], + "type": "string" + } + }, + "required": ["message", "type"] + } + ] + }, + "ServerNotification": { + "oneOf": [ + { "$ref": "#/definitions/v2/ThreadStartedNotification" }, + { + "title": "ServerRequestResolvedNotification", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/ServerRequestResolvedNotificationPayload" } + } + } + ] + }, + "SharedHelper": { + "type": "object", + "properties": { + "leaf": { "$ref": "#/definitions/SharedLeaf" } + } + }, + "SharedLeaf": { + "title": "SharedLeaf", + "type": "string" + }, + "InitializeParams": { + "title": "InitializeParams", + "type": "string" + }, + "ServerRequestResolvedNotificationPayload": { + "title": "ServerRequestResolvedNotificationPayload", + "type": "string" + }, + "v2": { + "ThreadStartParams": { + "title": "ThreadStartParams", + "type": "object", + "properties": { + "cwd": { "type": "string" } + } + }, + "ThreadStartResponse": { + "title": "ThreadStartResponse", + "type": "object", + "properties": { + "ok": { "type": "boolean" } + } + }, + "ThreadStartedEventMsg": { + "title": "ThreadStartedEventMsg", + "type": "object", + "properties": { + "thread_id": { "type": "string" } + } + }, + "ThreadStartedNotification": { + "title": "ThreadStartedNotification", + "type": "object", + "properties": { + "thread_id": { "type": "string" } + } + } + } + } + }); + + let flat_bundle = build_flat_v2_schema(&bundle)?; + let definitions = flat_bundle["definitions"] + .as_object() + .expect("flat v2 schema should include definitions"); + + assert_eq!( + flat_bundle["title"], + serde_json::json!("CodexAppServerProtocolV2") + ); + assert_eq!(definitions.contains_key("v2"), false); + assert_eq!(definitions.contains_key("ThreadStartParams"), true); + assert_eq!(definitions.contains_key("ThreadStartResponse"), true); + assert_eq!(definitions.contains_key("ThreadStartedNotification"), true); + assert_eq!(definitions.contains_key("SharedHelper"), true); + assert_eq!(definitions.contains_key("SharedLeaf"), true); + assert_eq!(definitions.contains_key("InitializeParams"), true); + assert_eq!( + definitions.contains_key("ServerRequestResolvedNotificationPayload"), + true + ); + let client_request_titles: BTreeSet = definitions["ClientRequest"]["oneOf"] + .as_array() + .expect("ClientRequest should remain a oneOf") + .iter() + .map(|variant| { + variant["title"] + .as_str() + .expect("ClientRequest variant should have a title") + .to_string() + }) + .collect(); + assert_eq!( + client_request_titles, + BTreeSet::from([ + "InitializeRequest".to_string(), + "LogoutRequest".to_string(), + "StartRequest".to_string(), + ]) + ); + let notification_titles: BTreeSet = definitions["ServerNotification"]["oneOf"] + .as_array() + .expect("ServerNotification should remain a oneOf") + .iter() + .map(|variant| { + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }) + .collect(); + assert_eq!( + notification_titles, + BTreeSet::from([ + "".to_string(), + "ServerRequestResolvedNotification".to_string(), + ]) + ); + assert_eq!( + first_ref_with_prefix(&flat_bundle, "#/definitions/v2/").is_none(), + true + ); + + Ok(()) + } + #[test] fn experimental_type_fields_ts_filter_handles_interface_shape() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_ts_filter_{}", Uuid::now_v7())); @@ -1816,6 +2731,79 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k Ok(()) } + #[test] + fn experimental_type_fields_ts_filter_handles_generated_command_params_shape() -> Result<()> { + let output_dir = std::env::temp_dir().join(format!("codex_ts_filter_{}", Uuid::now_v7())); + fs::create_dir_all(&output_dir)?; + + struct TempDirGuard(PathBuf); + + impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let _guard = TempDirGuard(output_dir.clone()); + let path = output_dir.join("CommandExecParams.ts"); + let content = r#"import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +import type { PermissionProfile } from "./PermissionProfile"; +import type { SandboxPolicy } from "./SandboxPolicy"; + +export type CommandExecParams = {/** + * Command argv vector. Empty arrays are rejected. + */ +command: Array, /** + * Optional environment overrides merged into the server-computed + * environment. + */ +env?: { [key in string]?: string | null } | null, /** + * Optional initial PTY size in character cells. Only valid when `tty` is + * true. + */ +size?: CommandExecTerminalSize | null, /** + * Optional sandbox policy for this command. + * + * Uses the same shape as thread/turn execution sandbox configuration and + * defaults to the user's configured policy when omitted. Cannot be + * combined with `permissionProfile`. + */ +sandboxPolicy?: SandboxPolicy | null, +/** + * Optional full permissions profile for this command. + * + * Defaults to the user's configured permissions when omitted. Cannot be + * combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null}; +"#; + fs::write(&path, content)?; + + static CUSTOM_FIELD: crate::experimental_api::ExperimentalField = + crate::experimental_api::ExperimentalField { + type_name: "CommandExecParams", + field_name: "permissionProfile", + reason: "command/exec.permissionProfile", + }; + filter_experimental_type_fields_ts(&output_dir, &[&CUSTOM_FIELD])?; + + let filtered = fs::read_to_string(&path)?; + assert_eq!( + filtered.contains("permissionProfile?: PermissionProfile"), + false + ); + assert_eq!( + filtered.contains(r#"import type { PermissionProfile } from "./PermissionProfile";"#), + false + ); + assert_eq!(filtered.contains("sandboxPolicy?: SandboxPolicy"), true); + assert_eq!( + filtered.contains(r#"import type { SandboxPolicy } from "./SandboxPolicy";"#), + true + ); + Ok(()) + } + #[test] fn stable_schema_filter_removes_mock_experimental_method() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7())); @@ -1835,26 +2823,105 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k fn generate_json_filters_experimental_fields_and_methods() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7())); fs::create_dir(&output_dir)?; - generate_json_with_experimental(&output_dir, false)?; + generate_json_with_experimental(&output_dir, /*experimental_api*/ false)?; let thread_start_json = fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.json"))?; assert_eq!(thread_start_json.contains("mockExperimentalField"), false); + let command_execution_request_approval_json = + fs::read_to_string(output_dir.join("CommandExecutionRequestApprovalParams.json"))?; + assert_eq!( + command_execution_request_approval_json.contains("additionalPermissions"), + false + ); let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert_eq!( client_request_json.contains("mock/experimentalMethod"), false ); + assert_eq!(output_dir.join("EventMsg.json").exists(), false); let bundle_json = fs::read_to_string(output_dir.join("codex_app_server_protocol.schemas.json"))?; assert_eq!(bundle_json.contains("mockExperimentalField"), false); + assert_eq!(bundle_json.contains("additionalPermissions"), false); assert_eq!(bundle_json.contains("MockExperimentalMethodParams"), false); assert_eq!( bundle_json.contains("MockExperimentalMethodResponse"), false ); + let flat_v2_bundle_json = + fs::read_to_string(output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; + assert_eq!(flat_v2_bundle_json.contains("mockExperimentalField"), false); + assert_eq!(flat_v2_bundle_json.contains("additionalPermissions"), false); + assert_eq!( + flat_v2_bundle_json.contains("MockExperimentalMethodParams"), + false + ); + assert_eq!( + flat_v2_bundle_json.contains("MockExperimentalMethodResponse"), + false + ); + assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false); + assert_eq!( + flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""), + true + ); + let flat_v2_bundle = + read_json_value(&output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; + let definitions = flat_v2_bundle["definitions"] + .as_object() + .expect("flat v2 bundle should include definitions"); + let client_request_methods: BTreeSet = definitions["ClientRequest"]["oneOf"] + .as_array() + .expect("flat v2 ClientRequest should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["method"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_client_request_methods: Vec = [ + "account/logout", + "account/rateLimits/read", + "config/mcpServer/reload", + "configRequirements/read", + "fuzzyFileSearch", + "initialize", + ] + .into_iter() + .filter(|method| !client_request_methods.contains(*method)) + .map(str::to_string) + .collect(); + assert_eq!(missing_client_request_methods, Vec::::new()); + let server_notification_methods: BTreeSet = + definitions["ServerNotification"]["oneOf"] + .as_array() + .expect("flat v2 ServerNotification should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["method"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_server_notification_methods: Vec = [ + "fuzzyFileSearch/sessionCompleted", + "fuzzyFileSearch/sessionUpdated", + "serverRequest/resolved", + ] + .into_iter() + .filter(|method| !server_notification_methods.contains(*method)) + .map(str::to_string) + .collect(); + assert_eq!(missing_server_notification_methods, Vec::::new()); + assert_eq!(definitions.contains_key("EventMsg"), false); assert_eq!( output_dir .join("v2") diff --git a/code-rs/app-server-protocol/src/jsonrpc_lite.rs b/code-rs/app-server-protocol/src/jsonrpc_lite.rs index 7e9ac3f2639..4e8858ce00a 100644 --- a/code-rs/app-server-protocol/src/jsonrpc_lite.rs +++ b/code-rs/app-server-protocol/src/jsonrpc_lite.rs @@ -1,14 +1,18 @@ //! We do not do true JSON-RPC 2.0, as we neither send nor expect the //! "jsonrpc": "2.0" field. +use codex_protocol::protocol::W3cTraceContext; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::fmt; use ts_rs::TS; pub const JSONRPC_VERSION: &str = "2.0"; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] +#[derive( + Debug, Clone, PartialEq, PartialOrd, Ord, Deserialize, Serialize, Hash, Eq, JsonSchema, TS, +)] #[serde(untagged)] pub enum RequestId { String(String), @@ -16,6 +20,15 @@ pub enum RequestId { Integer(i64), } +impl fmt::Display for RequestId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::String(value) => f.write_str(value), + Self::Integer(value) => write!(f, "{value}"), + } + } +} + pub type Result = serde_json::Value; /// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. @@ -36,6 +49,10 @@ pub struct JSONRPCRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub params: Option, + /// Optional W3C Trace Context for distributed tracing. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub trace: Option, } /// A notification which does not expect a response. @@ -69,4 +86,3 @@ pub struct JSONRPCErrorError { pub data: Option, pub message: String, } - diff --git a/code-rs/app-server-protocol/src/lib.rs b/code-rs/app-server-protocol/src/lib.rs index df27c7cec48..2fcf54f4bee 100644 --- a/code-rs/app-server-protocol/src/lib.rs +++ b/code-rs/app-server-protocol/src/lib.rs @@ -6,6 +6,7 @@ mod schema_fixtures; pub use experimental_api::*; pub use export::GenerateTsOptions; +pub use export::generate_internal_json_schema; pub use export::generate_json; pub use export::generate_json_with_experimental; pub use export::generate_ts; @@ -13,13 +14,37 @@ pub use export::generate_ts_with_options; pub use export::generate_types; pub use jsonrpc_lite::*; pub use protocol::common::*; +pub use protocol::event_mapping::*; +pub use protocol::item_builders::*; pub use protocol::thread_history::*; -pub use protocol::v1::*; +pub use protocol::v1::ApplyPatchApprovalParams; +pub use protocol::v1::ApplyPatchApprovalResponse; +pub use protocol::v1::ClientInfo; +pub use protocol::v1::ConversationGitInfo; +pub use protocol::v1::ConversationSummary; +pub use protocol::v1::ExecCommandApprovalParams; +pub use protocol::v1::ExecCommandApprovalResponse; +pub use protocol::v1::GetAuthStatusParams; +pub use protocol::v1::GetAuthStatusResponse; +pub use protocol::v1::GetConversationSummaryParams; +pub use protocol::v1::GetConversationSummaryResponse; +pub use protocol::v1::GitDiffToRemoteParams; +pub use protocol::v1::GitDiffToRemoteResponse; +pub use protocol::v1::GitSha; +pub use protocol::v1::InitializeCapabilities; +pub use protocol::v1::InitializeParams; +pub use protocol::v1::InitializeResponse; +pub use protocol::v1::InterruptConversationResponse; +pub use protocol::v1::LoginApiKeyParams; +pub use protocol::v1::Profile; +pub use protocol::v1::SandboxSettings; +pub use protocol::v1::Tools; +pub use protocol::v1::UserSavedConfig; pub use protocol::v2::*; pub use schema_fixtures::SchemaFixtureOptions; +#[doc(hidden)] +pub use schema_fixtures::generate_typescript_schema_fixture_subtree_for_tests; +pub use schema_fixtures::read_schema_fixture_subtree; pub use schema_fixtures::read_schema_fixture_tree; pub use schema_fixtures::write_schema_fixtures; pub use schema_fixtures::write_schema_fixtures_with_options; -// Backward-compatibility alias used by code-rs crates that still import from -// code-app-server-protocol. -pub use code_protocol::ConversationId; diff --git a/code-rs/app-server-protocol/src/protocol/common.rs b/code-rs/app-server-protocol/src/protocol/common.rs index 425a438acdb..87716e0c9a5 100644 --- a/code-rs/app-server-protocol/src/protocol/common.rs +++ b/code-rs/app-server-protocol/src/protocol/common.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::path::PathBuf; use crate::JSONRPCNotification; use crate::JSONRPCRequest; @@ -7,22 +8,13 @@ use crate::export::GeneratedSchema; use crate::export::write_json_schema; use crate::protocol::v1; use crate::protocol::v2; +use codex_experimental_api_macros::ExperimentalApi; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; use ts_rs::TS; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] -#[ts(type = "string")] -pub struct GitSha(pub String); - -impl GitSha { - pub fn new(sha: &str) -> Self { - Self(sha.to_string()) - } -} - /// Authentication mode for OpenAI-backed providers. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] @@ -39,28 +31,24 @@ pub enum AuthMode { #[ts(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] ChatgptAuthTokens, -} - -impl AuthMode { - #[allow(non_upper_case_globals)] - pub const ChatGPT: Self = Self::Chatgpt; - - pub fn is_chatgpt(self) -> bool { - matches!(self, Self::Chatgpt | Self::ChatgptAuthTokens) - } + /// Programmatic Codex auth backed by a registered Agent Identity. + #[serde(rename = "agentIdentity")] + #[ts(rename = "agentIdentity")] + #[strum(serialize = "agentIdentity")] + AgentIdentity, } macro_rules! experimental_reason_expr { // If a request variant is explicitly marked experimental, that reason wins. - (#[experimental($reason:expr)] $params:ident $(, $inspect_params:tt)?) => { + (variant $variant:ident, #[experimental($reason:expr)] $params:ident $(, $inspect_params:tt)?) => { Some($reason) }; // `inspect_params: true` is used when a method is mostly stable but needs // field-level gating from its params type (for example, ThreadStart). - ($params:ident, true) => { + (variant $variant:ident, $params:ident, true) => { crate::experimental_api::ExperimentalApi::experimental_reason($params) }; - ($params:ident $(, $inspect_params:tt)?) => { + (variant $variant:ident, $params:ident $(, $inspect_params:tt)?) => { None }; } @@ -86,6 +74,86 @@ macro_rules! experimental_type_entry { }; } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientRequestSerializationScope { + Global(&'static str), + GlobalSharedRead(&'static str), + Thread { thread_id: String }, + ThreadPath { path: PathBuf }, + CommandExecProcess { process_id: String }, + Process { process_handle: String }, + FuzzyFileSearchSession { session_id: String }, + FsWatch { watch_id: String }, + McpOauth { server_name: String }, +} + +macro_rules! serialization_scope_expr { + ($actual_params:ident, None) => { + None + }; + ($actual_params:ident, global($key:literal)) => { + Some(ClientRequestSerializationScope::Global($key)) + }; + ($actual_params:ident, global_shared_read($key:literal)) => { + Some(ClientRequestSerializationScope::GlobalSharedRead($key)) + }; + ($actual_params:ident, thread_id($params:ident . $field:ident)) => { + Some(ClientRequestSerializationScope::Thread { + thread_id: $actual_params.$field.clone(), + }) + }; + ($actual_params:ident, optional_thread_id($params:ident . $field:ident)) => { + $actual_params + .$field + .clone() + .map(|thread_id| ClientRequestSerializationScope::Thread { thread_id }) + }; + ($actual_params:ident, thread_or_path($params:ident . $thread_field:ident, $params2:ident . $path_field:ident)) => { + if !$actual_params.$thread_field.is_empty() { + Some(ClientRequestSerializationScope::Thread { + thread_id: $actual_params.$thread_field.clone(), + }) + } else if let Some(path) = $actual_params.$path_field.clone() { + Some(ClientRequestSerializationScope::ThreadPath { path }) + } else { + Some(ClientRequestSerializationScope::Thread { + thread_id: $actual_params.$thread_field.clone(), + }) + } + }; + ($actual_params:ident, optional_command_process_id($params:ident . $field:ident)) => { + $actual_params + .$field + .clone() + .map(|process_id| ClientRequestSerializationScope::CommandExecProcess { process_id }) + }; + ($actual_params:ident, command_process_id($params:ident . $field:ident)) => { + Some(ClientRequestSerializationScope::CommandExecProcess { + process_id: $actual_params.$field.clone(), + }) + }; + ($actual_params:ident, process_handle($params:ident . $field:ident)) => { + Some(ClientRequestSerializationScope::Process { + process_handle: $actual_params.$field.clone(), + }) + }; + ($actual_params:ident, fuzzy_session_id($params:ident . $field:ident)) => { + Some(ClientRequestSerializationScope::FuzzyFileSearchSession { + session_id: $actual_params.$field.clone(), + }) + }; + ($actual_params:ident, fs_watch_id($params:ident . $field:ident)) => { + Some(ClientRequestSerializationScope::FsWatch { + watch_id: $actual_params.$field.clone(), + }) + }; + ($actual_params:ident, mcp_oauth_server($params:ident . $field:ident)) => { + Some(ClientRequestSerializationScope::McpOauth { + server_name: $actual_params.$field.clone(), + }) + }; +} + /// Generates an `enum ClientRequest` where each variant is a request that the /// client can send to the server. Each variant has associated `params` and /// `response` types. Also generates a `export_client_responses()` function to @@ -98,6 +166,8 @@ macro_rules! client_request_definitions { $variant:ident $(=> $wire:literal)? { params: $(#[$params_meta:meta])* $params:ty, $(inspect_params: $inspect_params:tt,)? + serialization: $serialization:ident $( ( $($serialization_args:tt)* ) )?, + $(manual_payload_conversion: $manual_payload_conversion:ident,)? response: $response:ty, } ),* $(,)? @@ -118,12 +188,173 @@ macro_rules! client_request_definitions { )* } + impl ClientRequest { + pub fn id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + + pub fn method(&self) -> String { + serde_json::to_value(self) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "".to_string()) + } + + pub fn serialization_scope(&self) -> Option { + match self { + $( + Self::$variant { params, .. } => { + let _ = params; + serialization_scope_expr!( + params, $serialization $( ( $($serialization_args)* ) )? + ) + } + )* + } + } + } + + /// Typed response from the server to the client. + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ClientResponse { + $( + $(#[doc = $variant_doc])* + $(#[serde(rename = $wire)])? + $variant { + #[serde(rename = "id")] + request_id: RequestId, + response: $response, + }, + )* + } + + impl ClientResponse { + pub fn id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + + pub fn method(&self) -> String { + serde_json::to_value(self) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "".to_string()) + } + + pub fn into_jsonrpc_parts( + self, + ) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> { + match self { + $( + Self::$variant { request_id, response } => { + serde_json::to_value(response).map(|result| (request_id, result)) + } + )* + } + } + } + + #[derive(Debug, Clone)] + #[allow(clippy::large_enum_variant)] + pub enum ClientResponsePayload { + $( $variant($response), )* + InterruptConversation(v1::InterruptConversationResponse), + } + + impl ClientResponsePayload { + pub fn into_jsonrpc_parts_and_payload( + self, + request_id: RequestId, + ) -> std::result::Result< + (RequestId, crate::Result, Option), + serde_json::Error, + > { + match self { + $( + Self::$variant(response) => { + let result = serde_json::to_value(&response)?; + Ok((request_id, result, Some(Self::$variant(response)))) + } + )* + Self::InterruptConversation(response) => { + serde_json::to_value(response).map(|result| (request_id, result, None)) + } + } + } + + pub fn into_client_response(self, request_id: RequestId) -> Option { + match self { + $( + Self::$variant(response) => { + Some(ClientResponse::$variant { + request_id, + response, + }) + } + )* + Self::InterruptConversation(_) => None, + } + } + + pub fn into_jsonrpc_parts( + self, + request_id: RequestId, + ) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> { + self.to_jsonrpc_parts(request_id) + } + + pub fn to_jsonrpc_parts( + &self, + request_id: RequestId, + ) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> { + match self { + $( + Self::$variant(response) => { + serde_json::to_value(response).map(|result| (request_id, result)) + } + )* + Self::InterruptConversation(response) => { + serde_json::to_value(response).map(|result| (request_id, result)) + } + } + } + } + + impl From for ClientResponsePayload { + fn from(response: v1::InterruptConversationResponse) -> Self { + Self::InterruptConversation(response) + } + } + + $( + client_response_payload_from_impl!( + $variant, + $response + $(, $manual_payload_conversion)? + ); + )* + impl crate::experimental_api::ExperimentalApi for ClientRequest { fn experimental_reason(&self) -> Option<&'static str> { match self { $( Self::$variant { params: _params, .. } => { experimental_reason_expr!( + variant $variant, $(#[experimental($reason)])? _params $(, $inspect_params)? @@ -159,6 +390,12 @@ macro_rules! client_request_definitions { Ok(()) } + pub(crate) fn visit_client_response_types(v: &mut impl ::ts_rs::TypeVisitor) { + $( + v.visit::<$response>(); + )* + } + #[allow(clippy::vec_init_then_push)] pub fn export_client_response_schemas( out_dir: &::std::path::Path, @@ -183,9 +420,21 @@ macro_rules! client_request_definitions { }; } +macro_rules! client_response_payload_from_impl { + ($variant:ident, $response:ty) => { + impl From<$response> for ClientResponsePayload { + fn from(response: $response) -> Self { + Self::$variant(response) + } + } + }; + ($variant:ident, $response:ty, manual) => {}; +} + client_request_definitions! { Initialize { params: v1::InitializeParams, + serialization: None, response: v1::InitializeResponse, }, @@ -195,290 +444,584 @@ client_request_definitions! { ThreadStart => "thread/start" { params: v2::ThreadStartParams, inspect_params: true, + serialization: None, response: v2::ThreadStartResponse, }, ThreadResume => "thread/resume" { params: v2::ThreadResumeParams, inspect_params: true, + serialization: thread_or_path(params.thread_id, params.path), response: v2::ThreadResumeResponse, }, ThreadFork => "thread/fork" { params: v2::ThreadForkParams, inspect_params: true, + serialization: thread_or_path(params.thread_id, params.path), response: v2::ThreadForkResponse, }, ThreadArchive => "thread/archive" { params: v2::ThreadArchiveParams, + serialization: thread_id(params.thread_id), response: v2::ThreadArchiveResponse, }, + ThreadUnsubscribe => "thread/unsubscribe" { + params: v2::ThreadUnsubscribeParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadUnsubscribeResponse, + }, + #[experimental("thread/increment_elicitation")] + /// Increment the thread-local out-of-band elicitation counter. + /// + /// This is used by external helpers to pause timeout accounting while a user + /// approval or other elicitation is pending outside the app-server request flow. + ThreadIncrementElicitation => "thread/increment_elicitation" { + params: v2::ThreadIncrementElicitationParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadIncrementElicitationResponse, + }, + #[experimental("thread/decrement_elicitation")] + /// Decrement the thread-local out-of-band elicitation counter. + /// + /// When the count reaches zero, timeout accounting resumes for the thread. + ThreadDecrementElicitation => "thread/decrement_elicitation" { + params: v2::ThreadDecrementElicitationParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadDecrementElicitationResponse, + }, ThreadSetName => "thread/name/set" { params: v2::ThreadSetNameParams, + serialization: thread_id(params.thread_id), response: v2::ThreadSetNameResponse, }, + #[experimental("thread/goal/set")] + ThreadGoalSet => "thread/goal/set" { + params: v2::ThreadGoalSetParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadGoalSetResponse, + }, + #[experimental("thread/goal/get")] + ThreadGoalGet => "thread/goal/get" { + params: v2::ThreadGoalGetParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadGoalGetResponse, + }, + #[experimental("thread/goal/clear")] + ThreadGoalClear => "thread/goal/clear" { + params: v2::ThreadGoalClearParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadGoalClearResponse, + }, + ThreadMetadataUpdate => "thread/metadata/update" { + params: v2::ThreadMetadataUpdateParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadMetadataUpdateResponse, + }, + #[experimental("thread/memoryMode/set")] + ThreadMemoryModeSet => "thread/memoryMode/set" { + params: v2::ThreadMemoryModeSetParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadMemoryModeSetResponse, + }, + #[experimental("memory/reset")] + MemoryReset => "memory/reset" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("memory"), + response: v2::MemoryResetResponse, + }, ThreadUnarchive => "thread/unarchive" { params: v2::ThreadUnarchiveParams, + serialization: thread_id(params.thread_id), response: v2::ThreadUnarchiveResponse, }, ThreadCompactStart => "thread/compact/start" { params: v2::ThreadCompactStartParams, + serialization: thread_id(params.thread_id), response: v2::ThreadCompactStartResponse, }, + ThreadShellCommand => "thread/shellCommand" { + params: v2::ThreadShellCommandParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadShellCommandResponse, + }, + ThreadApproveGuardianDeniedAction => "thread/approveGuardianDeniedAction" { + params: v2::ThreadApproveGuardianDeniedActionParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadApproveGuardianDeniedActionResponse, + }, #[experimental("thread/backgroundTerminals/clean")] ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" { params: v2::ThreadBackgroundTerminalsCleanParams, + serialization: thread_id(params.thread_id), response: v2::ThreadBackgroundTerminalsCleanResponse, }, ThreadRollback => "thread/rollback" { params: v2::ThreadRollbackParams, + serialization: thread_id(params.thread_id), response: v2::ThreadRollbackResponse, }, ThreadList => "thread/list" { params: v2::ThreadListParams, + serialization: None, response: v2::ThreadListResponse, }, ThreadLoadedList => "thread/loaded/list" { params: v2::ThreadLoadedListParams, + serialization: None, response: v2::ThreadLoadedListResponse, }, ThreadRead => "thread/read" { params: v2::ThreadReadParams, + serialization: thread_id(params.thread_id), response: v2::ThreadReadResponse, }, + #[experimental("thread/turns/list")] + ThreadTurnsList => "thread/turns/list" { + params: v2::ThreadTurnsListParams, + // Explicitly concurrent: this primarily reads append-only rollout storage. + serialization: None, + response: v2::ThreadTurnsListResponse, + }, + #[experimental("thread/turns/items/list")] + ThreadTurnsItemsList => "thread/turns/items/list" { + params: v2::ThreadTurnsItemsListParams, + // Explicitly concurrent: this primarily reads append-only rollout storage. + serialization: None, + response: v2::ThreadTurnsItemsListResponse, + }, + /// Append raw Responses API items to the thread history without starting a user turn. + ThreadInjectItems => "thread/inject_items" { + params: v2::ThreadInjectItemsParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadInjectItemsResponse, + }, SkillsList => "skills/list" { params: v2::SkillsListParams, + serialization: global_shared_read("config"), response: v2::SkillsListResponse, }, - SkillsRemoteRead => "skills/remote/read" { - params: v2::SkillsRemoteReadParams, - response: v2::SkillsRemoteReadResponse, - }, - SkillsRemoteWrite => "skills/remote/write" { - params: v2::SkillsRemoteWriteParams, - response: v2::SkillsRemoteWriteResponse, + HooksList => "hooks/list" { + params: v2::HooksListParams, + serialization: global("config"), + response: v2::HooksListResponse, + }, + MarketplaceAdd => "marketplace/add" { + params: v2::MarketplaceAddParams, + serialization: global("config"), + response: v2::MarketplaceAddResponse, + }, + MarketplaceRemove => "marketplace/remove" { + params: v2::MarketplaceRemoveParams, + serialization: global("config"), + response: v2::MarketplaceRemoveResponse, + }, + MarketplaceUpgrade => "marketplace/upgrade" { + params: v2::MarketplaceUpgradeParams, + serialization: global("config"), + response: v2::MarketplaceUpgradeResponse, + }, + PluginList => "plugin/list" { + params: v2::PluginListParams, + serialization: global_shared_read("config"), + response: v2::PluginListResponse, + }, + PluginRead => "plugin/read" { + params: v2::PluginReadParams, + serialization: global("config"), + response: v2::PluginReadResponse, + }, + PluginSkillRead => "plugin/skill/read" { + params: v2::PluginSkillReadParams, + serialization: global("config"), + response: v2::PluginSkillReadResponse, + }, + PluginShareSave => "plugin/share/save" { + params: v2::PluginShareSaveParams, + serialization: global("config"), + response: v2::PluginShareSaveResponse, + }, + PluginShareUpdateTargets => "plugin/share/updateTargets" { + params: v2::PluginShareUpdateTargetsParams, + serialization: global("config"), + response: v2::PluginShareUpdateTargetsResponse, + }, + PluginShareList => "plugin/share/list" { + params: v2::PluginShareListParams, + serialization: global("config"), + response: v2::PluginShareListResponse, + }, + PluginShareDelete => "plugin/share/delete" { + params: v2::PluginShareDeleteParams, + serialization: global("config"), + response: v2::PluginShareDeleteResponse, }, AppsList => "app/list" { params: v2::AppsListParams, + serialization: None, response: v2::AppsListResponse, }, + // File system requests are intentionally concurrent. Desktop already treats local + // file system operations as concurrent, and app-server remote fs mirrors that model. + FsReadFile => "fs/readFile" { + params: v2::FsReadFileParams, + serialization: None, + response: v2::FsReadFileResponse, + }, + FsWriteFile => "fs/writeFile" { + params: v2::FsWriteFileParams, + serialization: None, + response: v2::FsWriteFileResponse, + }, + FsCreateDirectory => "fs/createDirectory" { + params: v2::FsCreateDirectoryParams, + serialization: None, + response: v2::FsCreateDirectoryResponse, + }, + FsGetMetadata => "fs/getMetadata" { + params: v2::FsGetMetadataParams, + serialization: None, + response: v2::FsGetMetadataResponse, + }, + FsReadDirectory => "fs/readDirectory" { + params: v2::FsReadDirectoryParams, + serialization: None, + response: v2::FsReadDirectoryResponse, + }, + FsRemove => "fs/remove" { + params: v2::FsRemoveParams, + serialization: None, + response: v2::FsRemoveResponse, + }, + FsCopy => "fs/copy" { + params: v2::FsCopyParams, + serialization: None, + response: v2::FsCopyResponse, + }, + FsWatch => "fs/watch" { + params: v2::FsWatchParams, + serialization: fs_watch_id(params.watch_id), + response: v2::FsWatchResponse, + }, + FsUnwatch => "fs/unwatch" { + params: v2::FsUnwatchParams, + serialization: fs_watch_id(params.watch_id), + response: v2::FsUnwatchResponse, + }, SkillsConfigWrite => "skills/config/write" { params: v2::SkillsConfigWriteParams, + serialization: global("config"), response: v2::SkillsConfigWriteResponse, }, + PluginInstall => "plugin/install" { + params: v2::PluginInstallParams, + serialization: global("config"), + response: v2::PluginInstallResponse, + }, + PluginUninstall => "plugin/uninstall" { + params: v2::PluginUninstallParams, + serialization: global("config"), + response: v2::PluginUninstallResponse, + }, TurnStart => "turn/start" { params: v2::TurnStartParams, inspect_params: true, + serialization: thread_id(params.thread_id), response: v2::TurnStartResponse, }, TurnSteer => "turn/steer" { params: v2::TurnSteerParams, + inspect_params: true, + serialization: thread_id(params.thread_id), response: v2::TurnSteerResponse, }, TurnInterrupt => "turn/interrupt" { params: v2::TurnInterruptParams, + serialization: thread_id(params.thread_id), response: v2::TurnInterruptResponse, }, + #[experimental("thread/realtime/start")] + ThreadRealtimeStart => "thread/realtime/start" { + params: v2::ThreadRealtimeStartParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadRealtimeStartResponse, + }, + #[experimental("thread/realtime/appendAudio")] + ThreadRealtimeAppendAudio => "thread/realtime/appendAudio" { + params: v2::ThreadRealtimeAppendAudioParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadRealtimeAppendAudioResponse, + }, + #[experimental("thread/realtime/appendText")] + ThreadRealtimeAppendText => "thread/realtime/appendText" { + params: v2::ThreadRealtimeAppendTextParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadRealtimeAppendTextResponse, + }, + #[experimental("thread/realtime/stop")] + ThreadRealtimeStop => "thread/realtime/stop" { + params: v2::ThreadRealtimeStopParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadRealtimeStopResponse, + }, + #[experimental("thread/realtime/listVoices")] + ThreadRealtimeListVoices => "thread/realtime/listVoices" { + params: v2::ThreadRealtimeListVoicesParams, + serialization: None, + response: v2::ThreadRealtimeListVoicesResponse, + }, ReviewStart => "review/start" { params: v2::ReviewStartParams, + serialization: thread_id(params.thread_id), response: v2::ReviewStartResponse, }, ModelList => "model/list" { params: v2::ModelListParams, + serialization: None, response: v2::ModelListResponse, }, + ModelProviderCapabilitiesRead => "modelProvider/capabilities/read" { + params: v2::ModelProviderCapabilitiesReadParams, + serialization: None, + response: v2::ModelProviderCapabilitiesReadResponse, + }, ExperimentalFeatureList => "experimentalFeature/list" { params: v2::ExperimentalFeatureListParams, + serialization: global("config"), response: v2::ExperimentalFeatureListResponse, }, + ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { + params: v2::ExperimentalFeatureEnablementSetParams, + serialization: global("config"), + response: v2::ExperimentalFeatureEnablementSetResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { params: v2::CollaborationModeListParams, + serialization: None, response: v2::CollaborationModeListResponse, }, #[experimental("mock/experimentalMethod")] /// Test-only method used to validate experimental gating. MockExperimentalMethod => "mock/experimentalMethod" { params: v2::MockExperimentalMethodParams, + serialization: None, response: v2::MockExperimentalMethodResponse, }, McpServerOauthLogin => "mcpServer/oauth/login" { params: v2::McpServerOauthLoginParams, + serialization: mcp_oauth_server(params.name), response: v2::McpServerOauthLoginResponse, }, McpServerRefresh => "config/mcpServer/reload" { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("mcp-registry"), response: v2::McpServerRefreshResponse, }, McpServerStatusList => "mcpServerStatus/list" { params: v2::ListMcpServerStatusParams, + serialization: global("mcp-registry"), response: v2::ListMcpServerStatusResponse, }, + McpResourceRead => "mcpServer/resource/read" { + params: v2::McpResourceReadParams, + serialization: optional_thread_id(params.thread_id), + response: v2::McpResourceReadResponse, + }, + + McpServerToolCall => "mcpServer/tool/call" { + params: v2::McpServerToolCallParams, + serialization: thread_id(params.thread_id), + response: v2::McpServerToolCallResponse, + }, + + WindowsSandboxSetupStart => "windowsSandbox/setupStart" { + params: v2::WindowsSandboxSetupStartParams, + serialization: global("windows-sandbox-setup"), + response: v2::WindowsSandboxSetupStartResponse, + }, + WindowsSandboxReadiness => "windowsSandbox/readiness" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("config"), + response: v2::WindowsSandboxReadinessResponse, + }, + LoginAccount => "account/login/start" { params: v2::LoginAccountParams, inspect_params: true, + serialization: global("account-auth"), response: v2::LoginAccountResponse, }, CancelLoginAccount => "account/login/cancel" { params: v2::CancelLoginAccountParams, + serialization: global("account-auth"), response: v2::CancelLoginAccountResponse, }, LogoutAccount => "account/logout" { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("account-auth"), response: v2::LogoutAccountResponse, }, GetAccountRateLimits => "account/rateLimits/read" { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, response: v2::GetAccountRateLimitsResponse, }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { + params: v2::SendAddCreditsNudgeEmailParams, + serialization: global("account-auth"), + response: v2::SendAddCreditsNudgeEmailResponse, + }, + FeedbackUpload => "feedback/upload" { params: v2::FeedbackUploadParams, + serialization: None, response: v2::FeedbackUploadResponse, }, - /// Execute a command (argv vector) under the server's sandbox. + /// Execute a standalone command (argv vector) under the server's sandbox. OneOffCommandExec => "command/exec" { params: v2::CommandExecParams, + inspect_params: true, + serialization: optional_command_process_id(params.process_id), response: v2::CommandExecResponse, }, + /// Write stdin bytes to a running `command/exec` session or close stdin. + CommandExecWrite => "command/exec/write" { + params: v2::CommandExecWriteParams, + serialization: command_process_id(params.process_id), + response: v2::CommandExecWriteResponse, + }, + /// Terminate a running `command/exec` session by client-supplied `processId`. + CommandExecTerminate => "command/exec/terminate" { + params: v2::CommandExecTerminateParams, + serialization: command_process_id(params.process_id), + response: v2::CommandExecTerminateResponse, + }, + /// Resize a running PTY-backed `command/exec` session by client-supplied `processId`. + CommandExecResize => "command/exec/resize" { + params: v2::CommandExecResizeParams, + serialization: command_process_id(params.process_id), + response: v2::CommandExecResizeResponse, + }, + #[experimental("process/spawn")] + /// Spawn a standalone process (argv vector) without a Codex sandbox. + ProcessSpawn => "process/spawn" { + params: v2::ProcessSpawnParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessSpawnResponse, + }, + #[experimental("process/writeStdin")] + /// Write stdin bytes to a running `process/spawn` session or close stdin. + ProcessWriteStdin => "process/writeStdin" { + params: v2::ProcessWriteStdinParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessWriteStdinResponse, + }, + #[experimental("process/kill")] + /// Terminate a running `process/spawn` session by client-supplied `processHandle`. + ProcessKill => "process/kill" { + params: v2::ProcessKillParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessKillResponse, + }, + #[experimental("process/resizePty")] + /// Resize a running PTY-backed `process/spawn` session by client-supplied `processHandle`. + ProcessResizePty => "process/resizePty" { + params: v2::ProcessResizePtyParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessResizePtyResponse, + }, ConfigRead => "config/read" { params: v2::ConfigReadParams, + serialization: global_shared_read("config"), response: v2::ConfigReadResponse, }, ExternalAgentConfigDetect => "externalAgentConfig/detect" { params: v2::ExternalAgentConfigDetectParams, + serialization: global("config"), response: v2::ExternalAgentConfigDetectResponse, }, ExternalAgentConfigImport => "externalAgentConfig/import" { params: v2::ExternalAgentConfigImportParams, + serialization: global("config"), response: v2::ExternalAgentConfigImportResponse, }, ConfigValueWrite => "config/value/write" { params: v2::ConfigValueWriteParams, + serialization: global("config"), + manual_payload_conversion: manual, response: v2::ConfigWriteResponse, }, ConfigBatchWrite => "config/batchWrite" { params: v2::ConfigBatchWriteParams, + serialization: global("config"), + manual_payload_conversion: manual, response: v2::ConfigWriteResponse, }, ConfigRequirementsRead => "configRequirements/read" { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("config"), response: v2::ConfigRequirementsReadResponse, }, GetAccount => "account/read" { params: v2::GetAccountParams, + serialization: global("account-auth"), response: v2::GetAccountResponse, }, /// DEPRECATED APIs below - NewConversation { - params: v1::NewConversationParams, - response: v1::NewConversationResponse, - }, GetConversationSummary { params: v1::GetConversationSummaryParams, + serialization: None, response: v1::GetConversationSummaryResponse, }, - /// List recorded Codex conversations (rollouts) with optional pagination and search. - ListConversations { - params: v1::ListConversationsParams, - response: v1::ListConversationsResponse, - }, - /// Resume a recorded Codex conversation from a rollout file. - ResumeConversation { - params: v1::ResumeConversationParams, - response: v1::ResumeConversationResponse, - }, - /// Fork a recorded Codex conversation into a new session. - ForkConversation { - params: v1::ForkConversationParams, - response: v1::ForkConversationResponse, - }, - ArchiveConversation { - params: v1::ArchiveConversationParams, - response: v1::ArchiveConversationResponse, - }, - SendUserMessage { - params: v1::SendUserMessageParams, - response: v1::SendUserMessageResponse, - }, - SendUserTurn { - params: v1::SendUserTurnParams, - response: v1::SendUserTurnResponse, - }, - InterruptConversation { - params: v1::InterruptConversationParams, - response: v1::InterruptConversationResponse, - }, - AddConversationListener { - params: v1::AddConversationListenerParams, - response: v1::AddConversationSubscriptionResponse, - }, - RemoveConversationListener { - params: v1::RemoveConversationListenerParams, - response: v1::RemoveConversationSubscriptionResponse, - }, GitDiffToRemote { params: v1::GitDiffToRemoteParams, + serialization: None, response: v1::GitDiffToRemoteResponse, }, - LoginApiKey { - params: v1::LoginApiKeyParams, - response: v1::LoginApiKeyResponse, - }, - LoginChatGpt { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::LoginChatGptResponse, - }, - // DEPRECATED in favor of CancelLoginAccount - CancelLoginChatGpt { - params: v1::CancelLoginChatGptParams, - response: v1::CancelLoginChatGptResponse, - }, - LogoutChatGpt { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::LogoutChatGptResponse, - }, /// DEPRECATED in favor of GetAccount GetAuthStatus { params: v1::GetAuthStatusParams, + serialization: global("account-auth"), response: v1::GetAuthStatusResponse, }, - GetUserSavedConfig { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::GetUserSavedConfigResponse, - }, - SetDefaultModel { - params: v1::SetDefaultModelParams, - response: v1::SetDefaultModelResponse, - }, - GetUserAgent { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::GetUserAgentResponse, - }, - UserInfo { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::UserInfoResponse, - }, + // Legacy fuzzy search cancellation is intentionally concurrent: clients reuse a + // cancellation token so a newer request can cancel an older in-flight search. FuzzyFileSearch { params: FuzzyFileSearchParams, + serialization: None, response: FuzzyFileSearchResponse, }, - /// Execute a command (argv vector) under the server's sandbox. - ExecOneOffCommand { - params: v1::ExecOneOffCommandParams, - response: v1::ExecOneOffCommandResponse, + #[experimental("fuzzyFileSearch/sessionStart")] + FuzzyFileSearchSessionStart => "fuzzyFileSearch/sessionStart" { + params: FuzzyFileSearchSessionStartParams, + serialization: fuzzy_session_id(params.session_id), + response: FuzzyFileSearchSessionStartResponse, + }, + #[experimental("fuzzyFileSearch/sessionUpdate")] + FuzzyFileSearchSessionUpdate => "fuzzyFileSearch/sessionUpdate" { + params: FuzzyFileSearchSessionUpdateParams, + serialization: fuzzy_session_id(params.session_id), + response: FuzzyFileSearchSessionUpdateResponse, + }, + #[experimental("fuzzyFileSearch/sessionStop")] + FuzzyFileSearchSessionStop => "fuzzyFileSearch/sessionStop" { + params: FuzzyFileSearchSessionStopParams, + serialization: fuzzy_session_id(params.session_id), + response: FuzzyFileSearchSessionStopResponse, }, } @@ -498,6 +1041,7 @@ macro_rules! server_request_definitions { ) => { /// Request initiated from the server and sent to the client. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] + #[allow(clippy::large_enum_variant)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerRequest { $( @@ -511,7 +1055,68 @@ macro_rules! server_request_definitions { )* } + impl ServerRequest { + pub fn id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + + pub fn response_from_result( + &self, + result: crate::Result, + ) -> serde_json::Result { + match self { + $( + Self::$variant { request_id, .. } => { + let response = serde_json::from_value::<$response>(result)?; + Ok(ServerResponse::$variant { + request_id: request_id.clone(), + response, + }) + } + )* + } + } + } + + /// Typed response from the client to the server. + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ServerResponse { + $( + $(#[$variant_meta])* + $(#[serde(rename = $wire)])? + $variant { + #[serde(rename = "id")] + request_id: RequestId, + response: $response, + }, + )* + } + + impl ServerResponse { + pub fn id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + + pub fn method(&self) -> String { + serde_json::to_value(self) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "".to_string()) + } + } + #[derive(Debug, Clone, PartialEq, JsonSchema)] + #[allow(clippy::large_enum_variant)] pub enum ServerRequestPayload { $( $variant($params), )* } @@ -533,6 +1138,12 @@ macro_rules! server_request_definitions { Ok(()) } + pub(crate) fn visit_server_response_types(v: &mut impl ::ts_rs::TypeVisitor) { + $( + v.visit::<$response>(); + )* + } + #[allow(clippy::vec_init_then_push)] pub fn export_server_response_schemas( out_dir: &Path, @@ -573,7 +1184,17 @@ macro_rules! server_notification_definitions { ),* $(,)? ) => { /// Notification sent from the server to the client. - #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] + #[derive( + Serialize, + Deserialize, + Debug, + Clone, + JsonSchema, + TS, + Display, + ExperimentalApi, + )] + #[allow(clippy::large_enum_variant)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ServerNotification { @@ -668,6 +1289,18 @@ server_request_definitions! { response: v2::ToolRequestUserInputResponse, }, + /// Request input for an MCP server elicitation. + McpServerElicitationRequest => "mcpServer/elicitation/request" { + params: v2::McpServerElicitationRequestParams, + response: v2::McpServerElicitationRequestResponse, + }, + + /// Request approval for additional permissions from the user. + PermissionsRequestApproval => "item/permissions/requestApproval" { + params: v2::PermissionsRequestApprovalParams, + response: v2::PermissionsRequestApprovalResponse, + }, + /// Execute a dynamic tool call on the client. DynamicToolCall => "item/tool/call" { params: v2::DynamicToolCallParams, @@ -709,63 +1342,165 @@ pub struct FuzzyFileSearchParams { pub struct FuzzyFileSearchResult { pub root: String, pub path: String, + pub match_type: FuzzyFileSearchMatchType, pub file_name: String, pub score: u32, pub indices: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub enum FuzzyFileSearchMatchType { + File, + Directory, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResponse { pub files: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchSessionStartParams { + pub session_id: String, + pub roots: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)] +pub struct FuzzyFileSearchSessionStartResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchSessionUpdateParams { + pub session_id: String, + pub query: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)] +pub struct FuzzyFileSearchSessionUpdateResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchSessionStopParams { + pub session_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)] +pub struct FuzzyFileSearchSessionStopResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchSessionUpdatedNotification { + pub session_id: String, + pub query: String, + pub files: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchSessionCompletedNotification { + pub session_id: String, +} + server_notification_definitions! { /// NEW NOTIFICATIONS Error => "error" (v2::ErrorNotification), ThreadStarted => "thread/started" (v2::ThreadStartedNotification), + ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification), + ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification), + ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification), + ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), + SkillsChanged => "skills/changed" (v2::SkillsChangedNotification), ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), + #[experimental("thread/goal/updated")] + ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), + #[experimental("thread/goal/cleared")] + ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), + HookStarted => "hook/started" (v2::HookStartedNotification), TurnCompleted => "turn/completed" (v2::TurnCompletedNotification), + HookCompleted => "hook/completed" (v2::HookCompletedNotification), TurnDiffUpdated => "turn/diff/updated" (v2::TurnDiffUpdatedNotification), TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification), ItemStarted => "item/started" (v2::ItemStartedNotification), + ItemGuardianApprovalReviewStarted => "item/autoApprovalReview/started" (v2::ItemGuardianApprovalReviewStartedNotification), + ItemGuardianApprovalReviewCompleted => "item/autoApprovalReview/completed" (v2::ItemGuardianApprovalReviewCompletedNotification), ItemCompleted => "item/completed" (v2::ItemCompletedNotification), /// This event is internal-only. Used by Codex Cloud. RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), /// EXPERIMENTAL - proposed plan streaming deltas for plan items. PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification), + /// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session. + CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification), + /// Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session. + #[experimental("process/outputDelta")] + ProcessOutputDelta => "process/outputDelta" (v2::ProcessOutputDeltaNotification), + /// Final exit notification for a `process/spawn` session. + #[experimental("process/exited")] + ProcessExited => "process/exited" (v2::ProcessExitedNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), + /// Deprecated legacy apply_patch output stream notification. FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), + FileChangePatchUpdated => "item/fileChange/patchUpdated" (v2::FileChangePatchUpdatedNotification), + ServerRequestResolved => "serverRequest/resolved" (v2::ServerRequestResolvedNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), + McpServerStatusUpdated => "mcpServer/startupStatus/updated" (v2::McpServerStatusUpdatedNotification), AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification), + RemoteControlStatusChanged => "remoteControl/status/changed" (v2::RemoteControlStatusChangedNotification), + ExternalAgentConfigImportCompleted => "externalAgentConfig/import/completed" (v2::ExternalAgentConfigImportCompletedNotification), + FsChanged => "fs/changed" (v2::FsChangedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification), ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification), /// Deprecated: Use `ContextCompaction` item type instead. ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), + ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), + ModelVerification => "model/verification" (v2::ModelVerificationNotification), + Warning => "warning" (v2::WarningNotification), + GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), + FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), + FuzzyFileSearchSessionCompleted => "fuzzyFileSearch/sessionCompleted" (FuzzyFileSearchSessionCompletedNotification), + #[experimental("thread/realtime/started")] + ThreadRealtimeStarted => "thread/realtime/started" (v2::ThreadRealtimeStartedNotification), + #[experimental("thread/realtime/itemAdded")] + ThreadRealtimeItemAdded => "thread/realtime/itemAdded" (v2::ThreadRealtimeItemAddedNotification), + #[experimental("thread/realtime/transcript/delta")] + ThreadRealtimeTranscriptDelta => "thread/realtime/transcript/delta" (v2::ThreadRealtimeTranscriptDeltaNotification), + #[experimental("thread/realtime/transcript/done")] + ThreadRealtimeTranscriptDone => "thread/realtime/transcript/done" (v2::ThreadRealtimeTranscriptDoneNotification), + #[experimental("thread/realtime/outputAudio/delta")] + ThreadRealtimeOutputAudioDelta => "thread/realtime/outputAudio/delta" (v2::ThreadRealtimeOutputAudioDeltaNotification), + #[experimental("thread/realtime/sdp")] + ThreadRealtimeSdp => "thread/realtime/sdp" (v2::ThreadRealtimeSdpNotification), + #[experimental("thread/realtime/error")] + ThreadRealtimeError => "thread/realtime/error" (v2::ThreadRealtimeErrorNotification), + #[experimental("thread/realtime/closed")] + ThreadRealtimeClosed => "thread/realtime/closed" (v2::ThreadRealtimeClosedNotification), /// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox. WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification), + WindowsSandboxSetupCompleted => "windowsSandbox/setupCompleted" (v2::WindowsSandboxSetupCompletedNotification), #[serde(rename = "account/login/completed")] #[ts(rename = "account/login/completed")] #[strum(serialize = "account/login/completed")] AccountLoginCompleted(v2::AccountLoginCompletedNotification), - /// DEPRECATED NOTIFICATIONS below - AuthStatusChange(v1::AuthStatusChangeNotification), - - /// Deprecated: use `account/login/completed` instead. - LoginChatGptComplete(v1::LoginChatGptCompleteNotification), - SessionConfigured(v1::SessionConfiguredNotification), } client_notification_definitions! { @@ -776,58 +1511,486 @@ client_notification_definitions! { mod tests { use super::*; use anyhow::Result; - use code_protocol::ThreadId; - use code_protocol::account::PlanType; - use code_protocol::parse_command::ParsedCommand; - use code_protocol::protocol::AskForApproval; + use codex_protocol::ThreadId; + use codex_protocol::account::PlanType; + use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::RealtimeConversationVersion; + use codex_protocol::protocol::RealtimeOutputModality; + use codex_protocol::protocol::RealtimeVoice; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; + fn absolute_path_string(path: &str) -> String { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).display().to_string() + } + + fn absolute_path(path: &str) -> AbsolutePathBuf { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).abs() + } + + fn request_id() -> RequestId { + const REQUEST_ID: i64 = 1; + RequestId::Integer(REQUEST_ID) + } + #[test] - fn serialize_new_conversation() -> Result<()> { - let request = ClientRequest::NewConversation { - request_id: RequestId::Integer(42), - params: v1::NewConversationParams { - model: Some("gpt-5.1-codex-max".to_string()), - model_provider: None, - profile: None, - cwd: None, - approval_policy: Some(AskForApproval::OnRequest), - sandbox: None, - config: None, - base_instructions: None, - developer_instructions: None, - compact_prompt: None, - include_apply_patch_tool: None, + fn client_request_serialization_scope_covers_keyed_families() { + let thread_id = "thread-1".to_string(); + let thread_resume = ClientRequest::ThreadResume { + request_id: request_id(), + params: v2::ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() }, }; assert_eq!( - json!({ - "method": "newConversation", - "id": 42, - "params": { - "model": "gpt-5.1-codex-max", - "modelProvider": null, - "profile": null, - "cwd": null, - "approvalPolicy": "on-request", - "sandbox": null, - "config": null, - "baseInstructions": null, - "includeApplyPatchTool": null - } - }), - serde_json::to_value(&request)?, + thread_resume.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { + thread_id: thread_id.clone() + }) ); - Ok(()) - } - - #[test] - fn conversation_id_serializes_as_plain_string() -> Result<()> { - let id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; - assert_eq!( + let thread_resume_with_path = ClientRequest::ThreadResume { + request_id: request_id(), + params: v2::ThreadResumeParams { + thread_id: thread_id.clone(), + path: Some(PathBuf::from("/tmp/resume-thread.jsonl")), + ..Default::default() + }, + }; + assert_eq!( + thread_resume_with_path.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { + thread_id: thread_id.clone() + }) + ); + + let thread_fork = ClientRequest::ThreadFork { + request_id: request_id(), + params: v2::ThreadForkParams { + thread_id: thread_id.clone(), + path: Some(PathBuf::from("/tmp/source-thread.jsonl")), + ..Default::default() + }, + }; + assert_eq!( + thread_fork.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { thread_id }) + ); + + let command_exec = ClientRequest::OneOffCommandExec { + request_id: request_id(), + params: v2::CommandExecParams { + command: vec!["sleep".to_string(), "10".to_string()], + process_id: Some("proc-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }, + }; + assert_eq!( + command_exec.serialization_scope(), + Some(ClientRequestSerializationScope::CommandExecProcess { + process_id: "proc-1".to_string() + }) + ); + + let fuzzy_update = ClientRequest::FuzzyFileSearchSessionUpdate { + request_id: request_id(), + params: FuzzyFileSearchSessionUpdateParams { + session_id: "search-1".to_string(), + query: "lib".to_string(), + }, + }; + assert_eq!( + fuzzy_update.serialization_scope(), + Some(ClientRequestSerializationScope::FuzzyFileSearchSession { + session_id: "search-1".to_string() + }) + ); + + let fs_watch = ClientRequest::FsWatch { + request_id: request_id(), + params: v2::FsWatchParams { + watch_id: "watch-1".to_string(), + path: absolute_path("/tmp/repo"), + }, + }; + assert_eq!( + fs_watch.serialization_scope(), + Some(ClientRequestSerializationScope::FsWatch { + watch_id: "watch-1".to_string() + }) + ); + + let plugin_install = ClientRequest::PluginInstall { + request_id: request_id(), + params: v2::PluginInstallParams { + marketplace_path: Some(absolute_path("/tmp/marketplace")), + remote_marketplace_name: None, + plugin_name: "plugin-a".to_string(), + }, + }; + assert_eq!( + plugin_install.serialization_scope(), + Some(ClientRequestSerializationScope::Global("config")) + ); + + let skills_list = ClientRequest::SkillsList { + request_id: request_id(), + params: v2::SkillsListParams { + cwds: Vec::new(), + force_reload: false, + }, + }; + assert_eq!( + skills_list.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead("config")) + ); + + let plugin_list = ClientRequest::PluginList { + request_id: request_id(), + params: v2::PluginListParams { + cwds: None, + marketplace_kinds: None, + }, + }; + assert_eq!( + plugin_list.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead("config")) + ); + + let plugin_uninstall = ClientRequest::PluginUninstall { + request_id: request_id(), + params: v2::PluginUninstallParams { + plugin_id: "plugin-a".to_string(), + }, + }; + assert_eq!( + plugin_uninstall.serialization_scope(), + Some(ClientRequestSerializationScope::Global("config")) + ); + + let mcp_oauth = ClientRequest::McpServerOauthLogin { + request_id: request_id(), + params: v2::McpServerOauthLoginParams { + name: "server-a".to_string(), + scopes: None, + timeout_secs: None, + }, + }; + assert_eq!( + mcp_oauth.serialization_scope(), + Some(ClientRequestSerializationScope::McpOauth { + server_name: "server-a".to_string() + }) + ); + + let mcp_resource_read = ClientRequest::McpResourceRead { + request_id: request_id(), + params: v2::McpResourceReadParams { + thread_id: Some("thread-1".to_string()), + server: "server-a".to_string(), + uri: "file:///tmp/resource".to_string(), + }, + }; + assert_eq!( + mcp_resource_read.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { + thread_id: "thread-1".to_string() + }) + ); + + let config_read = ClientRequest::ConfigRead { + request_id: request_id(), + params: v2::ConfigReadParams { + include_layers: false, + cwd: None, + }, + }; + assert_eq!( + config_read.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead("config")) + ); + + let account_read = ClientRequest::GetAccount { + request_id: request_id(), + params: v2::GetAccountParams { + refresh_token: false, + }, + }; + assert_eq!( + account_read.serialization_scope(), + Some(ClientRequestSerializationScope::Global("account-auth")) + ); + + let thread_goal_set = ClientRequest::ThreadGoalSet { + request_id: request_id(), + params: v2::ThreadGoalSetParams { + thread_id: "goal-thread".to_string(), + objective: Some("ship it".to_string()), + status: None, + token_budget: None, + }, + }; + assert_eq!( + thread_goal_set.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { + thread_id: "goal-thread".to_string() + }) + ); + + let guardian_approval = ClientRequest::ThreadApproveGuardianDeniedAction { + request_id: request_id(), + params: v2::ThreadApproveGuardianDeniedActionParams { + thread_id: "guardian-thread".to_string(), + event: json!({ "type": "guardian" }), + }, + }; + assert_eq!( + guardian_approval.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { + thread_id: "guardian-thread".to_string() + }) + ); + + let marketplace_remove = ClientRequest::MarketplaceRemove { + request_id: request_id(), + params: v2::MarketplaceRemoveParams { + marketplace_name: "marketplace".to_string(), + }, + }; + assert_eq!( + marketplace_remove.serialization_scope(), + Some(ClientRequestSerializationScope::Global("config")) + ); + + let add_credits_nudge = ClientRequest::SendAddCreditsNudgeEmail { + request_id: request_id(), + params: v2::SendAddCreditsNudgeEmailParams { + credit_type: v2::AddCreditsNudgeCreditType::Credits, + }, + }; + assert_eq!( + add_credits_nudge.serialization_scope(), + Some(ClientRequestSerializationScope::Global("account-auth")) + ); + } + + #[test] + fn client_request_serialization_scope_covers_unkeyed_representatives() { + let initialize = ClientRequest::Initialize { + request_id: request_id(), + params: v1::InitializeParams { + client_info: v1::ClientInfo { + name: "test".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: None, + }, + }; + assert_eq!(initialize.serialization_scope(), None); + + let thread_start = ClientRequest::ThreadStart { + request_id: request_id(), + params: v2::ThreadStartParams::default(), + }; + assert_eq!(thread_start.serialization_scope(), None); + + let command_exec = ClientRequest::OneOffCommandExec { + request_id: request_id(), + params: v2::CommandExecParams { + command: vec!["true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }, + }; + assert_eq!(command_exec.serialization_scope(), None); + + let fs_read = ClientRequest::FsReadFile { + request_id: request_id(), + params: v2::FsReadFileParams { + path: absolute_path("/tmp/file.txt"), + }, + }; + assert_eq!(fs_read.serialization_scope(), None); + + let thread_turns_list = ClientRequest::ThreadTurnsList { + request_id: request_id(), + params: v2::ThreadTurnsListParams { + thread_id: "thread-1".to_string(), + cursor: None, + limit: None, + sort_direction: None, + items_view: None, + }, + }; + assert_eq!(thread_turns_list.serialization_scope(), None); + + let thread_turns_items_list = ClientRequest::ThreadTurnsItemsList { + request_id: request_id(), + params: v2::ThreadTurnsItemsListParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + cursor: None, + limit: None, + sort_direction: None, + }, + }; + assert_eq!(thread_turns_items_list.serialization_scope(), None); + + let mcp_resource_read = ClientRequest::McpResourceRead { + request_id: request_id(), + params: v2::McpResourceReadParams { + thread_id: None, + server: "server-a".to_string(), + uri: "file:///tmp/resource".to_string(), + }, + }; + assert_eq!(mcp_resource_read.serialization_scope(), None); + } + + #[test] + fn serialize_get_conversation_summary() -> Result<()> { + let request = ClientRequest::GetConversationSummary { + request_id: RequestId::Integer(42), + params: v1::GetConversationSummaryParams::ThreadId { + conversation_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?, + }, + }; + assert_eq!( + json!({ + "method": "getConversationSummary", + "id": 42, + "params": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> { + let request = ClientRequest::Initialize { + request_id: RequestId::Integer(42), + params: v1::InitializeParams { + client_info: v1::ClientInfo { + name: "codex_vscode".to_string(), + title: Some("Codex VS Code Extension".to_string()), + version: "0.1.0".to_string(), + }, + capabilities: Some(v1::InitializeCapabilities { + experimental_api: true, + opt_out_notification_methods: Some(vec![ + "thread/started".to_string(), + "item/agentMessage/delta".to_string(), + ]), + }), + }, + }; + + assert_eq!( + json!({ + "method": "initialize", + "id": 42, + "params": { + "clientInfo": { + "name": "codex_vscode", + "title": "Codex VS Code Extension", + "version": "0.1.0" + }, + "capabilities": { + "experimentalApi": true, + "optOutNotificationMethods": [ + "thread/started", + "item/agentMessage/delta" + ] + } + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> { + let request: ClientRequest = serde_json::from_value(json!({ + "method": "initialize", + "id": 42, + "params": { + "clientInfo": { + "name": "codex_vscode", + "title": "Codex VS Code Extension", + "version": "0.1.0" + }, + "capabilities": { + "experimentalApi": true, + "optOutNotificationMethods": [ + "thread/started", + "item/agentMessage/delta" + ] + } + } + }))?; + + assert_eq!( + request, + ClientRequest::Initialize { + request_id: RequestId::Integer(42), + params: v1::InitializeParams { + client_info: v1::ClientInfo { + name: "codex_vscode".to_string(), + title: Some("Codex VS Code Extension".to_string()), + version: "0.1.0".to_string(), + }, + capabilities: Some(v1::InitializeCapabilities { + experimental_api: true, + opt_out_notification_methods: Some(vec![ + "thread/started".to_string(), + "item/agentMessage/delta".to_string(), + ]), + }), + }, + } + ); + Ok(()) + } + + #[test] + fn conversation_id_serializes_as_plain_string() -> Result<()> { + let id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + + assert_eq!( json!("67e55044-10b1-426f-9247-bb680e5fe0c8"), serde_json::to_value(id)? ); @@ -864,7 +2027,7 @@ mod tests { let params = v1::ExecCommandApprovalParams { conversation_id, call_id: "call-42".to_string(), - approval_id: None, + approval_id: Some("approval-42".to_string()), command: vec!["echo".to_string(), "hello".to_string()], cwd: PathBuf::from("/tmp"), reason: Some("because tests".to_string()), @@ -884,6 +2047,7 @@ mod tests { "params": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", "callId": "call-42", + "approvalId": "approval-42", "command": ["echo", "hello"], "cwd": "/tmp", "reason": "because tests", @@ -899,6 +2063,7 @@ mod tests { ); let payload = ServerRequestPayload::ExecCommandApproval(params); + assert_eq!(request.id(), &RequestId::Integer(7)); assert_eq!(payload.request_with_id(RequestId::Integer(7)), request); Ok(()) } @@ -926,12 +2091,95 @@ mod tests { Ok(()) } + #[test] + fn serialize_server_response() -> Result<()> { + let response = ServerResponse::CommandExecutionRequestApproval { + request_id: RequestId::Integer(8), + response: v2::CommandExecutionRequestApprovalResponse { + decision: v2::CommandExecutionApprovalDecision::AcceptForSession, + }, + }; + + assert_eq!(response.id(), &RequestId::Integer(8)); + assert_eq!(response.method(), "item/commandExecution/requestApproval"); + assert_eq!( + json!({ + "method": "item/commandExecution/requestApproval", + "id": 8, + "response": { + "decision": "acceptForSession" + } + }), + serde_json::to_value(&response)?, + ); + Ok(()) + } + + #[test] + fn serialize_mcp_server_elicitation_request() -> Result<()> { + let requested_schema: v2::McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean" + } + }, + "required": ["confirmed"] + }))?; + let params = v2::McpServerElicitationRequestParams { + thread_id: "thr_123".to_string(), + turn_id: Some("turn_123".to_string()), + server_name: "codex_apps".to_string(), + request: v2::McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema, + }, + }; + let request = ServerRequest::McpServerElicitationRequest { + request_id: RequestId::Integer(9), + params: params.clone(), + }; + + assert_eq!( + json!({ + "method": "mcpServer/elicitation/request", + "id": 9, + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "serverName": "codex_apps", + "mode": "form", + "_meta": null, + "message": "Allow this request?", + "requestedSchema": { + "type": "object", + "properties": { + "confirmed": { + "type": "boolean" + } + }, + "required": ["confirmed"] + } + } + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::McpServerElicitationRequest(params); + assert_eq!(request.id(), &RequestId::Integer(9)); + assert_eq!(payload.request_with_id(RequestId::Integer(9)), request); + Ok(()) + } + #[test] fn serialize_get_account_rate_limits() -> Result<()> { let request = ClientRequest::GetAccountRateLimits { request_id: RequestId::Integer(1), params: None, }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/rateLimits/read"); assert_eq!( json!({ "method": "account/rateLimits/read", @@ -942,6 +2190,97 @@ mod tests { Ok(()) } + #[test] + fn serialize_client_response() -> Result<()> { + let cwd = absolute_path("/tmp"); + let response = ClientResponse::ThreadStart { + request_id: RequestId::Integer(7), + response: v2::ThreadStartResponse { + thread: v2::Thread { + id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), + forked_from_id: None, + preview: "first prompt".to_string(), + ephemeral: true, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: v2::ThreadStatus::Idle, + path: None, + cwd: cwd.clone(), + cli_version: "0.0.0".to_string(), + source: v2::SessionSource::Exec, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + }, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd, + instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], + approval_policy: v2::AskForApproval::OnFailure, + approvals_reviewer: v2::ApprovalsReviewer::User, + sandbox: v2::SandboxPolicy::DangerFullAccess, + permission_profile: None, + active_permission_profile: None, + reasoning_effort: None, + }, + }; + + assert_eq!(response.id(), &RequestId::Integer(7)); + assert_eq!(response.method(), "thread/start"); + assert_eq!( + json!({ + "method": "thread/start", + "id": 7, + "response": { + "thread": { + "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", + "forkedFromId": null, + "preview": "first prompt", + "ephemeral": true, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 2, + "status": { + "type": "idle" + }, + "path": null, + "cwd": absolute_path_string("tmp"), + "cliVersion": "0.0.0", + "source": "exec", + "threadSource": null, + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5", + "modelProvider": "openai", + "serviceTier": null, + "cwd": absolute_path_string("tmp"), + "instructionSources": [absolute_path_string("tmp/AGENTS.md")], + "approvalPolicy": "on-failure", + "approvalsReviewer": "user", + "sandbox": { + "type": "dangerFullAccess" + }, + "permissionProfile": null, + "activePermissionProfile": null, + "reasoningEffort": null + } + }), + serde_json::to_value(&response)?, + ); + Ok(()) + } + #[test] fn serialize_config_requirements_read() -> Result<()> { let request = ClientRequest::ConfigRequirementsRead { @@ -984,7 +2323,9 @@ mod tests { fn serialize_account_login_chatgpt() -> Result<()> { let request = ClientRequest::LoginAccount { request_id: RequestId::Integer(3), - params: v2::LoginAccountParams::Chatgpt, + params: v2::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, }; assert_eq!( json!({ @@ -999,16 +2340,57 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_streamlined() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(3), + params: v2::LoginAccountParams::Chatgpt { + codex_streamlined_login: true, + }, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 3, + "params": { + "type": "chatgpt", + "codexStreamlinedLogin": true + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_account_login_chatgpt_device_code() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(4), + params: v2::LoginAccountParams::ChatgptDeviceCode, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 4, + "params": { + "type": "chatgptDeviceCode" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_account_logout() -> Result<()> { let request = ClientRequest::LogoutAccount { - request_id: RequestId::Integer(4), + request_id: RequestId::Integer(5), params: None, }; assert_eq!( json!({ "method": "account/logout", - "id": 4, + "id": 5, }), serde_json::to_value(&request)?, ); @@ -1018,7 +2400,7 @@ mod tests { #[test] fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> { let request = ClientRequest::LoginAccount { - request_id: RequestId::Integer(5), + request_id: RequestId::Integer(6), params: v2::LoginAccountParams::ChatgptAuthTokens { access_token: "access-token".to_string(), chatgpt_account_id: "org-123".to_string(), @@ -1028,7 +2410,7 @@ mod tests { assert_eq!( json!({ "method": "account/login/start", - "id": 5, + "id": 6, "params": { "type": "chatgptAuthTokens", "accessToken": "access-token", @@ -1109,6 +2491,23 @@ mod tests { Ok(()) } + #[test] + fn serialize_model_provider_capabilities_read() -> Result<()> { + let request = ClientRequest::ModelProviderCapabilitiesRead { + request_id: RequestId::Integer(7), + params: v2::ModelProviderCapabilitiesReadParams {}, + }; + assert_eq!( + json!({ + "method": "modelProvider/capabilities/read", + "id": 7, + "params": {} + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_list_collaboration_modes() -> Result<()> { let request = ClientRequest::CollaborationModeList { @@ -1147,6 +2546,50 @@ mod tests { Ok(()) } + #[test] + fn serialize_fs_get_metadata() -> Result<()> { + let request = ClientRequest::FsGetMetadata { + request_id: RequestId::Integer(9), + params: v2::FsGetMetadataParams { + path: absolute_path("tmp/example"), + }, + }; + assert_eq!( + json!({ + "method": "fs/getMetadata", + "id": 9, + "params": { + "path": absolute_path_string("tmp/example") + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_fs_watch() -> Result<()> { + let request = ClientRequest::FsWatch { + request_id: RequestId::Integer(10), + params: v2::FsWatchParams { + watch_id: "watch-git".to_string(), + path: absolute_path("tmp/repo/.git"), + }, + }; + assert_eq!( + json!({ + "method": "fs/watch", + "id": 10, + "params": { + "watchId": "watch-git", + "path": absolute_path_string("tmp/repo/.git") + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_list_experimental_features() -> Result<()> { let request = ClientRequest::ExperimentalFeatureList { @@ -1188,6 +2631,183 @@ mod tests { Ok(()) } + #[test] + fn serialize_thread_realtime_start() -> Result<()> { + let request = ClientRequest::ThreadRealtimeStart { + request_id: RequestId::Integer(9), + params: v2::ThreadRealtimeStartParams { + thread_id: "thr_123".to_string(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("You are on a call".to_string())), + realtime_session_id: Some("sess_456".to_string()), + transport: None, + voice: Some(RealtimeVoice::Marin), + }, + }; + assert_eq!( + json!({ + "method": "thread/realtime/start", + "id": 9, + "params": { + "threadId": "thr_123", + "outputModality": "audio", + "prompt": "You are on a call", + "realtimeSessionId": "sess_456", + "transport": null, + "voice": "marin" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_thread_realtime_start_prompt_default_and_null() -> Result<()> { + let default_prompt_request = ClientRequest::ThreadRealtimeStart { + request_id: RequestId::Integer(9), + params: v2::ThreadRealtimeStartParams { + thread_id: "thr_123".to_string(), + output_modality: RealtimeOutputModality::Audio, + prompt: None, + realtime_session_id: None, + transport: None, + voice: None, + }, + }; + assert_eq!( + json!({ + "method": "thread/realtime/start", + "id": 9, + "params": { + "threadId": "thr_123", + "outputModality": "audio", + "realtimeSessionId": null, + "transport": null, + "voice": null + } + }), + serde_json::to_value(&default_prompt_request)?, + ); + + let null_prompt_request = ClientRequest::ThreadRealtimeStart { + request_id: RequestId::Integer(9), + params: v2::ThreadRealtimeStartParams { + thread_id: "thr_123".to_string(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(None), + realtime_session_id: None, + transport: None, + voice: None, + }, + }; + assert_eq!( + json!({ + "method": "thread/realtime/start", + "id": 9, + "params": { + "threadId": "thr_123", + "outputModality": "audio", + "prompt": null, + "realtimeSessionId": null, + "transport": null, + "voice": null + } + }), + serde_json::to_value(&null_prompt_request)?, + ); + + let default_prompt_value = json!({ + "method": "thread/realtime/start", + "id": 9, + "params": { + "threadId": "thr_123", + "outputModality": "audio", + "realtimeSessionId": null, + "transport": null, + "voice": null + } + }); + assert_eq!( + serde_json::from_value::(default_prompt_value)?, + default_prompt_request, + ); + + let null_prompt_value = json!({ + "method": "thread/realtime/start", + "id": 9, + "params": { + "threadId": "thr_123", + "outputModality": "audio", + "prompt": null, + "realtimeSessionId": null, + "transport": null, + "voice": null + } + }); + assert_eq!( + serde_json::from_value::(null_prompt_value)?, + null_prompt_request, + ); + + Ok(()) + } + + #[test] + fn serialize_thread_status_changed_notification() -> Result<()> { + let notification = + ServerNotification::ThreadStatusChanged(v2::ThreadStatusChangedNotification { + thread_id: "thr_123".to_string(), + status: v2::ThreadStatus::Idle, + }); + assert_eq!( + json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thr_123", + "status": { + "type": "idle" + }, + } + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + + #[test] + fn serialize_thread_realtime_output_audio_delta_notification() -> Result<()> { + let notification = ServerNotification::ThreadRealtimeOutputAudioDelta( + v2::ThreadRealtimeOutputAudioDeltaNotification { + thread_id: "thr_123".to_string(), + audio: v2::ThreadRealtimeAudioChunk { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(512), + item_id: None, + }, + }, + ); + assert_eq!( + json!({ + "method": "thread/realtime/outputAudio/delta", + "params": { + "threadId": "thr_123", + "audio": { + "data": "AQID", + "sampleRate": 24000, + "numChannels": 1, + "samplesPerChannel": 512, + "itemId": null + } + } + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + #[test] fn mock_experimental_method_is_marked_experimental() { let request = ClientRequest::MockExperimentalMethod { @@ -1199,16 +2819,145 @@ mod tests { } #[test] - fn thread_start_mock_field_is_marked_experimental() { - let request = ClientRequest::ThreadStart { + fn command_exec_permission_profile_is_marked_experimental() { + let request = ClientRequest::OneOffCommandExec { request_id: RequestId::Integer(1), - params: v2::ThreadStartParams { - mock_experimental_field: Some("mock".to_string()), - ..Default::default() + params: v2::CommandExecParams { + command: vec!["pwd".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(v2::PermissionProfile::Disabled), + }, + }; + + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); + assert_eq!(reason, Some("command/exec.permissionProfile")); + } + + #[test] + fn thread_realtime_start_is_marked_experimental() { + let request = ClientRequest::ThreadRealtimeStart { + request_id: RequestId::Integer(1), + params: v2::ThreadRealtimeStartParams { + thread_id: "thr_123".to_string(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("You are on a call".to_string())), + realtime_session_id: None, + transport: None, + voice: None, }, }; let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); - assert_eq!(reason, Some("thread/start.mockExperimentalField")); + assert_eq!(reason, Some("thread/realtime/start")); + } + + #[test] + fn thread_goal_methods_are_marked_experimental() { + let set_request = ClientRequest::ThreadGoalSet { + request_id: RequestId::Integer(1), + params: v2::ThreadGoalSetParams { + thread_id: "thr_123".to_string(), + objective: Some("ship goal mode".to_string()), + status: Some(v2::ThreadGoalStatus::Active), + token_budget: Some(Some(10_000)), + }, + }; + let get_request = ClientRequest::ThreadGoalGet { + request_id: RequestId::Integer(2), + params: v2::ThreadGoalGetParams { + thread_id: "thr_123".to_string(), + }, + }; + let clear_request = ClientRequest::ThreadGoalClear { + request_id: RequestId::Integer(3), + params: v2::ThreadGoalClearParams { + thread_id: "thr_123".to_string(), + }, + }; + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&set_request), + Some("thread/goal/set") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&get_request), + Some("thread/goal/get") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request), + Some("thread/goal/clear") + ); + } + + #[test] + fn thread_goal_notifications_are_marked_experimental() { + let goal = v2::ThreadGoal { + thread_id: "thr_123".to_string(), + objective: "ship goal mode".to_string(), + status: v2::ThreadGoalStatus::Active, + token_budget: Some(10_000), + tokens_used: 123, + time_used_seconds: 45, + created_at: 1_700_000_000, + updated_at: 1_700_000_123, + }; + let updated = ServerNotification::ThreadGoalUpdated(v2::ThreadGoalUpdatedNotification { + thread_id: "thr_123".to_string(), + turn_id: None, + goal, + }); + let cleared = ServerNotification::ThreadGoalCleared(v2::ThreadGoalClearedNotification { + thread_id: "thr_123".to_string(), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&updated), + Some("thread/goal/updated") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&cleared), + Some("thread/goal/cleared") + ); + } + + #[test] + fn thread_realtime_started_notification_is_marked_experimental() { + let notification = + ServerNotification::ThreadRealtimeStarted(v2::ThreadRealtimeStartedNotification { + thread_id: "thr_123".to_string(), + realtime_session_id: Some("sess_456".to_string()), + version: RealtimeConversationVersion::V1, + }); + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification); + assert_eq!(reason, Some("thread/realtime/started")); + } + + #[test] + fn thread_realtime_output_audio_delta_notification_is_marked_experimental() { + let notification = ServerNotification::ThreadRealtimeOutputAudioDelta( + v2::ThreadRealtimeOutputAudioDeltaNotification { + thread_id: "thr_123".to_string(), + audio: v2::ThreadRealtimeAudioChunk { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(512), + item_id: None, + }, + }, + ); + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification); + assert_eq!(reason, Some("thread/realtime/outputAudio/delta")); } #[test] @@ -1217,6 +2966,7 @@ mod tests { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), item_id: "call_123".to_string(), + started_at_ms: 0, approval_id: None, reason: None, network_approval_context: None, @@ -1226,14 +2976,16 @@ mod tests { additional_permissions: Some(v2::AdditionalPermissionProfile { network: None, file_system: Some(v2::AdditionalFileSystemPermissions { - read: Some(vec![std::path::PathBuf::from("/tmp/allowed")]), + read: Some(vec![absolute_path("/tmp/allowed")]), write: None, + glob_scan_max_depth: None, + entries: None, }), - macos: None, }), proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, }; - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¶ms); assert_eq!( reason, @@ -1241,3 +2993,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "common_tests.rs"] +mod common_tests; diff --git a/code-rs/app-server-protocol/src/protocol/common_tests.rs b/code-rs/app-server-protocol/src/protocol/common_tests.rs new file mode 100644 index 00000000000..83e5d371175 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/common_tests.rs @@ -0,0 +1,44 @@ +use super::*; +use anyhow::Result; +use codex_protocol::protocol::TurnAbortReason; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn client_response_payload_returns_jsonrpc_parts_and_client_response() -> Result<()> { + let (request_id, result, payload) = + ClientResponsePayload::ThreadArchive(v2::ThreadArchiveResponse {}) + .into_jsonrpc_parts_and_payload(RequestId::Integer(7))?; + + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!(result, json!({})); + + let Some(ClientResponse::ThreadArchive { + request_id, + response: _, + }) = payload.and_then(|payload| payload.into_client_response(RequestId::Integer(7))) + else { + panic!("expected thread/archive client response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + Ok(()) +} + +#[test] +fn interrupt_conversation_payload_stays_jsonrpc_only() -> Result<()> { + let (request_id, result, payload) = + ClientResponsePayload::InterruptConversation(v1::InterruptConversationResponse { + abort_reason: TurnAbortReason::Interrupted, + }) + .into_jsonrpc_parts_and_payload(RequestId::Integer(8))?; + + assert_eq!(request_id, RequestId::Integer(8)); + assert_eq!( + result, + json!({ + "abortReason": "interrupted", + }) + ); + assert!(payload.is_none()); + Ok(()) +} diff --git a/code-rs/app-server-protocol/src/protocol/event_mapping.rs b/code-rs/app-server-protocol/src/protocol/event_mapping.rs new file mode 100644 index 00000000000..609ca83a5dd --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -0,0 +1,597 @@ +use crate::protocol::common::ServerNotification; +use crate::protocol::item_builders::build_command_execution_begin_item; +use crate::protocol::item_builders::build_command_execution_end_item; +use crate::protocol::item_builders::convert_patch_changes; +use crate::protocol::v2::AgentMessageDeltaNotification; +use crate::protocol::v2::CollabAgentState; +use crate::protocol::v2::CollabAgentTool; +use crate::protocol::v2::CollabAgentToolCallStatus; +use crate::protocol::v2::CommandExecutionOutputDeltaNotification; +use crate::protocol::v2::DynamicToolCallOutputContentItem; +use crate::protocol::v2::DynamicToolCallStatus; +use crate::protocol::v2::FileChangePatchUpdatedNotification; +use crate::protocol::v2::ItemCompletedNotification; +use crate::protocol::v2::ItemStartedNotification; +use crate::protocol::v2::PlanDeltaNotification; +use crate::protocol::v2::ReasoningSummaryPartAddedNotification; +use crate::protocol::v2::ReasoningSummaryTextDeltaNotification; +use crate::protocol::v2::ReasoningTextDeltaNotification; +use crate::protocol::v2::TerminalInteractionNotification; +use crate::protocol::v2::ThreadItem; +use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; +use codex_protocol::protocol::EventMsg; +use std::collections::HashMap; + +/// Build the v2 app-server notification that directly corresponds to a single core event. +/// +/// This only covers the stateless event-to-notification projections that have a one-to-one +/// mapping. Callers remain responsible for any surrounding state checks or side effects before +/// invoking this helper. +pub fn item_event_to_server_notification( + msg: EventMsg, + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + let thread_id = thread_id.to_string(); + let turn_id = turn_id.to_string(); + match msg { + EventMsg::DynamicToolCallResponse(response) => { + let status = if response.success { + DynamicToolCallStatus::Completed + } else { + DynamicToolCallStatus::Failed + }; + let duration_ms = i64::try_from(response.duration.as_millis()).ok(); + let item = ThreadItem::DynamicToolCall { + id: response.call_id, + namespace: response.namespace, + tool: response.tool, + arguments: response.arguments, + status, + content_items: Some( + response + .content_items + .into_iter() + .map(|item| match item { + CoreDynamicToolCallOutputContentItem::InputText { text } => { + DynamicToolCallOutputContentItem::InputText { text } + } + CoreDynamicToolCallOutputContentItem::InputImage { image_url } => { + DynamicToolCallOutputContentItem::InputImage { image_url } + } + }) + .collect(), + ), + success: Some(response.success), + duration_ms, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id: response.turn_id, + item, + completed_at_ms: response.completed_at_ms, + }) + } + EventMsg::CollabAgentSpawnBegin(begin_event) => { + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some(begin_event.prompt), + model: Some(begin_event.model), + reasoning_effort: Some(begin_event.reasoning_effort), + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + started_at_ms: begin_event.started_at_ms, + }) + } + EventMsg::CollabAgentSpawnEnd(end_event) => { + let has_receiver = end_event.new_thread_id.is_some(); + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ if has_receiver => CollabAgentToolCallStatus::Completed, + _ => CollabAgentToolCallStatus::Failed, + }; + let (receiver_thread_ids, agents_states) = match end_event.new_thread_id { + Some(id) => { + let receiver_id = id.to_string(); + let received_status = CollabAgentState::from(end_event.status.clone()); + ( + vec![receiver_id.clone()], + [(receiver_id, received_status)].into_iter().collect(), + ) + } + None => (Vec::new(), HashMap::new()), + }; + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::SpawnAgent, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: Some(end_event.prompt), + model: Some(end_event.model), + reasoning_effort: Some(end_event.reasoning_effort), + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + completed_at_ms: end_event.completed_at_ms, + }) + } + EventMsg::CollabAgentInteractionBegin(begin_event) => { + let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()]; + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: Some(begin_event.prompt), + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + started_at_ms: begin_event.started_at_ms, + }) + } + EventMsg::CollabAgentInteractionEnd(end_event) => { + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let received_status = CollabAgentState::from(end_event.status); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::SendInput, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: Some(end_event.prompt), + model: None, + reasoning_effort: None, + agents_states: [(receiver_id, received_status)].into_iter().collect(), + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + completed_at_ms: end_event.completed_at_ms, + }) + } + EventMsg::CollabWaitingBegin(begin_event) => { + let receiver_thread_ids = begin_event + .receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + started_at_ms: begin_event.started_at_ms, + }) + } + EventMsg::CollabWaitingEnd(end_event) => { + let status = if end_event.statuses.values().any(|status| { + matches!( + status, + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound + ) + }) { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + }; + let receiver_thread_ids = end_event.statuses.keys().map(ToString::to_string).collect(); + let agents_states = end_event + .statuses + .iter() + .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::Wait, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + completed_at_ms: end_event.completed_at_ms, + }) + } + EventMsg::CollabCloseBegin(begin_event) => { + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + started_at_ms: begin_event.started_at_ms, + }) + } + EventMsg::CollabCloseEnd(end_event) => { + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + CollabAgentState::from(end_event.status), + )] + .into_iter() + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::CloseAgent, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + completed_at_ms: end_event.completed_at_ms, + }) + } + EventMsg::CollabResumeBegin(begin_event) => { + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + started_at_ms: begin_event.started_at_ms, + }) + } + EventMsg::CollabResumeEnd(end_event) => { + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + CollabAgentState::from(end_event.status), + )] + .into_iter() + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::ResumeAgent, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + completed_at_ms: end_event.completed_at_ms, + }) + } + EventMsg::AgentMessageContentDelta(event) => { + let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } = + event; + ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id, + turn_id, + item_id, + delta, + }) + } + EventMsg::PlanDelta(event) => ServerNotification::PlanDelta(PlanDeltaNotification { + thread_id, + turn_id, + item_id: event.item_id, + delta: event.delta, + }), + EventMsg::ReasoningContentDelta(event) => { + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id, + turn_id, + item_id: event.item_id, + delta: event.delta, + summary_index: event.summary_index, + }) + } + EventMsg::ReasoningRawContentDelta(event) => { + ServerNotification::ReasoningTextDelta(ReasoningTextDeltaNotification { + thread_id, + turn_id, + item_id: event.item_id, + delta: event.delta, + content_index: event.content_index, + }) + } + EventMsg::AgentReasoningSectionBreak(event) => { + ServerNotification::ReasoningSummaryPartAdded(ReasoningSummaryPartAddedNotification { + thread_id, + turn_id, + item_id: event.item_id, + summary_index: event.summary_index, + }) + } + EventMsg::ItemStarted(item_started_event) => { + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item: item_started_event.item.into(), + started_at_ms: item_started_event.started_at_ms, + }) + } + EventMsg::ItemCompleted(item_completed_event) => { + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item: item_completed_event.item.into(), + completed_at_ms: item_completed_event.completed_at_ms, + }) + } + EventMsg::PatchApplyUpdated(event) => { + ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification { + thread_id, + turn_id, + item_id: event.call_id, + changes: convert_patch_changes(&event.changes), + }) + } + EventMsg::ExecCommandBegin(exec_command_begin_event) => { + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item: build_command_execution_begin_item(&exec_command_begin_event), + started_at_ms: exec_command_begin_event.started_at_ms, + }) + } + EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { + let item_id = exec_command_output_delta_event.call_id; + let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string(); + ServerNotification::CommandExecutionOutputDelta( + CommandExecutionOutputDeltaNotification { + thread_id, + turn_id, + item_id, + delta, + }, + ) + } + EventMsg::TerminalInteraction(terminal_event) => { + ServerNotification::TerminalInteraction(TerminalInteractionNotification { + thread_id, + turn_id, + item_id: terminal_event.call_id, + process_id: terminal_event.process_id, + stdin: terminal_event.stdin, + }) + } + EventMsg::ExecCommandEnd(exec_command_end_event) => { + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item: build_command_execution_end_item(&exec_command_end_event), + completed_at_ms: exec_command_end_event.completed_at_ms, + }) + } + _ => unreachable!("unsupported item event"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ThreadId; + use codex_protocol::protocol::CollabResumeBeginEvent; + use codex_protocol::protocol::CollabResumeEndEvent; + use codex_protocol::protocol::ExecCommandOutputDeltaEvent; + use codex_protocol::protocol::ExecOutputStream; + use pretty_assertions::assert_eq; + + fn assert_item_started_server_notification( + notification: ServerNotification, + expected: ItemStartedNotification, + ) { + match notification { + ServerNotification::ItemStarted(payload) => assert_eq!(payload, expected), + other => panic!("expected item started notification, got {other:?}"), + } + } + + fn assert_item_completed_server_notification( + notification: ServerNotification, + expected: ItemCompletedNotification, + ) { + match notification { + ServerNotification::ItemCompleted(payload) => assert_eq!(payload, expected), + other => panic!("expected item completed notification, got {other:?}"), + } + } + + fn assert_command_execution_output_delta_server_notification( + notification: ServerNotification, + expected: CommandExecutionOutputDeltaNotification, + ) { + match notification { + ServerNotification::CommandExecutionOutputDelta(payload) => { + assert_eq!(payload, expected) + } + other => panic!("expected command execution output delta, got {other:?}"), + } + } + + #[test] + fn collab_resume_begin_maps_to_item_started_resume_agent() { + let event = CollabResumeBeginEvent { + call_id: "call-1".to_string(), + started_at_ms: 123, + sender_thread_id: ThreadId::new(), + receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, + }; + + let notification = item_event_to_server_notification( + EventMsg::CollabResumeBegin(event.clone()), + "thread-1", + "turn-1", + ); + assert_item_started_server_notification( + notification, + ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: event.started_at_ms, + item: ThreadItem::CollabAgentToolCall { + id: event.call_id, + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: event.sender_thread_id.to_string(), + receiver_thread_ids: vec![event.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }, + }, + ); + } + + #[test] + fn collab_resume_end_maps_to_item_completed_resume_agent() { + let event = CollabResumeEndEvent { + call_id: "call-2".to_string(), + completed_at_ms: 456, + sender_thread_id: ThreadId::new(), + receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, + status: codex_protocol::protocol::AgentStatus::NotFound, + }; + + let receiver_id = event.receiver_thread_id.to_string(); + let notification = item_event_to_server_notification( + EventMsg::CollabResumeEnd(event.clone()), + "thread-2", + "turn-2", + ); + assert_item_completed_server_notification( + notification, + ItemCompletedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + completed_at_ms: event.completed_at_ms, + item: ThreadItem::CollabAgentToolCall { + id: event.call_id, + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::Failed, + sender_thread_id: event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [( + receiver_id, + CollabAgentState::from(codex_protocol::protocol::AgentStatus::NotFound), + )] + .into_iter() + .collect(), + }, + }, + ); + } + + #[test] + fn exec_command_output_delta_maps_to_command_execution_output_delta() { + let notification = item_event_to_server_notification( + EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: "call-1".to_string(), + stream: ExecOutputStream::Stdout, + chunk: b"hello".to_vec(), + }), + "thread-1", + "turn-1", + ); + + assert_command_execution_output_delta_server_notification( + notification, + CommandExecutionOutputDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + delta: "hello".to_string(), + }, + ); + } +} diff --git a/code-rs/app-server-protocol/src/protocol/item_builders.rs b/code-rs/app-server-protocol/src/protocol/item_builders.rs new file mode 100644 index 00000000000..17e0f9aef48 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/item_builders.rs @@ -0,0 +1,317 @@ +//! Shared builders for app-server [`ThreadItem`] values derived from compatibility events. +//! +//! Most live tool items now come from first-class core `ItemStarted` / `ItemCompleted` events. +//! These builders remain for approval flows, rebuilt legacy history, and other pre-execution +//! paths where the underlying tool has not started or never starts at all. +//! +//! Keeping these builders in one place is useful for two reasons: +//! - Live notifications and rebuilt `thread/read` history both need to construct the same +//! synthetic items, so sharing the logic avoids drift between those paths. +//! - The projection is presentation-specific. Core protocol events stay generic, while the +//! app-server protocol decides how to surface those events as `ThreadItem`s for clients. +use crate::protocol::common::ServerNotification; +use crate::protocol::v2::AutoReviewDecisionSource; +use crate::protocol::v2::CommandAction; +use crate::protocol::v2::CommandExecutionSource; +use crate::protocol::v2::CommandExecutionStatus; +use crate::protocol::v2::FileUpdateChange; +use crate::protocol::v2::GuardianApprovalReview; +use crate::protocol::v2::GuardianApprovalReviewStatus; +use crate::protocol::v2::ItemGuardianApprovalReviewCompletedNotification; +use crate::protocol::v2::ItemGuardianApprovalReviewStartedNotification; +use crate::protocol::v2::PatchApplyStatus; +use crate::protocol::v2::PatchChangeKind; +use crate::protocol::v2::ThreadItem; +use codex_protocol::ThreadId; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::GuardianAssessmentAction; +use codex_protocol::protocol::GuardianAssessmentEvent; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_shell_command::parse_command::parse_command; +use codex_shell_command::parse_command::shlex_join; +use std::collections::HashMap; +use std::path::PathBuf; + +pub fn build_file_change_approval_request_item( + payload: &ApplyPatchApprovalRequestEvent, +) -> ThreadItem { + ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: PatchApplyStatus::InProgress, + } +} + +pub fn build_file_change_begin_item(payload: &PatchApplyBeginEvent) -> ThreadItem { + ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: PatchApplyStatus::InProgress, + } +} + +pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { + ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: (&payload.status).into(), + } +} + +pub fn build_command_execution_approval_request_item( + payload: &ExecApprovalRequestEvent, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command: shlex_join(&payload.command), + cwd: payload.cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: payload + .parsed_cmd + .iter() + .cloned() + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .collect(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + } +} + +pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { + ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command: shlex_join(&payload.command), + cwd: payload.cwd.clone(), + process_id: payload.process_id.clone(), + source: payload.source.into(), + status: CommandExecutionStatus::InProgress, + command_actions: payload + .parsed_cmd + .iter() + .cloned() + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .collect(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + } +} + +pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> ThreadItem { + let aggregated_output = if payload.aggregated_output.is_empty() { + None + } else { + Some(payload.aggregated_output.clone()) + }; + let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); + + ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command: shlex_join(&payload.command), + cwd: payload.cwd.clone(), + process_id: payload.process_id.clone(), + source: payload.source.into(), + status: (&payload.status).into(), + command_actions: payload + .parsed_cmd + .iter() + .cloned() + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .collect(), + aggregated_output, + exit_code: Some(payload.exit_code), + duration_ms: Some(duration_ms), + } +} + +/// Build a guardian-derived [`ThreadItem`]. +/// +/// Currently this only synthesizes [`ThreadItem::CommandExecution`] for +/// [`GuardianAssessmentAction::Command`] and [`GuardianAssessmentAction::Execve`]. +pub fn build_item_from_guardian_event( + assessment: &GuardianAssessmentEvent, + status: CommandExecutionStatus, +) -> Option { + match &assessment.action { + GuardianAssessmentAction::Command { command, cwd, .. } => { + let id = assessment.target_item_id.as_ref()?; + let command = command.clone(); + let command_actions = vec![CommandAction::Unknown { + command: command.clone(), + }]; + Some(ThreadItem::CommandExecution { + id: id.clone(), + command, + cwd: cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }) + } + GuardianAssessmentAction::Execve { + program, argv, cwd, .. + } => { + let id = assessment.target_item_id.as_ref()?; + let argv = if argv.is_empty() { + vec![program.clone()] + } else { + std::iter::once(program.clone()) + .chain(argv.iter().skip(1).cloned()) + .collect::>() + }; + let command = shlex_join(&argv); + let parsed_cmd = parse_command(&argv); + let command_actions = if parsed_cmd.is_empty() { + vec![CommandAction::Unknown { + command: command.clone(), + }] + } else { + parsed_cmd + .into_iter() + .map(|parsed| CommandAction::from_core_with_cwd(parsed, cwd)) + .collect() + }; + Some(ThreadItem::CommandExecution { + id: id.clone(), + command, + cwd: cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }) + } + GuardianAssessmentAction::ApplyPatch { .. } + | GuardianAssessmentAction::NetworkAccess { .. } + | GuardianAssessmentAction::McpToolCall { .. } + | GuardianAssessmentAction::RequestPermissions { .. } => None, + } +} + +pub fn guardian_auto_approval_review_notification( + conversation_id: &ThreadId, + event_turn_id: &str, + assessment: &GuardianAssessmentEvent, +) -> ServerNotification { + let turn_id = if assessment.turn_id.is_empty() { + event_turn_id.to_string() + } else { + assessment.turn_id.clone() + }; + let review = GuardianApprovalReview { + status: match assessment.status { + codex_protocol::protocol::GuardianAssessmentStatus::InProgress => { + GuardianApprovalReviewStatus::InProgress + } + codex_protocol::protocol::GuardianAssessmentStatus::Approved => { + GuardianApprovalReviewStatus::Approved + } + codex_protocol::protocol::GuardianAssessmentStatus::Denied => { + GuardianApprovalReviewStatus::Denied + } + codex_protocol::protocol::GuardianAssessmentStatus::TimedOut => { + GuardianApprovalReviewStatus::TimedOut + } + codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { + GuardianApprovalReviewStatus::Aborted + } + }, + risk_level: assessment.risk_level.map(Into::into), + user_authorization: assessment.user_authorization.map(Into::into), + rationale: assessment.rationale.clone(), + }; + let action = assessment.action.clone().into(); + match assessment.status { + codex_protocol::protocol::GuardianAssessmentStatus::InProgress => { + ServerNotification::ItemGuardianApprovalReviewStarted( + ItemGuardianApprovalReviewStartedNotification { + thread_id: conversation_id.to_string(), + turn_id, + review_id: assessment.id.clone(), + started_at_ms: assessment.started_at_ms, + target_item_id: assessment.target_item_id.clone(), + review, + action, + }, + ) + } + codex_protocol::protocol::GuardianAssessmentStatus::Approved + | codex_protocol::protocol::GuardianAssessmentStatus::Denied + | codex_protocol::protocol::GuardianAssessmentStatus::TimedOut + | codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { + ServerNotification::ItemGuardianApprovalReviewCompleted( + ItemGuardianApprovalReviewCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id, + review_id: assessment.id.clone(), + started_at_ms: assessment.started_at_ms, + completed_at_ms: assessment + .completed_at_ms + .unwrap_or(assessment.started_at_ms), + target_item_id: assessment.target_item_id.clone(), + decision_source: assessment + .decision_source + .map(AutoReviewDecisionSource::from) + .unwrap_or(AutoReviewDecisionSource::Agent), + review, + action, + }, + ) + } + } +} + +pub fn convert_patch_changes(changes: &HashMap) -> Vec { + let mut converted: Vec = changes + .iter() + .map(|(path, change)| FileUpdateChange { + path: path.to_string_lossy().into_owned(), + kind: map_patch_change_kind(change), + diff: format_file_change_diff(change), + }) + .collect(); + converted.sort_by(|a, b| a.path.cmp(&b.path)); + converted +} + +fn map_patch_change_kind(change: &FileChange) -> PatchChangeKind { + match change { + FileChange::Add { .. } => PatchChangeKind::Add, + FileChange::Delete { .. } => PatchChangeKind::Delete, + FileChange::Update { move_path, .. } => PatchChangeKind::Update { + move_path: move_path.clone(), + }, + } +} + +fn format_file_change_diff(change: &FileChange) -> String { + match change { + FileChange::Add { content } => content.clone(), + FileChange::Delete { content } => content.clone(), + FileChange::Update { + unified_diff, + move_path, + } => { + if let Some(path) = move_path { + format!("{unified_diff}\n\nMoved to: {}", path.display()) + } else { + unified_diff.clone() + } + } + } +} diff --git a/code-rs/app-server-protocol/src/protocol/mappers.rs b/code-rs/app-server-protocol/src/protocol/mappers.rs index f8eb4c49aa3..dae91e650f9 100644 --- a/code-rs/app-server-protocol/src/protocol/mappers.rs +++ b/code-rs/app-server-protocol/src/protocol/mappers.rs @@ -1,16 +1,24 @@ use crate::protocol::v1; use crate::protocol::v2; - impl From for v2::CommandExecParams { fn from(value: v1::ExecOneOffCommandParams) -> Self { Self { command: value.command, + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, timeout_ms: value .timeout_ms .map(|timeout| i64::try_from(timeout).unwrap_or(60_000)), cwd: value.cwd, + env: None, + size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), + permission_profile: None, } } } - diff --git a/code-rs/app-server-protocol/src/protocol/mod.rs b/code-rs/app-server-protocol/src/protocol/mod.rs index e312a37c856..592944b35f2 100644 --- a/code-rs/app-server-protocol/src/protocol/mod.rs +++ b/code-rs/app-server-protocol/src/protocol/mod.rs @@ -2,8 +2,10 @@ // Exposes protocol pieces used by `lib.rs` via `pub use protocol::common::*;`. pub mod common; +pub mod event_mapping; +pub mod item_builders; mod mappers; +mod serde_helpers; pub mod thread_history; pub mod v1; pub mod v2; - diff --git a/code-rs/app-server-protocol/src/protocol/serde_helpers.rs b/code-rs/app-server-protocol/src/protocol/serde_helpers.rs new file mode 100644 index 00000000000..0e35ebdba7a --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/serde_helpers.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; + +pub fn deserialize_double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + serde_with::rust::double_option::deserialize(deserializer) +} + +pub fn serialize_double_option( + value: &Option>, + serializer: S, +) -> Result +where + T: Serialize, + S: Serializer, +{ + serde_with::rust::double_option::serialize(value, serializer) +} diff --git a/code-rs/app-server-protocol/src/protocol/thread_history.rs b/code-rs/app-server-protocol/src/protocol/thread_history.rs index 90d92d7c3b8..1121d3a35b6 100644 --- a/code-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/code-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1,92 +1,304 @@ +use crate::protocol::item_builders::build_command_execution_begin_item; +use crate::protocol::item_builders::build_command_execution_end_item; +use crate::protocol::item_builders::build_file_change_approval_request_item; +use crate::protocol::item_builders::build_file_change_begin_item; +use crate::protocol::item_builders::build_file_change_end_item; +use crate::protocol::item_builders::build_item_from_guardian_event; +use crate::protocol::v2::CollabAgentState; +use crate::protocol::v2::CollabAgentTool; +use crate::protocol::v2::CollabAgentToolCallStatus; +use crate::protocol::v2::CommandExecutionStatus; +use crate::protocol::v2::DynamicToolCallOutputContentItem; +use crate::protocol::v2::DynamicToolCallStatus; +use crate::protocol::v2::McpToolCallError; +use crate::protocol::v2::McpToolCallResult; +use crate::protocol::v2::McpToolCallStatus; use crate::protocol::v2::ThreadItem; use crate::protocol::v2::Turn; +use crate::protocol::v2::TurnError as V2TurnError; use crate::protocol::v2::TurnError; +use crate::protocol::v2::TurnItemsView; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; -use code_protocol::protocol::AgentReasoningEvent; -use code_protocol::protocol::AgentReasoningRawContentEvent; -use code_protocol::protocol::EventMsg; -use code_protocol::protocol::ImageGenerationEndEvent; -use code_protocol::protocol::ItemCompletedEvent; -use code_protocol::protocol::ThreadRolledBackEvent; -use code_protocol::protocol::TurnAbortedEvent; -use code_protocol::protocol::UserMessageEvent; - -/// Convert persisted [`EventMsg`] entries into a sequence of [`Turn`] values. +use crate::protocol::v2::WebSearchAction; +use codex_protocol::items::parse_hook_prompt_message; +use codex_protocol::models::MessagePhase; +use codex_protocol::protocol::AgentReasoningEvent; +use codex_protocol::protocol::AgentReasoningRawContentEvent; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::ContextCompactedEvent; +use codex_protocol::protocol::DynamicToolCallResponseEvent; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::GuardianAssessmentEvent; +use codex_protocol::protocol::GuardianAssessmentStatus; +use codex_protocol::protocol::ImageGenerationBeginEvent; +use codex_protocol::protocol::ImageGenerationEndEvent; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::UserMessageEvent; +use codex_protocol::protocol::ViewImageToolCallEvent; +use codex_protocol::protocol::WebSearchBeginEvent; +use codex_protocol::protocol::WebSearchEndEvent; +use std::collections::HashMap; +use tracing::warn; +use uuid::Uuid; + +#[cfg(test)] +use crate::protocol::v2::CommandAction; +#[cfg(test)] +use crate::protocol::v2::FileUpdateChange; +#[cfg(test)] +use crate::protocol::v2::PatchApplyStatus; +#[cfg(test)] +use crate::protocol::v2::PatchChangeKind; +#[cfg(test)] +use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +#[cfg(test)] +use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; + +/// Convert persisted [`RolloutItem`] entries into a sequence of [`Turn`] values. /// -/// The purpose of this is to convert the EventMsgs persisted in a rollout file -/// into a sequence of Turns and ThreadItems, which allows the client to render -/// the historical messages when resuming a thread. -pub fn build_turns_from_event_msgs(events: &[EventMsg]) -> Vec { +/// When available, this uses `TurnContext.turn_id` as the canonical turn id so +/// resumed/rebuilt thread history preserves the original turn identifiers. +pub fn build_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { let mut builder = ThreadHistoryBuilder::new(); - for event in events { - builder.handle_event(event); + for item in items { + builder.handle_rollout_item(item); } builder.finish() } -struct ThreadHistoryBuilder { +pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, - next_turn_index: i64, next_item_index: i64, + current_rollout_index: usize, + next_rollout_index: usize, +} + +impl Default for ThreadHistoryBuilder { + fn default() -> Self { + Self::new() + } } impl ThreadHistoryBuilder { - fn new() -> Self { + pub fn new() -> Self { Self { turns: Vec::new(), current_turn: None, - next_turn_index: 1, next_item_index: 1, + current_rollout_index: 0, + next_rollout_index: 0, } } - fn finish(mut self) -> Vec { + pub fn reset(&mut self) { + *self = Self::new(); + } + + pub fn finish(mut self) -> Vec { self.finish_current_turn(); self.turns } + pub fn active_turn_snapshot(&self) -> Option { + self.current_turn + .as_ref() + .map(Turn::from) + .or_else(|| self.turns.last().cloned()) + } + + /// Returns the index of the active turn snapshot within the finished turn list. + /// + /// When a turn is still open, this is the index it will occupy after + /// `finish`. When no turn is open, it is the index of the last finished turn. + pub fn active_turn_position(&self) -> Option { + if self.current_turn.is_some() { + Some(self.turns.len()) + } else if self.turns.is_empty() { + None + } else { + Some(self.turns.len() - 1) + } + } + + pub fn has_active_turn(&self) -> bool { + self.current_turn.is_some() + } + + pub fn active_turn_id_if_explicit(&self) -> Option { + self.current_turn + .as_ref() + .filter(|turn| turn.opened_explicitly) + .map(|turn| turn.id.clone()) + } + + pub fn active_turn_start_index(&self) -> Option { + self.current_turn + .as_ref() + .map(|turn| turn.rollout_start_index) + } + + /// Shared reducer for persisted rollout replay and in-memory current-turn + /// tracking used by running thread resume/rejoin. + /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message(payload.message.clone()), + EventMsg::AgentMessage(payload) => self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ), EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) } - EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), + EventMsg::WebSearchBegin(payload) => self.handle_web_search_begin(payload), + EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload), + EventMsg::ExecCommandBegin(payload) => self.handle_exec_command_begin(payload), + EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload), + EventMsg::GuardianAssessment(payload) => self.handle_guardian_assessment(payload), + EventMsg::ApplyPatchApprovalRequest(payload) => { + self.handle_apply_patch_approval_request(payload) + } + EventMsg::PatchApplyBegin(payload) => self.handle_patch_apply_begin(payload), + EventMsg::PatchApplyEnd(payload) => self.handle_patch_apply_end(payload), + EventMsg::DynamicToolCallRequest(payload) => { + self.handle_dynamic_tool_call_request(payload) + } + EventMsg::DynamicToolCallResponse(payload) => { + self.handle_dynamic_tool_call_response(payload) + } + EventMsg::McpToolCallBegin(payload) => self.handle_mcp_tool_call_begin(payload), + EventMsg::McpToolCallEnd(payload) => self.handle_mcp_tool_call_end(payload), + EventMsg::ViewImageToolCall(payload) => self.handle_view_image_tool_call(payload), + EventMsg::ImageGenerationBegin(payload) => self.handle_image_generation_begin(payload), EventMsg::ImageGenerationEnd(payload) => self.handle_image_generation_end(payload), + EventMsg::CollabAgentSpawnBegin(payload) => { + self.handle_collab_agent_spawn_begin(payload) + } + EventMsg::CollabAgentSpawnEnd(payload) => self.handle_collab_agent_spawn_end(payload), + EventMsg::CollabAgentInteractionBegin(payload) => { + self.handle_collab_agent_interaction_begin(payload) + } + EventMsg::CollabAgentInteractionEnd(payload) => { + self.handle_collab_agent_interaction_end(payload) + } + EventMsg::CollabWaitingBegin(payload) => self.handle_collab_waiting_begin(payload), + EventMsg::CollabWaitingEnd(payload) => self.handle_collab_waiting_end(payload), + EventMsg::CollabCloseBegin(payload) => self.handle_collab_close_begin(payload), + EventMsg::CollabCloseEnd(payload) => self.handle_collab_close_end(payload), + EventMsg::CollabResumeBegin(payload) => self.handle_collab_resume_begin(payload), + EventMsg::CollabResumeEnd(payload) => self.handle_collab_resume_end(payload), + EventMsg::ContextCompacted(payload) => self.handle_context_compacted(payload), + EventMsg::EnteredReviewMode(payload) => self.handle_entered_review_mode(payload), + EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload), + EventMsg::ItemStarted(payload) => self.handle_item_started(payload), + EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), + EventMsg::HookStarted(_) | EventMsg::HookCompleted(_) => {} + EventMsg::Error(payload) => self.handle_error(payload), EventMsg::TokenCount(_) => {} - EventMsg::EnteredReviewMode(_) => {} - EventMsg::ExitedReviewMode(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), - EventMsg::UndoCompleted(_) => {} EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + EventMsg::TurnStarted(payload) => self.handle_turn_started(payload), + EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload), _ => {} } } + pub fn handle_rollout_item(&mut self, item: &RolloutItem) { + self.current_rollout_index = self.next_rollout_index; + self.next_rollout_index += 1; + match item { + RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::Compacted(payload) => self.handle_compacted(payload), + RolloutItem::ResponseItem(item) => self.handle_response_item(item), + RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} + } + } + + fn handle_response_item(&mut self, item: &codex_protocol::models::ResponseItem) { + let codex_protocol::models::ResponseItem::Message { + role, content, id, .. + } = item + else { + return; + }; + + if role != "user" { + return; + } + + let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { + return; + }; + + self.ensure_turn().items.push(ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(crate::protocol::v2::HookPromptFragment::from) + .collect(), + }); + } + fn handle_user_message(&mut self, payload: &UserMessageEvent) { - self.finish_current_turn(); - let mut turn = self.new_turn(); + // User messages should stay in explicitly opened turns. For backward + // compatibility with older streams that did not open turns explicitly, + // close any implicit/inactive turn and start a fresh one for this input. + if let Some(turn) = self.current_turn.as_ref() + && !turn.opened_explicitly + && !(turn.saw_compaction && turn.items.is_empty()) + { + self.finish_current_turn(); + } + let mut turn = self + .current_turn + .take() + .unwrap_or_else(|| self.new_turn(/*id*/ None)); let id = self.next_item_id(); let content = self.build_user_inputs(payload); turn.items.push(ThreadItem::UserMessage { id, content }); self.current_turn = Some(turn); } - fn handle_agent_message(&mut self, text: String) { + fn handle_agent_message( + &mut self, + text: String, + phase: Option, + memory_citation: Option, + ) { if text.is_empty() { return; } let id = self.next_item_id(); - self.ensure_turn() - .items - .push(ThreadItem::AgentMessage { id, text }); + self.ensure_turn().items.push(ThreadItem::AgentMessage { + id, + text, + phase, + memory_citation, + }); } fn handle_agent_reasoning(&mut self, payload: &AgentReasoningEvent) { @@ -129,35 +341,638 @@ impl ThreadHistoryBuilder { }); } + fn handle_item_started(&mut self, payload: &ItemStartedEvent) { + match &payload.item { + codex_protocol::items::TurnItem::Plan(plan) => { + if plan.text.is_empty() { + return; + } + self.upsert_item_in_turn_id( + &payload.turn_id, + ThreadItem::from(payload.item.clone()), + ); + } + codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::HookPrompt(_) + | codex_protocol::items::TurnItem::AgentMessage(_) + | codex_protocol::items::TurnItem::Reasoning(_) + | codex_protocol::items::TurnItem::WebSearch(_) + | codex_protocol::items::TurnItem::ImageView(_) + | codex_protocol::items::TurnItem::ImageGeneration(_) + | codex_protocol::items::TurnItem::FileChange(_) + | codex_protocol::items::TurnItem::McpToolCall(_) + | codex_protocol::items::TurnItem::ContextCompaction(_) => {} + } + } + fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { - if let code_protocol::items::TurnItem::Plan(plan) = &payload.item { - if plan.text.is_empty() { - return; + match &payload.item { + codex_protocol::items::TurnItem::Plan(plan) => { + if plan.text.is_empty() { + return; + } + self.upsert_item_in_turn_id( + &payload.turn_id, + ThreadItem::from(payload.item.clone()), + ); } - let id = self.next_item_id(); - self.ensure_turn().items.push(ThreadItem::Plan { - id, - text: plan.text.clone(), - }); + codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::HookPrompt(_) + | codex_protocol::items::TurnItem::AgentMessage(_) + | codex_protocol::items::TurnItem::Reasoning(_) + | codex_protocol::items::TurnItem::WebSearch(_) + | codex_protocol::items::TurnItem::ImageView(_) + | codex_protocol::items::TurnItem::ImageGeneration(_) + | codex_protocol::items::TurnItem::FileChange(_) + | codex_protocol::items::TurnItem::McpToolCall(_) + | codex_protocol::items::TurnItem::ContextCompaction(_) => {} + } + } + + fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) { + let item = ThreadItem::WebSearch { + id: payload.call_id.clone(), + query: String::new(), + action: None, + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) { + let item = ThreadItem::WebSearch { + id: payload.call_id.clone(), + query: payload.query.clone(), + action: Some(WebSearchAction::from(payload.action.clone())), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) { + let item = build_command_execution_begin_item(payload); + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + + fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) { + let item = build_command_execution_end_item(payload); + // Command completions can arrive out of order. Unified exec may return + // while a PTY is still running, then emit ExecCommandEnd later from a + // background exit watcher when that process finally exits. By then, a + // newer user turn may already have started. Route by event turn_id so + // replay preserves the original turn association. + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + + fn handle_guardian_assessment(&mut self, payload: &GuardianAssessmentEvent) { + let status = match payload.status { + GuardianAssessmentStatus::InProgress => CommandExecutionStatus::InProgress, + GuardianAssessmentStatus::Denied | GuardianAssessmentStatus::Aborted => { + CommandExecutionStatus::Declined + } + GuardianAssessmentStatus::TimedOut => CommandExecutionStatus::Failed, + GuardianAssessmentStatus::Approved => return, + }; + let Some(item) = build_item_from_guardian_event(payload, status) else { + return; + }; + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_apply_patch_approval_request(&mut self, payload: &ApplyPatchApprovalRequestEvent) { + let item = build_file_change_approval_request_item(payload); + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) { + let item = build_file_change_begin_item(payload); + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_patch_apply_end(&mut self, payload: &PatchApplyEndEvent) { + let item = build_file_change_end_item(payload); + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_dynamic_tool_call_request( + &mut self, + payload: &codex_protocol::dynamic_tools::DynamicToolCallRequest, + ) { + let item = ThreadItem::DynamicToolCall { + id: payload.call_id.clone(), + namespace: payload.namespace.clone(), + tool: payload.tool.clone(), + arguments: payload.arguments.clone(), + status: DynamicToolCallStatus::InProgress, + content_items: None, + success: None, + duration_ms: None, + }; + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_dynamic_tool_call_response(&mut self, payload: &DynamicToolCallResponseEvent) { + let status = if payload.success { + DynamicToolCallStatus::Completed + } else { + DynamicToolCallStatus::Failed + }; + let duration_ms = i64::try_from(payload.duration.as_millis()).ok(); + let item = ThreadItem::DynamicToolCall { + id: payload.call_id.clone(), + namespace: payload.namespace.clone(), + tool: payload.tool.clone(), + arguments: payload.arguments.clone(), + status, + content_items: Some(convert_dynamic_tool_content_items(&payload.content_items)), + success: Some(payload.success), + duration_ms, + }; + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); } } + fn handle_mcp_tool_call_begin(&mut self, payload: &McpToolCallBeginEvent) { + let item = ThreadItem::McpToolCall { + id: payload.call_id.clone(), + server: payload.invocation.server.clone(), + tool: payload.invocation.tool.clone(), + status: McpToolCallStatus::InProgress, + arguments: payload + .invocation + .arguments + .clone() + .unwrap_or(serde_json::Value::Null), + mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), + result: None, + error: None, + duration_ms: None, + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_mcp_tool_call_end(&mut self, payload: &McpToolCallEndEvent) { + let status = if payload.is_success() { + McpToolCallStatus::Completed + } else { + McpToolCallStatus::Failed + }; + let duration_ms = i64::try_from(payload.duration.as_millis()).ok(); + let (result, error) = match &payload.result { + Ok(value) => ( + Some(Box::new(McpToolCallResult { + content: value.content.clone(), + structured_content: value.structured_content.clone(), + meta: value.meta.clone(), + })), + None, + ), + Err(message) => ( + None, + Some(McpToolCallError { + message: message.clone(), + }), + ), + }; + let item = ThreadItem::McpToolCall { + id: payload.call_id.clone(), + server: payload.invocation.server.clone(), + tool: payload.invocation.tool.clone(), + status, + arguments: payload + .invocation + .arguments + .clone() + .unwrap_or(serde_json::Value::Null), + mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), + result, + error, + duration_ms, + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) { + let item = ThreadItem::ImageView { + id: payload.call_id.clone(), + path: payload.path.clone(), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_image_generation_begin(&mut self, payload: &ImageGenerationBeginEvent) { + let item = ThreadItem::ImageGeneration { + id: payload.call_id.clone(), + status: String::new(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }; + self.upsert_item_in_current_turn(item); + } + fn handle_image_generation_end(&mut self, payload: &ImageGenerationEndEvent) { - let id = payload.call_id.clone(); - self.ensure_turn().items.push(ThreadItem::ImageGeneration { - id, + let item = ThreadItem::ImageGeneration { + id: payload.call_id.clone(), status: payload.status.clone(), revised_prompt: payload.revised_prompt.clone(), result: payload.result.clone(), saved_path: payload.saved_path.clone(), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_agent_spawn_begin( + &mut self, + payload: &codex_protocol::protocol::CollabAgentSpawnBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some(payload.prompt.clone()), + model: Some(payload.model.clone()), + reasoning_effort: Some(payload.reasoning_effort), + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_agent_spawn_end( + &mut self, + payload: &codex_protocol::protocol::CollabAgentSpawnEndEvent, + ) { + let has_receiver = payload.new_thread_id.is_some(); + let status = match &payload.status { + AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed, + _ if has_receiver => CollabAgentToolCallStatus::Completed, + _ => CollabAgentToolCallStatus::Failed, + }; + let (receiver_thread_ids, agents_states) = match &payload.new_thread_id { + Some(id) => { + let receiver_id = id.to_string(); + let received_status = CollabAgentState::from(payload.status.clone()); + ( + vec![receiver_id.clone()], + [(receiver_id, received_status)].into_iter().collect(), + ) + } + None => (Vec::new(), HashMap::new()), + }; + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: Some(payload.prompt.clone()), + model: Some(payload.model.clone()), + reasoning_effort: Some(payload.reasoning_effort), + agents_states, }); } - fn handle_turn_aborted(&mut self, _payload: &TurnAbortedEvent) { + fn handle_collab_agent_interaction_begin( + &mut self, + payload: &codex_protocol::protocol::CollabAgentInteractionBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: Some(payload.prompt.clone()), + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_agent_interaction_end( + &mut self, + payload: &codex_protocol::protocol::CollabAgentInteractionEndEvent, + ) { + let status = match &payload.status { + AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed, + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = payload.receiver_thread_id.to_string(); + let received_status = CollabAgentState::from(payload.status.clone()); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SendInput, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: Some(payload.prompt.clone()), + model: None, + reasoning_effort: None, + agents_states: [(receiver_id, received_status)].into_iter().collect(), + }); + } + + fn handle_collab_waiting_begin( + &mut self, + payload: &codex_protocol::protocol::CollabWaitingBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: payload + .receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_waiting_end( + &mut self, + payload: &codex_protocol::protocol::CollabWaitingEndEvent, + ) { + let status = if payload + .statuses + .values() + .any(|status| matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound)) + { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + }; + let mut receiver_thread_ids: Vec = + payload.statuses.keys().map(ToString::to_string).collect(); + receiver_thread_ids.sort(); + let agents_states = payload + .statuses + .iter() + .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) + .collect(); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::Wait, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }); + } + + fn handle_collab_close_begin( + &mut self, + payload: &codex_protocol::protocol::CollabCloseBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_close_end(&mut self, payload: &codex_protocol::protocol::CollabCloseEndEvent) { + let status = match &payload.status { + AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed, + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = payload.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + CollabAgentState::from(payload.status.clone()), + )] + .into_iter() + .collect(); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }); + } + + fn handle_collab_resume_begin( + &mut self, + payload: &codex_protocol::protocol::CollabResumeBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_resume_end( + &mut self, + payload: &codex_protocol::protocol::CollabResumeEndEvent, + ) { + let status = match &payload.status { + AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed, + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = payload.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + CollabAgentState::from(payload.status.clone()), + )] + .into_iter() + .collect(); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }); + } + + fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) { + let id = self.next_item_id(); + self.ensure_turn() + .items + .push(ThreadItem::ContextCompaction { id }); + } + + fn handle_entered_review_mode(&mut self, payload: &codex_protocol::protocol::ReviewRequest) { + let review = payload + .user_facing_hint + .clone() + .unwrap_or_else(|| "Review requested.".to_string()); + let id = self.next_item_id(); + self.ensure_turn() + .items + .push(ThreadItem::EnteredReviewMode { id, review }); + } + + fn handle_exited_review_mode( + &mut self, + payload: &codex_protocol::protocol::ExitedReviewModeEvent, + ) { + let review = payload + .review_output + .as_ref() + .map(render_review_output_text) + .unwrap_or_else(|| REVIEW_FALLBACK_MESSAGE.to_string()); + let id = self.next_item_id(); + self.ensure_turn() + .items + .push(ThreadItem::ExitedReviewMode { id, review }); + } + + fn handle_error(&mut self, payload: &ErrorEvent) { + if !payload.affects_turn_status() { + return; + } let Some(turn) = self.current_turn.as_mut() else { return; }; - turn.status = TurnStatus::Interrupted; + turn.status = TurnStatus::Failed; + turn.error = Some(V2TurnError { + message: payload.message.clone(), + codex_error_info: payload.codex_error_info.clone().map(Into::into), + additional_details: None, + }); + } + + fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; + if let Some(turn_id) = payload.turn_id.as_deref() { + // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. + if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { + apply_abort(turn); + return; + } + + if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return; + } + } + + // If the event has no ID (or refers to an unknown turn), fall back to the active turn. + if let Some(turn) = self.current_turn.as_mut() { + apply_abort(turn); + } + } + + fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { + self.finish_current_turn(); + self.current_turn = Some( + self.new_turn(Some(payload.turn_id.clone())) + .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) + .opened_explicitly(), + ); + } + + fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; + + // Prefer an exact ID match from the active turn and then close it. + if let Some(current_turn) = self + .current_turn + .as_mut() + .filter(|turn| turn.id == payload.turn_id) + { + mark_completed(current_turn); + self.finish_current_turn(); + return; + } + + if let Some(turn) = self + .turns + .iter_mut() + .find(|turn| turn.id == payload.turn_id) + { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return; + } + + // If the completion event cannot be matched, apply it to the active turn. + if let Some(current_turn) = self.current_turn.as_mut() { + mark_completed(current_turn); + self.finish_current_turn(); + } + } + + /// Marks the current turn as containing a persisted compaction marker. + /// + /// This keeps compaction-only legacy turns from being dropped by + /// `finish_current_turn` when they have no renderable items and were not + /// explicitly opened. + fn handle_compacted(&mut self, _payload: &CompactedItem) { + self.ensure_turn().saw_compaction = true; } fn handle_thread_rollback(&mut self, payload: &ThreadRolledBackEvent) { @@ -170,34 +985,44 @@ impl ThreadHistoryBuilder { self.turns.truncate(self.turns.len().saturating_sub(n)); } - // Re-number subsequent synthetic ids so the pruned history is consistent. - self.next_turn_index = - i64::try_from(self.turns.len().saturating_add(1)).unwrap_or(i64::MAX); let item_count: usize = self.turns.iter().map(|t| t.items.len()).sum(); self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX); } fn finish_current_turn(&mut self) { if let Some(turn) = self.current_turn.take() { - if turn.items.is_empty() { + if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { return; } - self.turns.push(turn.into()); + self.turns.push(Turn::from(turn)); } } - fn new_turn(&mut self) -> PendingTurn { + fn new_turn(&mut self, id: Option) -> PendingTurn { + let id = id.unwrap_or_else(|| { + if self.next_rollout_index == 0 { + Uuid::now_v7().to_string() + } else { + format!("rollout-{}", self.current_rollout_index) + } + }); PendingTurn { - id: self.next_turn_id(), + id, items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, + opened_explicitly: false, + saw_compaction: false, + rollout_start_index: self.current_rollout_index, } } fn ensure_turn(&mut self) -> &mut PendingTurn { if self.current_turn.is_none() { - let turn = self.new_turn(); + let turn = self.new_turn(/*id*/ None); return self.current_turn.insert(turn); } @@ -208,10 +1033,28 @@ impl ThreadHistoryBuilder { unreachable!("current turn must exist after initialization"); } - fn next_turn_id(&mut self) -> String { - let id = format!("turn-{}", self.next_turn_index); - self.next_turn_index += 1; - id + fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItem) { + if let Some(turn) = self.current_turn.as_mut() + && turn.id == turn_id + { + upsert_turn_item(&mut turn.items, item); + return; + } + + if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { + upsert_turn_item(&mut turn.items, item); + return; + } + + warn!( + item_id = item.id(), + "dropping turn-scoped item for unknown turn id `{turn_id}`" + ); + } + + fn upsert_item_in_current_turn(&mut self, item: ThreadItem) { + let turn = self.ensure_turn(); + upsert_turn_item(&mut turn.items, item); } fn next_item_id(&mut self) -> String { @@ -245,11 +1088,78 @@ impl ThreadHistoryBuilder { } } +const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; + +fn render_review_output_text(output: &ReviewOutputEvent) -> String { + let explanation = output.overall_explanation.trim(); + if explanation.is_empty() { + REVIEW_FALLBACK_MESSAGE.to_string() + } else { + explanation.to_string() + } +} + +fn convert_dynamic_tool_content_items( + items: &[codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem], +) -> Vec { + items + .iter() + .cloned() + .map(|item| match item { + codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputText { text } => { + DynamicToolCallOutputContentItem::InputText { text } + } + codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { + image_url, + } => DynamicToolCallOutputContentItem::InputImage { image_url }, + }) + .collect() +} + +fn upsert_turn_item(items: &mut Vec, item: ThreadItem) { + if let Some(existing_item) = items + .iter_mut() + .find(|existing_item| existing_item.id() == item.id()) + { + *existing_item = item; + return; + } + items.push(item); +} + struct PendingTurn { id: String, items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, + /// True when this turn originated from an explicit `turn_started`/`turn_complete` + /// boundary, so we preserve it even if it has no renderable items. + opened_explicitly: bool, + /// True when this turn includes a persisted `RolloutItem::Compacted`, which + /// should keep the turn from being dropped even without normal items. + saw_compaction: bool, + /// Index of the rollout item that opened this turn during replay. + rollout_start_index: usize, +} + +impl PendingTurn { + fn opened_explicitly(mut self) -> Self { + self.opened_explicitly = true; + self + } + + fn with_status(mut self, status: TurnStatus) -> Self { + self.status = status; + self + } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -257,8 +1167,27 @@ impl From for Turn { Self { id: value.id, items: value.items, + items_view: TurnItemsView::Full, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, + } + } +} + +impl From<&PendingTurn> for Turn { + fn from(value: &PendingTurn) -> Self { + Self { + id: value.id.clone(), + items: value.items.clone(), + items_view: TurnItemsView::Full, + error: value.error.clone(), + status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -266,14 +1195,43 @@ impl From for Turn { #[cfg(test)] mod tests { use super::*; - use code_protocol::protocol::AgentMessageEvent; - use code_protocol::protocol::AgentReasoningEvent; - use code_protocol::protocol::AgentReasoningRawContentEvent; - use code_protocol::protocol::ThreadRolledBackEvent; - use code_protocol::protocol::TurnAbortReason; - use code_protocol::protocol::TurnAbortedEvent; - use code_protocol::protocol::UserMessageEvent; + use crate::protocol::v2::CommandExecutionSource; + use codex_protocol::ThreadId; + use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; + use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment; + use codex_protocol::items::TurnItem as CoreTurnItem; + use codex_protocol::items::UserMessageItem as CoreUserMessageItem; + use codex_protocol::items::build_hook_prompt_message; + use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::MessagePhase as CoreMessagePhase; + use codex_protocol::models::WebSearchAction as CoreWebSearchAction; + use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::AgentReasoningEvent; + use codex_protocol::protocol::AgentReasoningRawContentEvent; + use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; + use codex_protocol::protocol::CodexErrorInfo; + use codex_protocol::protocol::CompactedItem; + use codex_protocol::protocol::DynamicToolCallResponseEvent; + use codex_protocol::protocol::ExecCommandEndEvent; + use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ItemStartedEvent; + use codex_protocol::protocol::McpInvocation; + use codex_protocol::protocol::McpToolCallEndEvent; + use codex_protocol::protocol::PatchApplyBeginEvent; + use codex_protocol::protocol::ThreadRolledBackEvent; + use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::TurnAbortedEvent; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; + use codex_protocol::protocol::UserMessageEvent; + use codex_protocol::protocol::WebSearchEndEvent; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; + use std::path::PathBuf; + use std::time::Duration; + use uuid::Uuid; #[test] fn builds_multiple_turns_with_reasoning_items() { @@ -286,6 +1244,8 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Hi there".into(), + phase: None, + memory_citation: None, }), EventMsg::AgentReasoning(AgentReasoningEvent { text: "thinking".into(), @@ -301,14 +1261,20 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Reply two".into(), + phase: None, + memory_citation: None, }), ]; - let turns = build_turns_from_event_msgs(&events); + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); assert_eq!(turns.len(), 2); let first = &turns[0]; - assert_eq!(first.id, "turn-1"); + assert!(Uuid::parse_str(&first.id).is_ok()); assert_eq!(first.status, TurnStatus::Completed); assert_eq!(first.items.len(), 3); assert_eq!( @@ -331,6 +1297,8 @@ mod tests { ThreadItem::AgentMessage { id: "item-2".into(), text: "Hi there".into(), + phase: None, + memory_citation: None, } ); assert_eq!( @@ -343,7 +1311,8 @@ mod tests { ); let second = &turns[1]; - assert_eq!(second.id, "turn-2"); + assert!(Uuid::parse_str(&second.id).is_ok()); + assert_ne!(first.id, second.id); assert_eq!(second.items.len(), 2); assert_eq!( second.items[0], @@ -360,36 +1329,186 @@ mod tests { ThreadItem::AgentMessage { id: "item-5".into(), text: "Reply two".into(), + phase: None, + memory_citation: None, } ); } #[test] - fn splits_reasoning_when_interleaved() { + fn ignores_non_plan_item_lifecycle_events() { + let turn_id = "turn-1"; + let thread_id = ThreadId::new(); let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), EventMsg::UserMessage(UserMessageEvent { - message: "Turn start".into(), + message: "hello".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), }), - EventMsg::AgentReasoning(AgentReasoningEvent { - text: "first summary".into(), - }), - EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { - text: "first content".into(), - }), - EventMsg::AgentMessage(AgentMessageEvent { - message: "interlude".into(), + EventMsg::ItemStarted(ItemStartedEvent { + thread_id, + turn_id: turn_id.to_string(), + item: CoreTurnItem::UserMessage(CoreUserMessageItem { + id: "user-item-id".to_string(), + content: Vec::new(), + }), + started_at_ms: 0, }), - EventMsg::AgentReasoning(AgentReasoningEvent { - text: "second summary".into(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, }), ]; - let turns = build_turns_from_event_msgs(&events); + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 1); - let turn = &turns[0]; + assert_eq!(turns[0].items.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + } + ); + } + + #[test] + fn preserves_agent_message_phase_in_history() { + let events = vec![EventMsg::AgentMessage(AgentMessageEvent { + message: "Final reply".into(), + phase: Some(CoreMessagePhase::FinalAnswer), + memory_citation: None, + })]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::AgentMessage { + id: "item-1".into(), + text: "Final reply".into(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + } + ); + } + + #[test] + fn replays_image_generation_end_events_into_turn_history() { + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-image".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "generate an image".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: "ig_123".into(), + status: "completed".into(), + revised_prompt: Some("final prompt".into()), + result: "Zm9v".into(), + saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), + })), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-image".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0], + Turn { + id: "turn-image".into(), + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + items_view: TurnItemsView::Full, + items: vec![ + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "generate an image".into(), + text_elements: Vec::new(), + }], + }, + ThreadItem::ImageGeneration { + id: "ig_123".into(), + status: "completed".into(), + revised_prompt: Some("final prompt".into()), + result: "Zm9v".into(), + saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), + }, + ], + } + ); + } + + #[test] + fn splits_reasoning_when_interleaved() { + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "Turn start".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::AgentReasoning(AgentReasoningEvent { + text: "first summary".into(), + }), + EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { + text: "first content".into(), + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "interlude".into(), + phase: None, + memory_citation: None, + }), + EventMsg::AgentReasoning(AgentReasoningEvent { + text: "second summary".into(), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + let turn = &turns[0]; assert_eq!(turn.items.len(), 4); assert_eq!( @@ -421,9 +1540,14 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Working...".into(), + phase: None, + memory_citation: None, }), EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -433,10 +1557,16 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Second attempt complete.".into(), + phase: None, + memory_citation: None, }), ]; - let turns = build_turns_from_event_msgs(&events); + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 2); let first_turn = &turns[0]; @@ -457,6 +1587,8 @@ mod tests { ThreadItem::AgentMessage { id: "item-2".into(), text: "Working...".into(), + phase: None, + memory_citation: None, } ); @@ -478,6 +1610,8 @@ mod tests { ThreadItem::AgentMessage { id: "item-4".into(), text: "Second attempt complete.".into(), + phase: None, + memory_citation: None, } ); } @@ -493,6 +1627,8 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), + phase: None, + memory_citation: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Second".into(), @@ -502,6 +1638,8 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), + phase: None, + memory_citation: None, }), EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), EventMsg::UserMessage(UserMessageEvent { @@ -512,49 +1650,58 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A3".into(), + phase: None, + memory_citation: None, }), ]; - let turns = build_turns_from_event_msgs(&events); - let expected = vec![ - Turn { - id: "turn-1".into(), - status: TurnStatus::Completed, - error: None, - items: vec![ - ThreadItem::UserMessage { - id: "item-1".into(), - content: vec![UserInput::Text { - text: "First".into(), - text_elements: Vec::new(), - }], - }, - ThreadItem::AgentMessage { - id: "item-2".into(), - text: "A1".into(), - }, - ], - }, - Turn { - id: "turn-2".into(), - status: TurnStatus::Completed, - error: None, - items: vec![ - ThreadItem::UserMessage { - id: "item-3".into(), - content: vec![UserInput::Text { - text: "Third".into(), - text_elements: Vec::new(), - }], - }, - ThreadItem::AgentMessage { - id: "item-4".into(), - text: "A3".into(), - }, - ], - }, - ]; - assert_eq!(turns, expected); + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].id, "rollout-0"); + assert_eq!(turns[1].id, "rollout-5"); + assert_ne!(turns[0].id, turns[1].id); + assert_eq!(turns[0].status, TurnStatus::Completed); + assert_eq!(turns[1].status, TurnStatus::Completed); + assert_eq!( + turns[0].items, + vec![ + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "First".into(), + text_elements: Vec::new(), + }], + }, + ThreadItem::AgentMessage { + id: "item-2".into(), + text: "A1".into(), + phase: None, + memory_citation: None, + }, + ] + ); + assert_eq!( + turns[1].items, + vec![ + ThreadItem::UserMessage { + id: "item-3".into(), + content: vec![UserInput::Text { + text: "Third".into(), + text_elements: Vec::new(), + }], + }, + ThreadItem::AgentMessage { + id: "item-4".into(), + text: "A3".into(), + phase: None, + memory_citation: None, + }, + ] + ); } #[test] @@ -568,6 +1715,8 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), + phase: None, + memory_citation: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Two".into(), @@ -577,12 +1726,1418 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), + phase: None, + memory_citation: None, }), EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 99 }), ]; - let turns = build_turns_from_event_msgs(&events); + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); assert_eq!(turns, Vec::::new()); } -} + #[test] + fn uses_explicit_turn_boundaries_for_mid_turn_steering() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "Start".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "Steer".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!( + turns[0].items, + vec![ + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "Start".into(), + text_elements: Vec::new(), + }], + }, + ThreadItem::UserMessage { + id: "item-2".into(), + content: vec![UserInput::Text { + text: "Steer".into(), + text_elements: Vec::new(), + }], + }, + ] + ); + } + + #[test] + fn reconstructs_tool_items_from_persisted_completion_events() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "run tools".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: "search-1".into(), + query: "codex".into(), + action: CoreWebSearchAction::Search { + query: Some("codex".into()), + queries: None, + }, + }), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "exec-1".into(), + process_id: Some("pid-1".into()), + turn_id: "turn-1".into(), + completed_at_ms: 0, + command: vec!["echo".into(), "hello world".into()], + cwd: test_path_buf("/tmp").abs(), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo hello world".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: "hello world\n".into(), + exit_code: 0, + duration: Duration::from_millis(12), + formatted_output: String::new(), + status: CoreExecCommandStatus::Completed, + }), + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: "mcp-1".into(), + invocation: McpInvocation { + server: "docs".into(), + tool: "lookup".into(), + arguments: Some(serde_json::json!({"id":"123"})), + }, + mcp_app_resource_uri: None, + duration: Duration::from_millis(8), + result: Err("boom".into()), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 4); + assert_eq!( + turns[0].items[1], + ThreadItem::WebSearch { + id: "search-1".into(), + query: "codex".into(), + action: Some(WebSearchAction::Search { + query: Some("codex".into()), + queries: None, + }), + } + ); + assert_eq!( + turns[0].items[2], + ThreadItem::CommandExecution { + id: "exec-1".into(), + command: "echo 'hello world'".into(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-1".into()), + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::Completed, + command_actions: vec![CommandAction::Unknown { + command: "echo hello world".into(), + }], + aggregated_output: Some("hello world\n".into()), + exit_code: Some(0), + duration_ms: Some(12), + } + ); + assert_eq!( + turns[0].items[3], + ThreadItem::McpToolCall { + id: "mcp-1".into(), + server: "docs".into(), + tool: "lookup".into(), + status: McpToolCallStatus::Failed, + arguments: serde_json::json!({"id":"123"}), + mcp_app_resource_uri: None, + result: None, + error: Some(McpToolCallError { + message: "boom".into(), + }), + duration_ms: Some(8), + } + ); + } + + #[test] + fn reconstructs_mcp_tool_result_meta_from_persisted_completion_events() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: "mcp-1".into(), + invocation: McpInvocation { + server: "docs".into(), + tool: "lookup".into(), + arguments: Some(serde_json::json!({"id":"123"})), + }, + mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), + duration: Duration::from_millis(8), + result: Ok(CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "result" + })], + structured_content: Some(serde_json::json!({"id":"123"})), + is_error: Some(false), + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/lookup.html" + })), + }), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::McpToolCall { + id: "mcp-1".into(), + server: "docs".into(), + tool: "lookup".into(), + status: McpToolCallStatus::Completed, + arguments: serde_json::json!({"id":"123"}), + mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), + result: Some(Box::new(McpToolCallResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "result" + })], + structured_content: Some(serde_json::json!({"id":"123"})), + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/lookup.html" + })), + })), + error: None, + duration_ms: Some(8), + } + ); + } + + #[test] + fn reconstructs_dynamic_tool_items_from_request_and_response_events() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "run dynamic tool".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::DynamicToolCallRequest( + codex_protocol::dynamic_tools::DynamicToolCallRequest { + call_id: "dyn-1".into(), + turn_id: "turn-1".into(), + started_at_ms: 0, + namespace: Some("codex_app".into()), + tool: "lookup_ticket".into(), + arguments: serde_json::json!({"id":"ABC-123"}), + }, + ), + EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { + call_id: "dyn-1".into(), + turn_id: "turn-1".into(), + completed_at_ms: 0, + namespace: Some("codex_app".into()), + tool: "lookup_ticket".into(), + arguments: serde_json::json!({"id":"ABC-123"}), + content_items: vec![CoreDynamicToolCallOutputContentItem::InputText { + text: "Ticket is open".into(), + }], + success: true, + error: None, + duration: Duration::from_millis(42), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::DynamicToolCall { + id: "dyn-1".into(), + namespace: Some("codex_app".into()), + tool: "lookup_ticket".into(), + arguments: serde_json::json!({"id":"ABC-123"}), + status: DynamicToolCallStatus::Completed, + content_items: Some(vec![DynamicToolCallOutputContentItem::InputText { + text: "Ticket is open".into(), + }]), + success: Some(true), + duration_ms: Some(42), + } + ); + } + + #[test] + fn reconstructs_declined_exec_and_patch_items() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "run tools".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "exec-declined".into(), + process_id: Some("pid-2".into()), + turn_id: "turn-1".into(), + completed_at_ms: 0, + command: vec!["ls".into()], + cwd: test_path_buf("/tmp").abs(), + parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: "exec command rejected by user".into(), + aggregated_output: "exec command rejected by user".into(), + exit_code: -1, + duration: Duration::ZERO, + formatted_output: String::new(), + status: CoreExecCommandStatus::Declined, + }), + EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: "patch-declined".into(), + turn_id: "turn-1".into(), + stdout: String::new(), + stderr: "patch rejected by user".into(), + success: false, + changes: [( + PathBuf::from("README.md"), + codex_protocol::protocol::FileChange::Add { + content: "hello\n".into(), + }, + )] + .into_iter() + .collect(), + status: CorePatchApplyStatus::Declined, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 3); + assert_eq!( + turns[0].items[1], + ThreadItem::CommandExecution { + id: "exec-declined".into(), + command: "ls".into(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-2".into()), + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::Declined, + command_actions: vec![CommandAction::Unknown { + command: "ls".into(), + }], + aggregated_output: Some("exec command rejected by user".into()), + exit_code: Some(-1), + duration_ms: Some(0), + } + ); + assert_eq!( + turns[0].items[2], + ThreadItem::FileChange { + id: "patch-declined".into(), + changes: vec![FileUpdateChange { + path: "README.md".into(), + kind: PatchChangeKind::Add, + diff: "hello\n".into(), + }], + status: PatchApplyStatus::Declined, + } + ); + } + + #[test] + fn reconstructs_declined_guardian_command_item() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "review this command".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "review-guardian-exec".into(), + target_item_id: Some("guardian-exec".into()), + turn_id: "turn-1".into(), + started_at_ms: 1_000, + completed_at_ms: None, + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: serde_json::from_value(serde_json::json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/guardian", + "cwd": test_path_buf("/tmp"), + })) + .expect("guardian action"), + }), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "review-guardian-exec".into(), + target_item_id: Some("guardian-exec".into()), + turn_id: "turn-1".into(), + started_at_ms: 1_000, + completed_at_ms: Some(1_042), + status: GuardianAssessmentStatus::Denied, + risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), + user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), + rationale: Some("Would delete user data.".into()), + decision_source: Some( + codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + ), + action: serde_json::from_value(serde_json::json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/guardian", + "cwd": test_path_buf("/tmp"), + })) + .expect("guardian action"), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::CommandExecution { + id: "guardian-exec".into(), + command: "rm -rf /tmp/guardian".into(), + cwd: test_path_buf("/tmp").abs(), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::Declined, + command_actions: vec![CommandAction::Unknown { + command: "rm -rf /tmp/guardian".into(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); + } + + #[test] + fn reconstructs_in_progress_guardian_execve_item() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "run a subcommand".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "review-guardian-execve".into(), + target_item_id: Some("guardian-execve".into()), + turn_id: "turn-1".into(), + started_at_ms: 2_000, + completed_at_ms: None, + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: serde_json::from_value(serde_json::json!({ + "type": "execve", + "source": "shell", + "program": "/bin/rm", + "argv": ["/usr/bin/rm", "-f", "/tmp/file.sqlite"], + "cwd": test_path_buf("/tmp"), + })) + .expect("guardian action"), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::CommandExecution { + id: "guardian-execve".into(), + command: "/bin/rm -f /tmp/file.sqlite".into(), + cwd: test_path_buf("/tmp").abs(), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: "/bin/rm -f /tmp/file.sqlite".into(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); + } + + #[test] + fn assigns_late_exec_completion_to_original_turn() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "first".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-b".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "second".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "exec-late".into(), + process_id: Some("pid-42".into()), + turn_id: "turn-a".into(), + completed_at_ms: 0, + command: vec!["echo".into(), "done".into()], + cwd: test_path_buf("/tmp").abs(), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo done".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done\n".into(), + stderr: String::new(), + aggregated_output: "done\n".into(), + exit_code: 0, + duration: Duration::from_millis(5), + formatted_output: "done\n".into(), + status: CoreExecCommandStatus::Completed, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-b".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!(turns[1].id, "turn-b"); + assert_eq!(turns[0].items.len(), 2); + assert_eq!(turns[1].items.len(), 1); + assert_eq!( + turns[0].items[1], + ThreadItem::CommandExecution { + id: "exec-late".into(), + command: "echo done".into(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-42".into()), + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::Completed, + command_actions: vec![CommandAction::Unknown { + command: "echo done".into(), + }], + aggregated_output: Some("done\n".into()), + exit_code: Some(0), + duration_ms: Some(5), + } + ); + } + + #[test] + fn drops_late_turn_scoped_item_for_unknown_turn_id() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "first".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-b".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "second".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "exec-unknown-turn".into(), + process_id: Some("pid-42".into()), + turn_id: "turn-missing".into(), + completed_at_ms: 0, + command: vec!["echo".into(), "done".into()], + cwd: test_path_buf("/tmp").abs(), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo done".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done\n".into(), + stderr: String::new(), + aggregated_output: "done\n".into(), + exit_code: 0, + duration: Duration::from_millis(5), + formatted_output: "done\n".into(), + status: CoreExecCommandStatus::Completed, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-b".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!(turns[1].id, "turn-b"); + assert_eq!(turns[0].items.len(), 1); + assert_eq!(turns[1].items.len(), 1); + assert_eq!( + turns[1].items[0], + ThreadItem::UserMessage { + id: "item-2".into(), + content: vec![UserInput::Text { + text: "second".into(), + text_elements: Vec::new(), + }], + } + ); + } + + #[test] + fn patch_apply_begin_updates_active_turn_snapshot_with_file_change() { + let turn_id = "turn-1"; + let mut builder = ThreadHistoryBuilder::new(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "apply patch".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "patch-call".into(), + turn_id: turn_id.to_string(), + auto_approved: false, + changes: [( + PathBuf::from("README.md"), + codex_protocol::protocol::FileChange::Add { + content: "hello\n".into(), + }, + )] + .into_iter() + .collect(), + }), + ]; + + for event in &events { + builder.handle_event(event); + } + + let snapshot = builder + .active_turn_snapshot() + .expect("active turn snapshot"); + assert_eq!(snapshot.id, turn_id); + assert_eq!(snapshot.status, TurnStatus::InProgress); + assert_eq!( + snapshot.items, + vec![ + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "apply patch".into(), + text_elements: Vec::new(), + }], + }, + ThreadItem::FileChange { + id: "patch-call".into(), + changes: vec![FileUpdateChange { + path: "README.md".into(), + kind: PatchChangeKind::Add, + diff: "hello\n".into(), + }], + status: PatchApplyStatus::InProgress, + }, + ] + ); + } + + #[test] + fn apply_patch_approval_request_updates_active_turn_snapshot_with_file_change() { + let turn_id = "turn-1"; + let mut builder = ThreadHistoryBuilder::new(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "apply patch".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "patch-call".into(), + turn_id: turn_id.to_string(), + started_at_ms: 0, + changes: [( + PathBuf::from("README.md"), + codex_protocol::protocol::FileChange::Add { + content: "hello\n".into(), + }, + )] + .into_iter() + .collect(), + reason: None, + grant_root: None, + }), + ]; + + for event in &events { + builder.handle_event(event); + } + + let snapshot = builder + .active_turn_snapshot() + .expect("active turn snapshot"); + assert_eq!(snapshot.id, turn_id); + assert_eq!(snapshot.status, TurnStatus::InProgress); + assert_eq!( + snapshot.items, + vec![ + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "apply patch".into(), + text_elements: Vec::new(), + }], + }, + ThreadItem::FileChange { + id: "patch-call".into(), + changes: vec![FileUpdateChange { + path: "README.md".into(), + kind: PatchChangeKind::Add, + diff: "hello\n".into(), + }], + status: PatchApplyStatus::InProgress, + }, + ] + ); + } + + #[test] + fn late_turn_complete_does_not_close_active_turn() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "first".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-b".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "second".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "still in b".into(), + phase: None, + memory_citation: None, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-b".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!(turns[1].id, "turn-b"); + assert_eq!(turns[1].items.len(), 2); + } + + #[test] + fn late_turn_aborted_does_not_interrupt_active_turn() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "first".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-b".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "second".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-a".into()), + reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "still in b".into(), + phase: None, + memory_citation: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!(turns[1].id, "turn-b"); + assert_eq!(turns[1].status, TurnStatus::InProgress); + assert_eq!(turns[1].items.len(), 2); + } + + #[test] + fn preserves_compaction_only_turn() { + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-compact".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: None, + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-compact".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + assert_eq!( + turns, + vec![Turn { + id: "turn-compact".into(), + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + items_view: TurnItemsView::Full, + items: Vec::new(), + }] + ); + } + + #[test] + fn reconstructs_collab_resume_end_item() { + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "resume agent".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent { + call_id: "resume-1".into(), + completed_at_ms: 0, + sender_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"), + receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid receiver thread id"), + receiver_agent_nickname: None, + receiver_agent_role: None, + status: AgentStatus::Completed(None), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::CollabAgentToolCall { + id: "resume-1".into(), + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: "00000000-0000-0000-0000-000000000001".into(), + receiver_thread_ids: vec!["00000000-0000-0000-0000-000000000002".into()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [( + "00000000-0000-0000-0000-000000000002".into(), + CollabAgentState { + status: crate::protocol::v2::CollabAgentStatus::Completed, + message: None, + }, + )] + .into_iter() + .collect(), + } + ); + } + + #[test] + fn reconstructs_collab_spawn_end_item_with_model_metadata() { + let sender_thread_id = ThreadId::try_from("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let spawned_thread_id = ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid receiver thread id"); + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "spawn agent".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent { + call_id: "spawn-1".into(), + completed_at_ms: 0, + sender_thread_id, + new_thread_id: Some(spawned_thread_id), + new_agent_nickname: Some("Scout".into()), + new_agent_role: Some("explorer".into()), + prompt: "inspect the repo".into(), + model: "gpt-5.4-mini".into(), + reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium, + status: AgentStatus::Running, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::CollabAgentToolCall { + id: "spawn-1".into(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: "00000000-0000-0000-0000-000000000001".into(), + receiver_thread_ids: vec!["00000000-0000-0000-0000-000000000002".into()], + prompt: Some("inspect the repo".into()), + model: Some("gpt-5.4-mini".into()), + reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), + agents_states: [( + "00000000-0000-0000-0000-000000000002".into(), + CollabAgentState { + status: crate::protocol::v2::CollabAgentStatus::Running, + message: None, + }, + )] + .into_iter() + .collect(), + } + ); + } + + #[test] + fn reconstructs_interrupted_send_input_as_completed_collab_call() { + // `send_input(interrupt=true)` first stops the child's active turn, then redirects it with + // new input. The transient interrupted status should remain visible in agent state, but the + // collab tool call itself is still a successful redirect rather than a failed operation. + let sender = ThreadId::try_from("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let receiver = ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid receiver thread id"); + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "redirect".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::CollabAgentInteractionBegin( + codex_protocol::protocol::CollabAgentInteractionBeginEvent { + call_id: "send-1".into(), + started_at_ms: 0, + sender_thread_id: sender, + receiver_thread_id: receiver, + prompt: "new task".into(), + }, + ), + EventMsg::CollabAgentInteractionEnd( + codex_protocol::protocol::CollabAgentInteractionEndEvent { + call_id: "send-1".into(), + completed_at_ms: 0, + sender_thread_id: sender, + receiver_thread_id: receiver, + receiver_agent_nickname: None, + receiver_agent_role: None, + prompt: "new task".into(), + status: AgentStatus::Interrupted, + }, + ), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::CollabAgentToolCall { + id: "send-1".into(), + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender.to_string(), + receiver_thread_ids: vec![receiver.to_string()], + prompt: Some("new task".into()), + model: None, + reasoning_effort: None, + agents_states: [( + receiver.to_string(), + CollabAgentState { + status: crate::protocol::v2::CollabAgentStatus::Interrupted, + message: None, + }, + )] + .into_iter() + .collect(), + } + ); + } + + #[test] + fn rollback_failed_error_does_not_mark_turn_failed() { + let events = vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "done".into(), + phase: None, + memory_citation: None, + }), + EventMsg::Error(ErrorEvent { + message: "rollback failed".into(), + codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].status, TurnStatus::Completed); + assert_eq!(turns[0].error, None); + } + + #[test] + fn out_of_turn_error_does_not_create_or_fail_a_turn() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + EventMsg::Error(ErrorEvent { + message: "request-level failure".into(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0], + Turn { + id: "turn-a".into(), + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + items_view: TurnItemsView::Full, + items: vec![ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + }], + } + ); + } + + #[test] + fn error_then_turn_complete_preserves_failed_status() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::Error(ErrorEvent { + message: "stream failure".into(), + codex_error_info: Some(CodexErrorInfo::ResponseStreamDisconnected { + http_status_code: Some(502), + }), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!(turns[0].status, TurnStatus::Failed); + assert_eq!( + turns[0].error, + Some(TurnError { + message: "stream failure".into(), + codex_error_info: Some( + crate::protocol::v2::CodexErrorInfo::ResponseStreamDisconnected { + http_status_code: Some(502), + } + ), + additional_details: None, + }) + ); + } + + #[test] + fn rebuilds_hook_prompt_items_from_rollout_response_items() { + let hook_prompt = build_hook_prompt_message(&[ + CoreHookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"), + CoreHookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]) + .expect("hook prompt message"); + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::ResponseItem(hook_prompt), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::HookPrompt { + id: turns[0].items[1].id().to_string(), + fragments: vec![ + crate::protocol::v2::HookPromptFragment { + text: "Retry with tests.".into(), + hook_run_id: "hook-run-1".into(), + }, + crate::protocol::v2::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_id: "hook-run-2".into(), + }, + ], + } + ); + } + + #[test] + fn ignores_plain_user_response_items_in_rollout_replay() { + let items = vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::Message { + id: Some("msg-1".into()), + role: "user".into(), + content: vec![codex_protocol::models::ContentItem::InputText { + text: "plain text".into(), + }], + phase: None, + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]; + + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert!(turns[0].items.is_empty()); + } +} diff --git a/code-rs/app-server-protocol/src/protocol/v1.rs b/code-rs/app-server-protocol/src/protocol/v1.rs index a498c3ebc7e..d642e7fab95 100644 --- a/code-rs/app-server-protocol/src/protocol/v1.rs +++ b/code-rs/app-server-protocol/src/protocol/v1.rs @@ -1,33 +1,27 @@ use std::collections::HashMap; use std::path::PathBuf; -use code_protocol::ThreadId; -use code_protocol::config_types::ForcedLoginMethod; -use code_protocol::config_types::ReasoningSummary; -use code_protocol::config_types::SandboxMode; -use code_protocol::config_types::Verbosity; -use code_protocol::models::ResponseItem; -use code_protocol::openai_models::ReasoningEffort; -use code_protocol::parse_command::ParsedCommand; -use code_protocol::protocol::AskForApproval; -use code_protocol::protocol::EventMsg; -use code_protocol::protocol::FileChange; -use code_protocol::protocol::ReviewDecision; -use code_protocol::protocol::SandboxPolicy; -use code_protocol::protocol::SessionSource; -use code_protocol::protocol::TurnAbortReason; -use code_protocol::user_input::ByteRange as CoreByteRange; -use code_protocol::user_input::TextElement as CoreTextElement; -use code_utils_absolute_path::AbsolutePathBuf; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::FileChange; +pub use codex_protocol::protocol::GitSha; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnAbortReason; +use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -use uuid::Uuid; -// Reuse shared types defined in `common.rs`. use crate::protocol::common::AuthMode; -use crate::protocol::common::GitSha; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -46,63 +40,30 @@ pub struct ClientInfo { } /// Client-declared capabilities negotiated during initialize. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeCapabilities { /// Opt into receiving experimental API methods and fields. #[serde(default)] pub experimental_api: bool, + /// Exact notification method names that should be suppressed for this + /// connection (for example `thread/started`). + #[ts(optional = nullable)] + pub opt_out_notification_methods: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeResponse { pub user_agent: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct NewConversationParams { - pub model: Option, - pub model_provider: Option, - pub profile: Option, - pub cwd: Option, - pub approval_policy: Option, - pub sandbox: Option, - pub config: Option>, - pub base_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub developer_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub compact_prompt: Option, - pub include_apply_patch_tool: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct NewConversationResponse { - pub conversation_id: ThreadId, - pub model: String, - pub reasoning_effort: Option, - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ResumeConversationResponse { - pub conversation_id: ThreadId, - pub model: String, - pub initial_messages: Option>, - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ForkConversationResponse { - pub conversation_id: ThreadId, - pub model: String, - pub initial_messages: Option>, - pub rollout_path: PathBuf, + /// Absolute path to the server's $CODEX_HOME directory. + pub codex_home: AbsolutePathBuf, + /// Platform family for the running app-server target, for example + /// `"unix"` or `"windows"`. + pub platform_family: String, + /// Operating system for the running app-server target, for example + /// `"macos"`, `"linux"`, or `"windows"`. + pub platform_os: String, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -124,14 +85,6 @@ pub struct GetConversationSummaryResponse { pub summary: ConversationSummary, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ListConversationsParams { - pub page_size: Option, - pub cursor: Option, - pub model_providers: Option>, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ConversationSummary { @@ -155,70 +108,12 @@ pub struct ConversationGitInfo { pub origin_url: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ListConversationsResponse { - pub items: Vec, - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ResumeConversationParams { - pub path: Option, - pub conversation_id: Option, - pub history: Option>, - pub overrides: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ForkConversationParams { - pub path: Option, - pub conversation_id: Option, - pub overrides: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct AddConversationSubscriptionResponse { - #[schemars(with = "String")] - pub subscription_id: Uuid, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ArchiveConversationParams { - pub conversation_id: ThreadId, - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ArchiveConversationResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct RemoveConversationSubscriptionResponse {} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginApiKeyParams { pub api_key: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginApiKeyResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginChatGptResponse { - #[schemars(with = "String")] - pub login_id: Uuid, - pub auth_url: String, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GitDiffToRemoteResponse { @@ -230,8 +125,8 @@ pub struct GitDiffToRemoteResponse { #[serde(rename_all = "camelCase")] pub struct ApplyPatchApprovalParams { pub conversation_id: ThreadId, - /// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] - /// and [codex_core::protocol::PatchApplyEndEvent]. + /// Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] + /// and [codex_protocol::protocol::PatchApplyEndEvent]. pub call_id: String, pub file_changes: HashMap, /// Optional explanatory reason (e.g. request for extra write access). @@ -251,12 +146,10 @@ pub struct ApplyPatchApprovalResponse { #[serde(rename_all = "camelCase")] pub struct ExecCommandApprovalParams { pub conversation_id: ThreadId, - /// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] - /// and [codex_core::protocol::ExecCommandEndEvent]. + /// Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] + /// and [codex_protocol::protocol::ExecCommandEndEvent]. pub call_id: String, /// Identifier for this specific approval callback. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] pub approval_id: Option, pub command: Vec, pub cwd: PathBuf, @@ -269,31 +162,12 @@ pub struct ExecCommandApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct CancelLoginChatGptParams { - #[schemars(with = "String")] - pub login_id: Uuid, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GitDiffToRemoteParams { pub cwd: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct CancelLoginChatGptResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct LogoutChatGptParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct LogoutChatGptResponse {} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusParams { @@ -310,14 +184,6 @@ pub struct ExecOneOffCommandParams { pub sandbox_policy: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct ExecOneOffCommandResponse { - pub exit_code: i32, - pub stdout: String, - pub stderr: String, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusResponse { @@ -326,35 +192,6 @@ pub struct GetAuthStatusResponse { pub requires_openai_auth: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct GetUserAgentResponse { - pub user_agent: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct UserInfoResponse { - pub alleged_user_email: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct GetUserSavedConfigResponse { - pub config: UserSavedConfig, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SetDefaultModelParams { - pub model: Option, - pub reasoning_effort: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SetDefaultModelResponse {} - #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UserSavedConfig { @@ -401,160 +238,8 @@ pub struct SandboxSettings { pub exclude_slash_tmp: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserMessageParams { - pub conversation_id: ThreadId, - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserTurnParams { - pub conversation_id: ThreadId, - pub items: Vec, - pub cwd: PathBuf, - pub approval_policy: AskForApproval, - pub sandbox_policy: SandboxPolicy, - pub model: String, - pub effort: Option, - pub summary: ReasoningSummary, - /// Optional JSON Schema used to constrain the final assistant message for this turn. - pub output_schema: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserTurnResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct InterruptConversationParams { - pub conversation_id: ThreadId, -} - #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationResponse { pub abort_reason: TurnAbortReason, } - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserMessageResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct AddConversationListenerParams { - pub conversation_id: ThreadId, - #[serde(default)] - pub experimental_raw_events: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct RemoveConversationListenerParams { - #[schemars(with = "String")] - pub subscription_id: Uuid, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[serde(tag = "type", content = "data")] -pub enum InputItem { - Text { - text: String, - /// UI-defined spans within `text` used to render or persist special elements. - #[serde(default)] - text_elements: Vec, - }, - Image { - image_url: String, - }, - LocalImage { - path: PathBuf, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ByteRange")] -pub struct V1ByteRange { - /// Start byte offset (inclusive) within the UTF-8 text buffer. - pub start: usize, - /// End byte offset (exclusive) within the UTF-8 text buffer. - pub end: usize, -} - -impl From for V1ByteRange { - fn from(value: CoreByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -impl From for CoreByteRange { - fn from(value: V1ByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TextElement")] -pub struct V1TextElement { - /// Byte range in the parent `text` buffer that this element occupies. - pub byte_range: V1ByteRange, - /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, -} - -impl From for V1TextElement { - fn from(value: CoreTextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value._placeholder_for_conversion_only().map(str::to_string), - } - } -} - -impl From for CoreTextElement { - fn from(value: V1TextElement) -> Self { - Self::new(value.byte_range.into(), value.placeholder) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -/// Deprecated in favor of AccountLoginCompletedNotification. -pub struct LoginChatGptCompleteNotification { - #[schemars(with = "String")] - pub login_id: Uuid, - pub success: bool, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SessionConfiguredNotification { - pub session_id: ThreadId, - pub model: String, - pub reasoning_effort: Option, - pub history_log_id: u64, - #[ts(type = "number")] - pub history_entry_count: usize, - pub initial_messages: Option>, - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -/// Deprecated notification. Use AccountUpdatedNotification instead. -pub struct AuthStatusChangeNotification { - pub auth_method: Option, -} diff --git a/code-rs/app-server-protocol/src/protocol/v2.rs b/code-rs/app-server-protocol/src/protocol/v2.rs deleted file mode 100644 index 32d2c5b073d..00000000000 --- a/code-rs/app-server-protocol/src/protocol/v2.rs +++ /dev/null @@ -1,3849 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use crate::protocol::common::AuthMode; -use code_experimental_api_macros::ExperimentalApi; -use code_protocol::account::PlanType; -use code_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; -use code_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; -use code_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; -use code_protocol::config_types::CollaborationMode; -use code_protocol::config_types::CollaborationModeMask; -use code_protocol::config_types::ForcedLoginMethod; -use code_protocol::config_types::Personality; -use code_protocol::config_types::ReasoningSummary; -use code_protocol::config_types::SandboxMode as CoreSandboxMode; -use code_protocol::config_types::Verbosity; -use code_protocol::config_types::WebSearchMode; -use code_protocol::config_types::WebSearchToolConfig; -use code_protocol::items::AgentMessageContent as CoreAgentMessageContent; -use code_protocol::items::TurnItem as CoreTurnItem; -use code_protocol::mcp::Resource as McpResource; -use code_protocol::mcp::ResourceTemplate as McpResourceTemplate; -use code_protocol::mcp::Tool as McpTool; -use code_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use code_protocol::models::MacOsAutomationValue as CoreMacOsAutomationValue; -use code_protocol::models::MacOsPermissions as CoreMacOsPermissions; -use code_protocol::models::MacOsPreferencesValue as CoreMacOsPreferencesValue; -use code_protocol::models::PermissionProfile as CorePermissionProfile; -use code_protocol::models::ResponseItem; -use code_protocol::openai_models::InputModality; -use code_protocol::openai_models::ReasoningEffort; -use code_protocol::openai_models::default_input_modalities; -use code_protocol::parse_command::ParsedCommand as CoreParsedCommand; -use code_protocol::plan_tool::PlanItemArg as CorePlanItemArg; -use code_protocol::plan_tool::StepStatus as CorePlanStepStatus; -use code_protocol::protocol::AgentStatus as CoreAgentStatus; -use code_protocol::protocol::AskForApproval as CoreAskForApproval; -use code_protocol::protocol::RejectConfig as CoreRejectConfig; -use code_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; -use code_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; -use code_protocol::protocol::NetworkAccess as CoreNetworkAccess; -use code_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; -use code_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; -use code_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; -use code_protocol::protocol::SessionSource as CoreSessionSource; -use code_protocol::protocol::SkillDependencies as CoreSkillDependencies; -use code_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo; -use code_protocol::protocol::SkillInterface as CoreSkillInterface; -use code_protocol::protocol::SkillMetadata as CoreSkillMetadata; -use code_protocol::protocol::SkillScope as CoreSkillScope; -use code_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; -use code_protocol::protocol::SubAgentSource as CoreSubAgentSource; -use code_protocol::protocol::TokenUsage as CoreTokenUsage; -use code_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; -use code_protocol::user_input::ByteRange as CoreByteRange; -use code_protocol::user_input::TextElement as CoreTextElement; -use code_protocol::user_input::UserInput as CoreUserInput; -use code_utils_absolute_path::AbsolutePathBuf; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; -use thiserror::Error; -use ts_rs::TS; - -// Macro to declare a camelCased API v2 enum mirroring a core enum which -// tends to use either snake_case or kebab-case. -macro_rules! v2_enum_from_core { - ( - pub enum $Name:ident from $Src:path { $( $Variant:ident ),+ $(,)? } - ) => { - #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] - #[serde(rename_all = "camelCase")] - #[ts(export_to = "v2/")] - pub enum $Name { $( $Variant ),+ } - - impl $Name { - pub fn to_core(self) -> $Src { - match self { $( $Name::$Variant => <$Src>::$Variant ),+ } - } - } - - impl From<$Src> for $Name { - fn from(value: $Src) -> Self { - match value { $( <$Src>::$Variant => $Name::$Variant ),+ } - } - } - }; -} - -/// This translation layer make sure that we expose codex error code in camel case. -/// -/// When an upstream HTTP status is available (for example, from the Responses API or a provider), -/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CodexErrorInfo { - ContextWindowExceeded, - UsageLimitExceeded, - CyberPolicy, - ModelCap { - model: String, - reset_after_seconds: Option, - }, - HttpConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Failed to connect to the response SSE stream. - ResponseStreamConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - InternalServerError, - Unauthorized, - BadRequest, - ThreadRollbackFailed, - SandboxError, - /// The response SSE stream disconnected in the middle of a turn before completion. - ResponseStreamDisconnected { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Reached the retry limit for responses. - ResponseTooManyFailedAttempts { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - Other, -} - -impl From for CodexErrorInfo { - fn from(value: CoreCodexErrorInfo) -> Self { - match value { - CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, - CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, - CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, - CoreCodexErrorInfo::ModelCap { - model, - reset_after_seconds, - } => CodexErrorInfo::ModelCap { - model, - reset_after_seconds, - }, - CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { - CodexErrorInfo::HttpConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { - CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, - CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, - CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, - CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, - CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, - CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { - CodexErrorInfo::ResponseStreamDisconnected { http_status_code } - } - CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { - CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } - } - CoreCodexErrorInfo::Other => CodexErrorInfo::Other, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum AskForApproval { - #[serde(rename = "untrusted")] - #[ts(rename = "untrusted")] - UnlessTrusted, - OnFailure, - OnRequest, - Reject { - sandbox_approval: bool, - rules: bool, - #[serde(default)] - skill_approval: bool, - #[serde(default)] - request_permissions: bool, - mcp_elicitations: bool, - }, - Never, -} - -impl AskForApproval { - pub fn to_core(self) -> CoreAskForApproval { - match self { - AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, - AskForApproval::OnFailure => CoreAskForApproval::OnFailure, - AskForApproval::OnRequest => CoreAskForApproval::OnRequest, - AskForApproval::Reject { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - } => CoreAskForApproval::Reject(CoreRejectConfig { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - }), - AskForApproval::Never => CoreAskForApproval::Never, - } - } -} - -impl From for AskForApproval { - fn from(value: CoreAskForApproval) -> Self { - match value { - CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, - CoreAskForApproval::OnFailure => AskForApproval::OnFailure, - CoreAskForApproval::OnRequest => AskForApproval::OnRequest, - CoreAskForApproval::Reject(reject_config) => AskForApproval::Reject { - sandbox_approval: reject_config.sandbox_approval, - rules: reject_config.rules, - skill_approval: reject_config.skill_approval, - request_permissions: reject_config.request_permissions, - mcp_elicitations: reject_config.mcp_elicitations, - }, - CoreAskForApproval::Never => AskForApproval::Never, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum SandboxMode { - ReadOnly, - WorkspaceWrite, - DangerFullAccess, -} - -impl SandboxMode { - pub fn to_core(self) -> CoreSandboxMode { - match self { - SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, - SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, - SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, - } - } -} - -impl From for SandboxMode { - fn from(value: CoreSandboxMode) -> Self { - match value { - CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, - CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, - CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, - } - } -} - -v2_enum_from_core!( - pub enum ReviewDelivery from code_protocol::protocol::ReviewDelivery { - Inline, Detached - } -); - -v2_enum_from_core!( - pub enum McpAuthStatus from code_protocol::protocol::McpAuthStatus { - Unsupported, - NotLoggedIn, - BearerToken, - OAuth - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ConfigLayerSource { - /// Managed preferences layer delivered by MDM (macOS only). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Mdm { - domain: String, - key: String, - }, - - /// Managed config layer from a file (usually `managed_config.toml`). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - System { - /// This is the path to the system config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// User config layer from $CODEX_HOME/config.toml. This layer is special - /// in that it is expected to be: - /// - writable by the user - /// - generally outside the workspace directory - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - User { - /// This is the path to the user's config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// Path to a .codex/ folder within a project. There could be multiple of - /// these between `cwd` and the project/repo root. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Project { - dot_codex_folder: AbsolutePathBuf, - }, - - /// Session-layer overrides supplied via `-c`/`--config`. - SessionFlags, - - /// `managed_config.toml` was designed to be a config that was loaded - /// as the last layer on top of everything else. This scheme did not quite - /// work out as intended, but we keep this variant as a "best effort" while - /// we phase out `managed_config.toml` in favor of `requirements.toml`. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - LegacyManagedConfigTomlFromFile { - file: AbsolutePathBuf, - }, - - LegacyManagedConfigTomlFromMdm, -} - -impl ConfigLayerSource { - /// A settings from a layer with a higher precedence will override a setting - /// from a layer with a lower precedence. - pub fn precedence(&self) -> i16 { - match self { - ConfigLayerSource::Mdm { .. } => 0, - ConfigLayerSource::System { .. } => 10, - ConfigLayerSource::User { .. } => 20, - ConfigLayerSource::Project { .. } => 25, - ConfigLayerSource::SessionFlags => 30, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, - ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, - } - } -} - -/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from -/// layer `A` will be overridden by settings from layer `B`. -impl PartialOrd for ConfigLayerSource { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.precedence().cmp(&other.precedence())) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct SandboxWorkspaceWrite { - #[serde(default)] - pub writable_roots: Vec, - #[serde(default)] - pub network_access: bool, - #[serde(default)] - pub exclude_tmpdir_env_var: bool, - #[serde(default)] - pub exclude_slash_tmp: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ToolsV2 { - pub web_search: Option, - pub view_image: Option, -} - -#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolSpec { - #[ts(optional)] - pub namespace: Option, - pub name: String, - pub description: String, - pub input_schema: JsonValue, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub defer_loading: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DynamicToolSpecDe { - namespace: Option, - name: String, - description: String, - input_schema: JsonValue, - defer_loading: Option, - expose_to_context: Option, -} - -impl<'de> Deserialize<'de> for DynamicToolSpec { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let DynamicToolSpecDe { - namespace, - name, - description, - input_schema, - defer_loading, - expose_to_context, - } = DynamicToolSpecDe::deserialize(deserializer)?; - - Ok(Self { - namespace, - name, - description, - input_schema, - defer_loading: defer_loading - .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), - }) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ProfileV2 { - pub model: Option, - pub model_provider: Option, - pub approval_policy: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub web_search: Option, - pub chatgpt_base_url: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AnalyticsConfig { - pub enabled: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum AppDisabledReason { - Unknown, - User, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, - pub disabled_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppsConfig { - #[serde(default, flatten)] - #[schemars(with = "HashMap")] - pub apps: HashMap, -} - -const fn default_enabled() -> bool { - true -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct Config { - pub model: Option, - pub review_model: Option, - pub model_context_window: Option, - pub model_auto_compact_token_limit: Option, - pub model_provider: Option, - pub approval_policy: Option, - pub sandbox_mode: Option, - pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, - pub forced_login_method: Option, - pub web_search: Option, - pub tools: Option, - pub profile: Option, - #[serde(default)] - pub profiles: HashMap, - pub instructions: Option, - pub developer_instructions: Option, - pub compact_prompt: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub analytics: Option, - #[experimental("config/read.apps")] - #[serde(default)] - pub apps: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayerMetadata { - pub name: ConfigLayerSource, - pub version: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayer { - pub name: ConfigLayerSource, - pub version: String, - pub config: JsonValue, - #[serde(skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum MergeStrategy { - Replace, - Upsert, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WriteStatus { - Ok, - OkOverridden, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct OverriddenMetadata { - pub message: String, - pub overriding_layer: ConfigLayerMetadata, - pub effective_value: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWriteResponse { - pub status: WriteStatus, - pub version: String, - /// Canonical path to the config file that was written. - pub file_path: AbsolutePathBuf, - pub overridden_metadata: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ConfigWriteErrorCode { - ConfigLayerReadonly, - ConfigVersionConflict, - ConfigValidationError, - ConfigPathNotFound, - ConfigSchemaUnknownKey, - UserLayerNotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadParams { - #[serde(default)] - pub include_layers: bool, - /// Optional working directory to resolve project config layers. If specified, - /// return the effective config as seen from that directory (i.e., including any - /// project layers between `cwd` and the project/repo root). - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadResponse { - pub config: Config, - pub origins: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub layers: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirements { - pub allowed_approval_policies: Option>, - pub allowed_sandbox_modes: Option>, - pub allowed_web_search_modes: Option>, - pub enforce_residency: Option, - #[experimental("configRequirements/read.network")] - pub network: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkRequirements { - pub enabled: Option, - pub http_port: Option, - pub socks_port: Option, - pub allow_upstream_proxy: Option, - pub dangerously_allow_non_loopback_proxy: Option, - pub dangerously_allow_non_loopback_admin: Option, - pub allowed_domains: Option>, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, - pub allow_local_binding: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum ResidencyRequirement { - Us, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirementsReadResponse { - /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). - pub requirements: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum ExternalAgentConfigMigrationItemType { - #[serde(rename = "AGENTS_MD")] - #[ts(rename = "AGENTS_MD")] - AgentsMd, - #[serde(rename = "CONFIG")] - #[ts(rename = "CONFIG")] - Config, - #[serde(rename = "SKILLS")] - #[ts(rename = "SKILLS")] - Skills, - #[serde(rename = "MCP_SERVER_CONFIG")] - #[ts(rename = "MCP_SERVER_CONFIG")] - McpServerConfig, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigMigrationItem { - pub item_type: ExternalAgentConfigMigrationItemType, - pub description: String, - /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectResponse { - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub include_home: bool, - /// Zero or more working directories to include for repo-scoped detection. - #[ts(optional = nullable)] - pub cwds: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportParams { - pub migration_items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigValueWriteParams { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigBatchWriteParams { - pub edits: Vec, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigEdit { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionApprovalDecision { - /// User approved the command. - Accept, - /// User approved the command and future identical commands should run without prompting. - AcceptForSession, - /// User approved the command, and wants to apply the proposed execpolicy amendment so future - /// matching commands can run without prompting. - AcceptWithExecpolicyAmendment { - execpolicy_amendment: ExecPolicyAmendment, - }, - /// User denied the command. The agent will continue the turn. - Decline, - /// User denied the command. The turn will also be immediately interrupted. - Cancel, -} - -v2_enum_from_core! { - pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { - Http, - Https, - Socks5Tcp, - Socks5Udp, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkApprovalContext { - pub host: String, - pub protocol: NetworkApprovalProtocol, -} - -impl From for NetworkApprovalContext { - fn from(value: CoreNetworkApprovalContext) -> Self { - Self { - host: value.host, - protocol: value.protocol.into(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalFileSystemPermissions { - pub read: Option>, - pub write: Option>, -} - -impl From for AdditionalFileSystemPermissions { - fn from(value: CoreFileSystemPermissions) -> Self { - Self { - read: value.read, - write: value.write, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalMacOsPermissions { - pub preferences: Option, - pub automations: Option, - pub accessibility: Option, - pub calendar: Option, -} - -impl From for AdditionalMacOsPermissions { - fn from(value: CoreMacOsPermissions) -> Self { - Self { - preferences: value.preferences, - automations: value.automations, - accessibility: value.accessibility, - calendar: value.calendar, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalPermissionProfile { - pub network: Option, - pub file_system: Option, - pub macos: Option, -} - -impl From for AdditionalPermissionProfile { - fn from(value: CorePermissionProfile) -> Self { - Self { - network: value.network, - file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - macos: value.macos.map(AdditionalMacOsPermissions::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum FileChangeApprovalDecision { - /// User approved the file changes. - Accept, - /// User approved the file changes and future changes to the same files should run without prompting. - AcceptForSession, - /// User denied the file changes. The agent will continue the turn. - Decline, - /// User denied the file changes. The turn will also be immediately interrupted. - Cancel, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum NetworkAccess { - #[default] - Restricted, - Enabled, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum SandboxPolicy { - DangerFullAccess, - ReadOnly, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, - #[serde(default)] - network_access: bool, - #[serde(default)] - exclude_tmpdir_env_var: bool, - #[serde(default)] - exclude_slash_tmp: bool, - }, -} - -impl SandboxPolicy { - pub fn to_core(&self) -> code_protocol::protocol::SandboxPolicy { - match self { - SandboxPolicy::DangerFullAccess => { - code_protocol::protocol::SandboxPolicy::DangerFullAccess - } - SandboxPolicy::ReadOnly => code_protocol::protocol::SandboxPolicy::ReadOnly, - SandboxPolicy::ExternalSandbox { network_access } => { - code_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: match network_access { - NetworkAccess::Restricted => CoreNetworkAccess::Restricted, - NetworkAccess::Enabled => CoreNetworkAccess::Enabled, - }, - } - } - SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => code_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - allow_git_writes: false, - }, - } - } -} - -impl From for SandboxPolicy { - fn from(value: code_protocol::protocol::SandboxPolicy) -> Self { - match value { - code_protocol::protocol::SandboxPolicy::DangerFullAccess => { - SandboxPolicy::DangerFullAccess - } - code_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, - code_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { - SandboxPolicy::ExternalSandbox { - network_access: match network_access { - CoreNetworkAccess::Restricted => NetworkAccess::Restricted, - CoreNetworkAccess::Enabled => NetworkAccess::Enabled, - }, - } - } - code_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - .. - } => SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(transparent)] -#[ts(type = "Array", export_to = "v2/")] -pub struct ExecPolicyAmendment { - pub command: Vec, -} - -impl ExecPolicyAmendment { - pub fn into_core(self) -> CoreExecPolicyAmendment { - CoreExecPolicyAmendment::new(self.command) - } -} - -impl From for ExecPolicyAmendment { - fn from(value: CoreExecPolicyAmendment) -> Self { - Self { - command: value.command().to_vec(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum CommandAction { - ReadCommand { - command: String, - }, - Read { - command: String, - name: String, - path: PathBuf, - }, - ListFiles { - command: String, - path: Option, - }, - Search { - command: String, - query: Option, - path: Option, - }, - Unknown { - command: String, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -#[derive(Default)] -pub enum SessionSource { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - #[default] - VsCode, - Exec, - AppServer, - SubAgent(CoreSubAgentSource), - #[serde(other)] - Unknown, -} - -impl From for SessionSource { - fn from(value: CoreSessionSource) -> Self { - match value { - CoreSessionSource::Cli => SessionSource::Cli, - CoreSessionSource::VSCode => SessionSource::VsCode, - CoreSessionSource::Exec => SessionSource::Exec, - CoreSessionSource::Mcp => SessionSource::AppServer, - CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), - CoreSessionSource::Unknown => SessionSource::Unknown, - } - } -} - -impl From for CoreSessionSource { - fn from(value: SessionSource) -> Self { - match value { - SessionSource::Cli => CoreSessionSource::Cli, - SessionSource::VsCode => CoreSessionSource::VSCode, - SessionSource::Exec => CoreSessionSource::Exec, - SessionSource::AppServer => CoreSessionSource::Mcp, - SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), - SessionSource::Unknown => CoreSessionSource::Unknown, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GitInfo { - pub sha: Option, - pub branch: Option, - pub origin_url: Option, -} - -impl CommandAction { - pub fn into_core(self) -> CoreParsedCommand { - match self { - CommandAction::ReadCommand { command: cmd } => CoreParsedCommand::ReadCommand { cmd }, - CommandAction::Read { - command: cmd, - name, - path, - } => CoreParsedCommand::Read { cmd, name, path }, - CommandAction::ListFiles { command: cmd, path } => { - CoreParsedCommand::ListFiles { cmd, path } - } - CommandAction::Search { - command: cmd, - query, - path, - } => CoreParsedCommand::Search { cmd, query, path }, - CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, - } - } -} - -impl From for CommandAction { - fn from(value: CoreParsedCommand) -> Self { - match value { - CoreParsedCommand::ReadCommand { cmd } => CommandAction::ReadCommand { - command: cmd, - }, - CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { - command: cmd, - name, - path, - }, - CoreParsedCommand::ListFiles { cmd, path } => { - CommandAction::ListFiles { command: cmd, path } - } - CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { - command: cmd, - query, - path, - }, - CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum Account { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(tag = "type")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountParams { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey { - #[serde(rename = "apiKey")] - #[ts(rename = "apiKey")] - api_key: String, - }, - #[serde(rename = "chatgpt")] - #[ts(rename = "chatgpt")] - Chatgpt, - /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. - /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. - #[experimental("account/login/start.chatgptAuthTokens")] - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens { - /// Access token (JWT) supplied by the client. - /// This token is used for backend API requests and email extraction. - access_token: String, - /// Workspace/account identifier supplied by the client. - chatgpt_account_id: String, - /// Optional plan type supplied by the client. - /// - /// When `null`, Codex attempts to derive the plan type from access-token - /// claims. If unavailable, the plan defaults to `unknown`. - #[ts(optional = nullable)] - chatgpt_plan_type: Option, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountResponse { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - login_id: String, - /// URL the client should open in a browser to initiate the OAuth flow. - auth_url: String, - }, - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens {}, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountParams { - pub login_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CancelLoginAccountStatus { - Canceled, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountResponse { - pub status: CancelLoginAccountStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct LogoutAccountResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ChatgptAuthTokensRefreshReason { - /// Codex attempted a backend request and received `401 Unauthorized`. - Unauthorized, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshParams { - pub reason: ChatgptAuthTokensRefreshReason, - /// Workspace/account identifier that Codex was previously using. - /// - /// Clients that manage multiple accounts/workspaces can use this as a hint - /// to refresh the token for the correct workspace. - /// - /// This may be `null` when the prior auth state did not include a workspace - /// identifier (`chatgpt_account_id`). - #[ts(optional = nullable)] - pub previous_account_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshResponse { - pub access_token: String, - pub chatgpt_account_id: String, - pub chatgpt_plan_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountRateLimitsResponse { - /// Backward-compatible single-bucket view. - pub rate_limits: RateLimitSnapshot, - /// Multi-bucket view keyed by metered `limit_id`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rate_limits_by_limit_id: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountParams { - /// When `true`, requests a proactive token refresh before returning. - /// - /// In managed auth mode this triggers the normal refresh-token flow. In - /// external auth mode this flag is ignored. Clients should refresh tokens - /// themselves and call `account/login/start` with `chatgptAuthTokens`. - #[serde(default)] - pub refresh_token: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountResponse { - pub account: Option, - pub requires_openai_auth: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// When true, include models that are hidden from the default picker list. - #[ts(optional = nullable)] - pub include_hidden: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelAvailabilityNux { - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Model { - pub id: String, - pub model: String, - pub upgrade: Option, - pub upgrade_info: Option, - pub availability_nux: Option, - pub display_name: String, - pub description: String, - pub hidden: bool, - pub supported_reasoning_efforts: Vec, - pub default_reasoning_effort: ReasoningEffort, - #[serde(default = "default_input_modalities")] - pub input_modalities: Vec, - #[serde(default)] - pub supports_personality: bool, - // Only one model should be marked as default. - pub is_default: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelUpgradeInfo { - pub model: String, - pub upgrade_copy: Option, - pub model_link: Option, - pub migration_markdown: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningEffortOption { - pub reasoning_effort: ReasoningEffort, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -/// EXPERIMENTAL - list collaboration mode presets. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListParams {} - -/// EXPERIMENTAL - collaboration mode presets response. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ExperimentalFeatureStage { - /// Feature is available for user testing and feedback. - Beta, - /// Feature is still being built and not ready for broad use. - UnderDevelopment, - /// Feature is production-ready. - Stable, - /// Feature is deprecated and should be avoided. - Deprecated, - /// Feature flag is retained only for backwards compatibility. - Removed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeature { - /// Stable key used in config.toml and CLI flag toggles. - pub name: String, - /// Lifecycle stage of this feature flag. - pub stage: ExperimentalFeatureStage, - /// User-facing display name shown in the experimental features UI. - /// Null when this feature is not in beta. - pub display_name: Option, - /// Short summary describing what the feature does. - /// Null when this feature is not in beta. - pub description: Option, - /// Announcement copy shown to users when the feature is introduced. - /// Null when this feature is not in beta. - pub announcement: Option, - /// Whether this feature is currently enabled in the loaded config. - pub enabled: bool, - /// Whether this feature is enabled by default. - pub default_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a server-defined value. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerStatus { - pub name: String, - pub tools: std::collections::HashMap, - pub resources: Vec, - pub resource_templates: Vec, - pub auth_status: McpAuthStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - list available apps/connectors. -pub struct AppsListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional thread id used to evaluate app feature gating from that thread's config. - #[ts(optional = nullable)] - pub thread_id: Option, - /// When true, bypass app caches and fetch the latest data from sources. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_refetch: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata returned by app-list APIs. -pub struct AppInfo { - pub id: String, - pub name: String, - pub description: Option, - pub logo_url: Option, - pub logo_url_dark: Option, - pub distribution_channel: Option, - pub install_url: Option, - #[serde(default)] - pub is_accessible: bool, - /// Whether this app is enabled in config.toml. - /// Example: - /// ```toml - /// [apps.bad_app] - /// enabled = false - /// ``` - #[serde(default = "default_enabled")] - pub is_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app list response. -pub struct AppsListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - notification emitted when the app list changes. -pub struct AppListUpdatedNotification { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginParams { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub scopes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub timeout_secs: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginResponse { - pub authorization_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadParams { - pub classification: String, - #[ts(optional = nullable)] - pub reason: Option, - #[ts(optional = nullable)] - pub thread_id: Option, - pub include_logs: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadResponse { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecParams { - pub command: Vec, - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub timeout_ms: Option, - #[ts(optional = nullable)] - pub cwd: Option, - #[ts(optional = nullable)] - pub sandbox_policy: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResponse { - pub exit_code: i32, - pub stdout: String, - pub stderr: String, -} - -// === Threads, Turns, and Items === -// Thread APIs -#[derive( - Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartParams { - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[ts(optional = nullable)] - pub cwd: Option, - #[ts(optional = nullable)] - pub approval_policy: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, - #[ts(optional = nullable)] - pub ephemeral: Option, - #[experimental("thread/start.dynamicTools")] - #[ts(optional = nullable)] - pub dynamic_tools: Option>, - /// Test-only experimental field used to validate experimental gating and - /// schema filtering behavior in a stable way. - #[experimental("thread/start.mockExperimentalField")] - #[ts(optional = nullable)] - pub mock_experimental_field: Option, - /// If true, opt into emitting raw Responses API items on the event stream. - /// This is for internal use only (e.g. Codex Cloud). - #[experimental("thread/start.experimentalRawEvents")] - #[serde(default)] - pub experimental_raw_events: bool, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodParams { - /// Test-only payload field. - #[ts(optional = nullable)] - pub value: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodResponse { - /// Echoes the input `value`. - pub echoed: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub cwd: PathBuf, - pub approval_policy: AskForApproval, - pub sandbox: SandboxPolicy, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are three ways to resume a thread: -/// 1. By thread_id: load the thread from disk by thread_id and resume it. -/// 2. By history: instantiate the thread from memory and resume it. -/// 3. By path: load the thread from disk by path and resume it. -/// -/// The precedence is: history > path > thread_id. -/// If using history or path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadResumeParams { - pub thread_id: String, - - /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. - /// If specified, the thread will be resumed with the provided history - /// instead of loaded from disk. - #[experimental("thread/resume.history")] - #[ts(optional = nullable)] - pub history: Option>, - - /// [UNSTABLE] Specify the rollout path to resume from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/resume.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the resumed thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[ts(optional = nullable)] - pub cwd: Option, - #[ts(optional = nullable)] - pub approval_policy: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadResumeResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub cwd: PathBuf, - pub approval_policy: AskForApproval, - pub sandbox: SandboxPolicy, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are two ways to fork a thread: -/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. -/// 2. By path: load the thread from disk by path and fork it into a new thread. -/// -/// If using path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadForkParams { - pub thread_id: String, - - /// [UNSTABLE] Specify the rollout path to fork from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/fork.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the forked thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[ts(optional = nullable)] - pub cwd: Option, - #[ts(optional = nullable)] - pub approval_policy: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadForkResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub cwd: PathBuf, - pub approval_policy: AskForApproval, - pub sandbox: SandboxPolicy, - pub reasoning_effort: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameParams { - pub thread_id: String, - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackParams { - pub thread_id: String, - /// The number of turns to drop from the end of the thread. Must be >= 1. - /// - /// This only modifies the thread's history and does not revert local file changes - /// that have been made by the agent. Clients are responsible for reverting these changes. - pub num_turns: u32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackResponse { - /// The updated thread after applying the rollback, with `turns` populated. - /// - /// The ThreadItems stored in each Turn are lossy since we explicitly do not - /// persist all agent interactions, such as command executions. This is the same - /// behavior as `thread/resume`. - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional sort key; defaults to created_at. - #[ts(optional = nullable)] - pub sort_key: Option, - /// Optional provider filter; when set, only sessions recorded under these - /// providers are returned. When present but empty, includes all providers. - #[ts(optional = nullable)] - pub model_providers: Option>, - /// Optional source filter; when set, only sessions from these source kinds - /// are returned. When omitted or empty, defaults to interactive sources. - #[ts(optional = nullable)] - pub source_kinds: Option>, - /// Optional archived filter; when set to true, only archived threads are returned. - /// If false or null, only non-archived threads are returned. - #[ts(optional = nullable)] - pub archived: Option, - /// Optional cwd filter; when set, only threads whose session cwd exactly - /// matches this path are returned. - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum ThreadSourceKind { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - VsCode, - Exec, - AppServer, - SubAgent, - SubAgentReview, - SubAgentCompact, - SubAgentThreadSpawn, - SubAgentOther, - Unknown, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum ThreadSortKey { - CreatedAt, - UpdatedAt, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to no limit. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListResponse { - /// Thread ids for sessions currently loaded in memory. - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadParams { - pub thread_id: String, - /// When true, include turns and their items from rollout history. - #[serde(default)] - pub include_turns: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListParams { - /// When empty, defaults to the current session working directory. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cwds: Vec, - - /// When true, bypass the skills cache and re-scan skills from disk. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_reload: bool, - - /// Optional per-cwd extra roots to scan as user-scoped skills. - #[serde(default)] - #[ts(optional = nullable)] - pub per_cwd_extra_user_roots: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListExtraRootsForCwd { - pub cwd: PathBuf, - pub extra_user_roots: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteReadParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RemoteSkillSummary { - pub id: String, - pub name: String, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteReadResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteWriteParams { - pub hazelnut_id: String, - pub is_preload: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsRemoteWriteResponse { - pub id: String, - pub name: String, - pub path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum SkillScope { - User, - Repo, - System, - Admin, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillMetadata { - pub name: String, - pub description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. - pub short_description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub interface: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub dependencies: Option, - pub path: PathBuf, - pub scope: SkillScope, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillInterface { - #[ts(optional)] - pub display_name: Option, - #[ts(optional)] - pub short_description: Option, - #[ts(optional)] - pub icon_small: Option, - #[ts(optional)] - pub icon_large: Option, - #[ts(optional)] - pub brand_color: Option, - #[ts(optional)] - pub default_prompt: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillDependencies { - pub tools: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillToolDependency { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub r#type: String, - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub transport: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub command: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub url: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListEntry { - pub cwd: PathBuf, - pub skills: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteParams { - pub path: PathBuf, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteResponse { - pub effective_enabled: bool, -} - -impl From for SkillMetadata { - fn from(value: CoreSkillMetadata) -> Self { - Self { - name: value.name, - description: value.description, - short_description: value.short_description, - interface: value.interface.map(SkillInterface::from), - dependencies: value.dependencies.map(SkillDependencies::from), - path: value.path, - scope: value.scope.into(), - enabled: value.allow_implicit_invocation, - } - } -} - -impl From for SkillInterface { - fn from(value: CoreSkillInterface) -> Self { - Self { - display_name: value.display_name, - short_description: value.short_description, - brand_color: value.brand_color, - default_prompt: value.default_prompt, - icon_small: value.icon_small, - icon_large: value.icon_large, - } - } -} - -impl From for SkillDependencies { - fn from(value: CoreSkillDependencies) -> Self { - Self { - tools: value - .tools - .into_iter() - .map(SkillToolDependency::from) - .collect(), - } - } -} - -impl From for SkillToolDependency { - fn from(value: CoreSkillToolDependency) -> Self { - Self { - r#type: value.r#type, - value: value.value, - description: value.description, - transport: value.transport, - command: value.command, - url: value.url, - } - } -} - -impl From for SkillScope { - fn from(value: CoreSkillScope) -> Self { - match value { - CoreSkillScope::User => Self::User, - CoreSkillScope::Repo => Self::Repo, - CoreSkillScope::System => Self::System, - CoreSkillScope::Admin => Self::Admin, - } - } -} - -impl From for SkillErrorInfo { - fn from(value: CoreSkillErrorInfo) -> Self { - Self { - path: value.path, - message: value.message, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Thread { - pub id: String, - /// Usually the first user message in the thread, if available. - pub preview: String, - /// Model provider used for this thread (for example, 'openai'). - pub model_provider: String, - /// Unix timestamp (in seconds) when the thread was created. - #[ts(type = "number")] - pub created_at: i64, - /// Unix timestamp (in seconds) when the thread was last updated. - #[ts(type = "number")] - pub updated_at: i64, - /// [UNSTABLE] Path to the thread on disk. - pub path: Option, - /// Working directory captured for the thread. - pub cwd: PathBuf, - /// Version of the CLI that created the thread. - pub cli_version: String, - /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). - pub source: SessionSource, - /// Optional Git metadata captured when the thread was created. - pub git_info: Option, - /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` - /// (when `includeTurns` is true) responses. - /// For all other responses and notifications returning a Thread, - /// the turns field will be an empty list. - pub turns: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountUpdatedNotification { - pub auth_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsageUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub token_usage: ThreadTokenUsage, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsage { - pub total: TokenUsageBreakdown, - pub last: TokenUsageBreakdown, - // TODO(aibrahim): make this not optional - #[ts(type = "number | null")] - pub model_context_window: Option, -} - -impl From for ThreadTokenUsage { - fn from(value: CoreTokenUsageInfo) -> Self { - Self { - total: value.total_token_usage.into(), - last: value.last_token_usage.into(), - model_context_window: value.model_context_window, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TokenUsageBreakdown { - #[ts(type = "number")] - pub total_tokens: i64, - #[ts(type = "number")] - pub input_tokens: i64, - #[ts(type = "number")] - pub cached_input_tokens: i64, - #[ts(type = "number")] - pub output_tokens: i64, - #[ts(type = "number")] - pub reasoning_output_tokens: i64, -} - -impl From for TokenUsageBreakdown { - fn from(value: CoreTokenUsage) -> Self { - Self { - total_tokens: value.total_tokens, - input_tokens: value.input_tokens, - cached_input_tokens: value.cached_input_tokens, - output_tokens: value.output_tokens, - reasoning_output_tokens: value.reasoning_output_tokens, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Turn { - pub id: String, - /// Only populated on a `thread/resume` or `thread/fork` response. - /// For all other responses and notifications returning a Turn, - /// the items field will be an empty list. - pub items: Vec, - pub status: TurnStatus, - /// Only populated when the Turn's status is failed. - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -#[error("{message}")] -pub struct TurnError { - pub message: String, - pub codex_error_info: Option, - #[serde(default)] - pub additional_details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ErrorNotification { - pub error: TurnError, - // Set to true if the error is transient and the app-server process will automatically retry. - // If true, this will not interrupt a turn. - pub will_retry: bool, - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnStatus { - Completed, - Interrupted, - Failed, - InProgress, -} - -// Turn APIs -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartParams { - pub thread_id: String, - pub input: Vec, - /// Override the working directory for this turn and subsequent turns. - #[ts(optional = nullable)] - pub cwd: Option, - /// Override the approval policy for this turn and subsequent turns. - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override the sandbox policy for this turn and subsequent turns. - #[ts(optional = nullable)] - pub sandbox_policy: Option, - /// Override the model for this turn and subsequent turns. - #[ts(optional = nullable)] - pub model: Option, - /// Override the reasoning effort for this turn and subsequent turns. - #[ts(optional = nullable)] - pub effort: Option, - /// Override the reasoning summary for this turn and subsequent turns. - #[ts(optional = nullable)] - pub summary: Option, - /// Override the personality for this turn and subsequent turns. - #[ts(optional = nullable)] - pub personality: Option, - /// Optional JSON Schema used to constrain the final assistant message for this turn. - #[ts(optional = nullable)] - pub output_schema: Option, - - /// EXPERIMENTAL - Set a pre-set collaboration mode. - /// Takes precedence over model, reasoning_effort, and developer instructions if set. - /// - /// For `collaboration_mode.settings.developer_instructions`, `null` means - /// "use the built-in instructions for the selected mode". - #[experimental("turn/start.collaborationMode")] - #[ts(optional = nullable)] - pub collaboration_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartParams { - pub thread_id: String, - pub target: ReviewTarget, - - /// Where to run the review: inline (default) on the current thread or - /// detached on a new thread (returned in `reviewThreadId`). - #[serde(default)] - #[ts(optional = nullable)] - pub delivery: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartResponse { - pub turn: Turn, - /// Identifies the thread where the review runs. - /// - /// For inline reviews, this is the original thread id. - /// For detached reviews, this is the id of the new review thread. - pub review_thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", export_to = "v2/")] -pub enum ReviewTarget { - /// Review the working tree: staged, unstaged, and untracked files. - UncommittedChanges, - - /// Review changes between the current branch and the given base branch. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - BaseBranch { branch: String }, - - /// Review the changes introduced by a specific commit. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Commit { - sha: String, - /// Optional human-readable label (e.g., commit subject) for UIs. - title: Option, - }, - - /// Arbitrary instructions, equivalent to the old free-form prompt. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Custom { instructions: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartResponse { - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerParams { - pub thread_id: String, - pub input: Vec, - /// Required active turn id precondition. The request fails when it does not - /// match the currently active turn. - pub expected_turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerResponse { - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptParams { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptResponse {} - -// User input types -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ByteRange { - pub start: usize, - pub end: usize, -} - -impl From for ByteRange { - fn from(value: CoreByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -impl From for CoreByteRange { - fn from(value: ByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextElement { - /// Byte range in the parent `text` buffer that this element occupies. - pub byte_range: ByteRange, - /// Optional human-readable placeholder for the element, displayed in the UI. - placeholder: Option, -} - -impl TextElement { - pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { - Self { - byte_range, - placeholder, - } - } - - pub fn set_placeholder(&mut self, placeholder: Option) { - self.placeholder = placeholder; - } - - pub fn placeholder(&self) -> Option<&str> { - self.placeholder.as_deref() - } -} - -impl From for TextElement { - fn from(value: CoreTextElement) -> Self { - Self::new( - value.byte_range.into(), - value._placeholder_for_conversion_only().map(str::to_string), - ) - } -} - -impl From for CoreTextElement { - fn from(value: TextElement) -> Self { - Self::new(value.byte_range.into(), value.placeholder) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum UserInput { - Text { - text: String, - /// UI-defined spans within `text` used to render or persist special elements. - #[serde(default)] - text_elements: Vec, - }, - Image { - url: String, - }, - LocalImage { - path: PathBuf, - }, - Skill { - name: String, - path: PathBuf, - }, - Mention { - name: String, - path: String, - }, -} - -impl UserInput { - pub fn into_core(self) -> CoreUserInput { - match self { - UserInput::Text { - text, - text_elements, - } => CoreUserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - UserInput::Image { url } => CoreUserInput::Image { image_url: url }, - UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, - UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, - UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, - } - } -} - -impl From for UserInput { - fn from(value: CoreUserInput) -> Self { - match value { - CoreUserInput::Text { - text, - text_elements, - } => UserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, - CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, - CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, - CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, - _ => unreachable!("unsupported user input variant"), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ThreadItem { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - UserMessage { id: String, content: Vec }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AgentMessage { id: String, text: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - /// EXPERIMENTAL - proposed plan item content. The completed plan item is - /// authoritative and may not match the concatenation of `PlanDelta` text. - Plan { id: String, text: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Reasoning { - id: String, - #[serde(default)] - summary: Vec, - #[serde(default)] - content: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CommandExecution { - id: String, - /// The command to be executed. - command: String, - /// The command's working directory. - cwd: PathBuf, - /// Identifier for the underlying PTY process (when available). - process_id: Option, - status: CommandExecutionStatus, - /// A best-effort parsing of the command to understand the action(s) it will perform. - /// This returns a list of CommandAction objects because a single shell command may - /// be composed of many commands piped together. - command_actions: Vec, - /// The command's output, aggregated from stdout and stderr. - aggregated_output: Option, - /// The command's exit code. - exit_code: Option, - /// The duration of the command execution in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - FileChange { - id: String, - changes: Vec, - status: PatchApplyStatus, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - McpToolCall { - id: String, - server: String, - tool: String, - status: McpToolCallStatus, - arguments: JsonValue, - result: Option, - error: Option, - /// The duration of the MCP tool call in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CollabAgentToolCall { - /// Unique identifier for this collab tool call. - id: String, - /// Name of the collab tool that was invoked. - tool: CollabAgentTool, - /// Current status of the collab tool call. - status: CollabAgentToolCallStatus, - /// Thread ID of the agent issuing the collab request. - sender_thread_id: String, - /// Thread ID of the receiving agent, when applicable. In case of spawn operation, - /// this corresponds to the newly spawned agent. - receiver_thread_ids: Vec, - /// Prompt text sent as part of the collab tool call, when available. - prompt: Option, - /// Last known status of the target agents, when available. - agents_states: HashMap, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WebSearch { - id: String, - query: String, - action: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageView { id: String, path: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageGeneration { - id: String, - status: String, - revised_prompt: Option, - result: String, - saved_path: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - EnteredReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExitedReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ContextCompaction { id: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WebSearchAction { - Search { - query: Option, - queries: Option>, - }, - OpenPage { - url: Option, - }, - FindInPage { - url: Option, - pattern: Option, - }, - #[serde(other)] - Other, -} - -impl From for WebSearchAction { - fn from(value: code_protocol::models::WebSearchAction) -> Self { - match value { - code_protocol::models::WebSearchAction::Search { query, queries } => { - WebSearchAction::Search { query, queries } - } - code_protocol::models::WebSearchAction::OpenPage { url } => { - WebSearchAction::OpenPage { url } - } - code_protocol::models::WebSearchAction::FindInPage { url, pattern } => { - WebSearchAction::FindInPage { url, pattern } - } - code_protocol::models::WebSearchAction::Other => WebSearchAction::Other, - } - } -} - -impl From for ThreadItem { - fn from(value: CoreTurnItem) -> Self { - match value { - CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { - id: user.id, - content: user.content.into_iter().map(UserInput::from).collect(), - }, - CoreTurnItem::AgentMessage(agent) => { - let text = agent - .content - .into_iter() - .map(|entry| match entry { - CoreAgentMessageContent::Text { text } => text, - }) - .collect::(); - ThreadItem::AgentMessage { id: agent.id, text } - } - CoreTurnItem::Plan(plan) => ThreadItem::Plan { - id: plan.id, - text: plan.text, - }, - CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { - id: reasoning.id, - summary: reasoning.summary_text, - content: reasoning.raw_content, - }, - CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { - id: search.id, - query: search.query, - action: Some(WebSearchAction::from(search.action)), - }, - CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { - id: image.id, - status: image.status, - revised_prompt: image.revised_prompt, - result: image.result, - saved_path: image.saved_path, - }, - CoreTurnItem::ContextCompaction(compaction) => { - ThreadItem::ContextCompaction { id: compaction.id } - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionStatus { - InProgress, - Completed, - Failed, - Declined, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentTool { - SpawnAgent, - SendInput, - ResumeAgent, - Wait, - CloseAgent, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileUpdateChange { - pub path: String, - pub kind: PatchChangeKind, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PatchChangeKind { - Add, - Delete, - Update { move_path: Option }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum PatchApplyStatus { - InProgress, - Completed, - Failed, - Declined, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentStatus { - PendingInit, - Running, - Completed, - Errored, - Shutdown, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollabAgentState { - pub status: CollabAgentStatus, - pub message: Option, -} - -impl From for CollabAgentState { - fn from(value: CoreAgentStatus) -> Self { - match value { - CoreAgentStatus::PendingInit => Self { - status: CollabAgentStatus::PendingInit, - message: None, - }, - CoreAgentStatus::Running => Self { - status: CollabAgentStatus::Running, - message: None, - }, - CoreAgentStatus::Completed(message) => Self { - status: CollabAgentStatus::Completed, - message, - }, - CoreAgentStatus::Errored(message) => Self { - status: CollabAgentStatus::Errored, - message: Some(message), - }, - CoreAgentStatus::Shutdown => Self { - status: CollabAgentStatus::Shutdown, - message: None, - }, - CoreAgentStatus::NotFound => Self { - status: CollabAgentStatus::NotFound, - message: None, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallResult { - // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust - // representation of MCP content blocks. We intentionally use `serde_json::Value` here because - // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types - // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust - // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. - pub content: Vec, - pub structured_content: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallError { - pub message: String, -} - -// === Server Notifications === -// Thread/Turn lifecycle notifications and item progress events -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartedNotification { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadNameUpdatedNotification { - pub thread_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub thread_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Usage { - pub input_tokens: i32, - pub cached_input_tokens: i32, - pub output_tokens: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnCompletedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// Notification that the turn-level unified diff has changed. -/// Contains the latest aggregated diff across all file changes in the turn. -pub struct TurnDiffUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub explanation: Option, - pub plan: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanStep { - pub step: String, - pub status: TurnPlanStepStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnPlanStepStatus { - Pending, - InProgress, - Completed, -} - -impl From for TurnPlanStep { - fn from(value: CorePlanItemArg) -> Self { - Self { - step: value.step, - status: value.status.into(), - } - } -} - -impl From for TurnPlanStepStatus { - fn from(value: CorePlanStepStatus) -> Self { - match value { - CorePlanStepStatus::Pending => Self::Pending, - CorePlanStepStatus::InProgress => Self::InProgress, - CorePlanStepStatus::Completed => Self::Completed, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemStartedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemCompletedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RawResponseItemCompletedNotification { - pub thread_id: String, - pub turn_id: String, - pub item: ResponseItem, -} - -// Item-specific progress notifications -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AgentMessageDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should -/// not assume concatenated deltas match the completed plan item content. -pub struct PlanDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryPartAddedNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub content_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TerminalInteractionNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub process_id: String, - pub stdin: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallProgressNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginCompletedNotification { - pub name: String, - pub success: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsWorldWritableWarningNotification { - pub sample_paths: Vec, - pub extra_count: usize, - pub failed_scan: bool, -} - -/// Deprecated: Use `ContextCompaction` item type instead. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ContextCompactedNotification { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Identifier for this specific approval callback. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub approval_id: Option, - /// Optional explanatory reason (e.g. request for network access). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub reason: Option, - /// Optional context for a managed-network approval prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub network_approval_context: Option, - /// The command to be executed. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command: Option, - /// The command's working directory. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub cwd: Option, - /// Best-effort parsed command actions for friendly display. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command_actions: Option>, - /// Optional additional permissions requested for this command. - #[experimental("item/commandExecution/requestApproval.additionalPermissions")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub additional_permissions: Option, - /// Optional proposed execpolicy amendment to allow similar commands without prompting. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub proposed_execpolicy_amendment: Option, -} - -impl CommandExecutionRequestApprovalParams { - pub fn strip_experimental_fields(&mut self) { - self.additional_permissions = None; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalResponse { - pub decision: CommandExecutionApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Optional explanatory reason (e.g. request for extra write access). - #[ts(optional = nullable)] - pub reason: Option, - /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root - /// for the remainder of the session (unclear if this is honored today). - #[ts(optional = nullable)] - pub grant_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalResponse { - pub decision: FileChangeApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallParams { - pub thread_id: String, - pub turn_id: String, - pub call_id: String, - #[ts(optional = nullable)] - pub namespace: Option, - pub tool: String, - pub arguments: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallResponse { - pub content_items: Vec, - pub success: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum DynamicToolCallOutputContentItem { - #[serde(rename_all = "camelCase")] - InputText { text: String }, - #[serde(rename_all = "camelCase")] - InputImage { image_url: String }, -} - -impl From - for code_protocol::dynamic_tools::DynamicToolCallOutputContentItem -{ - fn from(item: DynamicToolCallOutputContentItem) -> Self { - match item { - DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, - DynamicToolCallOutputContentItem::InputImage { image_url } => { - Self::InputImage { image_url } - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Defines a single selectable option for request_user_input. -pub struct ToolRequestUserInputOption { - pub label: String, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Represents one request_user_input question and its required options. -pub struct ToolRequestUserInputQuestion { - pub id: String, - pub header: String, - pub question: String, - #[serde(default)] - pub is_other: bool, - #[serde(default)] - pub is_secret: bool, - pub options: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Params sent with a request_user_input event. -pub struct ToolRequestUserInputParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub questions: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. -pub struct ToolRequestUserInputAnswer { - pub answers: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Response payload mapping question ids to answers. -pub struct ToolRequestUserInputResponse { - pub answers: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountRateLimitsUpdatedNotification { - pub rate_limits: RateLimitSnapshot, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitSnapshot { - #[ts(type = "string | null")] - pub limit_id: Option, - #[ts(type = "string | null")] - pub limit_name: Option, - pub primary: Option, - pub secondary: Option, - pub credits: Option, - pub plan_type: Option, - #[serde(default)] - pub rate_limit_reached_type: Option, -} - -impl From for RateLimitSnapshot { - fn from(value: CoreRateLimitSnapshot) -> Self { - Self { - limit_id: value.limit_id, - limit_name: value.limit_name, - primary: value.primary.map(RateLimitWindow::from), - secondary: value.secondary.map(RateLimitWindow::from), - credits: value.credits.map(CreditsSnapshot::from), - plan_type: value.plan_type, - rate_limit_reached_type: value - .rate_limit_reached_type - .map(RateLimitReachedType::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum RateLimitReachedType { - RateLimitReached, - WorkspaceOwnerCreditsDepleted, - WorkspaceMemberCreditsDepleted, - WorkspaceOwnerUsageLimitReached, - WorkspaceMemberUsageLimitReached, -} - -impl From for RateLimitReachedType { - fn from(value: CoreRateLimitReachedType) -> Self { - match value { - CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, - CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - Self::WorkspaceOwnerCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { - Self::WorkspaceMemberCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - Self::WorkspaceOwnerUsageLimitReached - } - CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { - Self::WorkspaceMemberUsageLimitReached - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitWindow { - pub used_percent: i32, - #[ts(type = "number | null")] - pub window_duration_mins: Option, - #[ts(type = "number | null")] - pub resets_at: Option, -} - -impl From for RateLimitWindow { - fn from(value: CoreRateLimitWindow) -> Self { - Self { - used_percent: value.used_percent.round() as i32, - window_duration_mins: value - .window_minutes - .and_then(|minutes| i64::try_from(minutes).ok()), - resets_at: value.resets_at, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CreditsSnapshot { - pub has_credits: bool, - pub unlimited: bool, - pub balance: Option, -} - -impl From for CreditsSnapshot { - fn from(value: CoreCreditsSnapshot) -> Self { - Self { - has_credits: value.has_credits, - unlimited: value.unlimited, - balance: value.balance, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountLoginCompletedNotification { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - pub login_id: Option, - pub success: bool, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeprecationNoticeNotification { - /// Concise summary of what is deprecated. - pub summary: String, - /// Optional extra guidance, such as migration steps or rationale. - pub details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextPosition { - /// 1-based line number. - pub line: usize, - /// 1-based column number (in Unicode scalar values). - pub column: usize, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextRange { - pub start: TextPosition, - pub end: TextPosition, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWarningNotification { - /// Concise summary of the warning. - pub summary: String, - /// Optional extra guidance or error details. - pub details: Option, - /// Optional path to the config file that triggered the warning. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub path: Option, - /// Optional range for the error location inside the config file. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub range: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use code_protocol::items::AgentMessageContent; - use code_protocol::items::AgentMessageItem; - use code_protocol::items::ReasoningItem; - use code_protocol::items::TurnItem; - use code_protocol::items::UserMessageItem; - use code_protocol::items::WebSearchItem; - use code_protocol::models::WebSearchAction as CoreWebSearchAction; - use code_protocol::protocol::NetworkAccess as CoreNetworkAccess; - use code_protocol::user_input::UserInput as CoreUserInput; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::path::PathBuf; - - #[test] - fn sandbox_policy_round_trips_external_sandbox_network_access() { - let v2_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - code_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: CoreNetworkAccess::Enabled, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn core_turn_item_into_thread_item_converts_supported_variants() { - let user_item = TurnItem::UserMessage(UserMessageItem { - id: "user-1".to_string(), - content: vec![ - CoreUserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - CoreUserInput::Image { - image_url: "https://example.com/image.png".to_string(), - }, - CoreUserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - CoreUserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.code/skills/skill-creator/SKILL.md"), - }, - CoreUserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - }, - ], - }); - - assert_eq!( - ThreadItem::from(user_item), - ThreadItem::UserMessage { - id: "user-1".to_string(), - content: vec![ - UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - UserInput::Image { - url: "https://example.com/image.png".to_string(), - }, - UserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - UserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.code/skills/skill-creator/SKILL.md"), - }, - UserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - }, - ], - } - ); - - let agent_item = TurnItem::AgentMessage(AgentMessageItem { - id: "agent-1".to_string(), - content: vec![ - AgentMessageContent::Text { - text: "Hello ".to_string(), - }, - AgentMessageContent::Text { - text: "world".to_string(), - }, - ], - phase: None, - }); - - assert_eq!( - ThreadItem::from(agent_item), - ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "Hello world".to_string(), - } - ); - - let reasoning_item = TurnItem::Reasoning(ReasoningItem { - id: "reasoning-1".to_string(), - summary_text: vec!["line one".to_string(), "line two".to_string()], - raw_content: vec![], - }); - - assert_eq!( - ThreadItem::from(reasoning_item), - ThreadItem::Reasoning { - id: "reasoning-1".to_string(), - summary: vec!["line one".to_string(), "line two".to_string()], - content: vec![], - } - ); - - let search_item = TurnItem::WebSearch(WebSearchItem { - id: "search-1".to_string(), - query: "docs".to_string(), - action: CoreWebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }, - }); - - assert_eq!( - ThreadItem::from(search_item), - ThreadItem::WebSearch { - id: "search-1".to_string(), - query: "docs".to_string(), - action: Some(WebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }), - } - ); - } - - #[test] - fn skills_list_params_serialization_uses_force_reload() { - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: Vec::new(), - force_reload: false, - per_cwd_extra_user_roots: None, - }) - .unwrap(), - json!({ - "perCwdExtraUserRoots": null, - }), - ); - - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: vec![PathBuf::from("/repo")], - force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: PathBuf::from("/repo"), - extra_user_roots: vec![ - PathBuf::from("/shared/skills"), - PathBuf::from("/tmp/x") - ], - }]), - }) - .unwrap(), - json!({ - "cwds": ["/repo"], - "forceReload": true, - "perCwdExtraUserRoots": [ - { - "cwd": "/repo", - "extraUserRoots": ["/shared/skills", "/tmp/x"], - } - ], - }), - ); - } - - #[test] - fn skill_metadata_enabled_reflects_implicit_invocation_policy() { - let core_skill = CoreSkillMetadata { - name: "manual".to_string(), - description: "Manual skill".to_string(), - short_description: None, - interface: None, - dependencies: None, - path: PathBuf::from("/tmp/manual/SKILL.md"), - scope: CoreSkillScope::User, - allow_implicit_invocation: false, - enabled: true, - }; - - let v2_skill = SkillMetadata::from(core_skill); - - assert!(!v2_skill.enabled); - } - - #[test] - fn codex_error_info_serializes_http_status_code_in_camel_case() { - let value = CodexErrorInfo::ResponseTooManyFailedAttempts { - http_status_code: Some(401), - }; - - assert_eq!( - serde_json::to_value(value).unwrap(), - json!({ - "responseTooManyFailedAttempts": { - "httpStatusCode": 401 - } - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - } - ], - "success": true, - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_text_and_image_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![ - DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }, - DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - }, - ], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - }, - { - "type": "inputImage", - "imageUrl": "data:image/png;base64,AAA" - } - ], - "success": true, - }) - ); - } -} diff --git a/code-rs/app-server-protocol/src/protocol/v2/account.rs b/code-rs/app-server-protocol/src/protocol/v2/account.rs new file mode 100644 index 00000000000..efb4a26f603 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/account.rs @@ -0,0 +1,383 @@ +use crate::protocol::common::AuthMode; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::account::PlanType; +use codex_protocol::account::ProviderAccount; +use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum Account { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { email: String, plan_type: PlanType }, + + #[serde(rename = "amazonBedrock", rename_all = "camelCase")] + #[ts(rename = "amazonBedrock", rename_all = "camelCase")] + AmazonBedrock {}, +} + +impl From for Account { + fn from(account: ProviderAccount) -> Self { + match account { + ProviderAccount::ApiKey => Self::ApiKey {}, + ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, + ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(tag = "type")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountParams { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey { + #[serde(rename = "apiKey")] + #[ts(rename = "apiKey")] + api_key: String, + }, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + codex_streamlined_login: bool, + }, + #[serde(rename = "chatgptDeviceCode")] + #[ts(rename = "chatgptDeviceCode")] + ChatgptDeviceCode, + /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. + /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. + #[experimental("account/login/start.chatgptAuthTokens")] + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens { + /// Access token (JWT) supplied by the client. + /// This token is used for backend API requests and email extraction. + access_token: String, + /// Workspace/account identifier supplied by the client. + chatgpt_account_id: String, + /// Optional plan type supplied by the client. + /// + /// When `null`, Codex attempts to derive the plan type from access-token + /// claims. If unavailable, the plan defaults to `unknown`. + #[ts(optional = nullable)] + chatgpt_plan_type: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountResponse { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to initiate the OAuth flow. + auth_url: String, + }, + #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] + #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] + ChatgptDeviceCode { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to complete device code authorization. + verification_url: String, + /// One-time code the user must enter after signing in. + user_code: String, + }, + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountParams { + pub login_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CancelLoginAccountStatus { + Canceled, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse { + pub status: CancelLoginAccountStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct LogoutAccountResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ChatgptAuthTokensRefreshReason { + /// Codex attempted a backend request and received `401 Unauthorized`. + Unauthorized, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshParams { + pub reason: ChatgptAuthTokensRefreshReason, + /// Workspace/account identifier that Codex was previously using. + /// + /// Clients that manage multiple accounts/workspaces can use this as a hint + /// to refresh the token for the correct workspace. + /// + /// This may be `null` when the prior auth state did not include a workspace + /// identifier (`chatgpt_account_id`). + #[ts(optional = nullable)] + pub previous_account_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshResponse { + pub access_token: String, + pub chatgpt_account_id: String, + pub chatgpt_plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountRateLimitsResponse { + /// Backward-compatible single-bucket view; mirrors the historical payload. + pub rate_limits: RateLimitSnapshot, + /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). + pub rate_limits_by_limit_id: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailParams { + pub credit_type: AddCreditsNudgeCreditType, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailResponse { + pub status: AddCreditsNudgeEmailStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountParams { + /// When `true`, requests a proactive token refresh before returning. + /// + /// In managed auth mode this triggers the normal refresh-token flow. In + /// external auth mode this flag is ignored. Clients should refresh tokens + /// themselves and call `account/login/start` with `chatgptAuthTokens`. + #[serde(default)] + pub refresh_token: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountResponse { + pub account: Option, + pub requires_openai_auth: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountUpdatedNotification { + pub auth_mode: Option, + pub plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountRateLimitsUpdatedNotification { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitSnapshot { + pub limit_id: Option, + pub limit_name: Option, + pub primary: Option, + pub secondary: Option, + pub credits: Option, + pub plan_type: Option, + pub rate_limit_reached_type: Option, +} + +impl From for RateLimitSnapshot { + fn from(value: CoreRateLimitSnapshot) -> Self { + Self { + limit_id: value.limit_id, + limit_name: value.limit_name, + primary: value.primary.map(RateLimitWindow::from), + secondary: value.secondary.map(RateLimitWindow::from), + credits: value.credits.map(CreditsSnapshot::from), + plan_type: value.plan_type, + rate_limit_reached_type: value + .rate_limit_reached_type + .map(RateLimitReachedType::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, +} + +impl From for RateLimitReachedType { + fn from(value: CoreRateLimitReachedType) -> Self { + match value { + CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, + CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +impl From for CoreRateLimitReachedType { + fn from(value: RateLimitReachedType) -> Self { + match value { + RateLimitReachedType::RateLimitReached => Self::RateLimitReached, + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitWindow { + pub used_percent: i32, + #[ts(type = "number | null")] + pub window_duration_mins: Option, + #[ts(type = "number | null")] + pub resets_at: Option, +} + +impl From for RateLimitWindow { + fn from(value: CoreRateLimitWindow) -> Self { + Self { + used_percent: value.used_percent.round() as i32, + window_duration_mins: value.window_minutes, + resets_at: value.resets_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CreditsSnapshot { + pub has_credits: bool, + pub unlimited: bool, + pub balance: Option, +} + +impl From for CreditsSnapshot { + fn from(value: CoreCreditsSnapshot) -> Self { + Self { + has_credits: value.has_credits, + unlimited: value.unlimited, + balance: value.balance, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountLoginCompletedNotification { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + pub login_id: Option, + pub success: bool, + pub error: Option, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/apps.rs b/code-rs/app-server-protocol/src/protocol/v2/apps.rs new file mode 100644 index 00000000000..9f46525e6c1 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/apps.rs @@ -0,0 +1,146 @@ +use super::shared::default_enabled; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - list available apps/connectors. +pub struct AppsListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional thread id used to evaluate app feature gating from that thread's config. + #[ts(optional = nullable)] + pub thread_id: Option, + /// When true, bypass app caches and fetch the latest data from sources. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_refetch: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppBranding { + pub category: Option, + pub developer: Option, + pub website: Option, + pub privacy_policy: Option, + pub terms_of_service: Option, + pub is_discoverable_app: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppReview { + pub status: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppScreenshot { + pub url: Option, + #[serde(alias = "file_id")] + pub file_id: Option, + #[serde(alias = "user_prompt")] + pub user_prompt: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppMetadata { + pub review: Option, + pub categories: Option>, + pub sub_categories: Option>, + pub seo_description: Option, + pub screenshots: Option>, + pub developer: Option, + pub version: Option, + pub version_id: Option, + pub version_notes: Option, + pub first_party_type: Option, + pub first_party_requires_install: Option, + pub show_in_composer_when_unlinked: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppInfo { + pub id: String, + pub name: String, + pub description: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, + pub install_url: Option, + #[serde(default)] + pub is_accessible: bool, + /// Whether this app is enabled in config.toml. + /// Example: + /// ```toml + /// [apps.bad_app] + /// enabled = false + /// ``` + #[serde(default = "default_enabled")] + pub is_enabled: bool, + #[serde(default)] + pub plugin_display_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata summary for plugin responses. +pub struct AppSummary { + pub id: String, + pub name: String, + pub description: Option, + pub install_url: Option, + pub needs_auth: bool, +} + +impl From for AppSummary { + fn from(value: AppInfo) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + install_url: value.install_url, + needs_auth: false, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app list response. +pub struct AppsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - notification emitted when the app list changes. +pub struct AppListUpdatedNotification { + pub data: Vec, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs b/code-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs new file mode 100644 index 00000000000..b013bc13d4b --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs @@ -0,0 +1,45 @@ +use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ReasoningEffort; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// EXPERIMENTAL - list collaboration mode presets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListParams {} + +/// EXPERIMENTAL - collaboration mode preset metadata for clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeMask { + pub name: String, + pub mode: Option, + pub model: Option, + #[serde(rename = "reasoning_effort")] + #[ts(rename = "reasoning_effort")] + pub reasoning_effort: Option>, +} + +impl From for CollaborationModeMask { + fn from(value: CoreCollaborationModeMask) -> Self { + Self { + name: value.name, + mode: value.mode, + model: value.model, + reasoning_effort: value.reasoning_effort, + } + } +} + +/// EXPERIMENTAL - collaboration mode presets response. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListResponse { + pub data: Vec, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/code-rs/app-server-protocol/src/protocol/v2/command_exec.rs new file mode 100644 index 00000000000..ff0cecf4f91 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -0,0 +1,214 @@ +use super::PermissionProfile; +use super::SandboxPolicy; +use codex_experimental_api_macros::ExperimentalApi; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +/// PTY size in character cells for `command/exec` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Run a standalone command (argv vector) in the server sandbox without +/// creating a thread or turn. +/// +/// The final `command/exec` response is deferred until the process exits and is +/// sent only after all `command/exec/outputDelta` notifications for that +/// connection have been emitted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Optional client-supplied, connection-scoped process id. + /// + /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + /// `command/exec/write`, `command/exec/resize`, and + /// `command/exec/terminate` calls. When omitted, buffered execution gets an + /// internal id that is not exposed to the client. + #[ts(optional = nullable)] + pub process_id: Option, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `command/exec/write` requests to write stdin bytes. + /// + /// Requires a client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `command/exec/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the final response and require a + /// client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableOutputCap`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option, + /// Disable stdout/stderr capture truncation for this request. + /// + /// Cannot be combined with `outputBytesCap`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_output_cap: bool, + /// Disable the timeout entirely for this request. + /// + /// Cannot be combined with `timeoutMs`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_timeout: bool, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableTimeout`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option, + /// Optional working directory. Defaults to the server cwd. + #[ts(optional = nullable)] + pub cwd: Option, + /// Optional environment overrides merged into the server-computed + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, + /// Optional sandbox policy for this command. + /// + /// Uses the same shape as thread/turn execution sandbox configuration and + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("command/exec.permissionProfile")] + #[ts(optional = nullable)] + pub permission_profile: Option, +} + +/// Final buffered result for `command/exec`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResponse { + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `command/exec/outputDelta`. + pub stdout: String, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `command/exec/outputDelta`. + pub stderr: String, +} + +/// Write stdin bytes to a running `command/exec` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `command/exec/write`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteResponse {} + +/// Terminate a running `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, +} + +/// Empty success response for `command/exec/terminate`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateResponse {} + +/// Resize a running PTY-backed `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// New PTY size in character cells. + pub size: CommandExecTerminalSize, +} + +/// Empty success response for `command/exec/resize`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeResponse {} + +/// Stream label for `command/exec/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} +/// Base64-encoded output chunk emitted for a streaming `command/exec` request. +/// +/// These notifications are connection-scoped. If the originating connection +/// closes, the server terminates the process. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecOutputDeltaNotification { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Output stream for this chunk. + pub stream: CommandExecOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// `true` on the final streamed chunk for a stream when `outputBytesCap` + /// truncated later output on that stream. + pub cap_reached: bool, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/config.rs b/code-rs/app-server-protocol/src/protocol/v2/config.rs new file mode 100644 index 00000000000..8bc50bb1f22 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/config.rs @@ -0,0 +1,708 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::SandboxMode; +use super::shared::default_enabled; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ConfigLayerSource { + /// Managed preferences layer delivered by MDM (macOS only). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Mdm { + domain: String, + key: String, + }, + + /// Managed config layer from a file (usually `managed_config.toml`). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + System { + /// This is the path to the system config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// User config layer from $CODEX_HOME/config.toml. This layer is special + /// in that it is expected to be: + /// - writable by the user + /// - generally outside the workspace directory + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + User { + /// This is the path to the user's config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// Path to a .codex/ folder within a project. There could be multiple of + /// these between `cwd` and the project/repo root. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Project { + dot_codex_folder: AbsolutePathBuf, + }, + + /// Session-layer overrides supplied via `-c`/`--config`. + SessionFlags, + + /// `managed_config.toml` was designed to be a config that was loaded + /// as the last layer on top of everything else. This scheme did not quite + /// work out as intended, but we keep this variant as a "best effort" while + /// we phase out `managed_config.toml` in favor of `requirements.toml`. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf, + }, + + LegacyManagedConfigTomlFromMdm, +} + +impl ConfigLayerSource { + /// A settings from a layer with a higher precedence will override a setting + /// from a layer with a lower precedence. + pub fn precedence(&self) -> i16 { + match self { + ConfigLayerSource::Mdm { .. } => 0, + ConfigLayerSource::System { .. } => 10, + ConfigLayerSource::User { .. } => 20, + ConfigLayerSource::Project { .. } => 25, + ConfigLayerSource::SessionFlags => 30, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, + } + } +} + +/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from +/// layer `A` will be overridden by settings from layer `B`. +impl PartialOrd for ConfigLayerSource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.precedence().cmp(&other.precedence())) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional profile-level override for where approval requests + /// are routed for review. If omitted, the enclosing config default is + /// used. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub service_tier: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub web_search: Option, + pub tools: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AnalyticsConfig { + pub enabled: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_enabled")] + pub destructive_enabled: bool, + #[serde(default = "default_enabled")] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + pub destructive_enabled: Option, + pub open_world_enabled: Option, + pub default_tools_approval_mode: Option, + pub default_tools_enabled: Option, + pub tools: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsConfig { + #[serde(default, rename = "_default")] + pub default: Option, + #[serde(default, flatten)] + pub apps: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional default for where approval requests are routed for + /// review. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub web_search: Option, + pub tools: Option, + pub profile: Option, + #[experimental(nested)] + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub service_tier: Option, + pub analytics: Option, + #[experimental("config/read.apps")] + #[serde(default)] + pub apps: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayerMetadata { + pub name: ConfigLayerSource, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayer { + pub name: ConfigLayerSource, + pub version: String, + pub config: JsonValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum MergeStrategy { + Replace, + Upsert, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WriteStatus { + Ok, + OkOverridden, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct OverriddenMetadata { + pub message: String, + pub overriding_layer: ConfigLayerMetadata, + pub effective_value: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWriteResponse { + pub status: WriteStatus, + pub version: String, + /// Canonical path to the config file that was written. + pub file_path: AbsolutePathBuf, + pub overridden_metadata: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ConfigWriteErrorCode { + ConfigLayerReadonly, + ConfigVersionConflict, + ConfigValidationError, + ConfigPathNotFound, + ConfigSchemaUnknownKey, + UserLayerNotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadParams { + #[serde(default)] + pub include_layers: bool, + /// Optional working directory to resolve project config layers. If specified, + /// return the effective config as seen from that directory (i.e., including any + /// project layers between `cwd` and the project/repo root). + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadResponse { + #[experimental(nested)] + pub config: Config, + pub origins: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub layers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirements { + #[experimental(nested)] + pub allowed_approval_policies: Option>, + #[experimental("configRequirements/read.allowedApprovalsReviewers")] + pub allowed_approvals_reviewers: Option>, + pub allowed_sandbox_modes: Option>, + pub allowed_web_search_modes: Option>, + pub feature_requirements: Option>, + #[experimental("configRequirements/read.hooks")] + pub hooks: Option, + pub enforce_residency: Option, + #[experimental("configRequirements/read.network")] + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ManagedHooksRequirements { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(rename = "PreToolUse")] + #[ts(rename = "PreToolUse")] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest")] + #[ts(rename = "PermissionRequest")] + pub permission_request: Vec, + #[serde(rename = "PostToolUse")] + #[ts(rename = "PostToolUse")] + pub post_tool_use: Vec, + #[serde(rename = "PreCompact")] + #[ts(rename = "PreCompact")] + pub pre_compact: Vec, + #[serde(rename = "PostCompact")] + #[ts(rename = "PostCompact")] + pub post_compact: Vec, + #[serde(rename = "SessionStart")] + #[ts(rename = "SessionStart")] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit")] + #[ts(rename = "UserPromptSubmit")] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop")] + #[ts(rename = "Stop")] + pub stop: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfiguredHookMatcherGroup { + pub matcher: Option, + pub hooks: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ConfiguredHookHandler { + #[serde(rename = "command")] + #[ts(rename = "command")] + Command { + command: String, + #[serde(rename = "timeoutSec")] + #[ts(rename = "timeoutSec")] + timeout_sec: Option, + r#async: bool, + #[serde(rename = "statusMessage")] + #[ts(rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + #[ts(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + #[ts(rename = "agent")] + Agent {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkRequirements { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + /// Canonical network permission map for `experimental_network`. + pub domains: Option>, + /// When true, only managed allowlist entries are respected while managed + /// network enforcement is active. + pub managed_allowed_domains_only: Option, + /// Legacy compatibility view derived from `domains`. + pub allowed_domains: Option>, + /// Legacy compatibility view derived from `domains`. + pub denied_domains: Option>, + /// Canonical unix socket permission map for `experimental_network`. + pub unix_sockets: Option>, + /// Legacy compatibility view derived from `unix_sockets`. + pub allow_unix_sockets: Option>, + pub allow_local_binding: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkDomainPermission { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ResidencyRequirement { + Us, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirementsReadResponse { + /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). + #[experimental(nested)] + pub requirements: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum ExternalAgentConfigMigrationItemType { + #[serde(rename = "AGENTS_MD")] + #[ts(rename = "AGENTS_MD")] + AgentsMd, + #[serde(rename = "CONFIG")] + #[ts(rename = "CONFIG")] + Config, + #[serde(rename = "SKILLS")] + #[ts(rename = "SKILLS")] + Skills, + #[serde(rename = "PLUGINS")] + #[ts(rename = "PLUGINS")] + Plugins, + #[serde(rename = "MCP_SERVER_CONFIG")] + #[ts(rename = "MCP_SERVER_CONFIG")] + McpServerConfig, + #[serde(rename = "SUBAGENTS")] + #[ts(rename = "SUBAGENTS")] + Subagents, + #[serde(rename = "HOOKS")] + #[ts(rename = "HOOKS")] + Hooks, + #[serde(rename = "COMMANDS")] + #[ts(rename = "COMMANDS")] + Commands, + #[serde(rename = "SESSIONS")] + #[ts(rename = "SESSIONS")] + Sessions, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginsMigration { + #[serde(rename = "marketplaceName")] + #[ts(rename = "marketplaceName")] + pub marketplace_name: String, + #[serde(rename = "pluginNames")] + #[ts(rename = "pluginNames")] + pub plugin_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SessionMigration { + pub path: PathBuf, + pub cwd: PathBuf, + pub title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SubagentMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MigrationDetails { + #[serde(default)] + pub plugins: Vec, + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub mcp_servers: Vec, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub subagents: Vec, + #[serde(default)] + pub commands: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigMigrationItem { + pub item_type: ExternalAgentConfigMigrationItemType, + pub description: String, + /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. + pub cwd: Option, + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectResponse { + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectParams { + /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_home: bool, + /// Zero or more working directories to include for repo-scoped detection. + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportParams { + pub migration_items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportCompletedNotification {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigValueWriteParams { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigBatchWriteParams { + pub edits: Vec, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, + /// When true, hot-reload the updated user config into all loaded threads after writing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reload_user_config: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigEdit { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextPosition { + /// 1-based line number. + pub line: usize, + /// 1-based column number (in Unicode scalar values). + pub column: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWarningNotification { + /// Concise summary of the warning. + pub summary: String, + /// Optional extra guidance or error details. + pub details: Option, + /// Optional path to the config file that triggered the warning. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub path: Option, + /// Optional range for the error location inside the config file. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub range: Option, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs b/code-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs new file mode 100644 index 00000000000..6adc21b6ef7 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs @@ -0,0 +1,85 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ExperimentalFeatureStage { + /// Feature is available for user testing and feedback. + Beta, + /// Feature is still being built and not ready for broad use. + UnderDevelopment, + /// Feature is production-ready. + Stable, + /// Feature is deprecated and should be avoided. + Deprecated, + /// Feature flag is retained only for backwards compatibility. + Removed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeature { + /// Stable key used in config.toml and CLI flag toggles. + pub name: String, + /// Lifecycle stage of this feature flag. + pub stage: ExperimentalFeatureStage, + /// User-facing display name shown in the experimental features UI. + /// Null when this feature is not in beta. + pub display_name: Option, + /// Short summary describing what the feature does. + /// Null when this feature is not in beta. + pub description: Option, + /// Announcement copy shown to users when the feature is introduced. + /// Null when this feature is not in beta. + pub announcement: Option, + /// Whether this feature is currently enabled in the loaded config. + pub enabled: bool, + /// Whether this feature is enabled by default. + pub default_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: BTreeMap, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/feedback.rs b/code-rs/app-server-protocol/src/protocol/v2/feedback.rs new file mode 100644 index 00000000000..aaf966a4bfc --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/feedback.rs @@ -0,0 +1,29 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadParams { + pub classification: String, + #[ts(optional = nullable)] + pub reason: Option, + #[ts(optional = nullable)] + pub thread_id: Option, + pub include_logs: bool, + #[ts(optional = nullable)] + pub extra_log_files: Option>, + #[ts(optional = nullable)] + pub tags: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadResponse { + pub thread_id: String, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/fs.rs b/code-rs/app-server-protocol/src/protocol/v2/fs.rs new file mode 100644 index 00000000000..0132c6b2848 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/fs.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Read a file from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileParams { + /// Absolute path to read. + pub path: AbsolutePathBuf, +} + +/// Base64-encoded file contents returned by `fs/readFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileResponse { + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Write a file on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileParams { + /// Absolute path to write. + pub path: AbsolutePathBuf, + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Successful response for `fs/writeFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileResponse {} + +/// Create a directory on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryParams { + /// Absolute directory path to create. + pub path: AbsolutePathBuf, + /// Whether parent directories should also be created. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, +} + +/// Successful response for `fs/createDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryResponse {} + +/// Request metadata for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataParams { + /// Absolute path to inspect. + pub path: AbsolutePathBuf, +} + +/// Metadata returned by `fs/getMetadata`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataResponse { + /// Whether the path resolves to a directory. + pub is_directory: bool, + /// Whether the path resolves to a regular file. + pub is_file: bool, + /// Whether the path itself is a symbolic link. + pub is_symlink: bool, + /// File creation time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub created_at_ms: i64, + /// File modification time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub modified_at_ms: i64, +} + +/// List direct child names for a directory. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryParams { + /// Absolute directory path to read. + pub path: AbsolutePathBuf, +} + +/// A directory entry returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryEntry { + /// Direct child entry name only, not an absolute or relative path. + pub file_name: String, + /// Whether this entry resolves to a directory. + pub is_directory: bool, + /// Whether this entry resolves to a regular file. + pub is_file: bool, +} + +/// Directory entries returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryResponse { + /// Direct child entries in the requested directory. + pub entries: Vec, +} + +/// Remove a file or directory tree from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveParams { + /// Absolute path to remove. + pub path: AbsolutePathBuf, + /// Whether directory removal should recurse. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, + /// Whether missing paths should be ignored. Defaults to `true`. + #[ts(optional = nullable)] + pub force: Option, +} + +/// Successful response for `fs/remove`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveResponse {} + +/// Copy a file or directory tree on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyParams { + /// Absolute source path. + pub source_path: AbsolutePathBuf, + /// Absolute destination path. + pub destination_path: AbsolutePathBuf, + /// Required for directory copies; ignored for file copies. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recursive: bool, +} + +/// Successful response for `fs/copy`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyResponse {} + +/// Start filesystem watch notifications for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchParams { + /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + pub watch_id: String, + /// Absolute file or directory path to watch. + pub path: AbsolutePathBuf, +} + +/// Successful response for `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchResponse { + /// Canonicalized path associated with the watch. + pub path: AbsolutePathBuf, +} + +/// Stop filesystem watch notifications for a prior `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchParams { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, +} + +/// Successful response for `fs/unwatch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchResponse {} + +/// Filesystem watch notification emitted for `fs/watch` subscribers. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsChangedNotification { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, + /// File or directory paths associated with this event. + pub changed_paths: Vec, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/hook.rs b/code-rs/app-server-protocol/src/protocol/v2/hook.rs new file mode 100644 index 00000000000..4a07bd495b4 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -0,0 +1,154 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::protocol::HookEventName as CoreHookEventName; +use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode; +use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType; +use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; +use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; +use codex_protocol::protocol::HookScope as CoreHookScope; +use codex_protocol::protocol::HookSource as CoreHookSource; +use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum HookEventName from CoreHookEventName { + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, Stop + } +); + +v2_enum_from_core!( + pub enum HookHandlerType from CoreHookHandlerType { + Command, Prompt, Agent + } +); + +v2_enum_from_core!( + pub enum HookExecutionMode from CoreHookExecutionMode { + Sync, Async + } +); + +v2_enum_from_core!( + pub enum HookScope from CoreHookScope { + Thread, Turn + } +); + +v2_enum_from_core!( + pub enum HookSource from CoreHookSource { + System, + User, + Project, + Mdm, + SessionFlags, + Plugin, + CloudRequirements, + LegacyManagedConfigFile, + LegacyManagedConfigMdm, + Unknown, + } +); + +v2_enum_from_core!( + pub enum HookTrustStatus from CoreHookTrustStatus { + Managed, Untrusted, Trusted, Modified + } +); + +fn default_hook_source() -> HookSource { + HookSource::Unknown +} + +v2_enum_from_core!( + pub enum HookRunStatus from CoreHookRunStatus { + Running, Completed, Failed, Blocked, Stopped + } +); + +v2_enum_from_core!( + pub enum HookOutputEntryKind from CoreHookOutputEntryKind { + Warning, Stop, Feedback, Context, Error + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookOutputEntry { + pub kind: HookOutputEntryKind, + pub text: String, +} + +impl From for HookOutputEntry { + fn from(value: CoreHookOutputEntry) -> Self { + Self { + kind: value.kind.into(), + text: value.text, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookRunSummary { + pub id: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub execution_mode: HookExecutionMode, + pub scope: HookScope, + pub source_path: AbsolutePathBuf, + #[serde(default = "default_hook_source")] + pub source: HookSource, + pub display_order: i64, + pub status: HookRunStatus, + pub status_message: Option, + pub started_at: i64, + pub completed_at: Option, + pub duration_ms: Option, + pub entries: Vec, +} + +impl From for HookRunSummary { + fn from(value: CoreHookRunSummary) -> Self { + Self { + id: value.id, + event_name: value.event_name.into(), + handler_type: value.handler_type.into(), + execution_mode: value.execution_mode.into(), + scope: value.scope.into(), + source_path: value.source_path, + source: value.source.into(), + display_order: value.display_order, + status: value.status.into(), + status_message: value.status_message, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, + entries: value.entries.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookStartedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookCompletedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/item.rs b/code-rs/app-server-protocol/src/protocol/v2/item.rs new file mode 100644 index 00000000000..0e22c485900 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/item.rs @@ -0,0 +1,1447 @@ +use super::AdditionalPermissionProfile; +use super::ExecPolicyAmendment; +use super::McpToolCallError; +use super::McpToolCallResult; +use super::NetworkApprovalContext; +use super::NetworkApprovalProtocol; +use super::NetworkPolicyAmendment; +use super::RequestPermissionProfile; +use super::UserInput; +use super::shared::v2_enum_from_core; +use crate::protocol::item_builders::convert_patch_changes; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; +use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; +use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; +use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::TurnItem as CoreTurnItem; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; +use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use serde_with::serde_as; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionApprovalDecision { + /// User approved the command. + Accept, + /// User approved the command and future prompts in the same session-scoped + /// approval cache should run without prompting. + AcceptForSession, + /// User approved the command, and wants to apply the proposed execpolicy amendment so future + /// matching commands can run without prompting. + AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment, + }, + /// User chose a persistent network policy rule (allow/deny) for this host. + ApplyNetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment, + }, + /// User denied the command. The agent will continue the turn. + Decline, + /// User denied the command. The turn will also be immediately interrupted. + Cancel, +} + +impl From for CommandExecutionApprovalDecision { + fn from(value: CoreReviewDecision) -> Self { + match value { + CoreReviewDecision::Approved => Self::Accept, + CoreReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } => Self::AcceptWithExecpolicyAmendment { + execpolicy_amendment: proposed_execpolicy_amendment.into(), + }, + CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, + CoreReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => Self::ApplyNetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into(), + }, + CoreReviewDecision::Abort => Self::Cancel, + CoreReviewDecision::Denied => Self::Decline, + CoreReviewDecision::TimedOut => Self::Decline, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum FileChangeApprovalDecision { + /// User approved the file changes. + Accept, + /// User approved the file changes and future changes to the same files should run without prompting. + AcceptForSession, + /// User denied the file changes. The agent will continue the turn. + Decline, + /// User denied the file changes. The turn will also be immediately interrupted. + Cancel, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum CommandAction { + Read { + command: String, + name: String, + path: AbsolutePathBuf, + }, + ListFiles { + command: String, + path: Option, + }, + Search { + command: String, + query: Option, + path: Option, + }, + Unknown { + command: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitation { + pub entries: Vec, + pub thread_ids: Vec, +} + +impl From for MemoryCitation { + fn from(value: CoreMemoryCitation) -> Self { + Self { + entries: value.entries.into_iter().map(Into::into).collect(), + thread_ids: value.rollout_ids, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitationEntry { + pub path: String, + pub line_start: u32, + pub line_end: u32, + pub note: String, +} + +impl From for MemoryCitationEntry { + fn from(value: CoreMemoryCitationEntry) -> Self { + Self { + path: value.path, + line_start: value.line_start, + line_end: value.line_end, + note: value.note, + } + } +} + +impl CommandAction { + pub fn into_core(self) -> CoreParsedCommand { + match self { + CommandAction::Read { + command: cmd, + name, + path, + } => CoreParsedCommand::Read { + cmd, + name, + path: path.into_path_buf(), + }, + CommandAction::ListFiles { command: cmd, path } => { + CoreParsedCommand::ListFiles { cmd, path } + } + CommandAction::Search { + command: cmd, + query, + path, + } => CoreParsedCommand::Search { cmd, query, path }, + CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, + } + } + + pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { + match value { + CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { + command: cmd, + name, + path: cwd.join(path), + }, + CoreParsedCommand::ListFiles { cmd, path } => { + CommandAction::ListFiles { command: cmd, path } + } + CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { + command: cmd, + query, + path, + }, + CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadItem { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + UserMessage { id: String, content: Vec }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + HookPrompt { + id: String, + fragments: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AgentMessage { + id: String, + text: String, + #[serde(default)] + phase: Option, + #[serde(default)] + memory_citation: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + /// EXPERIMENTAL - proposed plan item content. The completed plan item is + /// authoritative and may not match the concatenation of `PlanDelta` text. + Plan { id: String, text: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Reasoning { + id: String, + #[serde(default)] + summary: Vec, + #[serde(default)] + content: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CommandExecution { + id: String, + /// The command to be executed. + command: String, + /// The command's working directory. + cwd: AbsolutePathBuf, + /// Identifier for the underlying PTY process (when available). + process_id: Option, + #[serde(default)] + source: CommandExecutionSource, + status: CommandExecutionStatus, + /// A best-effort parsing of the command to understand the action(s) it will perform. + /// This returns a list of CommandAction objects because a single shell command may + /// be composed of many commands piped together. + command_actions: Vec, + /// The command's output, aggregated from stdout and stderr. + aggregated_output: Option, + /// The command's exit code. + exit_code: Option, + /// The duration of the command execution in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + FileChange { + id: String, + changes: Vec, + status: PatchApplyStatus, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + id: String, + server: String, + tool: String, + status: McpToolCallStatus, + arguments: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + mcp_app_resource_uri: Option, + result: Option>, + error: Option, + /// The duration of the MCP tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + DynamicToolCall { + id: String, + namespace: Option, + tool: String, + arguments: JsonValue, + status: DynamicToolCallStatus, + content_items: Option>, + success: Option, + /// The duration of the dynamic tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CollabAgentToolCall { + /// Unique identifier for this collab tool call. + id: String, + /// Name of the collab tool that was invoked. + tool: CollabAgentTool, + /// Current status of the collab tool call. + status: CollabAgentToolCallStatus, + /// Thread ID of the agent issuing the collab request. + sender_thread_id: String, + /// Thread ID of the receiving agent, when applicable. In case of spawn operation, + /// this corresponds to the newly spawned agent. + receiver_thread_ids: Vec, + /// Prompt text sent as part of the collab tool call, when available. + prompt: Option, + /// Model requested for the spawned agent, when applicable. + model: Option, + /// Reasoning effort requested for the spawned agent, when applicable. + reasoning_effort: Option, + /// Last known status of the target agents, when available. + agents_states: HashMap, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WebSearch { + id: String, + query: String, + action: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageView { id: String, path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageGeneration { + id: String, + status: String, + revised_prompt: Option, + result: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + saved_path: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + EnteredReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExitedReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ContextCompaction { id: String }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_id: String, +} + +impl ThreadItem { + pub fn id(&self) -> &str { + match self { + ThreadItem::UserMessage { id, .. } + | ThreadItem::HookPrompt { id, .. } + | ThreadItem::AgentMessage { id, .. } + | ThreadItem::Plan { id, .. } + | ThreadItem::Reasoning { id, .. } + | ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageView { id, .. } + | ThreadItem::ImageGeneration { id, .. } + | ThreadItem::EnteredReviewMode { id, .. } + | ThreadItem::ExitedReviewMode { id, .. } + | ThreadItem::ContextCompaction { id, .. } => id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Lifecycle state for an approval auto-review. +pub enum GuardianApprovalReviewStatus { + InProgress, + Approved, + Denied, + TimedOut, + Aborted, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Source that produced a terminal approval auto-review decision. +pub enum AutoReviewDecisionSource { + Agent, +} + +impl From for AutoReviewDecisionSource { + fn from(value: CoreGuardianAssessmentDecisionSource) -> Self { + match value { + CoreGuardianAssessmentDecisionSource::Agent => Self::Agent, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Risk level assigned by approval auto-review. +pub enum GuardianRiskLevel { + Low, + Medium, + High, + Critical, +} + +impl From for GuardianRiskLevel { + fn from(value: CoreGuardianRiskLevel) -> Self { + match value { + CoreGuardianRiskLevel::Low => Self::Low, + CoreGuardianRiskLevel::Medium => Self::Medium, + CoreGuardianRiskLevel::High => Self::High, + CoreGuardianRiskLevel::Critical => Self::Critical, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Authorization level assigned by approval auto-review. +pub enum GuardianUserAuthorization { + Unknown, + Low, + Medium, + High, +} + +impl From for GuardianUserAuthorization { + fn from(value: CoreGuardianUserAuthorization) -> Self { + match value { + CoreGuardianUserAuthorization::Unknown => Self::Unknown, + CoreGuardianUserAuthorization::Low => Self::Low, + CoreGuardianUserAuthorization::Medium => Self::Medium, + CoreGuardianUserAuthorization::High => Self::High, + } + } +} + +/// [UNSTABLE] Temporary approval auto-review payload used by +/// `item/autoApprovalReview/*` notifications. This shape is expected to change +/// soon. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApprovalReview { + pub status: GuardianApprovalReviewStatus, + pub risk_level: Option, + pub user_authorization: Option, + pub rationale: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianCommandSource { + Shell, + UnifiedExec, +} + +impl From for GuardianCommandSource { + fn from(value: CoreGuardianCommandSource) -> Self { + match value { + CoreGuardianCommandSource::Shell => Self::Shell, + CoreGuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +impl From for CoreGuardianCommandSource { + fn from(value: GuardianCommandSource) -> Self { + match value { + GuardianCommandSource::Shell => Self::Shell, + GuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianCommandReviewAction { + pub source: GuardianCommandSource, + pub command: String, + pub cwd: AbsolutePathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianExecveReviewAction { + pub source: GuardianCommandSource, + pub program: String, + pub argv: Vec, + pub cwd: AbsolutePathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApplyPatchReviewAction { + pub cwd: AbsolutePathBuf, + pub files: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianNetworkAccessReviewAction { + pub target: String, + pub host: String, + pub protocol: NetworkApprovalProtocol, + pub port: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianMcpToolCallReviewAction { + pub server: String, + pub tool_name: String, + pub connector_id: Option, + pub connector_name: Option, + pub tool_title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianRequestPermissionsReviewAction { + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianApprovalReviewAction { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Command { + source: GuardianCommandSource, + command: String, + cwd: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Execve { + source: GuardianCommandSource, + program: String, + argv: Vec, + cwd: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ApplyPatch { + cwd: AbsolutePathBuf, + files: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + NetworkAccess { + target: String, + host: String, + protocol: NetworkApprovalProtocol, + port: u16, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + server: String, + tool_name: String, + connector_id: Option, + connector_name: Option, + tool_title: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RequestPermissions { + reason: Option, + permissions: RequestPermissionProfile, + }, +} + +impl From for GuardianApprovalReviewAction { + fn from(value: CoreGuardianAssessmentAction) -> Self { + match value { + CoreGuardianAssessmentAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + CoreGuardianAssessmentAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + CoreGuardianAssessmentAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + CoreGuardianAssessmentAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.into(), + port, + }, + CoreGuardianAssessmentAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + CoreGuardianAssessmentAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +impl From for CoreGuardianAssessmentAction { + fn from(value: GuardianApprovalReviewAction) -> Self { + match value { + GuardianApprovalReviewAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + GuardianApprovalReviewAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + GuardianApprovalReviewAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + GuardianApprovalReviewAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.to_core(), + port, + }, + GuardianApprovalReviewAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + GuardianApprovalReviewAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WebSearchAction { + Search { + query: Option, + queries: Option>, + }, + OpenPage { + url: Option, + }, + FindInPage { + url: Option, + pattern: Option, + }, + #[serde(other)] + Other, +} + +impl From for WebSearchAction { + fn from(value: codex_protocol::models::WebSearchAction) -> Self { + match value { + codex_protocol::models::WebSearchAction::Search { query, queries } => { + WebSearchAction::Search { query, queries } + } + codex_protocol::models::WebSearchAction::OpenPage { url } => { + WebSearchAction::OpenPage { url } + } + codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { + WebSearchAction::FindInPage { url, pattern } + } + codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, + } + } +} + +impl From for ThreadItem { + fn from(value: CoreTurnItem) -> Self { + match value { + CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { + id: user.id, + content: user.content.into_iter().map(UserInput::from).collect(), + }, + CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(HookPromptFragment::from) + .collect(), + }, + CoreTurnItem::AgentMessage(agent) => { + let text = agent + .content + .into_iter() + .map(|entry| match entry { + CoreAgentMessageContent::Text { text } => text, + }) + .collect::(); + ThreadItem::AgentMessage { + id: agent.id, + text, + phase: agent.phase, + memory_citation: agent.memory_citation.map(Into::into), + } + } + CoreTurnItem::Plan(plan) => ThreadItem::Plan { + id: plan.id, + text: plan.text, + }, + CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { + id: reasoning.id, + summary: reasoning.summary_text, + content: reasoning.raw_content, + }, + CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { + id: search.id, + query: search.query, + action: Some(WebSearchAction::from(search.action)), + }, + CoreTurnItem::ImageView(image) => ThreadItem::ImageView { + id: image.id, + path: image.path, + }, + CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { + id: image.id, + status: image.status, + revised_prompt: image.revised_prompt, + result: image.result, + saved_path: image.saved_path, + }, + CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { + id: file_change.id, + changes: convert_patch_changes(&file_change.changes), + status: file_change + .status + .as_ref() + .map(PatchApplyStatus::from) + .unwrap_or(PatchApplyStatus::InProgress), + }, + CoreTurnItem::McpToolCall(mcp) => { + let duration_ms = mcp + .duration + .and_then(|duration| i64::try_from(duration.as_millis()).ok()); + + ThreadItem::McpToolCall { + id: mcp.id, + server: mcp.server, + tool: mcp.tool, + status: McpToolCallStatus::from(mcp.status), + arguments: mcp.arguments, + mcp_app_resource_uri: mcp.mcp_app_resource_uri, + result: mcp.result.map(McpToolCallResult::from).map(Box::new), + error: mcp.error.map(McpToolCallError::from), + duration_ms, + } + } + CoreTurnItem::ContextCompaction(compaction) => { + ThreadItem::ContextCompaction { id: compaction.id } + } + } + } +} + +impl From for HookPromptFragment { + fn from(value: codex_protocol::items::HookPromptFragment) -> Self { + Self { + text: value.text, + hook_run_id: value.hook_run_id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for CommandExecutionStatus { + fn from(value: CoreExecCommandStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CoreExecCommandStatus> for CommandExecutionStatus { + fn from(value: &CoreExecCommandStatus) -> Self { + match value { + CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed, + CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed, + CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined, + } + } +} + +v2_enum_from_core! { + #[derive(Default)] + pub enum CommandExecutionSource from CoreExecCommandSource { + #[default] + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentTool { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileUpdateChange { + pub path: String, + pub kind: PatchChangeKind, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PatchChangeKind { + Add, + Delete, + Update { move_path: Option }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchApplyStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for PatchApplyStatus { + fn from(value: CorePatchApplyStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CorePatchApplyStatus> for PatchApplyStatus { + fn from(value: &CorePatchApplyStatus) -> Self { + match value { + CorePatchApplyStatus::Completed => PatchApplyStatus::Completed, + CorePatchApplyStatus::Failed => PatchApplyStatus::Failed, + CorePatchApplyStatus::Declined => PatchApplyStatus::Declined, + } + } +} + +impl From for McpToolCallStatus { + fn from(value: CoreMcpToolCallStatus) -> Self { + match value { + CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress, + CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed, + CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentStatus { + PendingInit, + Running, + Interrupted, + Completed, + Errored, + Shutdown, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollabAgentState { + pub status: CollabAgentStatus, + pub message: Option, +} + +impl From for CollabAgentState { + fn from(value: CoreAgentStatus) -> Self { + match value { + CoreAgentStatus::PendingInit => Self { + status: CollabAgentStatus::PendingInit, + message: None, + }, + CoreAgentStatus::Running => Self { + status: CollabAgentStatus::Running, + message: None, + }, + CoreAgentStatus::Interrupted => Self { + status: CollabAgentStatus::Interrupted, + message: None, + }, + CoreAgentStatus::Completed(message) => Self { + status: CollabAgentStatus::Completed, + message, + }, + CoreAgentStatus::Errored(message) => Self { + status: CollabAgentStatus::Errored, + message: Some(message), + }, + CoreAgentStatus::Shutdown => Self { + status: CollabAgentStatus::Shutdown, + message: None, + }, + CoreAgentStatus::NotFound => Self { + status: CollabAgentStatus::NotFound, + message: None, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemStartedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle started. + #[ts(type = "number")] + pub started_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewStartedNotification { + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this review started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewCompletedNotification { + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this review started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Unix timestamp (in milliseconds) when this review completed. + #[ts(type = "number")] + pub completed_at_ms: i64, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub decision_source: AutoReviewDecisionSource, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemCompletedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle completed. + #[ts(type = "number")] + pub completed_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RawResponseItemCompletedNotification { + pub thread_id: String, + pub turn_id: String, + pub item: ResponseItem, +} + +// Item-specific progress notifications +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AgentMessageDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should +/// not assume concatenated deltas match the completed plan item content. +pub struct PlanDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryPartAddedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub content_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} +/// Deprecated legacy notification for `apply_patch` textual output. +/// +/// The server no longer emits this notification. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangePatchUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub changes: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Unique identifier for this specific approval callback. + /// + /// For regular shell/unified_exec approvals, this is null. + /// + /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to + /// one parent `itemId`, so `approvalId` is a distinct opaque callback id + /// (a UUID) used to disambiguate routing. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub approval_id: Option, + /// Optional explanatory reason (e.g. request for network access). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub reason: Option, + /// Optional context for a managed-network approval prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub network_approval_context: Option, + /// The command to be executed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command: Option, + /// The command's working directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub cwd: Option, + /// Best-effort parsed command actions for friendly display. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command_actions: Option>, + /// Optional additional permissions requested for this command. + #[experimental("item/commandExecution/requestApproval.additionalPermissions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub additional_permissions: Option, + /// Optional proposed execpolicy amendment to allow similar commands without prompting. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_execpolicy_amendment: Option, + /// Optional proposed network policy amendments (allow/deny host) for future requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_network_policy_amendments: Option>, + /// Ordered list of decisions the client may present for this prompt. + #[experimental("item/commandExecution/requestApproval.availableDecisions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub available_decisions: Option>, +} + +impl CommandExecutionRequestApprovalParams { + pub fn strip_experimental_fields(&mut self) { + // TODO: Avoid hardcoding individual experimental fields here. + // We need a generic outbound compatibility design for stripping or + // otherwise handling experimental server->client payloads. + self.additional_permissions = None; + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalResponse { + pub decision: CommandExecutionApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Optional explanatory reason (e.g. request for extra write access). + #[ts(optional = nullable)] + pub reason: Option, + /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root + /// for the remainder of the session (unclear if this is honored today). + #[ts(optional = nullable)] + pub grant_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalResponse { + pub decision: FileChangeApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallParams { + pub thread_id: String, + pub turn_id: String, + pub call_id: String, + pub namespace: Option, + pub tool: String, + pub arguments: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallResponse { + pub content_items: Vec, + pub success: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallOutputContentItem { + #[serde(rename_all = "camelCase")] + InputText { text: String }, + #[serde(rename_all = "camelCase")] + InputImage { image_url: String }, +} + +impl From + for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem +{ + fn from(item: DynamicToolCallOutputContentItem) -> Self { + match item { + DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, + DynamicToolCallOutputContentItem::InputImage { image_url } => { + Self::InputImage { image_url } + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_user_input. +pub struct ToolRequestUserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Represents one request_user_input question and its required options. +pub struct ToolRequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(default)] + pub is_other: bool, + #[serde(default)] + pub is_secret: bool, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_user_input event. +pub struct ToolRequestUserInputParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +pub struct ToolRequestUserInputAnswer { + pub answers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload mapping question ids to answers. +pub struct ToolRequestUserInputResponse { + pub answers: HashMap, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/mcp.rs b/code-rs/app-server-protocol/src/protocol/v2/mcp.rs new file mode 100644 index 00000000000..9fd93840768 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -0,0 +1,703 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; +use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::Resource as McpResource; +pub use codex_protocol::mcp::ResourceContent as McpResourceContent; +use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; +use codex_protocol::mcp::Tool as McpTool; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a server-defined value. + #[ts(optional = nullable)] + pub limit: Option, + /// Controls how much MCP inventory data to fetch for each server. + /// Defaults to `Full` when omitted. + #[ts(optional = nullable)] + pub detail: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum McpServerStatusDetail { + Full, + ToolsAndAuthOnly, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatus { + pub name: String, + pub tools: std::collections::HashMap, + pub resources: Vec, + pub resource_templates: Vec, + pub auth_status: McpAuthStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadParams { + #[ts(optional = nullable)] + pub thread_id: Option, + pub server: String, + pub uri: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadResponse { + pub contents: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallParams { + pub thread_id: String, + pub server: String, + pub tool: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub arguments: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallResponse { + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub structured_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub is_error: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallResult { + // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust + // representation of MCP content blocks. We intentionally use `serde_json::Value` here because + // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types + // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust + // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. + pub content: Vec, + pub structured_content: Option, + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallError { + pub message: String, +} + +impl From for McpServerToolCallResponse { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + is_error: result.is_error, + meta: result.meta, + } + } +} + +impl From for McpToolCallResult { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + meta: result.meta, + } + } +} + +impl From for McpToolCallError { + fn from(error: CoreMcpToolCallError) -> Self { + Self { + message: error.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallProgressNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerStartupState { + Starting, + Ready, + Failed, + Cancelled, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatusUpdatedNotification { + pub name: String, + pub status: McpServerStartupState, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationAction { + Accept, + Decline, + Cancel, +} + +impl McpServerElicitationAction { + pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { + match self { + Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, + Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, + Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, + } + } +} + +impl From for rmcp::model::ElicitationAction { + fn from(value: McpServerElicitationAction) -> Self { + match value { + McpServerElicitationAction::Accept => Self::Accept, + McpServerElicitationAction::Decline => Self::Decline, + McpServerElicitationAction::Cancel => Self::Cancel, + } + } +} + +impl From for McpServerElicitationAction { + fn from(value: rmcp::model::ElicitationAction) -> Self { + match value { + rmcp::model::ElicitationAction::Accept => Self::Accept, + rmcp::model::ElicitationAction::Decline => Self::Decline, + rmcp::model::ElicitationAction::Cancel => Self::Cancel, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestParams { + pub thread_id: String, + /// Active Codex turn when this elicitation was observed, if app-server could correlate one. + /// + /// This is nullable because MCP models elicitation as a standalone server-to-client request + /// identified by the MCP server request id. It may be triggered during a turn, but turn + /// context is app-server correlation rather than part of the protocol identity of the + /// elicitation itself. + pub turn_id: Option, + pub server_name: String, + #[serde(flatten)] + pub request: McpServerElicitationRequest, + // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated + // McpToolCall item id here as an optional field. The current core event does not carry that + // association. +} + +/// Typed form schema for MCP `elicitation/create` requests. +/// +/// This matches the `requestedSchema` shape from the MCP 2025-11-25 +/// `ElicitRequestFormParams` schema. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationSchema { + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "$schema")] + pub schema_uri: Option, + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationObjectType, + pub properties: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub required: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationObjectType { + Object, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationPrimitiveSchema { + Enum(McpElicitationEnumSchema), + String(McpElicitationStringSchema), + Number(McpElicitationNumberSchema), + Boolean(McpElicitationBooleanSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationStringSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationStringType { + String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum McpElicitationStringFormat { + Email, + Uri, + Date, + DateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationNumberSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationNumberType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub maximum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationNumberType { + Number, + Integer, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationBooleanSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationBooleanType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationBooleanType { + Boolean, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationEnumSchema { + SingleSelect(McpElicitationSingleSelectEnumSchema), + MultiSelect(McpElicitationMultiSelectEnumSchema), + Legacy(McpElicitationLegacyTitledEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationLegacyTitledEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "enumNames")] + pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationSingleSelectEnumSchema { + Untitled(McpElicitationUntitledSingleSelectEnumSchema), + Titled(McpElicitationTitledSingleSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "oneOf")] + #[ts(rename = "oneOf")] + pub one_of: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationMultiSelectEnumSchema { + Untitled(McpElicitationUntitledMultiSelectEnumSchema), + Titled(McpElicitationTitledMultiSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationUntitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationTitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationArrayType { + Array, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledEnumItems { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledEnumItems { + #[serde(rename = "anyOf", alias = "oneOf")] + #[ts(rename = "anyOf")] + pub any_of: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationConstOption { + #[serde(rename = "const")] + #[ts(rename = "const")] + pub const_: String, + pub title: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationRequest { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Form { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + requested_schema: McpElicitationSchema, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Url { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + url: String, + elicitation_id: String, + }, +} + +impl TryFrom for McpServerElicitationRequest { + type Error = serde_json::Error; + + fn try_from(value: CoreElicitationRequest) -> Result { + match value { + CoreElicitationRequest::Form { + meta, + message, + requested_schema, + } => Ok(Self::Form { + meta, + message, + requested_schema: serde_json::from_value(requested_schema)?, + }), + CoreElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } => Ok(Self::Url { + meta, + message, + url, + elicitation_id, + }), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestResponse { + pub action: McpServerElicitationAction, + /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. + /// + /// This is nullable because decline/cancel responses have no content. + pub content: Option, + /// Optional client metadata for form-mode action handling. + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +impl From for rmcp::model::CreateElicitationResult { + fn from(value: McpServerElicitationRequestResponse) -> Self { + Self { + action: value.action.into(), + content: value.content, + } + } +} + +impl From for McpServerElicitationRequestResponse { + fn from(value: rmcp::model::CreateElicitationResult) -> Self { + Self { + action: value.action.into(), + content: value.content, + meta: None, + } + } +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/mod.rs b/code-rs/app-server-protocol/src/protocol/v2/mod.rs new file mode 100644 index 00000000000..275e7ca45b4 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -0,0 +1,53 @@ +mod shared; + +mod account; +mod apps; +mod collaboration_mode; +mod command_exec; +mod config; +mod experimental_feature; +mod feedback; +mod fs; +mod hook; +mod item; +mod mcp; +mod model; +mod notification; +mod permissions; +mod plugin; +mod process; +mod realtime; +mod remote_control; +mod review; +mod thread; +mod thread_data; +mod turn; +mod windows_sandbox; + +pub use account::*; +pub use apps::*; +pub use collaboration_mode::*; +pub use command_exec::*; +pub use config::*; +pub use experimental_feature::*; +pub use feedback::*; +pub use fs::*; +pub use hook::*; +pub use item::*; +pub use mcp::*; +pub use model::*; +pub use notification::*; +pub use permissions::*; +pub use plugin::*; +pub use process::*; +pub use realtime::*; +pub use remote_control::*; +pub use review::*; +pub use shared::*; +pub use thread::*; +pub use thread_data::*; +pub use turn::*; +pub use windows_sandbox::*; + +#[cfg(test)] +mod tests; diff --git a/code-rs/app-server-protocol/src/protocol/v2/model.rs b/code-rs/app-server-protocol/src/protocol/v2/model.rs new file mode 100644 index 00000000000..cd139e9c4b4 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/model.rs @@ -0,0 +1,151 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; +use codex_protocol::protocol::ModelVerification as CoreModelVerification; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ModelRerouteReason from CoreModelRerouteReason { + HighRiskCyberActivity + } +); + +v2_enum_from_core!( + pub enum ModelVerification from CoreModelVerification { + TrustedAccessForCyber + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadResponse { + pub namespace_tools: bool, + pub image_generation: bool, + pub web_search: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// When true, include models that are hidden from the default picker list. + #[ts(optional = nullable)] + pub include_hidden: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelAvailabilityNux { + pub message: String, +} + +impl From for ModelAvailabilityNux { + fn from(value: CoreModelAvailabilityNux) -> Self { + Self { + message: value.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelServiceTier { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Model { + pub id: String, + pub model: String, + pub upgrade: Option, + pub upgrade_info: Option, + pub availability_nux: Option, + pub display_name: String, + pub description: String, + pub hidden: bool, + pub supported_reasoning_efforts: Vec, + pub default_reasoning_effort: ReasoningEffort, + #[serde(default = "default_input_modalities")] + pub input_modalities: Vec, + #[serde(default)] + pub supports_personality: bool, + /// Deprecated: use `serviceTiers` instead. + #[serde(default)] + pub additional_speed_tiers: Vec, + #[serde(default)] + pub service_tiers: Vec, + // Only one model should be marked as default. + pub is_default: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelUpgradeInfo { + pub model: String, + pub upgrade_copy: Option, + pub model_link: Option, + pub migration_markdown: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningEffortOption { + pub reasoning_effort: ReasoningEffort, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelReroutedNotification { + pub thread_id: String, + pub turn_id: String, + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelVerificationNotification { + pub thread_id: String, + pub turn_id: String, + pub verifications: Vec, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/notification.rs b/code-rs/app-server-protocol/src/protocol/v2/notification.rs new file mode 100644 index 00000000000..8289cf5683f --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/notification.rs @@ -0,0 +1,56 @@ +use super::TurnError; +use crate::RequestId; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeprecationNoticeNotification { + /// Concise summary of what is deprecated. + pub summary: String, + /// Optional extra guidance, such as migration steps or rationale. + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WarningNotification { + /// Optional thread target when the warning applies to a specific thread. + pub thread_id: Option, + /// Concise warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianWarningNotification { + /// Thread target for the guardian warning. + pub thread_id: String, + /// Concise guardian warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ErrorNotification { + pub error: TurnError, + // Set to true if the error is transient and the app-server process will automatically retry. + // If true, this will not interrupt a turn. + pub will_retry: bool, + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ServerRequestResolvedNotification { + pub thread_id: String, + pub request_id: RequestId, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/permissions.rs b/code-rs/app-server-protocol/src/protocol/v2/permissions.rs new file mode 100644 index 00000000000..86614a6aeb2 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -0,0 +1,857 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; +use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; +use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use ts_rs::TS; + +v2_enum_from_core! { + pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { + Http, + Https, + Socks5Tcp, + Socks5Udp, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + +impl From for NetworkApprovalContext { + fn from(value: CoreNetworkApprovalContext) -> Self { + Self { + host: value.host, + protocol: value.protocol.into(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalFileSystemPermissions { + /// This will be removed in favor of `entries`. + pub read: Option>, + /// This will be removed in favor of `entries`. + pub write: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub glob_scan_max_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub entries: Option>, +} + +impl From for AdditionalFileSystemPermissions { + fn from(value: CoreFileSystemPermissions) -> Self { + if let Some((read, write)) = value.legacy_read_write_roots() { + let mut entries = Vec::with_capacity( + read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), + ); + if let Some(paths) = read.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Read, + })); + } + if let Some(paths) = write.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Write, + })); + } + Self { + read, + write, + glob_scan_max_depth: None, + entries: Some(entries), + } + } else { + Self { + read: None, + write: None, + glob_scan_max_depth: value.glob_scan_max_depth, + entries: Some( + value + .entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + ), + } + } + } +} + +impl From for CoreFileSystemPermissions { + fn from(value: AdditionalFileSystemPermissions) -> Self { + let mut permissions = if let Some(entries) = value.entries { + Self { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth: None, + } + } else { + CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) + }; + permissions.glob_scan_max_depth = value.glob_scan_max_depth; + permissions + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalNetworkPermissions { + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileNetworkPermissions { + pub enabled: bool, +} + +impl From for AdditionalNetworkPermissions { + fn from(value: CoreNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for CoreNetworkPermissions { + fn from(value: AdditionalNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for PermissionProfileNetworkPermissions { + fn from(value: CoreNetworkSandboxPolicy) -> Self { + Self { + enabled: value.is_enabled(), + } + } +} + +impl From for CoreNetworkSandboxPolicy { + fn from(value: PermissionProfileNetworkPermissions) -> Self { + if value.enabled { + Self::Enabled + } else { + Self::Restricted + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct RequestPermissionProfile { + pub network: Option, + pub file_system: Option, +} + +impl From for RequestPermissionProfile { + fn from(value: CoreRequestPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreRequestPermissionProfile { + fn from(value: RequestPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +v2_enum_from_core!( + pub enum FileSystemAccessMode from CoreFileSystemAccessMode { + Read, + Write, + None + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(tag = "kind")] +#[ts(export_to = "v2/")] +pub enum FileSystemSpecialPath { + Root, + Minimal, + #[serde(alias = "current_working_directory")] + ProjectRoots { + subpath: Option, + }, + Tmpdir, + SlashTmp, + Unknown { + path: String, + subpath: Option, + }, +} + +impl From for FileSystemSpecialPath { + fn from(value: CoreFileSystemSpecialPath) -> Self { + match value { + CoreFileSystemSpecialPath::Root => Self::Root, + CoreFileSystemSpecialPath::Minimal => Self::Minimal, + CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, + CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, + CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +impl From for CoreFileSystemSpecialPath { + fn from(value: FileSystemSpecialPath) -> Self { + match value { + FileSystemSpecialPath::Root => Self::Root, + FileSystemSpecialPath::Minimal => Self::Minimal, + FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + FileSystemSpecialPath::Tmpdir => Self::Tmpdir, + FileSystemSpecialPath::SlashTmp => Self::SlashTmp, + FileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum FileSystemPath { + Path { path: AbsolutePathBuf }, + GlobPattern { pattern: String }, + Special { value: FileSystemSpecialPath }, +} + +impl From for FileSystemPath { + fn from(value: CoreFileSystemPath) -> Self { + match value { + CoreFileSystemPath::Path { path } => Self::Path { path }, + CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + CoreFileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +impl From for CoreFileSystemPath { + fn from(value: FileSystemPath) -> Self { + match value { + FileSystemPath::Path { path } => Self::Path { path }, + FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + FileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileSystemSandboxEntry { + pub path: FileSystemPath, + pub access: FileSystemAccessMode, +} + +impl From for FileSystemSandboxEntry { + fn from(value: CoreFileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.into(), + } + } +} + +impl From for CoreFileSystemSandboxEntry { + fn from(value: FileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.to_core(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileFileSystemPermissions { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl From for PermissionProfileFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl From for CoreManagedFileSystemPermissions { + fn from(value: PermissionProfileFileSystemPermissions) -> Self { + match value { + PermissionProfileFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfile { + /// Codex owns sandbox construction for this profile. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Managed { + network: PermissionProfileNetworkPermissions, + file_system: PermissionProfileFileSystemPermissions, + }, + /// Do not apply an outer sandbox. + Disabled, + /// Filesystem isolation is enforced by an external caller. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + External { + network: PermissionProfileNetworkPermissions, + }, +} + +impl From for PermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + network: network.into(), + file_system: file_system.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl From for CorePermissionProfile { + fn from(value: PermissionProfile) -> Self { + match value { + PermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + PermissionProfile::Disabled => Self::Disabled, + PermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ActivePermissionProfile { + /// Identifier from `default_permissions` or the implicit built-in default, + /// such as `:workspace` or a user-defined `[permissions.]` profile. + pub id: String, + /// Parent profile identifier once permissions profiles support + /// inheritance. This is currently always `null`. + #[serde(default)] + pub extends: Option, + /// Bounded user-requested modifications applied on top of the named + /// profile, if any. + #[serde(default)] + pub modifications: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ActivePermissionProfileModification { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +impl From for ActivePermissionProfileModification { + fn from(value: CoreActivePermissionProfileModification) -> Self { + match value { + CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for CoreActivePermissionProfileModification { + fn from(value: ActivePermissionProfileModification) -> Self { + match value { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(ActivePermissionProfileModification::from) + .collect(), + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(CoreActivePermissionProfileModification::from) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileSelectionParams { + /// Select a named built-in or user-defined profile and optionally apply + /// bounded modifications that Codex knows how to validate. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Profile { + id: String, + #[ts(optional = nullable)] + modifications: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileModificationParams { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalPermissionProfile { + /// Partial overlay used for per-command permission requests. + pub network: Option, + pub file_system: Option, +} + +impl From for AdditionalPermissionProfile { + fn from(value: CoreAdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: AdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedPermissionProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub file_system: Option, +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: GrantedPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NetworkAccess { + #[default] + Restricted, + Enabled, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum SandboxPolicy { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxPolicyDeserialize { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + #[serde(default)] + access: Option, + }, + #[serde(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + read_only_access: Option, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum LegacyReadOnlyAccess { + FullAccess, + Restricted, +} + +impl<'de> Deserialize<'de> for SandboxPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match SandboxPolicyDeserialize::deserialize(deserializer)? { + SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), + SandboxPolicyDeserialize::ReadOnly { + network_access, + access, + } => { + if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "readOnly.access is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::ReadOnly { network_access }) + } + SandboxPolicyDeserialize::ExternalSandbox { network_access } => { + Ok(SandboxPolicy::ExternalSandbox { network_access }) + } + SandboxPolicyDeserialize::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) + } + } + } +} + +impl SandboxPolicy { + pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { + match self { + SandboxPolicy::DangerFullAccess => { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + } + SandboxPolicy::ReadOnly { network_access } => { + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: *network_access, + } + } + SandboxPolicy::ExternalSandbox { network_access } => { + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: match network_access { + NetworkAccess::Restricted => CoreNetworkAccess::Restricted, + NetworkAccess::Enabled => CoreNetworkAccess::Enabled, + }, + } + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + } + } +} + +impl From for SandboxPolicy { + fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { + match value { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { + SandboxPolicy::ReadOnly { network_access } + } + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { + SandboxPolicy::ExternalSandbox { + network_access: match network_access { + CoreNetworkAccess::Restricted => NetworkAccess::Restricted, + CoreNetworkAccess::Enabled => NetworkAccess::Enabled, + }, + } + } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array", export_to = "v2/")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn into_core(self) -> CoreExecPolicyAmendment { + CoreExecPolicyAmendment::new(self.command) + } +} + +impl From for ExecPolicyAmendment { + fn from(value: CoreExecPolicyAmendment) -> Self { + Self { + command: value.command().to_vec(), + } + } +} + +v2_enum_from_core!( + pub enum NetworkPolicyRuleAction from CoreNetworkPolicyRuleAction { + Allow, Deny + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkPolicyAmendment { + pub host: String, + pub action: NetworkPolicyRuleAction, +} + +impl NetworkPolicyAmendment { + pub fn into_core(self) -> CoreNetworkPolicyAmendment { + CoreNetworkPolicyAmendment { + host: self.host, + action: self.action.to_core(), + } + } +} + +impl From for NetworkPolicyAmendment { + fn from(value: CoreNetworkPolicyAmendment) -> Self { + Self { + host: value.host, + action: NetworkPolicyRuleAction::from(value.action), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, + pub cwd: AbsolutePathBuf, + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +v2_enum_from_core!( + #[derive(Default)] + pub enum PermissionGrantScope from CorePermissionGrantScope { + #[default] + Turn, + Session + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalResponse { + pub permissions: GrantedPermissionProfile, + #[serde(default)] + pub scope: PermissionGrantScope, + /// Review every subsequent command in this turn before normal sandboxed execution. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub strict_auto_review: Option, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/plugin.rs b/code-rs/app-server-protocol/src/protocol/v2/plugin.rs new file mode 100644 index 00000000000..6f425b4a6a0 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -0,0 +1,755 @@ +use super::AppSummary; +use super::HookEventName; +use super::HookHandlerType; +use super::HookSource; +use super::HookTrustStatus; +use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; +use codex_protocol::protocol::SkillInterface as CoreSkillInterface; +use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; +use codex_protocol::protocol::SkillScope as CoreSkillScope; +use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, + + /// When true, bypass the skills cache and re-scan skills from disk. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_reload: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddParams { + pub source: String, + #[ts(optional = nullable)] + pub ref_name: Option, + #[ts(optional = nullable)] + pub sparse_paths: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddResponse { + pub marketplace_name: String, + pub installed_root: AbsolutePathBuf, + pub already_added: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveParams { + pub marketplace_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveResponse { + pub marketplace_name: String, + pub installed_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeParams { + #[ts(optional = nullable)] + pub marketplace_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeResponse { + pub selected_marketplaces: Vec, + pub upgraded_roots: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeErrorInfo { + pub marketplace_name: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListParams { + /// Optional working directories used to discover repo marketplaces. When omitted, + /// only home-scoped marketplaces and the official curated marketplace are considered. + #[ts(optional = nullable)] + pub cwds: Option>, + /// Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus + /// the default remote catalog when enabled by feature flag. + #[ts(optional = nullable)] + pub marketplace_kinds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginListMarketplaceKind { + #[serde(rename = "local")] + #[ts(rename = "local")] + Local, + #[serde(rename = "workspace-directory")] + #[ts(rename = "workspace-directory")] + WorkspaceDirectory, + #[serde(rename = "shared-with-me")] + #[ts(rename = "shared-with-me")] + SharedWithMe, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListResponse { + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, + #[serde(default)] + pub featured_plugin_ids: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceLoadErrorInfo { + pub marketplace_path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadResponse { + pub plugin: PluginDetail, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadParams { + pub remote_marketplace_name: String, + pub remote_plugin_id: String, + pub skill_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadResponse { + pub contents: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveParams { + pub plugin_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub remote_plugin_id: Option, + #[ts(optional = nullable)] + pub discoverability: Option, + #[ts(optional = nullable)] + pub share_targets: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveResponse { + pub remote_plugin_id: String, + pub share_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsParams { + pub remote_plugin_id: String, + pub discoverability: PluginShareUpdateDiscoverability, + pub share_targets: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsResponse { + pub principals: Vec, + pub discoverability: PluginShareDiscoverability, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteParams { + pub remote_plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListItem { + pub plugin: PluginSummary, + pub share_url: String, + pub local_plugin_path: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginShareDiscoverability { + #[serde(rename = "LISTED")] + #[ts(rename = "LISTED")] + Listed, + #[serde(rename = "UNLISTED")] + #[ts(rename = "UNLISTED")] + Unlisted, + #[serde(rename = "PRIVATE")] + #[ts(rename = "PRIVATE")] + Private, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginShareUpdateDiscoverability { + #[serde(rename = "UNLISTED")] + #[ts(rename = "UNLISTED")] + Unlisted, + #[serde(rename = "PRIVATE")] + #[ts(rename = "PRIVATE")] + Private, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginSharePrincipalType { + #[serde(rename = "user")] + #[ts(rename = "user")] + User, + #[serde(rename = "group")] + #[ts(rename = "group")] + Group, + #[serde(rename = "workspace")] + #[ts(rename = "workspace")] + Workspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareTarget { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSharePrincipal { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SkillScope { + User, + Repo, + System, + Admin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillMetadata { + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. + pub short_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub interface: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub dependencies: Option, + pub path: AbsolutePathBuf, + pub scope: SkillScope, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillInterface { + #[ts(optional)] + pub display_name: Option, + #[ts(optional)] + pub short_description: Option, + #[ts(optional)] + pub icon_small: Option, + #[ts(optional)] + pub icon_large: Option, + #[ts(optional)] + pub brand_color: Option, + #[ts(optional)] + pub default_prompt: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillDependencies { + pub tools: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillToolDependency { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub r#type: String, + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListEntry { + pub cwd: PathBuf, + pub hooks: Vec, + pub warnings: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMetadata { + pub key: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub display_order: i64, + pub enabled: bool, + pub is_managed: bool, + pub current_hash: String, + pub trust_status: HookTrustStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginMarketplaceEntry { + pub name: String, + /// Local marketplace file path when the marketplace is backed by a local file. + /// Remote-only catalog marketplaces do not have a local path. + pub path: Option, + pub interface: Option, + pub plugins: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceInterface { + pub display_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginInstallPolicy { + #[serde(rename = "NOT_AVAILABLE")] + #[ts(rename = "NOT_AVAILABLE")] + NotAvailable, + #[serde(rename = "AVAILABLE")] + #[ts(rename = "AVAILABLE")] + Available, + #[serde(rename = "INSTALLED_BY_DEFAULT")] + #[ts(rename = "INSTALLED_BY_DEFAULT")] + InstalledByDefault, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAuthPolicy { + #[serde(rename = "ON_INSTALL")] + #[ts(rename = "ON_INSTALL")] + OnInstall, + #[serde(rename = "ON_USE")] + #[ts(rename = "ON_USE")] + OnUse, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAvailability { + /// Plugin-service currently sends `"ENABLED"` for available remote plugins. + /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps + /// decoding compatible with that upstream response. + #[serde(rename = "AVAILABLE", alias = "ENABLED")] + #[ts(rename = "AVAILABLE")] + #[default] + Available, + #[serde(rename = "DISABLED_BY_ADMIN")] + #[ts(rename = "DISABLED_BY_ADMIN")] + DisabledByAdmin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSummary { + pub id: String, + pub name: String, + /// Remote sharing context associated with this plugin when available. + pub share_context: Option, + pub source: PluginSource, + pub installed: bool, + pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + /// Availability state for installing and using the plugin. + #[serde(default)] + pub availability: PluginAvailability, + pub interface: Option, + #[serde(default)] + pub keywords: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareContext { + pub remote_plugin_id: String, + pub share_url: Option, + pub creator_account_user_id: Option, + pub creator_name: Option, + pub share_targets: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginDetail { + pub marketplace_name: String, + pub marketplace_path: Option, + pub summary: PluginSummary, + pub description: Option, + pub skills: Vec, + pub hooks: Vec, + pub apps: Vec, + pub mcp_servers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillSummary { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, + pub path: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInterface { + pub display_name: Option, + pub short_description: Option, + pub long_description: Option, + pub developer_name: Option, + pub category: Option, + pub capabilities: Vec, + pub website_url: Option, + pub privacy_policy_url: Option, + pub terms_of_service_url: Option, + /// Starter prompts for the plugin. Capped at 3 entries with a maximum of + /// 128 characters per entry. + pub default_prompt: Option>, + pub brand_color: Option, + /// Local composer icon path, resolved from the installed plugin package. + pub composer_icon: Option, + /// Remote composer icon URL from the plugin catalog. + pub composer_icon_url: Option, + /// Local logo path, resolved from the installed plugin package. + pub logo: Option, + /// Remote logo URL from the plugin catalog. + pub logo_url: Option, + /// Local screenshot paths, resolved from the installed plugin package. + pub screenshots: Vec, + /// Remote screenshot URLs from the plugin catalog. + pub screenshot_urls: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PluginSource { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Local { path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Git { + url: String, + path: Option, + ref_name: Option, + sha: Option, + }, + /// The plugin is available in the remote catalog. Download metadata is + /// kept server-side and is not exposed through the app-server API. + Remote, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteParams { + /// Path-based selector. + #[ts(optional = nullable)] + pub path: Option, + /// Name-based selector. + #[ts(optional = nullable)] + pub name: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteResponse { + pub effective_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallResponse { + pub auth_policy: PluginAuthPolicy, + pub apps_needing_auth: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallParams { + pub plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallResponse {} + +impl From for SkillMetadata { + fn from(value: CoreSkillMetadata) -> Self { + Self { + name: value.name, + description: value.description, + short_description: value.short_description, + interface: value.interface.map(SkillInterface::from), + dependencies: value.dependencies.map(SkillDependencies::from), + path: value.path, + scope: value.scope.into(), + enabled: true, + } + } +} + +impl From for SkillInterface { + fn from(value: CoreSkillInterface) -> Self { + Self { + display_name: value.display_name, + short_description: value.short_description, + brand_color: value.brand_color, + default_prompt: value.default_prompt, + icon_small: value.icon_small, + icon_large: value.icon_large, + } + } +} + +impl From for SkillDependencies { + fn from(value: CoreSkillDependencies) -> Self { + Self { + tools: value + .tools + .into_iter() + .map(SkillToolDependency::from) + .collect(), + } + } +} + +impl From for SkillToolDependency { + fn from(value: CoreSkillToolDependency) -> Self { + Self { + r#type: value.r#type, + value: value.value, + description: value.description, + transport: value.transport, + command: value.command, + url: value.url, + } + } +} + +impl From for SkillScope { + fn from(value: CoreSkillScope) -> Self { + match value { + CoreSkillScope::User => Self::User, + CoreSkillScope::Repo => Self::Repo, + CoreSkillScope::System => Self::System, + CoreSkillScope::Admin => Self::Admin, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification emitted when watched local skill files change. +/// +/// Treat this as an invalidation signal and re-run `skills/list` with the +/// client's current parameters when refreshed skill metadata is needed. +pub struct SkillsChangedNotification {} diff --git a/code-rs/app-server-protocol/src/protocol/v2/process.rs b/code-rs/app-server-protocol/src/protocol/v2/process.rs new file mode 100644 index 00000000000..b70847165ea --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/process.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +/// PTY size in character cells for `process/spawn` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Spawn a standalone process (argv vector) without a Codex sandbox on the host +/// where the app server is running. +/// +/// `process/spawn` returns after the process has started and the connection-scoped +/// `processHandle` has been registered. Process output and exit are reported via +/// `process/outputDelta` and `process/exited` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Client-supplied, connection-scoped process handle. + /// + /// Duplicate active handles are rejected on the same connection. The same + /// handle can be reused after the prior process exits. + pub process_handle: String, + /// Absolute working directory for the process. + pub cwd: AbsolutePathBuf, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `process/writeStdin` requests to write stdin bytes. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `process/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the `process/exited` notification. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// cap. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option>, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// timeout. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option>, + /// Optional environment overrides merged into the app-server process + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, +} + +/// Successful response for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnResponse {} + +/// Write stdin bytes to a running `process/spawn` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `process/writeStdin`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinResponse {} + +/// Terminate a running `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, +} + +/// Empty success response for `process/kill`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillResponse {} + +/// Resize a running PTY-backed `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// New PTY size in character cells. + pub size: ProcessTerminalSize, +} + +/// Empty success response for `process/resizePty`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyResponse {} + +/// Stream label for `process/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ProcessOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} + +/// Base64-encoded output chunk emitted for a streaming `process/spawn` request. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessOutputDeltaNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Output stream this chunk belongs to. + pub stream: ProcessOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// True on the final streamed chunk for this stream when output was + /// truncated by `outputBytesCap`. + pub cap_reached: bool, +} + +/// Final process exit notification for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessExitedNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `process/outputDelta`. + pub stdout: String, + /// Whether stdout reached `outputBytesCap`. + /// + /// In streaming mode, stdout is empty and cap state is also reported on the + /// final stdout `process/outputDelta` notification. + pub stdout_cap_reached: bool, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `process/outputDelta`. + pub stderr: String, + /// Whether stderr reached `outputBytesCap`. + /// + /// In streaming mode, stderr is empty and cap state is also reported on the + /// final stderr `process/outputDelta` notification. + pub stderr_cap_reached: bool, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/realtime.rs b/code-rs/app-server-protocol/src/protocol/v2/realtime.rs new file mode 100644 index 00000000000..c6ea0744de2 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -0,0 +1,241 @@ +use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationVersion; +use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeVoice; +use codex_protocol::protocol::RealtimeVoicesList; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +/// EXPERIMENTAL - thread realtime audio chunk. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAudioChunk { + pub data: String, + pub sample_rate: u32, + pub num_channels: u16, + pub samples_per_channel: Option, + pub item_id: Option, +} + +impl From for ThreadRealtimeAudioChunk { + fn from(value: CoreRealtimeAudioFrame) -> Self { + let CoreRealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +impl From for CoreRealtimeAudioFrame { + fn from(value: ThreadRealtimeAudioChunk) -> Self { + let ThreadRealtimeAudioChunk { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +/// EXPERIMENTAL - start a thread-scoped realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartParams { + pub thread_id: String, + /// Selects text or audio output for the realtime session. Transport and voice stay + /// independent so clients can choose how they connect separately from what the model emits. + pub output_modality: RealtimeOutputModality, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub prompt: Option>, + #[ts(optional = nullable)] + pub realtime_session_id: Option, + #[ts(optional = nullable)] + pub transport: Option, + #[ts(optional = nullable)] + pub voice: Option, +} + +/// EXPERIMENTAL - transport used by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/", tag = "type")] +pub enum ThreadRealtimeStartTransport { + Websocket, + Webrtc { + /// SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the + /// realtime events data channel. + sdp: String, + }, +} + +/// EXPERIMENTAL - response for starting thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartResponse {} + +/// EXPERIMENTAL - append audio input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioParams { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - response for appending realtime audio input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioResponse {} + +/// EXPERIMENTAL - append text input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextParams { + pub thread_id: String, + pub text: String, +} + +/// EXPERIMENTAL - response for appending realtime text input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextResponse {} + +/// EXPERIMENTAL - stop thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopParams { + pub thread_id: String, +} + +/// EXPERIMENTAL - response for stopping thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopResponse {} + +/// EXPERIMENTAL - list voices supported by thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesParams {} + +/// EXPERIMENTAL - response for listing supported realtime voices. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesResponse { + pub voices: RealtimeVoicesList, +} + +/// EXPERIMENTAL - emitted when thread realtime startup is accepted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartedNotification { + pub thread_id: String, + pub realtime_session_id: Option, + pub version: RealtimeConversationVersion, +} + +/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeItemAddedNotification { + pub thread_id: String, + pub item: JsonValue, +} + +/// EXPERIMENTAL - flat transcript delta emitted whenever realtime +/// transcript text changes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDeltaNotification { + pub thread_id: String, + pub role: String, + /// Live transcript delta from the realtime event. + pub delta: String, +} + +/// EXPERIMENTAL - final transcript text emitted when realtime completes +/// a transcript part. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDoneNotification { + pub thread_id: String, + pub role: String, + /// Final complete text for the transcript part. + pub text: String, +} + +/// EXPERIMENTAL - streamed output audio emitted by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeOutputAudioDeltaNotification { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeSdpNotification { + pub thread_id: String, + pub sdp: String, +} + +/// EXPERIMENTAL - emitted when thread realtime encounters an error. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeErrorNotification { + pub thread_id: String, + pub message: String, +} + +/// EXPERIMENTAL - emitted when thread realtime transport closes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeClosedNotification { + pub thread_id: String, + pub reason: Option, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/code-rs/app-server-protocol/src/protocol/v2/remote_control.rs new file mode 100644 index 00000000000..7d6383f4680 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -0,0 +1,23 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Current remote-control connection status and environment id exposed to clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlStatusChangedNotification { + pub status: RemoteControlConnectionStatus, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum RemoteControlConnectionStatus { + Disabled, + Connecting, + Connected, + Errored, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/review.rs b/code-rs/app-server-protocol/src/protocol/v2/review.rs new file mode 100644 index 00000000000..82ec5b6f594 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/review.rs @@ -0,0 +1,65 @@ +use super::Turn; +use super::shared::v2_enum_from_core; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { + Inline, Detached + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartParams { + pub thread_id: String, + pub target: ReviewTarget, + + /// Where to run the review: inline (default) on the current thread or + /// detached on a new thread (returned in `reviewThreadId`). + #[serde(default)] + #[ts(optional = nullable)] + pub delivery: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartResponse { + pub turn: Turn, + /// Identifies the thread where the review runs. + /// + /// For inline reviews, this is the original thread id. + /// For detached reviews, this is the id of the new review thread. + pub review_thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ReviewTarget { + /// Review the working tree: staged, unstaged, and untracked files. + UncommittedChanges, + + /// Review changes between the current branch and the given base branch. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + BaseBranch { branch: String }, + + /// Review the changes introduced by a specific commit. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Commit { + sha: String, + /// Optional human-readable label (e.g., commit subject) for UIs. + title: Option, + }, + + /// Arbitrary instructions, equivalent to the old free-form prompt. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Custom { instructions: String }, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/shared.rs b/code-rs/app-server-protocol/src/protocol/v2/shared.rs new file mode 100644 index 00000000000..9ec1fb80cb3 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -0,0 +1,316 @@ +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +// Macro to declare a camelCased API v2 enum mirroring a core enum which +// tends to use either snake_case or kebab-case. +macro_rules! v2_enum_from_core { + ( + $(#[$enum_meta:meta])* + pub enum $Name:ident from $Src:path { + $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? + } + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + $(#[$enum_meta])* + #[serde(rename_all = "camelCase")] + #[ts(export_to = "v2/")] + pub enum $Name { + $( $(#[$variant_meta])* $Variant ),+ + } + + impl $Name { + pub fn to_core(self) -> $Src { + match self { $( $Name::$Variant => <$Src>::$Variant ),+ } + } + } + + impl From<$Src> for $Name { + fn from(value: $Src) -> Self { + match value { $( <$Src>::$Variant => $Name::$Variant ),+ } + } + } + }; +} + +pub(super) use v2_enum_from_core; + +pub(super) const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + +/// This translation layer make sure that we expose codex error code in camel case. +/// +/// When an upstream HTTP status is available (for example, from the Responses API or a provider), +/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CodexErrorInfo { + ContextWindowExceeded, + UsageLimitExceeded, + ServerOverloaded, + CyberPolicy, + HttpConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Failed to connect to the response SSE stream. + ResponseStreamConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + InternalServerError, + Unauthorized, + BadRequest, + ThreadRollbackFailed, + SandboxError, + /// The response SSE stream disconnected in the middle of a turn before completion. + ResponseStreamDisconnected { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Reached the retry limit for responses. + ResponseTooManyFailedAttempts { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, + Other, +} + +impl From for CodexErrorInfo { + fn from(value: CoreCodexErrorInfo) -> Self { + match value { + CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, + CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, + CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, + CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { + CodexErrorInfo::HttpConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { + CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, + CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, + CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, + CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, + CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, + CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { + CodexErrorInfo::ResponseStreamDisconnected { http_status_code } + } + CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } + } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } + CoreCodexErrorInfo::Other => CodexErrorInfo::Other, + } + } +} + +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + #[experimental("askForApproval.granular")] + Granular { + sandbox_approval: bool, + rules: bool, + #[serde(default)] + skill_approval: bool, + #[serde(default)] + request_permissions: bool, + mcp_elicitations: bool, + }, + Never, +} + +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Granular { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + } => CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + }), + AskForApproval::Never => CoreAskForApproval::Never, + } + } +} + +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { + sandbox_approval: granular_config.sandbox_approval, + rules: granular_config.rules, + skill_approval: granular_config.skill_approval, + request_permissions: granular_config.request_permissions, + mcp_elicitations: granular_config.mcp_elicitations, + }, + CoreAskForApproval::Never => AskForApproval::Never, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[ts( + type = r#""user" | "auto_review" | "guardian_subagent""#, + export_to = "v2/" +)] +/// Configures who approval requests are routed to for review. Examples +/// include sandbox escapes, blocked network access, MCP approval prompts, and +/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully +/// prompted subagent to gather relevant context and apply a risk-based +/// decision framework before approving or denying the request. +pub enum ApprovalsReviewer { + #[serde(rename = "user")] + User, + #[serde(rename = "guardian_subagent", alias = "auto_review")] + AutoReview, +} + +impl JsonSchema for ApprovalsReviewer { + fn schema_name() -> String { + "ApprovalsReviewer".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + string_enum_schema_with_description( + &["user", "auto_review", "guardian_subagent"], + "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + ) + } +} + +fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some(description.to_string()), + ..Default::default() + })), + ..Default::default() + }; + schema.enum_values = Some( + values + .iter() + .map(|value| JsonValue::String((*value).to_string())) + .collect(), + ); + Schema::Object(schema) +} + +impl ApprovalsReviewer { + pub fn to_core(self) -> CoreApprovalsReviewer { + match self { + ApprovalsReviewer::User => CoreApprovalsReviewer::User, + ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, + } + } +} + +impl From for ApprovalsReviewer { + fn from(value: CoreApprovalsReviewer) -> Self { + match value { + CoreApprovalsReviewer::User => ApprovalsReviewer::User, + CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/tests.rs b/code-rs/app-server-protocol/src/protocol/v2/tests.rs new file mode 100644 index 00000000000..da0ad2c10e0 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -0,0 +1,3532 @@ +use super::*; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::FileChangeItem; +use codex_protocol::items::ImageViewItem; +use codex_protocol::items::McpToolCallItem; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::ReasoningItem; +use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; +use codex_protocol::items::WebSearchItem; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::time::Duration; + +fn absolute_path_string(path: &str) -> String { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).display().to_string() +} + +fn absolute_path(path: &str) -> AbsolutePathBuf { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).abs() +} + +fn test_absolute_path() -> AbsolutePathBuf { + absolute_path("readable") +} + +#[test] +fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), + "\"guardian_subagent\"" + ); + + for value in ["user", "auto_review", "guardian_subagent"] { + let json = format!("\"{value}\""); + let reviewer: ApprovalsReviewer = + serde_json::from_str(&json).expect("deserialize reviewer"); + let expected = if value == "user" { + ApprovalsReviewer::User + } else { + ApprovalsReviewer::AutoReview + }; + assert_eq!(expected, reviewer); + } +} + +#[test] +fn turn_defaults_legacy_missing_items_view_to_full() { + let turn: Turn = serde_json::from_value(json!({ + "id": "turn_123", + "items": [], + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null, + })) + .expect("legacy turn should deserialize"); + + assert_eq!(turn.items_view, TurnItemsView::Full); +} + +#[test] +fn thread_turns_list_params_accepts_items_view() { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "cursor": null, + "limit": 25, + "sortDirection": "desc", + "itemsView": "notLoaded", + })) + .expect("thread turns list params should deserialize"); + + assert_eq!(params.thread_id, "thr_123"); + assert_eq!(params.items_view, Some(TurnItemsView::NotLoaded)); +} + +#[test] +fn thread_turns_items_list_round_trips() { + let params = ThreadTurnsItemsListParams { + thread_id: "thr_123".to_string(), + turn_id: "turn_456".to_string(), + cursor: Some("cursor_1".to_string()), + limit: Some(50), + sort_direction: Some(SortDirection::Asc), + }; + + assert_eq!( + serde_json::to_value(¶ms).expect("serialize params"), + json!({ + "threadId": "thr_123", + "turnId": "turn_456", + "cursor": "cursor_1", + "limit": 50, + "sortDirection": "asc", + }) + ); + let response = ThreadTurnsItemsListResponse { + data: vec![ThreadItem::ContextCompaction { + id: "item_1".to_string(), + }], + next_cursor: None, + backwards_cursor: Some("cursor_0".to_string()), + }; + + assert_eq!( + serde_json::to_value(&response).expect("serialize response"), + json!({ + "data": [{"type": "contextCompaction", "id": "item_1"}], + "nextCursor": null, + "backwardsCursor": "cursor_0", + }) + ); +} + +#[test] +fn thread_list_params_accepts_single_cwd() { + let params = serde_json::from_value::(json!({ + "cwd": "/workspace", + })) + .expect("single cwd should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One("/workspace".to_string())) + ); + assert!(!params.use_state_db_only); +} + +#[test] +fn thread_list_params_accepts_multiple_cwds() { + let params = serde_json::from_value::(json!({ + "cwd": ["/workspace", "/other-workspace"], + })) + .expect("cwd array should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::Many(vec![ + "/workspace".to_string(), + "/other-workspace".to_string(), + ])) + ); +} + +#[test] +fn thread_list_params_accepts_state_db_only_flag() { + let params = serde_json::from_value::(json!({ + "useStateDbOnly": true, + })) + .expect("state db only flag should deserialize"); + + assert!(params.use_state_db_only); +} + +#[test] +fn collab_agent_state_maps_interrupted_status() { + assert_eq!( + CollabAgentState::from(CoreAgentStatus::Interrupted), + CollabAgentState { + status: CollabAgentStatus::Interrupted, + message: None, + } + ); +} + +#[test] +fn external_agent_config_plugins_details_round_trip() { + let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + })) + .expect("plugins migration item should deserialize"); + + assert_eq!( + item, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + } + ); +} + +#[test] +fn external_agent_config_import_params_accept_legacy_plugin_details() { + let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({ + "migrationItems": [{ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + }] + })) + .expect("legacy plugin import params should deserialize"); + + assert_eq!( + params, + ExternalAgentConfigImportParams { + migration_items: vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + }], + } + ); +} + +#[test] +fn command_execution_request_approval_rejects_relative_additional_permission_paths() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "startedAtMs": 1, + "command": "cat file", + "cwd": absolute_path_string("tmp"), + "commandActions": null, + "reason": null, + "networkApprovalContext": null, + "additionalPermissions": { + "network": null, + "fileSystem": { + "read": ["relative/path"], + "write": null + } + }, + "proposedExecpolicyAmendment": null, + "proposedNetworkPolicyAmendments": null, + "availableDecisions": null + })) + .expect_err("relative additional permission paths should fail"); + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} + +#[test] +fn permissions_request_approval_uses_request_permission_profile() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "startedAtMs": 1, + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions request should deserialize"); + + assert_eq!(params.cwd, absolute_path("repo")); + assert_eq!( + params.permissions, + RequestPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreRequestPermissionProfile::from(params.permissions), + CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_rejects_macos_permissions() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "startedAtMs": 1, + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": null, + "fileSystem": null, + "macos": { + "preferences": "read_only", + "automations": "none", + "launchServices": false, + "accessibility": false, + "calendar": false, + "reminders": false, + "contacts": "none", + }, + }, + })) + .expect_err("permissions request should reject macos permissions"); + + assert!( + err.to_string().contains("unknown field `macos`"), + "unexpected error: {err}" + ); +} + +#[test] +fn additional_file_system_permissions_preserves_canonical_entries() { + let core_permissions = CoreFileSystemPermissions { + entries: vec![ + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::Special { + value: CoreFileSystemSpecialPath::Root, + }, + access: CoreFileSystemAccessMode::Write, + }, + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: None, + write: None, + glob_scan_max_depth: NonZeroUsize::new(2), + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_populates_entries_for_legacy_roots() { + let read_only_path = absolute_path("read-only"); + let read_write_path = absolute_path("read-write"); + let core_permissions = CoreFileSystemPermissions::from_read_write_roots( + Some(vec![read_only_path.clone()]), + Some(vec![read_write_path.clone()]), + ); + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: Some(vec![read_only_path.clone()]), + write: Some(vec![read_write_path.clone()]), + glob_scan_max_depth: None, + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_only_path, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_write_path, + }, + access: FileSystemAccessMode::Write, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "read": null, + "write": null, + "globScanMaxDepth": 0, + "entries": [], + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { + let core_permissions = CoreManagedFileSystemPermissions::Restricted { + entries: vec![CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + } + ); + assert_eq!( + CoreManagedFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "type": "restricted", + "entries": [], + "globScanMaxDepth": 0, + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { + let special_path = serde_json::from_value::(json!({ + "kind": "current_working_directory", + })) + .expect("legacy cwd special path should deserialize"); + + assert_eq!( + special_path, + FileSystemSpecialPath::ProjectRoots { subpath: None } + ); + assert_eq!( + serde_json::to_value(&special_path).expect("serialize special path"), + json!({ + "kind": "project_roots", + "subpath": null, + }) + ); +} + +#[test] +fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let response = serde_json::from_value::(json!({ + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions response should deserialize"); + + assert_eq!( + response.permissions, + GrantedPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreAdditionalPermissionProfile::from(response.permissions), + CoreAdditionalPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_response_defaults_scope_to_turn() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + })) + .expect("response should deserialize"); + + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert_eq!(response.strict_auto_review, None); +} + +#[test] +fn permissions_request_approval_response_accepts_strict_auto_review() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + "strictAutoReview": true, + })) + .expect("response should deserialize"); + + assert_eq!(response.strict_auto_review, Some(true)); +} + +#[test] +fn fs_get_metadata_response_round_trips_minimal_fields() { + let response = FsGetMetadataResponse { + is_directory: false, + is_file: true, + is_symlink: false, + created_at_ms: 123, + modified_at_ms: 456, + }; + + let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); + assert_eq!( + value, + json!({ + "isDirectory": false, + "isFile": true, + "isSymlink": false, + "createdAtMs": 123, + "modifiedAtMs": 456, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/getMetadata response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_response_round_trips_base64_data() { + let response = FsReadFileResponse { + data_base64: "aGVsbG8=".to_string(), + }; + + let value = serde_json::to_value(&response).expect("serialize fs/readFile response"); + assert_eq!( + value, + json!({ + "dataBase64": "aGVsbG8=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/readFile response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_params_round_trip() { + let params = FsReadFileParams { + path: absolute_path("tmp/example.txt"), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.txt"), + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/readFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_create_directory_params_round_trip_with_default_recursive() { + let params = FsCreateDirectoryParams { + path: absolute_path("tmp/example"), + recursive: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example"), + "recursive": null, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/createDirectory params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_write_file_params_round_trip_with_base64_data() { + let params = FsWriteFileParams { + path: absolute_path("tmp/example.bin"), + data_base64: "AAE=".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.bin"), + "dataBase64": "AAE=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/writeFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_copy_params_round_trip_with_recursive_directory_copy() { + let params = FsCopyParams { + source_path: absolute_path("tmp/source"), + destination_path: absolute_path("tmp/destination"), + recursive: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); + assert_eq!( + value, + json!({ + "sourcePath": absolute_path_string("tmp/source"), + "destinationPath": absolute_path_string("tmp/destination"), + "recursive": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/copy params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_params_round_trip() { + let params = ThreadShellCommandParams { + thread_id: "thr_123".to_string(), + command: "printf 'hello world\\n'".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); + assert_eq!( + value, + json!({ + "threadId": "thr_123", + "command": "printf 'hello world\\n'", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_response_round_trip() { + let response = ThreadShellCommandResponse {}; + + let value = serde_json::to_value(&response).expect("serialize thread/shellCommand response"); + assert_eq!(value, json!({})); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_changed_notification_round_trips() { + let notification = FsChangedNotification { + watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), + changed_paths: vec![ + absolute_path("tmp/repo/.git/HEAD"), + absolute_path("tmp/repo/.git/FETCH_HEAD"), + ], + }; + + let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); + assert_eq!( + value, + json!({ + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": [ + absolute_path_string("tmp/repo/.git/HEAD"), + absolute_path_string("tmp/repo/.git/FETCH_HEAD"), + ], + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/changed notification"); + assert_eq!(decoded, notification); +} + +#[test] +fn command_exec_params_default_optional_streaming_flags() { + let params = serde_json::from_value::(json!({ + "command": ["ls", "-la"], + "timeoutMs": 1000, + "cwd": "/tmp" + })) + .expect("command/exec payload should deserialize"); + + assert_eq!( + params, + CommandExecParams { + command: vec!["ls".to_string(), "-la".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(1000), + cwd: Some(PathBuf::from("/tmp")), + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_timeout() { + let params = CommandExecParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_id: Some("sleep-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processId": "sleep-1", + "disableTimeout": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + "outputBytesCap": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_round_trips_without_sandbox_policy() { + let params = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize process/spawn params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "env": null, + "size": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_distinguish_omitted_null_and_value_limits() { + let base = json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + }); + + let expected_omitted = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + let decoded = + serde_json::from_value::(base).expect("deserialize omitted limits"); + assert_eq!(decoded, expected_omitted); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": null, + "timeoutMs": null, + })) + .expect("deserialize disabled limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(None), + timeout_ms: Some(None), + ..expected_omitted.clone() + } + ); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": 123, + "timeoutMs": 456, + })) + .expect("deserialize explicit limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(Some(123)), + timeout_ms: Some(Some(456)), + ..expected_omitted + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_output_cap() { + let params = CommandExecParams { + command: vec!["yes".to_string()], + process_id: Some("yes-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["yes"], + "processId": "yes-1", + "streamStdoutStderr": true, + "outputBytesCap": null, + "disableOutputCap": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trips_env_overrides_and_unsets() { + let params = CommandExecParams { + command: vec!["printenv".to_string(), "FOO".to_string()], + process_id: Some("env-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ("FOO".to_string(), Some("override".to_string())), + ("BAR".to_string(), Some("added".to_string())), + ("BAZ".to_string(), None), + ])), + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["printenv", "FOO"], + "processId": "env-1", + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": { + "FOO": "override", + "BAR": "added", + "BAZ": null, + }, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_write_round_trips_close_only_payload() { + let params = CommandExecWriteParams { + process_id: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); + assert_eq!( + value, + json!({ + "processId": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_terminate_round_trips() { + let params = CommandExecTerminateParams { + process_id: "proc-8".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); + assert_eq!( + value, + json!({ + "processId": "proc-8", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trip_with_size() { + let params = CommandExecParams { + command: vec!["top".to_string()], + process_id: Some("pty-1".to_string()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 40, + cols: 120, + }), + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["top"], + "processId": "pty-1", + "tty": true, + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": { + "rows": 40, + "cols": 120, + }, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_resize_round_trips() { + let params = CommandExecResizeParams { + process_id: "proc-9".to_string(), + size: CommandExecTerminalSize { + rows: 50, + cols: 160, + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); + assert_eq!( + value, + json!({ + "processId": "proc-9", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_output_delta_round_trips() { + let notification = CommandExecOutputDeltaNotification { + process_id: "proc-1".to_string(), + stream: CommandExecOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize command/exec/outputDelta notification"); + assert_eq!( + value, + json!({ + "processId": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn process_control_params_round_trip() { + let write = ProcessWriteStdinParams { + process_handle: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + let value = serde_json::to_value(&write).expect("serialize process/writeStdin params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/writeStdin params"); + assert_eq!(decoded, write); + + let resize = ProcessResizePtyParams { + process_handle: "proc-7".to_string(), + size: ProcessTerminalSize { + rows: 50, + cols: 160, + }, + }; + let value = serde_json::to_value(&resize).expect("serialize process/resizePty params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/resizePty params"); + assert_eq!(decoded, resize); + + let kill = ProcessKillParams { + process_handle: "proc-7".to_string(), + }; + let value = serde_json::to_value(&kill).expect("serialize process/kill params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + }) + ); + let decoded = + serde_json::from_value::(value).expect("deserialize process/kill"); + assert_eq!(decoded, kill); +} + +#[test] +fn process_notifications_round_trip() { + let delta = ProcessOutputDeltaNotification { + process_handle: "proc-1".to_string(), + stream: ProcessOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + let value = serde_json::to_value(&delta).expect("serialize process/outputDelta"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/outputDelta"); + assert_eq!(decoded, delta); + + let exited = ProcessExitedNotification { + process_handle: "proc-1".to_string(), + exit_code: 0, + stdout: "out".to_string(), + stdout_cap_reached: false, + stderr: "err".to_string(), + stderr_cap_reached: true, + }; + let value = serde_json::to_value(&exited).expect("serialize process/exited"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "exitCode": 0, + "stdout": "out", + "stdoutCapReached": false, + "stderr": "err", + "stderrCapReached": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/exited"); + assert_eq!(decoded, exited); +} + +#[test] +fn command_execution_output_delta_round_trips() { + let notification = CommandExecutionOutputDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + delta: "\u{fffd}a\n".to_string(), + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize item/commandExecution/outputDelta notification"); + assert_eq!( + value, + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "item-1", + "delta": "\u{fffd}a\n", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn sandbox_policy_round_trips_external_sandbox_network_access() { + let v2_policy = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: CoreNetworkAccess::Enabled, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_round_trips_read_only_network_access() { + let v2_policy = SandboxPolicy::ReadOnly { + network_access: true, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: true, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_round_trips_request_permissions_flag() { + let v2_policy = AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }) + ); + + let back_to_v2 = AskForApproval::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() { + let decoded = serde_json::from_value::(serde_json::json!({ + "granular": { + "sandbox_approval": true, + "rules": false, + "mcp_elicitations": true, + } + })) + .expect("granular approval policy should deserialize"); + + assert_eq!( + decoded, + AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + } + ); +} + +#[test] +fn ask_for_approval_granular_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }); + + assert_eq!(reason, Some("askForApproval.granular")); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::OnRequest,), + None + ); +} + +#[test] +fn profile_v2_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_nested_profile_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_nested_profile_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { + allowed_approval_policies: Some(vec![AskForApproval::Granular { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + feature_requirements: None, + hooks: None, + enforce_residency: None, + network: None, + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadStart { + request_id: crate::RequestId::Integer(1), + params: ThreadStartParams { + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadResume { + request_id: crate::RequestId::Integer(2), + params: ThreadResumeParams { + thread_id: "thr_123".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadFork { + request_id: crate::RequestId::Integer(3), + params: ThreadForkParams { + thread_id: "thr_456".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::TurnStart { + request_id: crate::RequestId::Integer(4), + params: TurnStartParams { + thread_id: "thr_123".to_string(), + input: Vec::new(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn mcp_server_elicitation_response_round_trips_rmcp_result() { + let rmcp_result = rmcp::model::CreateElicitationResult { + action: rmcp::model::ElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + }; + + let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); + assert_eq!( + v2_response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + meta: None, + } + ); + assert_eq!( + rmcp::model::CreateElicitationResult::from(v2_response), + rmcp_result + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_url_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + }) + .expect("URL request should convert"); + + assert_eq!( + request, + McpServerElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_form_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + }), + }) + .expect("form request should convert"); + + let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + })) + .expect("expected schema should deserialize"); + + assert_eq!( + request, + McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: expected_schema, + } + ); +} + +#[test] +fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { + let schema: McpElicitationSchema = serde_json::from_value(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "title": "Email", + "description": "Work email address", + "format": "email", + "default": "dev@example.com", + }, + "count": { + "type": "integer", + "title": "Count", + "description": "How many items to create", + "minimum": 1, + "maximum": 5, + "default": 3, + }, + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action", + "default": true, + }, + "legacyChoice": { + "type": "string", + "title": "Action", + "description": "Legacy titled enum form", + "enum": ["allow", "deny"], + "enumNames": ["Allow", "Deny"], + "default": "allow", + }, + }, + "required": ["email", "confirmed"], + })) + .expect("schema should deserialize"); + + assert_eq!( + schema, + McpElicitationSchema { + schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), + type_: McpElicitationObjectType::Object, + properties: BTreeMap::from([ + ( + "confirmed".to_string(), + McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { + type_: McpElicitationBooleanType::Boolean, + title: Some("Confirm".to_string()), + description: Some("Approve the pending action".to_string()), + default: Some(true), + }), + ), + ( + "count".to_string(), + McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { + type_: McpElicitationNumberType::Integer, + title: Some("Count".to_string()), + description: Some("How many items to create".to_string()), + minimum: Some(1.0), + maximum: Some(5.0), + default: Some(3.0), + }), + ), + ( + "email".to_string(), + McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { + type_: McpElicitationStringType::String, + title: Some("Email".to_string()), + description: Some("Work email address".to_string()), + min_length: None, + max_length: None, + format: Some(McpElicitationStringFormat::Email), + default: Some("dev@example.com".to_string()), + }), + ), + ( + "legacyChoice".to_string(), + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( + McpElicitationLegacyTitledEnumSchema { + type_: McpElicitationStringType::String, + title: Some("Action".to_string()), + description: Some("Legacy titled enum form".to_string()), + enum_: vec!["allow".to_string(), "deny".to_string()], + enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), + default: Some("allow".to_string()), + }, + )), + ), + ]), + required: Some(vec!["email".to_string(), "confirmed".to_string()]), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_rejects_null_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: Some(json!({ + "persist": "session", + })), + message: "Allow this request?".to_string(), + requested_schema: JsonValue::Null, + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "object", + } + }, + }), + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_response_serializes_nullable_content() { + let response = McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + meta: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "action": "decline", + "content": null, + "_meta": null, + }) + ); +} + +#[test] +fn sandbox_policy_round_trips_workspace_write_access() { + let v2_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { + let policy = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "fullAccess" + }, + "networkAccess": true + })) + .expect("read-only policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::ReadOnly { + network_access: true + } + ); +} + +#[test] +fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "readOnlyAccess": { + "type": "fullAccess" + }, + "networkAccess": true, + "excludeTmpdirEnvVar": true, + "excludeSlashTmp": true + })) + .expect("workspace-write policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![absolute_path("/workspace")], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); +} + +#[test] +fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { + let err = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + } + })) + .expect_err("read-only policy should reject removed restricted access field"); + assert!(err.to_string().contains("readOnly.access")); +} + +#[test] +fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { + let err = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [], + "readOnlyAccess": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + }, + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + })) + .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); + assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); +} + +#[test] +fn automatic_approval_review_deserializes_aborted_status() { + let review: GuardianApprovalReview = serde_json::from_value(json!({ + "status": "aborted", + "riskLevel": null, + "userAuthorization": null, + "rationale": null + })) + .expect("aborted automatic review should deserialize"); + assert_eq!( + review, + GuardianApprovalReview { + status: GuardianApprovalReviewStatus::Aborted, + risk_level: None, + user_authorization: None, + rationale: None, + } + ); +} + +#[test] +fn guardian_approval_review_action_round_trips_command_shape() { + let value = json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/example.sqlite", + "cwd": absolute_path_string("tmp"), + }); + let action: GuardianApprovalReviewAction = + serde_json::from_value(value.clone()).expect("guardian review action"); + + assert_eq!( + action, + GuardianApprovalReviewAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: absolute_path("tmp"), + } + ); + assert_eq!( + serde_json::to_value(&action).expect("serialize guardian review action"), + value + ); +} + +#[test] +fn network_requirements_deserializes_legacy_fields() { + let requirements: NetworkRequirements = serde_json::from_value(json!({ + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "allowUnixSockets": ["/tmp/proxy.sock"] + })) + .expect("legacy network requirements should deserialize"); + + assert_eq!( + requirements, + NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: None, + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: None, + } + ); +} + +#[test] +fn network_requirements_serializes_canonical_and_legacy_fields() { + let requirements = NetworkRequirements { + enabled: Some(true), + http_port: Some(8080), + socks_port: Some(1080), + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(true), + domains: Some(BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow), + ( + "blocked.example.com".to_string(), + NetworkDomainPermission::Deny, + ), + ])), + managed_allowed_domains_only: Some(true), + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: Some(BTreeMap::from([ + ( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + ), + ])), + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: Some(true), + }; + + assert_eq!( + serde_json::to_value(requirements).expect("network requirements should serialize"), + json!({ + "enabled": true, + "httpPort": 8080, + "socksPort": 1080, + "allowUpstreamProxy": false, + "dangerouslyAllowNonLoopbackProxy": false, + "dangerouslyAllowAllUnixSockets": true, + "domains": { + "api.openai.com": "allow", + "blocked.example.com": "deny" + }, + "managedAllowedDomainsOnly": true, + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "unixSockets": { + "/tmp/ignored.sock": "none", + "/tmp/proxy.sock": "allow" + }, + "allowUnixSockets": ["/tmp/proxy.sock"], + "allowLocalBinding": true + }) + ); +} + +#[test] +fn core_turn_item_into_thread_item_converts_supported_variants() { + let user_item = TurnItem::UserMessage(UserMessageItem { + id: "user-1".to_string(), + content: vec![ + CoreUserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + }, + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + CoreUserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + CoreUserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + }); + + assert_eq!( + ThreadItem::from(user_item), + ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + UserInput::Image { + url: "https://example.com/image.png".to_string(), + }, + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + UserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + } + ); + + let agent_item = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-1".to_string(), + content: vec![ + AgentMessageContent::Text { + text: "Hello ".to_string(), + }, + AgentMessageContent::Text { + text: "world".to_string(), + }, + ], + phase: None, + memory_citation: None, + }); + + assert_eq!( + ThreadItem::from(agent_item), + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "Hello world".to_string(), + phase: None, + memory_citation: None, + } + ); + + let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-2".to_string(), + content: vec![AgentMessageContent::Text { + text: "final".to_string(), + }], + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(CoreMemoryCitation { + entries: vec![CoreMemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + rollout_ids: vec!["rollout-1".to_string()], + }), + }); + + assert_eq!( + ThreadItem::from(agent_item_with_phase), + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: "final".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(MemoryCitation { + entries: vec![MemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + thread_ids: vec!["rollout-1".to_string()], + }), + } + ); + + let reasoning_item = TurnItem::Reasoning(ReasoningItem { + id: "reasoning-1".to_string(), + summary_text: vec!["line one".to_string(), "line two".to_string()], + raw_content: vec![], + }); + + assert_eq!( + ThreadItem::from(reasoning_item), + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["line one".to_string(), "line two".to_string()], + content: vec![], + } + ); + + let search_item = TurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: CoreWebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }, + }); + + assert_eq!( + ThreadItem::from(search_item), + ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + } + ); + + let image_view_item = TurnItem::ImageView(ImageViewItem { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + }); + + assert_eq!( + ThreadItem::from(image_view_item), + ThreadItem::ImageView { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + } + ); + + let file_change_item = TurnItem::FileChange(FileChangeItem { + id: "patch-1".to_string(), + changes: [( + PathBuf::from("README.md"), + codex_protocol::protocol::FileChange::Add { + content: "hello\n".to_string(), + }, + )] + .into_iter() + .collect(), + status: Some(codex_protocol::protocol::PatchApplyStatus::Completed), + auto_approved: None, + stdout: Some("Done!".to_string()), + stderr: Some(String::new()), + }); + + assert_eq!( + ThreadItem::from(file_change_item), + ThreadItem::FileChange { + id: "patch-1".to_string(), + changes: vec![FileUpdateChange { + path: "README.md".to_string(), + kind: PatchChangeKind::Add, + diff: "hello\n".to_string(), + }], + status: PatchApplyStatus::Completed, + } + ); + + let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + status: CoreMcpToolCallStatus::InProgress, + result: None, + error: None, + duration: None, + }); + + assert_eq!( + ThreadItem::from(mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::InProgress, + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + result: None, + error: None, + duration_ms: None, + } + ); + + let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + status: CoreMcpToolCallStatus::Completed, + result: Some(CallToolResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + is_error: Some(false), + meta: Some(json!({"trace": "1"})), + }), + error: None, + duration: Some(Duration::from_millis(42)), + }); + + assert_eq!( + ThreadItem::from(completed_mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::Completed, + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + result: Some(Box::new(McpToolCallResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + meta: Some(json!({"trace": "1"})), + })), + error: None, + duration_ms: Some(42), + } + ); +} + +#[test] +fn skills_list_params_serialization_uses_force_reload() { + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: Vec::new(), + force_reload: false, + }) + .unwrap(), + json!({}), + ); + + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: vec![PathBuf::from("/repo")], + force_reload: true, + }) + .unwrap(), + json!({ + "cwds": ["/repo"], + "forceReload": true, + }), + ); +} + +#[test] +fn plugin_source_serializes_local_git_and_remote_variants() { + let local_path = if cfg!(windows) { + r"C:\plugins\linear" + } else { + "/plugins/linear" + }; + let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); + let local_path_json = local_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), + json!({ + "type": "local", + "path": local_path_json, + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Git { + url: "https://github.com/openai/example.git".to_string(), + path: Some("plugins/example".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), + }) + .unwrap(), + json!({ + "type": "git", + "url": "https://github.com/openai/example.git", + "path": "plugins/example", + "refName": "main", + "sha": "abc123", + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Remote).unwrap(), + json!({ + "type": "remote", + }), + ); +} + +#[test] +fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: None, + sparse_paths: None, + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": null, + "sparsePaths": null, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: Some("main".to_string()), + sparse_paths: Some(vec!["plugins/foo".to_string()]), + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": "main", + "sparsePaths": ["plugins/foo"], + }), + ); +} + +#[test] +fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: None, + }) + .unwrap(), + json!({ + "marketplaceName": null, + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + MarketplaceUpgradeParams { + marketplace_name: None, + }, + ); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: Some("debug".to_string()), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + }), + ); +} + +#[test] +fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { + assert_eq!( + serde_json::to_value(PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: None, + interface: None, + plugins: Vec::new(), + }) + .unwrap(), + json!({ + "name": "openai-curated", + "path": null, + "interface": null, + "plugins": [], + }), + ); +} + +#[test] +fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { + let composer_icon = if cfg!(windows) { + r"C:\plugins\linear\icon.png" + } else { + "/plugins/linear/icon.png" + }; + let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); + let composer_icon_json = composer_icon.as_path().display().to_string(); + + let interface = PluginInterface { + display_name: Some("Linear".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Productivity".to_string()), + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some(composer_icon), + composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), + logo: None, + logo_url: Some("https://example.com/linear/logo.png".to_string()), + screenshots: Vec::new(), + screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], + }; + + assert_eq!( + serde_json::to_value(interface).unwrap(), + json!({ + "displayName": "Linear", + "shortDescription": null, + "longDescription": null, + "developerName": null, + "category": "Productivity", + "capabilities": [], + "websiteUrl": null, + "privacyPolicyUrl": null, + "termsOfServiceUrl": null, + "defaultPrompt": null, + "brandColor": null, + "composerIcon": composer_icon_json, + "composerIconUrl": "https://example.com/linear/icon.png", + "logo": null, + "logoUrl": "https://example.com/linear/logo.png", + "screenshots": [], + "screenshotUrls": ["https://example.com/linear/screenshot.png"], + }), + ); +} + +#[test] +fn plugin_list_params_ignore_removed_force_remote_sync_field() { + assert_eq!( + serde_json::from_value::(json!({ + "cwds": null, + "forceRemoteSync": true, + })) + .unwrap(), + PluginListParams { + cwds: None, + marketplace_kinds: None, + }, + ); +} + +#[test] +fn plugin_list_params_serializes_marketplace_kind_filter() { + assert_eq!( + serde_json::to_value(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![ + PluginListMarketplaceKind::Local, + PluginListMarketplaceKind::WorkspaceDirectory, + PluginListMarketplaceKind::SharedWithMe, + ]), + }) + .unwrap(), + json!({ + "cwds": null, + "marketplaceKinds": [ + "local", + "workspace-directory", + "shared-with-me", + ], + }), + ); +} + +#[test] +fn plugin_read_params_serialization_uses_install_source_fields() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + })) + .unwrap(), + PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_install_params_serialization_omits_force_remote_sync() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginInstallParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { + assert_eq!( + serde_json::to_value(PluginSkillReadParams { + remote_marketplace_name: "chatgpt-global".to_string(), + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + skill_name: "plan-work".to_string(), + }) + .unwrap(), + json!({ + "remoteMarketplaceName": "chatgpt-global", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "skillName": "plan-work", + }), + ); +} + +#[test] +fn plugin_share_params_and_response_serialization_use_camel_case_fields() { + let plugin_path = if cfg!(windows) { + r"C:\plugins\gmail" + } else { + "/plugins/gmail" + }; + let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); + let plugin_path_json = plugin_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path: plugin_path.clone(), + remote_plugin_id: None, + discoverability: None, + share_targets: None, + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": null, + "discoverability": null, + "shareTargets": null, + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path, + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),), + discoverability: Some(PluginShareDiscoverability::Private), + share_targets: Some(vec![ + PluginShareTarget { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + PluginShareTarget { + principal_type: PluginSharePrincipalType::Workspace, + principal_id: "workspace-1".to_string(), + }, + ]), + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + { + "principalType": "workspace", + "principalId": "workspace-1", + }, + ], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveResponse { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_url: String::new(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareUrl": "", + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + discoverability: PluginShareUpdateDiscoverability::Unlisted, + share_targets: vec![PluginShareTarget { + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }], + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "discoverability": "UNLISTED", + "shareTargets": [{ + "principalType": "group", + "principalId": "group-1", + }], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + discoverability: PluginShareDiscoverability::Unlisted, + }) + .unwrap(), + json!({ + "principals": [{ + "principalType": "user", + "principalId": "user-1", + "name": "Gavin", + }], + "discoverability": "UNLISTED", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + PluginShareListParams {}, + ); + + assert_eq!( + serde_json::to_value(PluginShareDeleteParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); +} + +#[test] +fn plugin_share_list_response_serializes_share_items() { + assert_eq!( + serde_json::to_value(PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + name: "gmail".to_string(), + share_context: None, + source: PluginSource::Remote, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, + }], + }) + .unwrap(), + json!({ + "data": [{ + "plugin": { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "shareContext": null, + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "availability": "AVAILABLE", + "interface": null, + "keywords": [], + }, + "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", + "localPluginPath": null, + }], + }), + ); +} + +#[test] +fn plugin_summary_defaults_missing_availability_to_available() { + let summary: PluginSummary = serde_json::from_value(json!({ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "interface": null, + })) + .unwrap(); + + assert_eq!(summary.availability, PluginAvailability::Available); + assert_eq!(summary.share_context, None); +} + +#[test] +fn plugin_availability_deserializes_enabled_alias() { + let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); + + assert_eq!(availability, PluginAvailability::Available); + assert_eq!( + serde_json::to_value(availability).unwrap(), + json!("AVAILABLE") + ); +} + +#[test] +fn plugin_uninstall_params_serialization_omits_force_remote_sync() { + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "gmail@openai-curated", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "gmail@openai-curated", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }, + ); + + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "plugins~Plugin_gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "plugins~Plugin_gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }, + ); +} + +#[test] +fn marketplace_remove_response_serializes_nullable_installed_root() { + let installed_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap(); + let installed_root_json = installed_root.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: Some(installed_root), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": installed_root_json, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: None, + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": null, + }), + ); +} + +#[test] +fn marketplace_upgrade_response_serializes_camel_case_fields() { + let upgraded_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); + let upgraded_root_json = upgraded_root.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string()], + upgraded_roots: vec![upgraded_root], + errors: vec![MarketplaceUpgradeErrorInfo { + marketplace_name: "broken".to_string(), + message: "failed to clone".to_string(), + }], + }) + .unwrap(), + json!({ + "selectedMarketplaces": ["debug"], + "upgradedRoots": [upgraded_root_json], + "errors": [{ + "marketplaceName": "broken", + "message": "failed to clone", + }], + }), + ); +} + +#[test] +fn codex_error_info_serializes_http_status_code_in_camel_case() { + let value = CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(401), + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "responseTooManyFailedAttempts": { + "httpStatusCode": 401 + } + }) + ); +} + +#[test] +fn codex_error_info_serializes_cyber_policy_in_camel_case() { + assert_eq!( + serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), + json!("cyberPolicy") + ); +} + +#[test] +fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { + let value = CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "activeTurnNotSteerable": { + "turnKind": "review" + } + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_text_and_image_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![ + DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + }, + ], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + }, + { + "type": "inputImage", + "imageUrl": "data:image/png;base64,AAA" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_spec_deserializes_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + "deferLoading": true, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert_eq!( + actual, + DynamicToolSpec { + namespace: None, + name: "lookup_ticket".to_string(), + description: "Fetch a ticket".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string" } + } + }), + defer_loading: true, + } + ); +} + +#[test] +fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": {} + }, + "exposeToContext": false, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert!(actual.defer_loading); +} + +#[test] +fn thread_start_params_preserve_explicit_null_service_tier() { + let params: ThreadStartParams = + serde_json::from_value(json!({ "serviceTier": null })).expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn thread_lifecycle_responses_default_missing_optional_fields() { + let response = json!({ + "thread": { + "id": "thread-id", + "sessionId": "thread-id", + "forkedFromId": null, + "preview": "", + "ephemeral": false, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 1, + "status": { "type": "idle" }, + "path": null, + "cwd": absolute_path_string("tmp"), + "cliVersion": "0.0.0", + "source": "exec", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5", + "modelProvider": "openai", + "serviceTier": null, + "cwd": absolute_path_string("tmp"), + "approvalPolicy": "on-failure", + "approvalsReviewer": "user", + "sandbox": { "type": "dangerFullAccess" }, + "reasoningEffort": null + }); + + let start: ThreadStartResponse = + serde_json::from_value(response.clone()).expect("thread/start response"); + let resume: ThreadResumeResponse = + serde_json::from_value(response.clone()).expect("thread/resume response"); + let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); + + assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(resume.instruction_sources, Vec::::new()); + assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); + assert_eq!(start.active_permission_profile, None); + assert_eq!(resume.active_permission_profile, None); + assert_eq!(fork.active_permission_profile, None); +} + +#[test] +fn turn_start_params_preserve_explicit_null_service_tier() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = TurnStartParams { + thread_id: "thread_123".to_string(), + input: vec![], + responsesapi_client_metadata: None, + environments: None, + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permissions: None, + model: None, + service_tier: None, + effort: None, + summary: None, + output_schema: None, + collaboration_mode: None, + personality: None, + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn turn_start_params_round_trip_environments() { + let cwd = test_absolute_path(); + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], + })) + .expect("params should deserialize"); + + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: cwd.clone(), + }]) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("environments"), + Some(&json!([ + { + "environmentId": "local", + "cwd": cwd + } + ])) + ); +} + +#[test] +fn turn_start_params_preserve_empty_environments() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [], + })) + .expect("params should deserialize"); + + assert_eq!(params.environments, Some(Vec::new())); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized.get("environments"), Some(&json!([]))); +} + +#[test] +fn turn_start_params_treat_null_or_omitted_environments_as_default() { + let null_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": null, + })) + .expect("params should deserialize"); + let omitted_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + })) + .expect("params should deserialize"); + + assert_eq!(null_environments.environments, None); + assert_eq!(omitted_environments.environments, None); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), + None + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), + None + ); +} + +#[test] +fn turn_start_params_reject_relative_environment_cwd() { + let err = serde_json::from_value::(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": "relative" + } + ], + })) + .expect_err("relative environment cwd should fail"); + + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/thread.rs b/code-rs/app-server-protocol/src/protocol/v2/thread.rs new file mode 100644 index 00000000000..458722b3a21 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -0,0 +1,1187 @@ +use super::ActivePermissionProfile; +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfile; +use super::PermissionProfileSelectionParams; +use super::SandboxMode; +use super::SandboxPolicy; +use super::Thread; +use super::ThreadItem; +use super::ThreadSource; +use super::Turn; +use super::TurnEnvironmentParams; +use super::TurnItemsView; +use super::shared::v2_enum_from_core; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::Personality; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; +use codex_protocol::protocol::TokenUsage as CoreTokenUsage; +use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadStartSource { + Startup, + Clear, +} + +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolSpec { + #[ts(optional)] + pub namespace: Option, + pub name: String, + pub description: String, + pub input_schema: JsonValue, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub defer_loading: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DynamicToolSpecDe { + namespace: Option, + name: String, + description: String, + input_schema: JsonValue, + defer_loading: Option, + expose_to_context: Option, +} + +impl<'de> Deserialize<'de> for DynamicToolSpec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let DynamicToolSpecDe { + namespace, + name, + description, + input_schema, + defer_loading, + expose_to_context, + } = DynamicToolSpecDe::deserialize(deserializer)?; + + Ok(Self { + namespace, + name, + description, + input_schema, + defer_loading: defer_loading + .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), + }) + } +} + +// === Threads, Turns, and Items === +// Thread APIs +#[derive( + Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartParams { + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for this thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported turn/thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub service_name: Option, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + #[ts(optional = nullable)] + pub ephemeral: Option, + #[ts(optional = nullable)] + pub session_start_source: Option, + /// Optional client-supplied analytics source classification for this thread. + #[ts(optional = nullable)] + pub thread_source: Option, + /// Optional sticky environments for this thread. + /// + /// Omitted selects the default environment when environment access is + /// enabled. Empty disables environment access for turns that do not + /// provide a turn override. Non-empty selects the first environment as the + /// current turn environment. + #[experimental("thread/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + #[experimental("thread/start.dynamicTools")] + #[ts(optional = nullable)] + pub dynamic_tools: Option>, + /// Test-only experimental field used to validate experimental gating and + /// schema filtering behavior in a stable way. + #[experimental("thread/start.mockExperimentalField")] + #[ts(optional = nullable)] + pub mock_experimental_field: Option, + /// If true, opt into emitting raw Responses API items on the event stream. + /// This is for internal use only (e.g. Codex Cloud). + #[experimental("thread/start.experimentalRawEvents")] + #[serde(default)] + pub experimental_raw_events: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/start.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodParams { + /// Test-only payload field. + #[ts(optional = nullable)] + pub value: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodResponse { + /// Echoes the input `value`. + pub echoed: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/start.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/start.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are three ways to resume a thread: +/// 1. By thread_id: load the thread from disk by thread_id and resume it. +/// 2. By history: instantiate the thread from memory and resume it. +/// 3. By path: load the thread from disk by path and resume it. +/// +/// The precedence is: history > path > thread_id. +/// If using history or path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadResumeParams { + pub thread_id: String, + + /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. + /// If specified, the thread will be resumed with the provided history + /// instead of loaded from disk. + #[experimental("thread/resume.history")] + #[ts(optional = nullable)] + pub history: Option>, + + /// [UNSTABLE] Specify the rollout path to resume from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/resume.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the resumed thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the resumed thread. Cannot be combined + /// with `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/resume.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + /// When true, return only thread metadata and live-resume state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after resuming. + #[experimental("thread/resume.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/resume.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/resume.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/resume.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are two ways to fork a thread: +/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. +/// 2. By path: load the thread from disk by path and fork it into a new thread. +/// +/// If using path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadForkParams { + pub thread_id: String, + + /// [UNSTABLE] Specify the rollout path to fork from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/fork.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the forked thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the forked thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/fork.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ephemeral: bool, + /// Optional client-supplied analytics source classification for this forked thread. + #[ts(optional = nullable)] + pub thread_source: Option, + /// When true, return only thread metadata and live fork state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after forking. + #[experimental("thread/fork.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/fork.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadForkResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/fork.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/fork.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeResponse { + pub status: ThreadUnsubscribeStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadUnsubscribeStatus { + NotLoaded, + NotSubscribed, + Unsubscribed, +} + +/// Parameters for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be incremented. + pub thread_id: String, +} + +/// Response for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationResponse { + /// Current out-of-band elicitation count after the increment. + pub count: u64, + /// Whether timeout accounting is paused after applying the increment. + pub paused: bool, +} + +/// Parameters for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be decremented. + pub thread_id: String, +} + +/// Response for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationResponse { + /// Current out-of-band elicitation count after the decrement. + pub count: u64, + /// Whether timeout accounting remains paused after applying the decrement. + pub paused: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameParams { + pub thread_id: String, + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameResponse {} + +v2_enum_from_core! { + pub enum ThreadGoalStatus from CoreThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoal { + pub thread_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + #[ts(type = "number | null")] + pub token_budget: Option, + #[ts(type = "number")] + pub tokens_used: i64, + #[ts(type = "number")] + pub time_used_seconds: i64, + #[ts(type = "number")] + pub created_at: i64, + #[ts(type = "number")] + pub updated_at: i64, +} + +impl From for ThreadGoal { + fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { + Self { + thread_id: value.thread_id.to_string(), + objective: value.objective, + status: value.status.into(), + token_budget: value.token_budget, + tokens_used: value.tokens_used, + time_used_seconds: value.time_used_seconds, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub objective: Option, + #[ts(optional = nullable)] + pub status: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable, type = "number | null")] + pub token_budget: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetResponse { + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetResponse { + pub goal: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearResponse { + pub cleared: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateParams { + pub thread_id: String, + /// Patch the stored Git metadata for this thread. + /// Omit a field to leave it unchanged, set it to `null` to clear it, or + /// provide a string to replace the stored value. + #[ts(optional = nullable)] + pub git_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataGitInfoUpdateParams { + /// Omit to leave the stored commit unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub sha: Option>, + /// Omit to leave the stored branch unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub branch: Option>, + /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub origin_url: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +pub enum ThreadMemoryMode { + Enabled, + Disabled, +} + +impl ThreadMemoryMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + } + } + + pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode { + match self { + Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled, + Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetParams { + pub thread_id: String, + pub mode: ThreadMemoryMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryResetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandParams { + pub thread_id: String, + /// Shell command string evaluated by the thread's configured shell. + /// Unlike `command/exec`, this intentionally preserves shell syntax + /// such as pipes, redirects, and quoting. This runs unsandboxed with full + /// access rather than inheriting the thread sandbox policy. + pub command: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionParams { + pub thread_id: String, + /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. + pub event: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackParams { + pub thread_id: String, + /// The number of turns to drop from the end of the thread. Must be >= 1. + /// + /// This only modifies the thread's history and does not revert local file changes + /// that have been made by the agent. Clients are responsible for reverting these changes. + pub num_turns: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackResponse { + /// The updated thread after applying the rollback, with `turns` populated. + /// + /// The ThreadItems stored in each Turn are lossy since we explicitly do not + /// persist all agent interactions, such as command executions. This is the same + /// behavior as `thread/resume`. + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional sort key; defaults to created_at. + #[ts(optional = nullable)] + pub sort_key: Option, + /// Optional sort direction; defaults to descending (newest first). + #[ts(optional = nullable)] + pub sort_direction: Option, + /// Optional provider filter; when set, only sessions recorded under these + /// providers are returned. When present but empty, includes all providers. + #[ts(optional = nullable)] + pub model_providers: Option>, + /// Optional source filter; when set, only sessions from these source kinds + /// are returned. When omitted or empty, defaults to interactive sources. + #[ts(optional = nullable)] + pub source_kinds: Option>, + /// Optional archived filter; when set to true, only archived threads are returned. + /// If false or null, only non-archived threads are returned. + #[ts(optional = nullable)] + pub archived: Option, + /// Optional cwd filter or filters; when set, only threads whose session cwd + /// exactly matches one of these paths are returned. + #[ts(optional = nullable, type = "string | Array | null")] + pub cwd: Option, + /// If true, return from the state DB without scanning JSONL rollouts to + /// repair thread metadata. Omitted or false preserves scan-and-repair + /// behavior. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub use_state_db_only: bool, + /// Optional substring filter for the extracted thread title. + #[ts(optional = nullable)] + pub search_term: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ThreadListCwdFilter { + One(String), + Many(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadSourceKind { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + VsCode, + Exec, + AppServer, + SubAgent, + SubAgentReview, + SubAgentCompact, + SubAgentThreadSpawn, + SubAgentOther, + Unknown, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SortDirection { + Asc, + Desc, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one thread. + /// Use it with the opposite `sortDirection`; for timestamp sorts it anchors + /// at the start of the page timestamp so same-second updates are not skipped. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to no limit. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListResponse { + /// Thread ids for sessions currently loaded in memory. + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadStatus { + NotLoaded, + Idle, + SystemError, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Active { + active_flags: Vec, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadActiveFlag { + WaitingOnApproval, + WaitingOnUserInput, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadParams { + pub thread_id: String, + /// When true, include turns and their items from rollout history. + #[serde(default)] + pub include_turns: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsParams { + pub thread_id: String, + /// Raw Responses API items to append to the thread's model-visible history. + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListParams { + pub thread_id: String, + /// Opaque cursor to pass to the next call to continue after the last turn. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, + /// How much item detail to include for each returned turn; defaults to summary. + #[ts(optional = nullable)] + pub items_view: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last turn. + /// if None, there are no more turns to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one turn. + /// Use it with the opposite `sortDirection` to include the anchor turn again + /// and catch updates to that turn. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsItemsListParams { + pub thread_id: String, + pub turn_id: String, + /// Opaque cursor to pass to the next call to continue after the last item. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional item page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional item pagination direction; defaults to ascending. + #[ts(optional = nullable)] + pub sort_direction: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsItemsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one item. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsageUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub token_usage: ThreadTokenUsage, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsage { + pub total: TokenUsageBreakdown, + pub last: TokenUsageBreakdown, + // TODO(aibrahim): make this not optional + #[ts(type = "number | null")] + pub model_context_window: Option, +} + +impl From for ThreadTokenUsage { + fn from(value: CoreTokenUsageInfo) -> Self { + Self { + total: value.total_token_usage.into(), + last: value.last_token_usage.into(), + model_context_window: value.model_context_window, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TokenUsageBreakdown { + #[ts(type = "number")] + pub total_tokens: i64, + #[ts(type = "number")] + pub input_tokens: i64, + #[ts(type = "number")] + pub cached_input_tokens: i64, + #[ts(type = "number")] + pub output_tokens: i64, + #[ts(type = "number")] + pub reasoning_output_tokens: i64, +} + +impl From for TokenUsageBreakdown { + fn from(value: CoreTokenUsage) -> Self { + Self { + total_tokens: value.total_tokens, + input_tokens: value.input_tokens, + cached_input_tokens: value.cached_input_tokens, + output_tokens: value.output_tokens, + reasoning_output_tokens: value.reasoning_output_tokens, + } + } +} + +// Thread/Turn lifecycle notifications and item progress events +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartedNotification { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStatusChangedNotification { + pub thread_id: String, + pub status: ThreadStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadClosedNotification { + pub thread_id: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadNameUpdatedNotification { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalUpdatedNotification { + pub thread_id: String, + pub turn_id: Option, + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearedNotification { + pub thread_id: String, +} + +/// Deprecated: Use `ContextCompaction` item type instead. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ContextCompactedNotification { + pub thread_id: String, + pub turn_id: String, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/code-rs/app-server-protocol/src/protocol/v2/thread_data.rs new file mode 100644 index 00000000000..f0c518adf8d --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -0,0 +1,196 @@ +use super::CodexErrorInfo; +use super::ThreadItem; +use super::ThreadStatus; +use super::TurnStatus; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::ThreadSource as CoreThreadSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +#[derive(Default)] +pub enum SessionSource { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + #[default] + VsCode, + Exec, + AppServer, + Custom(String), + SubAgent(CoreSubAgentSource), + #[serde(other)] + Unknown, +} + +impl From for SessionSource { + fn from(value: CoreSessionSource) -> Self { + match value { + CoreSessionSource::Cli => SessionSource::Cli, + CoreSessionSource::VSCode => SessionSource::VsCode, + CoreSessionSource::Exec => SessionSource::Exec, + CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::Custom(source) => SessionSource::Custom(source), + // We do not want to render those at the app-server level. + CoreSessionSource::Internal(_) => SessionSource::Unknown, + CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), + CoreSessionSource::Unknown => SessionSource::Unknown, + } + } +} + +impl From for CoreSessionSource { + fn from(value: SessionSource) -> Self { + match value { + SessionSource::Cli => CoreSessionSource::Cli, + SessionSource::VsCode => CoreSessionSource::VSCode, + SessionSource::Exec => CoreSessionSource::Exec, + SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Custom(source) => CoreSessionSource::Custom(source), + SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), + SessionSource::Unknown => CoreSessionSource::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum ThreadSource { + User, + Subagent, + MemoryConsolidation, +} + +impl From for ThreadSource { + fn from(value: CoreThreadSource) -> Self { + match value { + CoreThreadSource::User => ThreadSource::User, + CoreThreadSource::Subagent => ThreadSource::Subagent, + CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation, + } + } +} + +impl From for CoreThreadSource { + fn from(value: ThreadSource) -> Self { + match value { + ThreadSource::User => CoreThreadSource::User, + ThreadSource::Subagent => CoreThreadSource::Subagent, + ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, + /// Session id shared by threads that belong to the same session tree. + pub session_id: String, + /// Source thread id when this thread was created by forking another thread. + pub forked_from_id: Option, + /// Usually the first user message in the thread, if available. + pub preview: String, + /// Whether the thread is ephemeral and should not be materialized on disk. + pub ephemeral: bool, + /// Model provider used for this thread (for example, 'openai'). + pub model_provider: String, + /// Unix timestamp (in seconds) when the thread was created. + #[ts(type = "number")] + pub created_at: i64, + /// Unix timestamp (in seconds) when the thread was last updated. + #[ts(type = "number")] + pub updated_at: i64, + /// Current runtime status for the thread. + pub status: ThreadStatus, + /// [UNSTABLE] Path to the thread on disk. + pub path: Option, + /// Working directory captured for the thread. + pub cwd: AbsolutePathBuf, + /// Version of the CLI that created the thread. + pub cli_version: String, + /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, + /// Optional Git metadata captured when the thread was created. + pub git_info: Option, + /// Optional user-facing thread title. + pub name: Option, + /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` + /// (when `includeTurns` is true) responses. + /// For all other responses and notifications returning a Thread, + /// the turns field will be an empty list. + pub turns: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Turn { + pub id: String, + /// Thread items currently included in this turn payload. + pub items: Vec, + /// Describes how much of `items` has been loaded for this turn. + #[serde(default)] + pub items_view: TurnItemsView, + pub status: TurnStatus, + /// Only populated when the Turn's status is failed. + pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnItemsView { + /// `items` was not loaded for this turn. The field is intentionally empty. + NotLoaded, + /// `items` contains only a display summary for this turn. + Summary, + /// `items` contains every ThreadItem available from persisted app-server history for this turn. + #[default] + Full, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +#[error("{message}")] +pub struct TurnError { + pub message: String, + pub codex_error_info: Option, + #[serde(default)] + pub additional_details: Option, +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/turn.rs b/code-rs/app-server-protocol/src/protocol/v2/turn.rs new file mode 100644 index 00000000000..61a09bfbf53 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -0,0 +1,389 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfileSelectionParams; +use super::SandboxPolicy; +use super::Turn; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; +use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::user_input::ByteRange as CoreByteRange; +use codex_protocol::user_input::TextElement as CoreTextElement; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnStatus { + Completed, + Interrupted, + Failed, + InProgress, +} + +// Turn APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/start.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Optional turn-scoped environments. + /// + /// Omitted uses the thread sticky environments. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. + #[experimental("turn/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + /// Override the working directory for this turn and subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for this turn and subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this turn and + /// subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for this turn and subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile for this turn and subsequent turns. + /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` + /// for supported turn adjustments instead of replacing the full + /// permissions profile. + #[experimental("turn/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for this turn and subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for this turn and subsequent turns. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for this turn and subsequent turns. + #[ts(optional = nullable)] + pub effort: Option, + /// Override the reasoning summary for this turn and subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// Override the personality for this turn and subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, + /// Optional JSON Schema used to constrain the final assistant message for + /// this turn. + #[ts(optional = nullable)] + pub output_schema: Option, + + /// EXPERIMENTAL - Set a pre-set collaboration mode. + /// Takes precedence over model, reasoning_effort, and developer instructions if set. + /// + /// For `collaboration_mode.settings.developer_instructions`, `null` means + /// "use the built-in instructions for the selected mode". + #[experimental("turn/start.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartResponse { + pub turn: Turn, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/steer.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Required active turn id precondition. The request fails when it does not + /// match the currently active turn. + pub expected_turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerResponse { + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptParams { + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptResponse {} + +// User input types +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ByteRange { + pub start: usize, + pub end: usize, +} + +impl From for ByteRange { + fn from(value: CoreByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +impl From for CoreByteRange { + fn from(value: ByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextElement { + /// Byte range in the parent `text` buffer that this element occupies. + pub byte_range: ByteRange, + /// Optional human-readable placeholder for the element, displayed in the UI. + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } +} + +impl From for TextElement { + fn from(value: CoreTextElement) -> Self { + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) + } +} + +impl From for CoreTextElement { + fn from(value: TextElement) -> Self { + Self::new(value.byte_range.into(), value.placeholder) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum UserInput { + Text { + text: String, + /// UI-defined spans within `text` used to render or persist special elements. + #[serde(default)] + text_elements: Vec, + }, + Image { + url: String, + }, + LocalImage { + path: PathBuf, + }, + Skill { + name: String, + path: PathBuf, + }, + Mention { + name: String, + path: String, + }, +} + +impl UserInput { + pub fn into_core(self) -> CoreUserInput { + match self { + UserInput::Text { + text, + text_elements, + } => CoreUserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + UserInput::Image { url } => CoreUserInput::Image { image_url: url }, + UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, + UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, + } + } +} + +impl From for UserInput { + fn from(value: CoreUserInput) -> Self { + match value { + CoreUserInput::Text { + text, + text_elements, + } => UserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, + CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, + CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, + _ => unreachable!("unsupported user input variant"), + } + } +} + +impl UserInput { + pub fn text_char_count(&self) -> usize { + match self { + UserInput::Text { text, .. } => text.chars().count(), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } => 0, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Usage { + pub input_tokens: i32, + pub cached_input_tokens: i32, + pub output_tokens: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnCompletedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification that the turn-level unified diff has changed. +/// Contains the latest aggregated diff across all file changes in the turn. +pub struct TurnDiffUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub explanation: Option, + pub plan: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanStep { + pub step: String, + pub status: TurnPlanStepStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnPlanStepStatus { + Pending, + InProgress, + Completed, +} + +impl From for TurnPlanStep { + fn from(value: CorePlanItemArg) -> Self { + Self { + step: value.step, + status: value.status.into(), + } + } +} + +impl From for TurnPlanStepStatus { + fn from(value: CorePlanStepStatus) -> Self { + match value { + CorePlanStepStatus::Pending => Self::Pending, + CorePlanStepStatus::InProgress => Self::InProgress, + CorePlanStepStatus::Completed => Self::Completed, + } + } +} diff --git a/code-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs b/code-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs new file mode 100644 index 00000000000..3e090c7bfd1 --- /dev/null +++ b/code-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs @@ -0,0 +1,63 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsWorldWritableWarningNotification { + pub sample_paths: Vec, + pub extra_count: usize, + pub failed_scan: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxSetupMode { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxReadiness { + Ready, + NotConfigured, + UpdateRequired, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartParams { + pub mode: WindowsSandboxSetupMode, + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartResponse { + pub started: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxReadinessResponse { + pub status: WindowsSandboxReadiness, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupCompletedNotification { + pub mode: WindowsSandboxSetupMode, + pub success: bool, + pub error: Option, +} diff --git a/code-rs/app-server-protocol/src/schema_fixtures.rs b/code-rs/app-server-protocol/src/schema_fixtures.rs index 83e2493a38d..447fcf8cfe3 100644 --- a/code-rs/app-server-protocol/src/schema_fixtures.rs +++ b/code-rs/app-server-protocol/src/schema_fixtures.rs @@ -1,11 +1,25 @@ +use crate::ClientNotification; +use crate::ClientRequest; +use crate::ServerNotification; +use crate::ServerRequest; +use crate::export::GENERATED_TS_HEADER; +use crate::export::filter_experimental_ts_tree; +use crate::export::generate_index_ts_tree; +use crate::export::trim_trailing_line_whitespace; +use crate::protocol::common::visit_client_response_types; +use crate::protocol::common::visit_server_response_types; use anyhow::Context; use anyhow::Result; use serde_json::Map; use serde_json::Value; +use std::any::TypeId; use std::cmp::Ordering; use std::collections::BTreeMap; +use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; +use ts_rs::TS; +use ts_rs::TypeVisitor; #[derive(Clone, Copy, Debug, Default)] pub struct SchemaFixtureOptions { @@ -27,6 +41,44 @@ pub fn read_schema_fixture_tree(schema_root: &Path) -> Result Result>> { + let subtree_root = schema_root.join(label); + collect_files_recursive(&subtree_root) + .with_context(|| format!("read schema fixture subtree {}", subtree_root.display())) +} + +#[doc(hidden)] +pub fn generate_typescript_schema_fixture_subtree_for_tests() -> Result>> +{ + let mut files = BTreeMap::new(); + let mut seen = HashSet::new(); + + collect_typescript_fixture_file::(&mut files, &mut seen)?; + visit_typescript_fixture_dependencies(&mut files, &mut seen, |visitor| { + visit_client_response_types(visitor); + })?; + collect_typescript_fixture_file::(&mut files, &mut seen)?; + collect_typescript_fixture_file::(&mut files, &mut seen)?; + visit_typescript_fixture_dependencies(&mut files, &mut seen, |visitor| { + visit_server_response_types(visitor); + })?; + collect_typescript_fixture_file::(&mut files, &mut seen)?; + + filter_experimental_ts_tree(&mut files)?; + generate_index_ts_tree(&mut files); + for content in files.values_mut() { + *content = trim_trailing_line_whitespace(content); + } + + Ok(files + .into_iter() + .map(|(path, content)| (path, content.into_bytes())) + .collect()) +} + /// Regenerates `schema/typescript/` and `schema/json/`. /// /// This is intended to be used by tooling (e.g., `just write-app-server-schema`). @@ -55,7 +107,6 @@ pub fn write_schema_fixtures_with_options( ..crate::GenerateTsOptions::default() }, )?; - normalize_typescript_tree(&typescript_out_dir)?; crate::generate_json_with_experimental(&json_out_dir, options.experimental_api)?; Ok(()) @@ -86,60 +137,18 @@ fn read_file_bytes(path: &Path) -> Result> { // fixture test is platform-independent. let text = String::from_utf8(bytes) .with_context(|| format!("expected UTF-8 TypeScript in {}", path.display()))?; - let text = normalize_typescript_text(&text); + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + // Fixture comparisons care about schema content, not whether the generator + // re-prepended the standard banner to every TypeScript file. + let text = text + .strip_prefix(GENERATED_TS_HEADER) + .unwrap_or(&text) + .to_string(); return Ok(text.into_bytes()); } Ok(bytes) } -fn normalize_typescript_tree(root: &Path) -> Result<()> { - for rel in collect_type_script_paths(root)? { - let path = root.join(rel); - let text = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - let normalized = normalize_typescript_text(&text); - std::fs::write(&path, normalized) - .with_context(|| format!("failed to write {}", path.display()))?; - } - Ok(()) -} - -fn normalize_typescript_text(text: &str) -> String { - let text = text.replace("\r\n", "\n").replace('\r', "\n"); - let mut normalized = text - .lines() - .map(str::trim_end) - .collect::>() - .join("\n"); - if text.ends_with('\n') { - normalized.push('\n'); - } - normalized -} - -fn collect_type_script_paths(root: &Path) -> Result> { - let mut paths = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(&dir) - .with_context(|| format!("failed to read dir {}", dir.display()))? - { - let entry = - entry.with_context(|| format!("failed to read dir entry in {}", dir.display()))?; - let path = entry.path(); - let metadata = std::fs::metadata(&path) - .with_context(|| format!("failed to stat {}", path.display()))?; - if metadata.is_dir() { - stack.push(path); - } else if metadata.is_file() && path.extension().is_some_and(|ext| ext == "ts") { - paths.push(path.strip_prefix(root)?.to_path_buf()); - } - } - } - paths.sort(); - Ok(paths) -} - fn canonicalize_json(value: &Value) -> Value { match value { Value::Array(items) => { @@ -258,6 +267,73 @@ fn collect_files_recursive(root: &Path) -> Result>> { Ok(files) } +fn collect_typescript_fixture_file( + files: &mut BTreeMap, + seen: &mut HashSet, +) -> Result<()> { + let Some(output_path) = T::output_path() else { + return Ok(()); + }; + if !seen.insert(TypeId::of::()) { + return Ok(()); + } + + let contents = T::export_to_string().context("export TypeScript fixture content")?; + let output_path = normalize_relative_fixture_path(&output_path); + files.insert( + output_path, + contents.replace("\r\n", "\n").replace('\r', "\n"), + ); + + let mut visitor = TypeScriptFixtureCollector { + files, + seen, + error: None, + }; + T::visit_dependencies(&mut visitor); + if let Some(error) = visitor.error { + return Err(error); + } + + Ok(()) +} + +fn normalize_relative_fixture_path(path: &Path) -> PathBuf { + path.components().collect() +} + +fn visit_typescript_fixture_dependencies( + files: &mut BTreeMap, + seen: &mut HashSet, + visit: impl FnOnce(&mut TypeScriptFixtureCollector<'_>), +) -> Result<()> { + let mut visitor = TypeScriptFixtureCollector { + files, + seen, + error: None, + }; + visit(&mut visitor); + if let Some(error) = visitor.error { + return Err(error); + } + Ok(()) +} + +struct TypeScriptFixtureCollector<'a> { + files: &'a mut BTreeMap, + seen: &'a mut HashSet, + error: Option, +} + +impl TypeVisitor for TypeScriptFixtureCollector<'_> { + fn visit(&mut self) { + if self.error.is_some() { + return; + } + self.error = collect_typescript_fixture_file::(self.files, self.seen).err(); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/code-rs/app-server-protocol/tests/schema_fixtures.rs b/code-rs/app-server-protocol/tests/schema_fixtures.rs index 977a89f5b95..20823466e79 100644 --- a/code-rs/app-server-protocol/tests/schema_fixtures.rs +++ b/code-rs/app-server-protocol/tests/schema_fixtures.rs @@ -1,19 +1,60 @@ use anyhow::Context; use anyhow::Result; -use code_app_server_protocol::read_schema_fixture_tree; -use code_app_server_protocol::write_schema_fixtures; +use codex_app_server_protocol::generate_json_with_experimental; +use codex_app_server_protocol::generate_typescript_schema_fixture_subtree_for_tests; +use codex_app_server_protocol::read_schema_fixture_subtree; use similar::TextDiff; +use std::collections::BTreeMap; use std::path::Path; +use std::path::PathBuf; #[test] -fn schema_fixtures_match_generated() -> Result<()> { +fn typescript_schema_fixtures_match_generated() -> Result<()> { let schema_root = schema_root()?; - let fixture_tree = read_tree(&schema_root)?; + let fixture_tree = read_tree(&schema_root, "typescript")?; + let generated_tree = generate_typescript_schema_fixture_subtree_for_tests() + .context("generate in-memory typescript schema fixtures")?; + + assert_schema_trees_match("typescript", &fixture_tree, &generated_tree)?; + + Ok(()) +} + +#[test] +fn json_schema_fixtures_match_generated() -> Result<()> { + assert_schema_fixtures_match_generated("json", |output_dir| { + generate_json_with_experimental(output_dir, /*experimental_api*/ false) + }) +} + +fn assert_schema_fixtures_match_generated( + label: &'static str, + generate: impl FnOnce(&Path) -> Result<()>, +) -> Result<()> { + let schema_root = schema_root()?; + let fixture_tree = read_tree(&schema_root, label)?; let temp_dir = tempfile::tempdir().context("create temp dir")?; - write_schema_fixtures(temp_dir.path(), None).context("generate schema fixtures")?; - let generated_tree = read_tree(temp_dir.path())?; + let generated_root = temp_dir.path().join(label); + generate(&generated_root).with_context(|| { + format!( + "generate {label} schema fixtures into {}", + generated_root.display() + ) + })?; + + let generated_tree = read_tree(temp_dir.path(), label)?; + assert_schema_trees_match(label, &fixture_tree, &generated_tree)?; + + Ok(()) +} + +fn assert_schema_trees_match( + label: &str, + fixture_tree: &BTreeMap>, + generated_tree: &BTreeMap>, +) -> Result<()> { let fixture_paths = fixture_tree .keys() .map(|p| p.display().to_string()) @@ -32,13 +73,13 @@ fn schema_fixtures_match_generated() -> Result<()> { .to_string(); panic!( - "Vendored app-server schema fixture file set doesn't match freshly generated output. \ + "Vendored {label} app-server schema fixture file set doesn't match freshly generated output. \ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}" ); } // If the file sets match, diff contents for each file for a nicer error. - for (path, expected) in &fixture_tree { + for (path, expected) in fixture_tree { let actual = generated_tree .get(path) .ok_or_else(|| anyhow::anyhow!("missing generated file: {}", path.display()))?; @@ -54,7 +95,7 @@ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}" .header("fixture", "generated") .to_string(); panic!( - "Vendored app-server schema fixture {} differs from generated output. \ + "Vendored {label} app-server schema fixture {} differs from generated output. \ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}", path.display() ); @@ -63,10 +104,10 @@ Run `just write-app-server-schema` to overwrite with your changes.\n\n{diff}", Ok(()) } -fn schema_root() -> Result { +fn schema_root() -> Result { // In Bazel runfiles (especially manifest-only mode), resolving directories is not // reliable. Resolve a known file, then walk up to the schema root. - let typescript_index = code_utils_cargo_bin::find_resource!("schema/typescript/index.ts") + let typescript_index = codex_utils_cargo_bin::find_resource!("schema/typescript/index.ts") .context("resolve TypeScript schema index.ts")?; let schema_root = typescript_index .parent() @@ -76,7 +117,7 @@ fn schema_root() -> Result { // Sanity check that the JSON fixtures resolve to the same schema root. let json_bundle = - code_utils_cargo_bin::find_resource!("schema/json/codex_app_server_protocol.schemas.json") + codex_utils_cargo_bin::find_resource!("schema/json/codex_app_server_protocol.schemas.json") .context("resolve JSON schema bundle")?; let json_root = json_bundle .parent() @@ -92,7 +133,11 @@ fn schema_root() -> Result { Ok(schema_root) } -fn read_tree(root: &Path) -> Result>> { - read_schema_fixture_tree(root).context("read schema fixture tree") +fn read_tree(root: &Path, label: &str) -> Result>> { + read_schema_fixture_subtree(root, label).with_context(|| { + format!( + "read {label} schema fixture subtree from {}", + root.display() + ) + }) } - diff --git a/code-rs/app-server-test-client/BUILD.bazel b/code-rs/app-server-test-client/BUILD.bazel new file mode 100644 index 00000000000..3a1686a04e1 --- /dev/null +++ b/code-rs/app-server-test-client/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "app-server-test-client", + crate_name = "codex_app_server_test_client", +) diff --git a/code-rs/app-server-test-client/Cargo.toml b/code-rs/app-server-test-client/Cargo.toml new file mode 100644 index 00000000000..603a5caf22d --- /dev/null +++ b/code-rs/app-server-test-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "codex-app-server-test-client" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +codex-app-server-protocol = { workspace = true } +codex-core = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-cli = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tungstenite = { workspace = true } +url = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + +[lib] +test = false +doctest = false diff --git a/code-rs/app-server-test-client/README.md b/code-rs/app-server-test-client/README.md new file mode 100644 index 00000000000..9a553913275 --- /dev/null +++ b/code-rs/app-server-test-client/README.md @@ -0,0 +1,58 @@ +# App Server Test Client +Quickstart for running and hitting `codex app-server`. + +## Quickstart + +Run from `/codex-rs`. + +```bash +# 1) Build debug codex binary +cargo build -p codex-cli --bin codex + +# 2) Start websocket app-server in background +cargo run -p codex-app-server-test-client -- \ + --codex-bin ./target/debug/codex \ + serve --listen ws://127.0.0.1:4222 --kill + +# 3) Call app-server (defaults to ws://127.0.0.1:4222) +cargo run -p codex-app-server-test-client -- model-list +``` + +## Watching Raw Inbound Traffic + +Initialize a connection, then print every inbound JSON-RPC message until you stop it with +`Ctrl+C`: + +```bash +cargo run -p codex-app-server-test-client -- watch +``` + +## Testing Thread Rejoin Behavior + +Build and start an app server using commands above. The app-server log is written to `/tmp/codex-app-server-test-client/app-server.log` + +### 1) Get a thread id + +Create at least one thread, then list threads: + +```bash +cargo run -p codex-app-server-test-client -- send-message-v2 "seed thread for rejoin test" +cargo run -p codex-app-server-test-client -- thread-list --limit 5 +``` + +Copy a thread id from the `thread-list` output. + +### 2) Rejoin while a turn is in progress (two terminals) + +Terminal A: + +```bash +cargo run --bin codex-app-server-test-client -- \ + resume-message-v2 "respond with thorough docs on the rust core" +``` + +Terminal B (while Terminal A is still streaming): + +```bash +cargo run --bin codex-app-server-test-client -- thread-resume +``` diff --git a/code-rs/app-server-test-client/scripts/live_elicitation_hold.sh b/code-rs/app-server-test-client/scripts/live_elicitation_hold.sh new file mode 100644 index 00000000000..838f28120ff --- /dev/null +++ b/code-rs/app-server-test-client/scripts/live_elicitation_hold.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +require_env() { + eval "value=\${$1-}" + if [ -z "$value" ]; then + echo "missing required env var: $1" >&2 + exit 1 + fi +} + +require_env APP_SERVER_URL +require_env APP_SERVER_TEST_CLIENT_BIN + +thread_id="${CODEX_THREAD_ID:-${THREAD_ID-}}" +if [ -z "$thread_id" ]; then + echo "missing required env var: CODEX_THREAD_ID" >&2 + exit 1 +fi + +hold_seconds="${ELICITATION_HOLD_SECONDS:-15}" +incremented=0 + +cleanup() { + if [ "$incremented" -eq 1 ]; then + "$APP_SERVER_TEST_CLIENT_BIN" --url "$APP_SERVER_URL" \ + thread-decrement-elicitation "$thread_id" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT INT TERM HUP + +echo "[elicitation-hold] increment thread=$thread_id" +"$APP_SERVER_TEST_CLIENT_BIN" --url "$APP_SERVER_URL" \ + thread-increment-elicitation "$thread_id" +incremented=1 + +echo "[elicitation-hold] sleeping ${hold_seconds}s" +sleep "$hold_seconds" + +echo "[elicitation-hold] decrement thread=$thread_id" +"$APP_SERVER_TEST_CLIENT_BIN" --url "$APP_SERVER_URL" \ + thread-decrement-elicitation "$thread_id" +incremented=0 + +echo "[elicitation-hold] done" diff --git a/code-rs/app-server-test-client/src/lib.rs b/code-rs/app-server-test-client/src/lib.rs new file mode 100644 index 00000000000..e67f6e02f3b --- /dev/null +++ b/code-rs/app-server-test-client/src/lib.rs @@ -0,0 +1,2230 @@ +use std::collections::VecDeque; +use std::ffi::OsString; +use std::fs; +use std::fs::OpenOptions; +use std::io::BufRead; +use std::io::BufReader; +use std::io::Write; +use std::net::TcpListener; +use std::net::TcpStream; +use std::path::Path; +use std::path::PathBuf; +use std::process::Child; +use std::process::ChildStdin; +use std::process::ChildStdout; +use std::process::Command; +use std::process::Stdio; +use std::thread; +use std::time::Duration; +use std::time::Instant; +use std::time::SystemTime; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use clap::ArgAction; +use clap::Parser; +use clap::Subcommand; +use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalParams; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::DynamicToolSpec; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileChangeRequestApprovalParams; +use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadDecrementElicitationParams; +use codex_app_server_protocol::ThreadDecrementElicitationResponse; +use codex_app_server_protocol::ThreadIncrementElicitationParams; +use codex_app_server_protocol::ThreadIncrementElicitationResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::config::Config; +use codex_otel::OtelProvider; +use codex_otel::current_span_w3c_trace_context; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::W3cTraceContext; +use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use tracing::info_span; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tungstenite::Message; +use tungstenite::WebSocket; +use tungstenite::connect; +use tungstenite::stream::MaybeTlsStream; +use url::Url; +use uuid::Uuid; + +const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ + // v2 item deltas. + "command/exec/outputDelta", + "item/agentMessage/delta", + "item/plan/delta", + "item/fileChange/outputDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", +]; +const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +const APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); +const DEFAULT_ANALYTICS_ENABLED: bool = true; +const OTEL_SERVICE_NAME: &str = "codex-app-server-test-client"; +const TRACE_DISABLED_MESSAGE: &str = + "Not enabled - enable tracing in $CODEX_HOME/config.toml to get a trace URL!"; + +/// Minimal launcher that initializes the Codex app-server and logs the handshake. +#[derive(Parser)] +#[command(author = "Codex", version, about = "Bootstrap Codex app-server", long_about = None)] +struct Cli { + /// Path to the `codex` CLI binary. When set, requests use stdio by + /// spawning `codex app-server` as a child process. + #[arg(long, env = "CODEX_BIN", global = true)] + codex_bin: Option, + + /// Existing websocket server URL to connect to. + /// + /// If neither `--codex-bin` nor `--url` is provided, defaults to + /// `ws://127.0.0.1:4222`. + #[arg(long, env = "CODEX_APP_SERVER_URL", global = true)] + url: Option, + + /// Forwarded to the `codex` CLI as `--config key=value`. Repeatable. + /// + /// Example: + /// `--config 'model_providers.mock.base_url="http://localhost:4010/v2"'` + #[arg( + short = 'c', + long = "config", + value_name = "key=value", + action = ArgAction::Append, + global = true + )] + config_overrides: Vec, + + /// JSON array of dynamic tool specs or a single tool object. + /// Prefix a filename with '@' to read from a file. + /// + /// Example: + /// --dynamic-tools '[{"name":"demo","description":"Demo","inputSchema":{"type":"object"}}]' + /// --dynamic-tools @/path/to/tools.json + #[arg(long, value_name = "json-or-@file", global = true)] + dynamic_tools: Option, + + #[command(subcommand)] + command: CliCommand, +} + +#[derive(Subcommand)] +enum CliCommand { + /// Start `codex app-server` on a websocket endpoint in the background. + /// + /// Logs are written to: + /// `/tmp/codex-app-server-test-client/` + Serve { + /// WebSocket listen URL passed to `codex app-server --listen`. + #[arg(long, default_value = "ws://127.0.0.1:4222")] + listen: String, + /// Kill any process listening on the same port before starting. + #[arg(long, default_value_t = false)] + kill: bool, + }, + /// Send a user message through the Codex app-server. + SendMessage { + /// User message to send to Codex. + user_message: String, + }, + /// Send a user message through the app-server V2 thread/turn APIs. + SendMessageV2 { + /// Opt into experimental app-server methods and fields. + #[arg(long)] + experimental_api: bool, + /// User message to send to Codex. + user_message: String, + }, + /// Resume a V2 thread by id, then send a user message. + ResumeMessageV2 { + /// Existing thread id to resume. + thread_id: String, + /// User message to send to Codex. + user_message: String, + }, + /// Resume a V2 thread and continuously stream notifications/events. + /// + /// This command does not auto-exit; stop it with SIGINT/SIGTERM/SIGKILL. + ThreadResume { + /// Existing thread id to resume. + thread_id: String, + }, + /// Initialize the app-server and dump all inbound messages until interrupted. + /// + /// This command does not auto-exit; stop it with SIGINT/SIGTERM/SIGKILL. + Watch, + /// Start a V2 turn that elicits an ExecCommand approval. + #[command(name = "trigger-cmd-approval")] + TriggerCmdApproval { + /// Optional prompt; defaults to a simple python command. + user_message: Option, + }, + /// Start a V2 turn that elicits an ApplyPatch approval. + #[command(name = "trigger-patch-approval")] + TriggerPatchApproval { + /// Optional prompt; defaults to creating a file via apply_patch. + user_message: Option, + }, + /// Start a V2 turn that should not elicit an ExecCommand approval. + #[command(name = "no-trigger-cmd-approval")] + NoTriggerCmdApproval, + /// Send two sequential V2 turns in the same thread to test follow-up behavior. + SendFollowUpV2 { + /// Initial user message for the first turn. + first_message: String, + /// Follow-up user message for the second turn. + follow_up_message: String, + }, + /// Trigger zsh-fork multi-subcommand approvals and assert expected approval behavior. + #[command(name = "trigger-zsh-fork-multi-cmd-approval")] + TriggerZshForkMultiCmdApproval { + /// Optional prompt; defaults to an explicit `/usr/bin/true && /usr/bin/true` command. + user_message: Option, + /// Minimum number of command-approval callbacks expected in the turn. + #[arg(long, default_value_t = 2)] + min_approvals: usize, + /// One-based approval index to abort (e.g. --abort-on 2 aborts the second approval). + #[arg(long)] + abort_on: Option, + }, + /// Trigger the ChatGPT login flow and wait for completion. + TestLogin { + /// Use the device-code login flow instead of the browser callback flow. + #[arg(long, default_value_t = false)] + device_code: bool, + }, + /// Fetch the current account rate limits from the Codex app-server. + GetAccountRateLimits, + /// List the available models from the Codex app-server. + #[command(name = "model-list")] + ModelList, + /// List stored threads from the Codex app-server. + #[command(name = "thread-list")] + ThreadList { + /// Number of threads to return. + #[arg(long, default_value_t = 20)] + limit: u32, + }, + /// Increment the out-of-band elicitation pause counter for a thread. + #[command(name = "thread-increment-elicitation")] + ThreadIncrementElicitation { + /// Existing thread id to update. + thread_id: String, + }, + /// Decrement the out-of-band elicitation pause counter for a thread. + #[command(name = "thread-decrement-elicitation")] + ThreadDecrementElicitation { + /// Existing thread id to update. + thread_id: String, + }, + /// Run the live websocket harness that proves elicitation pause prevents a + /// 10s unified exec timeout from killing a 15s helper script. + #[command(name = "live-elicitation-timeout-pause")] + LiveElicitationTimeoutPause { + /// Model passed to `thread/start`. + #[arg(long, env = "CODEX_E2E_MODEL", default_value = "gpt-5")] + model: String, + /// Existing workspace path used as the turn cwd. + #[arg(long, value_name = "path", default_value = ".")] + workspace: PathBuf, + /// Helper script to run from the model; defaults to the repo-local + /// live elicitation hold script. + #[arg(long, value_name = "path")] + script: Option, + /// Seconds the helper script should sleep while the timeout is paused. + #[arg(long, default_value_t = 15)] + hold_seconds: u64, + }, +} + +pub async fn run() -> Result<()> { + let Cli { + codex_bin, + url, + config_overrides, + dynamic_tools, + command, + } = Cli::parse(); + + let dynamic_tools = parse_dynamic_tools_arg(&dynamic_tools)?; + + match command { + CliCommand::Serve { listen, kill } => { + ensure_dynamic_tools_unused(&dynamic_tools, "serve")?; + let codex_bin = codex_bin.unwrap_or_else(|| PathBuf::from("codex")); + serve(&codex_bin, &config_overrides, &listen, kill) + } + CliCommand::SendMessage { user_message } => { + ensure_dynamic_tools_unused(&dynamic_tools, "send-message")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + send_message(&endpoint, &config_overrides, user_message).await + } + CliCommand::SendMessageV2 { + experimental_api, + user_message, + } => { + let endpoint = resolve_endpoint(codex_bin, url)?; + send_message_v2_endpoint( + &endpoint, + &config_overrides, + user_message, + experimental_api, + &dynamic_tools, + ) + .await + } + CliCommand::ResumeMessageV2 { + thread_id, + user_message, + } => { + let endpoint = resolve_endpoint(codex_bin, url)?; + resume_message_v2( + &endpoint, + &config_overrides, + thread_id, + user_message, + &dynamic_tools, + ) + .await + } + CliCommand::ThreadResume { thread_id } => { + ensure_dynamic_tools_unused(&dynamic_tools, "thread-resume")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + thread_resume_follow(&endpoint, &config_overrides, thread_id).await + } + CliCommand::Watch => { + ensure_dynamic_tools_unused(&dynamic_tools, "watch")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + watch(&endpoint, &config_overrides).await + } + CliCommand::TriggerCmdApproval { user_message } => { + let endpoint = resolve_endpoint(codex_bin, url)?; + trigger_cmd_approval(&endpoint, &config_overrides, user_message, &dynamic_tools).await + } + CliCommand::TriggerPatchApproval { user_message } => { + let endpoint = resolve_endpoint(codex_bin, url)?; + trigger_patch_approval(&endpoint, &config_overrides, user_message, &dynamic_tools).await + } + CliCommand::NoTriggerCmdApproval => { + let endpoint = resolve_endpoint(codex_bin, url)?; + no_trigger_cmd_approval(&endpoint, &config_overrides, &dynamic_tools).await + } + CliCommand::SendFollowUpV2 { + first_message, + follow_up_message, + } => { + let endpoint = resolve_endpoint(codex_bin, url)?; + send_follow_up_v2( + &endpoint, + &config_overrides, + first_message, + follow_up_message, + &dynamic_tools, + ) + .await + } + CliCommand::TriggerZshForkMultiCmdApproval { + user_message, + min_approvals, + abort_on, + } => { + let endpoint = resolve_endpoint(codex_bin, url)?; + trigger_zsh_fork_multi_cmd_approval( + &endpoint, + &config_overrides, + user_message, + min_approvals, + abort_on, + &dynamic_tools, + ) + .await + } + CliCommand::TestLogin { device_code } => { + ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + test_login(&endpoint, &config_overrides, device_code).await + } + CliCommand::GetAccountRateLimits => { + ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + get_account_rate_limits(&endpoint, &config_overrides).await + } + CliCommand::ModelList => { + ensure_dynamic_tools_unused(&dynamic_tools, "model-list")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + model_list(&endpoint, &config_overrides).await + } + CliCommand::ThreadList { limit } => { + ensure_dynamic_tools_unused(&dynamic_tools, "thread-list")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + thread_list(&endpoint, &config_overrides, limit).await + } + CliCommand::ThreadIncrementElicitation { thread_id } => { + ensure_dynamic_tools_unused(&dynamic_tools, "thread-increment-elicitation")?; + let url = resolve_shared_websocket_url(codex_bin, url, "thread-increment-elicitation")?; + thread_increment_elicitation(&url, thread_id) + } + CliCommand::ThreadDecrementElicitation { thread_id } => { + ensure_dynamic_tools_unused(&dynamic_tools, "thread-decrement-elicitation")?; + let url = resolve_shared_websocket_url(codex_bin, url, "thread-decrement-elicitation")?; + thread_decrement_elicitation(&url, thread_id) + } + CliCommand::LiveElicitationTimeoutPause { + model, + workspace, + script, + hold_seconds, + } => { + ensure_dynamic_tools_unused(&dynamic_tools, "live-elicitation-timeout-pause")?; + live_elicitation_timeout_pause( + codex_bin, + url, + &config_overrides, + model, + workspace, + script, + hold_seconds, + ) + } + } +} + +enum Endpoint { + SpawnCodex(PathBuf), + ConnectWs(String), +} + +struct BackgroundAppServer { + process: Child, + url: String, +} + +fn resolve_endpoint(codex_bin: Option, url: Option) -> Result { + if codex_bin.is_some() && url.is_some() { + bail!("--codex-bin and --url are mutually exclusive"); + } + if let Some(codex_bin) = codex_bin { + return Ok(Endpoint::SpawnCodex(codex_bin)); + } + if let Some(url) = url { + return Ok(Endpoint::ConnectWs(url)); + } + Ok(Endpoint::ConnectWs("ws://127.0.0.1:4222".to_string())) +} + +fn resolve_shared_websocket_url( + codex_bin: Option, + url: Option, + command: &str, +) -> Result { + if codex_bin.is_some() { + bail!( + "{command} requires --url or an already-running websocket app-server; --codex-bin would spawn a private stdio app-server instead" + ); + } + + Ok(url.unwrap_or_else(|| "ws://127.0.0.1:4222".to_string())) +} + +impl BackgroundAppServer { + fn spawn(codex_bin: &Path, config_overrides: &[String]) -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .context("failed to reserve a local port for websocket app-server")?; + let addr = listener.local_addr()?; + drop(listener); + + let url = format!("ws://{addr}"); + let mut cmd = Command::new(codex_bin); + if let Some(codex_bin_parent) = codex_bin.parent() { + let mut path = OsString::from(codex_bin_parent.as_os_str()); + if let Some(existing_path) = std::env::var_os("PATH") { + path.push(":"); + path.push(existing_path); + } + cmd.env("PATH", path); + } + for override_kv in config_overrides { + cmd.arg("--config").arg(override_kv); + } + let process = cmd + .arg("app-server") + .arg("--listen") + .arg(&url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("failed to start `{}` app-server", codex_bin.display()))?; + + Ok(Self { process, url }) + } +} + +impl Drop for BackgroundAppServer { + fn drop(&mut self) { + if let Ok(Some(status)) = self.process.try_wait() { + println!("[background app-server exited: {status}]"); + return; + } + + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +fn serve(codex_bin: &Path, config_overrides: &[String], listen: &str, kill: bool) -> Result<()> { + let runtime_dir = PathBuf::from("/tmp/codex-app-server-test-client"); + fs::create_dir_all(&runtime_dir) + .with_context(|| format!("failed to create runtime dir {}", runtime_dir.display()))?; + let log_path = runtime_dir.join("app-server.log"); + if kill { + kill_listeners_on_same_port(listen)?; + } + + let log_file = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .with_context(|| format!("failed to open log file {}", log_path.display()))?; + let log_file_stderr = log_file + .try_clone() + .with_context(|| format!("failed to clone log file handle {}", log_path.display()))?; + + let mut cmdline = format!( + "tail -f /dev/null | RUST_BACKTRACE=full RUST_LOG=warn,codex_=trace {}", + shell_quote(&codex_bin.display().to_string()) + ); + for override_kv in config_overrides { + cmdline.push_str(&format!(" --config {}", shell_quote(override_kv))); + } + cmdline.push_str(&format!(" app-server --listen {}", shell_quote(listen))); + + let child = Command::new("nohup") + .arg("sh") + .arg("-c") + .arg(cmdline) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(log_file_stderr)) + .spawn() + .with_context(|| format!("failed to start `{}` app-server", codex_bin.display()))?; + + let pid = child.id(); + + println!("started codex app-server"); + println!("listen: {listen}"); + println!("pid: {pid} (launcher process)"); + println!("log: {}", log_path.display()); + + Ok(()) +} + +fn kill_listeners_on_same_port(listen: &str) -> Result<()> { + let url = Url::parse(listen).with_context(|| format!("invalid --listen URL `{listen}`"))?; + let port = url + .port_or_known_default() + .with_context(|| format!("unable to infer port from --listen URL `{listen}`"))?; + + let output = Command::new("lsof") + .arg("-nP") + .arg(format!("-tiTCP:{port}")) + .arg("-sTCP:LISTEN") + .output() + .with_context(|| format!("failed to run lsof for port {port}"))?; + + if !output.status.success() { + return Ok(()); + } + + let pids: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect(); + + if pids.is_empty() { + return Ok(()); + } + + for pid in pids { + println!("killing listener pid {pid} on port {port}"); + let pid_str = pid.to_string(); + let term_status = Command::new("kill") + .arg(&pid_str) + .status() + .with_context(|| format!("failed to send SIGTERM to pid {pid}"))?; + if !term_status.success() { + continue; + } + } + + thread::sleep(Duration::from_millis(300)); + + let output = Command::new("lsof") + .arg("-nP") + .arg(format!("-tiTCP:{port}")) + .arg("-sTCP:LISTEN") + .output() + .with_context(|| format!("failed to re-check listeners on port {port}"))?; + if !output.status.success() { + return Ok(()); + } + let remaining: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect(); + for pid in remaining { + println!("force killing remaining listener pid {pid} on port {port}"); + let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status(); + } + + Ok(()) +} + +fn shell_quote(input: &str) -> String { + format!("'{}'", input.replace('\'', "'\\''")) +} + +struct SendMessagePolicies<'a> { + command_name: &'static str, + experimental_api: bool, + approval_policy: Option, + sandbox_policy: Option, + dynamic_tools: &'a Option>, +} + +async fn send_message( + endpoint: &Endpoint, + config_overrides: &[String], + user_message: String, +) -> Result<()> { + let dynamic_tools = None; + send_message_v2_with_policies( + endpoint, + config_overrides, + user_message, + SendMessagePolicies { + command_name: "send-message", + experimental_api: false, + approval_policy: None, + sandbox_policy: None, + dynamic_tools: &dynamic_tools, + }, + ) + .await +} + +pub async fn send_message_v2( + codex_bin: &Path, + config_overrides: &[String], + user_message: String, + dynamic_tools: &Option>, +) -> Result<()> { + let endpoint = Endpoint::SpawnCodex(codex_bin.to_path_buf()); + send_message_v2_endpoint( + &endpoint, + config_overrides, + user_message, + /*experimental_api*/ true, + dynamic_tools, + ) + .await +} + +async fn send_message_v2_endpoint( + endpoint: &Endpoint, + config_overrides: &[String], + user_message: String, + experimental_api: bool, + dynamic_tools: &Option>, +) -> Result<()> { + if dynamic_tools.is_some() && !experimental_api { + bail!("--dynamic-tools requires --experimental-api for send-message-v2"); + } + + send_message_v2_with_policies( + endpoint, + config_overrides, + user_message, + SendMessagePolicies { + command_name: "send-message-v2", + experimental_api, + approval_policy: None, + sandbox_policy: None, + dynamic_tools, + }, + ) + .await +} + +async fn trigger_zsh_fork_multi_cmd_approval( + endpoint: &Endpoint, + config_overrides: &[String], + user_message: Option, + min_approvals: usize, + abort_on: Option, + dynamic_tools: &Option>, +) -> Result<()> { + if let Some(abort_on) = abort_on + && abort_on == 0 + { + bail!("--abort-on must be >= 1 when provided"); + } + + let default_prompt = "Run this exact command using shell command execution without rewriting or splitting it: /usr/bin/true && /usr/bin/true"; + let message = user_message.unwrap_or_else(|| default_prompt.to_string()); + + with_client( + "trigger-zsh-fork-multi-cmd-approval", + endpoint, + config_overrides, + |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let thread_response = client.thread_start(ThreadStartParams { + dynamic_tools: dynamic_tools.clone(), + ..Default::default() + })?; + println!("< thread/start response: {thread_response:?}"); + + client.command_approval_behavior = match abort_on { + Some(index) => CommandApprovalBehavior::AbortOn(index), + None => CommandApprovalBehavior::AlwaysAccept, + }; + client.command_approval_count = 0; + client.command_approval_item_ids.clear(); + client.command_execution_statuses.clear(); + client.last_turn_status = None; + + let mut turn_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: message, + text_elements: Vec::new(), + }], + ..Default::default() + }; + turn_params.approval_policy = Some(AskForApproval::OnRequest); + turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly { + network_access: false, + }); + + let turn_response = client.turn_start(turn_params)?; + println!("< turn/start response: {turn_response:?}"); + client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?; + + if client.command_approval_count < min_approvals { + bail!( + "expected at least {min_approvals} command approvals, got {}", + client.command_approval_count + ); + } + let mut approvals_per_item = std::collections::BTreeMap::new(); + for item_id in &client.command_approval_item_ids { + *approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1; + } + let max_approvals_for_one_item = + approvals_per_item.values().copied().max().unwrap_or(0); + if max_approvals_for_one_item < min_approvals { + bail!( + "expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}" + ); + } + + let last_command_status = client.command_execution_statuses.last(); + if abort_on.is_none() { + if last_command_status != Some(&CommandExecutionStatus::Completed) { + bail!("expected completed command execution, got {last_command_status:?}"); + } + if client.last_turn_status != Some(TurnStatus::Completed) { + bail!( + "expected completed turn in all-accept flow, got {:?}", + client.last_turn_status + ); + } + } else if last_command_status == Some(&CommandExecutionStatus::Completed) { + bail!( + "expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}" + ); + } + + println!( + "[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}", + client.command_approval_count, + client.command_execution_statuses, + client.last_turn_status + ); + + Ok(()) + }, + ) + .await +} + +async fn resume_message_v2( + endpoint: &Endpoint, + config_overrides: &[String], + thread_id: String, + user_message: String, + dynamic_tools: &Option>, +) -> Result<()> { + ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?; + + with_client("resume-message-v2", endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let resume_response = client.thread_resume(ThreadResumeParams { + thread_id, + ..Default::default() + })?; + println!("< thread/resume response: {resume_response:?}"); + + let turn_response = client.turn_start(TurnStartParams { + thread_id: resume_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: user_message, + text_elements: Vec::new(), + }], + ..Default::default() + })?; + println!("< turn/start response: {turn_response:?}"); + + client.stream_turn(&resume_response.thread.id, &turn_response.turn.id)?; + + Ok(()) + }) + .await +} + +async fn thread_resume_follow( + endpoint: &Endpoint, + config_overrides: &[String], + thread_id: String, +) -> Result<()> { + with_client("thread-resume", endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let resume_response = client.thread_resume(ThreadResumeParams { + thread_id, + ..Default::default() + })?; + println!("< thread/resume response: {resume_response:?}"); + println!("< streaming notifications until process is terminated"); + + client.stream_notifications_forever() + }) + .await +} + +async fn watch(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { + with_client("watch", endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + println!("< streaming inbound messages until process is terminated"); + + client.stream_notifications_forever() + }) + .await +} + +async fn trigger_cmd_approval( + endpoint: &Endpoint, + config_overrides: &[String], + user_message: Option, + dynamic_tools: &Option>, +) -> Result<()> { + let default_prompt = + "Run `touch /tmp/should-trigger-approval` so I can confirm the file exists."; + let message = user_message.unwrap_or_else(|| default_prompt.to_string()); + send_message_v2_with_policies( + endpoint, + config_overrides, + message, + SendMessagePolicies { + command_name: "trigger-cmd-approval", + experimental_api: true, + approval_policy: Some(AskForApproval::OnRequest), + sandbox_policy: Some(SandboxPolicy::ReadOnly { + network_access: false, + }), + dynamic_tools, + }, + ) + .await +} + +async fn trigger_patch_approval( + endpoint: &Endpoint, + config_overrides: &[String], + user_message: Option, + dynamic_tools: &Option>, +) -> Result<()> { + let default_prompt = + "Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch."; + let message = user_message.unwrap_or_else(|| default_prompt.to_string()); + send_message_v2_with_policies( + endpoint, + config_overrides, + message, + SendMessagePolicies { + command_name: "trigger-patch-approval", + experimental_api: true, + approval_policy: Some(AskForApproval::OnRequest), + sandbox_policy: Some(SandboxPolicy::ReadOnly { + network_access: false, + }), + dynamic_tools, + }, + ) + .await +} + +async fn no_trigger_cmd_approval( + endpoint: &Endpoint, + config_overrides: &[String], + dynamic_tools: &Option>, +) -> Result<()> { + let prompt = "Run `touch should_not_trigger_approval.txt`"; + send_message_v2_with_policies( + endpoint, + config_overrides, + prompt.to_string(), + SendMessagePolicies { + command_name: "no-trigger-cmd-approval", + experimental_api: true, + approval_policy: None, + sandbox_policy: None, + dynamic_tools, + }, + ) + .await +} + +async fn send_message_v2_with_policies( + endpoint: &Endpoint, + config_overrides: &[String], + user_message: String, + policies: SendMessagePolicies<'_>, +) -> Result<()> { + with_client( + policies.command_name, + endpoint, + config_overrides, + |client| { + let initialize = client.initialize_with_experimental_api(policies.experimental_api)?; + println!("< initialize response: {initialize:?}"); + + let thread_response = client.thread_start(ThreadStartParams { + dynamic_tools: policies.dynamic_tools.clone(), + ..Default::default() + })?; + println!("< thread/start response: {thread_response:?}"); + let mut turn_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: user_message, + // Test client sends plain text without UI element ranges. + text_elements: Vec::new(), + }], + ..Default::default() + }; + turn_params.approval_policy = policies.approval_policy; + turn_params.sandbox_policy = policies.sandbox_policy; + + let turn_response = client.turn_start(turn_params)?; + println!("< turn/start response: {turn_response:?}"); + + client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?; + + Ok(()) + }, + ) + .await +} + +async fn send_follow_up_v2( + endpoint: &Endpoint, + config_overrides: &[String], + first_message: String, + follow_up_message: String, + dynamic_tools: &Option>, +) -> Result<()> { + with_client("send-follow-up-v2", endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let thread_response = client.thread_start(ThreadStartParams { + dynamic_tools: dynamic_tools.clone(), + ..Default::default() + })?; + println!("< thread/start response: {thread_response:?}"); + + let first_turn_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: first_message, + // Test client sends plain text without UI element ranges. + text_elements: Vec::new(), + }], + ..Default::default() + }; + let first_turn_response = client.turn_start(first_turn_params)?; + println!("< turn/start response (initial): {first_turn_response:?}"); + client.stream_turn(&thread_response.thread.id, &first_turn_response.turn.id)?; + + let follow_up_params = TurnStartParams { + thread_id: thread_response.thread.id.clone(), + input: vec![V2UserInput::Text { + text: follow_up_message, + // Test client sends plain text without UI element ranges. + text_elements: Vec::new(), + }], + ..Default::default() + }; + let follow_up_response = client.turn_start(follow_up_params)?; + println!("< turn/start response (follow-up): {follow_up_response:?}"); + client.stream_turn(&thread_response.thread.id, &follow_up_response.turn.id)?; + + Ok(()) + }) + .await +} + +async fn test_login( + endpoint: &Endpoint, + config_overrides: &[String], + device_code: bool, +) -> Result<()> { + with_client("test-login", endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let login_response = if device_code { + client.login_account_chatgpt_device_code()? + } else { + client.login_account_chatgpt()? + }; + println!("< account/login/start response: {login_response:?}"); + let login_id = match login_response { + LoginAccountResponse::Chatgpt { login_id, auth_url } => { + println!("Open the following URL in your browser to continue:\n{auth_url}"); + login_id + } + LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + } => { + println!( + "Open the following URL and enter the code to continue:\n{verification_url}\n\nCode: {user_code}" + ); + login_id + } + _ => bail!("expected chatgpt login response"), + }; + + let completion = client.wait_for_account_login_completion(&login_id)?; + println!("< account/login/completed notification: {completion:?}"); + + if completion.success { + println!("Login succeeded."); + Ok(()) + } else { + bail!( + "login failed: {}", + completion + .error + .as_deref() + .unwrap_or("unknown error from account/login/completed") + ); + } + }) + .await +} + +async fn get_account_rate_limits(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { + with_client( + "get-account-rate-limits", + endpoint, + config_overrides, + |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let response = client.get_account_rate_limits()?; + println!("< account/rateLimits/read response: {response:?}"); + + Ok(()) + }, + ) + .await +} + +async fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { + with_client("model-list", endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let response = client.model_list(ModelListParams::default())?; + println!("< model/list response: {response:?}"); + + Ok(()) + }) + .await +} + +async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) -> Result<()> { + with_client("thread-list", endpoint, config_overrides, |client| { + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let response = client.thread_list(ThreadListParams { + cursor: None, + limit: Some(limit), + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + })?; + println!("< thread/list response: {response:?}"); + + Ok(()) + }) + .await +} + +async fn with_client( + command_name: &'static str, + endpoint: &Endpoint, + config_overrides: &[String], + f: impl FnOnce(&mut CodexClient) -> Result, +) -> Result { + let tracing = TestClientTracing::initialize(config_overrides).await?; + let command_span = info_span!( + "app_server_test_client.command", + otel.kind = "client", + otel.name = command_name, + app_server_test_client.command = command_name, + ); + let trace_summary = command_span.in_scope(|| TraceSummary::capture(tracing.traces_enabled)); + let result = command_span.in_scope(|| { + let mut client = CodexClient::connect(endpoint, config_overrides)?; + f(&mut client) + }); + print_trace_summary(&trace_summary); + result +} + +fn thread_increment_elicitation(url: &str, thread_id: String) -> Result<()> { + let endpoint = Endpoint::ConnectWs(url.to_string()); + let mut client = CodexClient::connect(&endpoint, &[])?; + + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let response = + client.thread_increment_elicitation(ThreadIncrementElicitationParams { thread_id })?; + println!("< thread/increment_elicitation response: {response:?}"); + + Ok(()) +} + +fn thread_decrement_elicitation(url: &str, thread_id: String) -> Result<()> { + let endpoint = Endpoint::ConnectWs(url.to_string()); + let mut client = CodexClient::connect(&endpoint, &[])?; + + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let response = + client.thread_decrement_elicitation(ThreadDecrementElicitationParams { thread_id })?; + println!("< thread/decrement_elicitation response: {response:?}"); + + Ok(()) +} + +fn live_elicitation_timeout_pause( + codex_bin: Option, + url: Option, + config_overrides: &[String], + model: String, + workspace: PathBuf, + script: Option, + hold_seconds: u64, +) -> Result<()> { + if cfg!(windows) { + bail!("live-elicitation-timeout-pause currently requires a POSIX shell"); + } + if hold_seconds <= 10 { + bail!("--hold-seconds must be greater than 10 to exceed the unified exec timeout"); + } + + let mut _background_server = None; + let websocket_url = match (codex_bin, url) { + (Some(_), Some(_)) => bail!("--codex-bin and --url are mutually exclusive"), + (Some(codex_bin), None) => { + let server = BackgroundAppServer::spawn(&codex_bin, config_overrides)?; + let websocket_url = server.url.clone(); + _background_server = Some(server); + websocket_url + } + (None, Some(url)) => url, + (None, None) => "ws://127.0.0.1:4222".to_string(), + }; + + let script_path = script.unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("scripts") + .join("live_elicitation_hold.sh") + }); + if !script_path.is_file() { + bail!("helper script not found: {}", script_path.display()); + } + + let workspace = workspace + .canonicalize() + .with_context(|| format!("failed to resolve workspace `{}`", workspace.display()))?; + let app_server_test_client_bin = std::env::current_exe() + .context("failed to resolve codex-app-server-test-client binary path")?; + let endpoint = Endpoint::ConnectWs(websocket_url.clone()); + let mut client = CodexClient::connect(&endpoint, &[])?; + + let initialize = client.initialize()?; + println!("< initialize response: {initialize:?}"); + + let thread_response = client.thread_start(ThreadStartParams { + model: Some(model), + ..Default::default() + })?; + println!("< thread/start response: {thread_response:?}"); + + let thread_id = thread_response.thread.id; + let command = format!( + "APP_SERVER_URL={} APP_SERVER_TEST_CLIENT_BIN={} ELICITATION_HOLD_SECONDS={} sh {}", + shell_quote(&websocket_url), + shell_quote(&app_server_test_client_bin.display().to_string()), + hold_seconds, + shell_quote(&script_path.display().to_string()), + ); + let prompt = format!( + "Use the `exec_command` tool exactly once. Set its `cmd` field to the exact shell command below. Do not rewrite it, do not split it, do not call any other tool, do not set `yield_time_ms`, and wait for the command to finish before replying.\n\n{command}\n\nAfter the command finishes, reply with exactly `DONE`." + ); + + let started_at = Instant::now(); + let turn_response = client.turn_start(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![V2UserInput::Text { + text: prompt, + text_elements: Vec::new(), + }], + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + effort: Some(ReasoningEffort::High), + cwd: Some(workspace), + ..Default::default() + })?; + println!("< turn/start response: {turn_response:?}"); + + let stream_result = client.stream_turn(&thread_id, &turn_response.turn.id); + let elapsed = started_at.elapsed(); + + let validation_result = (|| -> Result<()> { + stream_result?; + + let helper_output = client + .command_execution_outputs + .iter() + .find(|output| output.contains("[elicitation-hold]")) + .cloned() + .ok_or_else(|| anyhow::anyhow!("expected helper script markers in command output"))?; + let minimum_elapsed = Duration::from_secs(hold_seconds.saturating_sub(1)); + + if client.last_turn_status != Some(TurnStatus::Completed) { + bail!( + "expected completed turn, got {:?} (last error: {:?})", + client.last_turn_status, + client.last_turn_error_message + ); + } + if !client + .command_execution_statuses + .contains(&CommandExecutionStatus::Completed) + { + bail!( + "expected a completed command execution, got {:?}", + client.command_execution_statuses + ); + } + if !client.helper_done_seen || !helper_output.contains("[elicitation-hold] done") { + bail!( + "expected helper script completion marker in command output, got: {helper_output:?}" + ); + } + if !client.unexpected_items_before_helper_done.is_empty() { + bail!( + "turn started new items before helper completion: {:?}", + client.unexpected_items_before_helper_done + ); + } + if client.turn_completed_before_helper_done { + bail!("turn completed before helper script finished"); + } + if elapsed < minimum_elapsed { + bail!( + "turn completed too quickly to prove timeout pause worked: elapsed={elapsed:?}, expected at least {minimum_elapsed:?}" + ); + } + + Ok(()) + })(); + + match client.thread_decrement_elicitation(ThreadDecrementElicitationParams { + thread_id: thread_id.clone(), + }) { + Ok(response) => { + println!("[cleanup] thread/decrement_elicitation response after harness: {response:?}"); + } + Err(err) => { + eprintln!("[cleanup] thread/decrement_elicitation ignored: {err:#}"); + } + } + + validation_result?; + + println!( + "[live elicitation timeout pause summary] thread_id={thread_id}, turn_id={}, elapsed={elapsed:?}, command_statuses={:?}", + turn_response.turn.id, client.command_execution_statuses + ); + + Ok(()) +} + +fn ensure_dynamic_tools_unused( + dynamic_tools: &Option>, + command: &str, +) -> Result<()> { + if dynamic_tools.is_some() { + bail!( + "dynamic tools are only supported for v2 thread/start; remove --dynamic-tools for {command} or use send-message-v2" + ); + } + Ok(()) +} + +fn parse_dynamic_tools_arg(dynamic_tools: &Option) -> Result>> { + let Some(raw_arg) = dynamic_tools.as_deref() else { + return Ok(None); + }; + + let raw_json = if let Some(path) = raw_arg.strip_prefix('@') { + fs::read_to_string(Path::new(path)) + .with_context(|| format!("read dynamic tools file {path}"))? + } else { + raw_arg.to_string() + }; + + let value: Value = serde_json::from_str(&raw_json).context("parse dynamic tools JSON")?; + let tools = match value { + Value::Array(_) => serde_json::from_value(value).context("decode dynamic tools array")?, + Value::Object(_) => vec![serde_json::from_value(value).context("decode dynamic tool")?], + _ => bail!("dynamic tools JSON must be an object or array"), + }; + + Ok(Some(tools)) +} + +enum ClientTransport { + Stdio { + child: Child, + stdin: Option, + stdout: BufReader, + }, + WebSocket { + url: String, + socket: Box>>, + }, +} + +struct CodexClient { + transport: ClientTransport, + pending_notifications: VecDeque, + command_approval_behavior: CommandApprovalBehavior, + command_approval_count: usize, + command_approval_item_ids: Vec, + command_execution_statuses: Vec, + command_execution_outputs: Vec, + command_output_stream: String, + command_item_started: bool, + helper_done_seen: bool, + turn_completed_before_helper_done: bool, + unexpected_items_before_helper_done: Vec, + last_turn_status: Option, + last_turn_error_message: Option, +} + +#[derive(Debug, Clone, Copy)] +enum CommandApprovalBehavior { + AlwaysAccept, + AbortOn(usize), +} + +fn item_started_before_helper_done_is_unexpected( + item: &ThreadItem, + command_item_started: bool, + helper_done_seen: bool, +) -> bool { + if !command_item_started || helper_done_seen { + return false; + } + + !matches!(item, ThreadItem::UserMessage { .. }) +} + +impl CodexClient { + fn connect(endpoint: &Endpoint, config_overrides: &[String]) -> Result { + match endpoint { + Endpoint::SpawnCodex(codex_bin) => Self::spawn_stdio(codex_bin, config_overrides), + Endpoint::ConnectWs(url) => Self::connect_websocket(url), + } + } + + fn spawn_stdio(codex_bin: &Path, config_overrides: &[String]) -> Result { + let codex_bin_display = codex_bin.display(); + let mut cmd = Command::new(codex_bin); + if let Some(codex_bin_parent) = codex_bin.parent() { + let mut path = OsString::from(codex_bin_parent.as_os_str()); + if let Some(existing_path) = std::env::var_os("PATH") { + path.push(":"); + path.push(existing_path); + } + cmd.env("PATH", path); + } + for override_kv in config_overrides { + cmd.arg("--config").arg(override_kv); + } + let mut codex_app_server = cmd + .arg("app-server") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("failed to start `{codex_bin_display}` app-server"))?; + + let stdin = codex_app_server + .stdin + .take() + .context("codex app-server stdin unavailable")?; + let stdout = codex_app_server + .stdout + .take() + .context("codex app-server stdout unavailable")?; + + Ok(Self { + transport: ClientTransport::Stdio { + child: codex_app_server, + stdin: Some(stdin), + stdout: BufReader::new(stdout), + }, + pending_notifications: VecDeque::new(), + command_approval_behavior: CommandApprovalBehavior::AlwaysAccept, + command_approval_count: 0, + command_approval_item_ids: Vec::new(), + command_execution_statuses: Vec::new(), + command_execution_outputs: Vec::new(), + command_output_stream: String::new(), + command_item_started: false, + helper_done_seen: false, + turn_completed_before_helper_done: false, + unexpected_items_before_helper_done: Vec::new(), + last_turn_status: None, + last_turn_error_message: None, + }) + } + + fn connect_websocket(url: &str) -> Result { + let parsed = Url::parse(url).with_context(|| format!("invalid websocket URL `{url}`"))?; + let deadline = Instant::now() + Duration::from_secs(10); + let (socket, _response) = loop { + match connect(parsed.as_str()) { + Ok(result) => break result, + Err(err) => { + if Instant::now() >= deadline { + return Err(err).with_context(|| { + format!( + "failed to connect to websocket app-server at `{url}`; if no server is running, start one with `codex-app-server-test-client serve --listen {url}`" + ) + }); + } + thread::sleep(Duration::from_millis(50)); + } + } + }; + Ok(Self { + transport: ClientTransport::WebSocket { + url: url.to_string(), + socket: Box::new(socket), + }, + pending_notifications: VecDeque::new(), + command_approval_behavior: CommandApprovalBehavior::AlwaysAccept, + command_approval_count: 0, + command_approval_item_ids: Vec::new(), + command_execution_statuses: Vec::new(), + command_execution_outputs: Vec::new(), + command_output_stream: String::new(), + command_item_started: false, + helper_done_seen: false, + turn_completed_before_helper_done: false, + unexpected_items_before_helper_done: Vec::new(), + last_turn_status: None, + last_turn_error_message: None, + }) + } + + fn note_helper_output(&mut self, output: &str) { + self.command_output_stream.push_str(output); + if self + .command_output_stream + .contains("[elicitation-hold] done") + { + self.helper_done_seen = true; + } + } + + fn initialize(&mut self) -> Result { + self.initialize_with_experimental_api(/*experimental_api*/ true) + } + + fn initialize_with_experimental_api( + &mut self, + experimental_api: bool, + ) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::Initialize { + request_id: request_id.clone(), + params: InitializeParams { + client_info: ClientInfo { + name: "codex-toy-app-server".to_string(), + title: Some("Codex Toy App Server".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api, + opt_out_notification_methods: Some( + NOTIFICATIONS_TO_OPT_OUT + .iter() + .map(|method| (*method).to_string()) + .collect(), + ), + }), + }, + }; + + let response: InitializeResponse = self.send_request(request, request_id, "initialize")?; + + // Complete the initialize handshake. + let initialized = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + self.write_jsonrpc_message(initialized)?; + + Ok(response) + } + + fn thread_start(&mut self, params: ThreadStartParams) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ThreadStart { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "thread/start") + } + + fn thread_resume(&mut self, params: ThreadResumeParams) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ThreadResume { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "thread/resume") + } + + fn turn_start(&mut self, params: TurnStartParams) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::TurnStart { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "turn/start") + } + + fn login_account_chatgpt(&mut self) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::LoginAccount { + request_id: request_id.clone(), + params: codex_app_server_protocol::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, + }; + + self.send_request(request, request_id, "account/login/start") + } + + fn login_account_chatgpt_device_code(&mut self) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::LoginAccount { + request_id: request_id.clone(), + params: codex_app_server_protocol::LoginAccountParams::ChatgptDeviceCode, + }; + + self.send_request(request, request_id, "account/login/start") + } + + fn get_account_rate_limits(&mut self) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::GetAccountRateLimits { + request_id: request_id.clone(), + params: None, + }; + + self.send_request(request, request_id, "account/rateLimits/read") + } + + fn model_list(&mut self, params: ModelListParams) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ModelList { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "model/list") + } + + fn thread_list(&mut self, params: ThreadListParams) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ThreadList { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "thread/list") + } + + fn thread_increment_elicitation( + &mut self, + params: ThreadIncrementElicitationParams, + ) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ThreadIncrementElicitation { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "thread/increment_elicitation") + } + + fn thread_decrement_elicitation( + &mut self, + params: ThreadDecrementElicitationParams, + ) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ThreadDecrementElicitation { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "thread/decrement_elicitation") + } + + fn wait_for_account_login_completion( + &mut self, + expected_login_id: &str, + ) -> Result { + loop { + let notification = self.next_notification()?; + + if let Ok(server_notification) = ServerNotification::try_from(notification) { + match server_notification { + ServerNotification::AccountLoginCompleted(completion) => { + if completion.login_id.as_deref() == Some(expected_login_id) { + return Ok(completion); + } + + println!( + "[ignoring account/login/completed for unexpected login_id: {:?}]", + completion.login_id + ); + } + ServerNotification::AccountRateLimitsUpdated(snapshot) => { + println!("< accountRateLimitsUpdated notification: {snapshot:?}"); + } + _ => {} + } + } + } + } + + fn stream_turn(&mut self, thread_id: &str, turn_id: &str) -> Result<()> { + loop { + let notification = self.next_notification()?; + + let Ok(server_notification) = ServerNotification::try_from(notification) else { + continue; + }; + + match server_notification { + ServerNotification::ThreadStarted(payload) => { + if payload.thread.id == thread_id { + println!("< thread/started notification: {:?}", payload.thread); + } + } + ServerNotification::TurnStarted(payload) => { + if payload.turn.id == turn_id { + println!("< turn/started notification: {:?}", payload.turn.status); + } + } + ServerNotification::AgentMessageDelta(delta) => { + print!("{}", delta.delta); + std::io::stdout().flush().ok(); + } + ServerNotification::CommandExecutionOutputDelta(delta) => { + self.note_helper_output(&delta.delta); + print!("{}", delta.delta); + std::io::stdout().flush().ok(); + } + ServerNotification::TerminalInteraction(delta) => { + println!("[stdin sent: {}]", delta.stdin); + std::io::stdout().flush().ok(); + } + ServerNotification::ItemStarted(payload) => { + if matches!(payload.item, ThreadItem::CommandExecution { .. }) { + if self.command_item_started && !self.helper_done_seen { + self.unexpected_items_before_helper_done + .push(payload.item.clone()); + } + self.command_item_started = true; + } else if item_started_before_helper_done_is_unexpected( + &payload.item, + self.command_item_started, + self.helper_done_seen, + ) { + self.unexpected_items_before_helper_done + .push(payload.item.clone()); + } + println!("\n< item started: {:?}", payload.item); + } + ServerNotification::ItemCompleted(payload) => { + if let ThreadItem::CommandExecution { + status, + aggregated_output, + .. + } = payload.item.clone() + { + self.command_execution_statuses.push(status); + if let Some(aggregated_output) = aggregated_output { + self.note_helper_output(&aggregated_output); + self.command_execution_outputs.push(aggregated_output); + } + } + println!("< item completed: {:?}", payload.item); + } + ServerNotification::TurnCompleted(payload) => { + if payload.turn.id == turn_id { + self.last_turn_status = Some(payload.turn.status.clone()); + if self.command_item_started && !self.helper_done_seen { + self.turn_completed_before_helper_done = true; + } + self.last_turn_error_message = payload + .turn + .error + .as_ref() + .map(|error| error.message.clone()); + println!("\n< turn/completed notification: {:?}", payload.turn.status); + if payload.turn.status == TurnStatus::Failed + && let Some(error) = payload.turn.error + { + println!("[turn error] {}", error.message); + } + break; + } + } + ServerNotification::McpToolCallProgress(payload) => { + println!("< MCP tool progress: {}", payload.message); + } + _ => { + println!("[UNKNOWN SERVER NOTIFICATION] {server_notification:?}"); + } + } + } + + Ok(()) + } + + fn stream_notifications_forever(&mut self) -> Result<()> { + loop { + let _ = self.next_notification()?; + } + } + + fn send_request( + &mut self, + request: ClientRequest, + request_id: RequestId, + method: &str, + ) -> Result + where + T: DeserializeOwned, + { + let request_span = info_span!( + "app_server_test_client.request", + otel.kind = "client", + otel.name = method, + rpc.system = "jsonrpc", + rpc.method = method, + rpc.request_id = ?request_id, + ); + request_span.in_scope(|| { + self.write_request(&request)?; + self.wait_for_response(request_id, method) + }) + } + + fn write_request(&mut self, request: &ClientRequest) -> Result<()> { + let request_value = serde_json::to_value(request)?; + let mut request: JSONRPCRequest = serde_json::from_value(request_value) + .context("client request was not a valid JSON-RPC request")?; + request.trace = current_span_w3c_trace_context(); + let request_json = serde_json::to_string(&request)?; + let request_pretty = serde_json::to_string_pretty(&request)?; + print_multiline_with_prefix("> ", &request_pretty); + self.write_payload(&request_json) + } + + fn wait_for_response(&mut self, request_id: RequestId, method: &str) -> Result + where + T: DeserializeOwned, + { + loop { + let message = self.read_jsonrpc_message()?; + + match message { + JSONRPCMessage::Response(JSONRPCResponse { id, result }) => { + if id == request_id { + return serde_json::from_value(result) + .with_context(|| format!("{method} response missing payload")); + } + } + JSONRPCMessage::Error(err) => { + if err.id == request_id { + bail!("{method} failed: {err:?}"); + } + } + JSONRPCMessage::Notification(notification) => { + self.pending_notifications.push_back(notification); + } + JSONRPCMessage::Request(request) => { + self.handle_server_request(request)?; + } + } + } + } + + fn next_notification(&mut self) -> Result { + if let Some(notification) = self.pending_notifications.pop_front() { + return Ok(notification); + } + + loop { + let message = self.read_jsonrpc_message()?; + + match message { + JSONRPCMessage::Notification(notification) => return Ok(notification), + JSONRPCMessage::Response(_) | JSONRPCMessage::Error(_) => { + // No outstanding requests, so ignore stray responses/errors for now. + continue; + } + JSONRPCMessage::Request(request) => { + self.handle_server_request(request)?; + } + } + } + } + + fn read_jsonrpc_message(&mut self) -> Result { + loop { + let raw = self.read_payload()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + let parsed: Value = + serde_json::from_str(trimmed).context("response was not valid JSON-RPC")?; + let pretty = serde_json::to_string_pretty(&parsed)?; + print_multiline_with_prefix("< ", &pretty); + let message: JSONRPCMessage = serde_json::from_value(parsed) + .context("response was not a valid JSON-RPC message")?; + return Ok(message); + } + } + + fn request_id(&self) -> RequestId { + RequestId::String(Uuid::new_v4().to_string()) + } + + fn handle_server_request(&mut self, request: JSONRPCRequest) -> Result<()> { + let server_request = ServerRequest::try_from(request) + .context("failed to deserialize ServerRequest from JSONRPCRequest")?; + + match server_request { + ServerRequest::CommandExecutionRequestApproval { request_id, params } => { + self.handle_command_execution_request_approval(request_id, params)?; + } + ServerRequest::FileChangeRequestApproval { request_id, params } => { + self.approve_file_change_request(request_id, params)?; + } + other => { + bail!("received unsupported server request: {other:?}"); + } + } + + Ok(()) + } + + fn handle_command_execution_request_approval( + &mut self, + request_id: RequestId, + params: CommandExecutionRequestApprovalParams, + ) -> Result<()> { + let CommandExecutionRequestApprovalParams { + thread_id, + turn_id, + item_id, + started_at_ms: _, + approval_id, + reason, + network_approval_context, + command, + cwd, + command_actions, + additional_permissions, + proposed_execpolicy_amendment, + proposed_network_policy_amendments, + available_decisions, + } = params; + + println!( + "\n< commandExecution approval requested for thread {thread_id}, turn {turn_id}, item {item_id}, approval {}", + approval_id.as_deref().unwrap_or("") + ); + self.command_approval_count += 1; + self.command_approval_item_ids.push(item_id.clone()); + if let Some(reason) = reason.as_deref() { + println!("< reason: {reason}"); + } + if let Some(network_approval_context) = network_approval_context.as_ref() { + println!("< network approval context: {network_approval_context:?}"); + } + if let Some(available_decisions) = available_decisions.as_ref() { + println!("< available decisions: {available_decisions:?}"); + } + if let Some(command) = command.as_deref() { + println!("< command: {command}"); + } + if let Some(cwd) = cwd.as_ref() { + println!("< cwd: {}", cwd.display()); + } + if let Some(command_actions) = command_actions.as_ref() + && !command_actions.is_empty() + { + println!("< command actions: {command_actions:?}"); + } + if let Some(additional_permissions) = additional_permissions.as_ref() { + println!("< additional permissions: {additional_permissions:?}"); + } + if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { + println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); + } + if let Some(network_policy_amendments) = proposed_network_policy_amendments.as_ref() { + println!("< proposed network policy amendments: {network_policy_amendments:?}"); + } + + let decision = match self.command_approval_behavior { + CommandApprovalBehavior::AlwaysAccept => CommandExecutionApprovalDecision::Accept, + CommandApprovalBehavior::AbortOn(index) if self.command_approval_count == index => { + CommandExecutionApprovalDecision::Cancel + } + CommandApprovalBehavior::AbortOn(_) => CommandExecutionApprovalDecision::Accept, + }; + let response = CommandExecutionRequestApprovalResponse { + decision: decision.clone(), + }; + self.send_server_request_response(request_id, &response)?; + println!( + "< commandExecution decision for approval #{} on item {item_id}: {:?}", + self.command_approval_count, decision + ); + Ok(()) + } + + fn approve_file_change_request( + &mut self, + request_id: RequestId, + params: FileChangeRequestApprovalParams, + ) -> Result<()> { + let FileChangeRequestApprovalParams { + thread_id, + turn_id, + item_id, + started_at_ms: _, + reason, + grant_root, + } = params; + + println!( + "\n< fileChange approval requested for thread {thread_id}, turn {turn_id}, item {item_id}" + ); + if let Some(reason) = reason.as_deref() { + println!("< reason: {reason}"); + } + if let Some(grant_root) = grant_root.as_deref() { + println!("< grant root: {}", grant_root.display()); + } + + let response = FileChangeRequestApprovalResponse { + decision: FileChangeApprovalDecision::Accept, + }; + self.send_server_request_response(request_id, &response)?; + println!("< approved fileChange request for item {item_id}"); + Ok(()) + } + + fn send_server_request_response(&mut self, request_id: RequestId, response: &T) -> Result<()> + where + T: Serialize, + { + let message = JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result: serde_json::to_value(response)?, + }); + self.write_jsonrpc_message(message) + } + + fn write_jsonrpc_message(&mut self, message: JSONRPCMessage) -> Result<()> { + let payload = serde_json::to_string(&message)?; + let pretty = serde_json::to_string_pretty(&message)?; + print_multiline_with_prefix("> ", &pretty); + self.write_payload(&payload) + } + + fn write_payload(&mut self, payload: &str) -> Result<()> { + match &mut self.transport { + ClientTransport::Stdio { stdin, .. } => { + if let Some(stdin) = stdin.as_mut() { + writeln!(stdin, "{payload}")?; + stdin + .flush() + .context("failed to flush payload to codex app-server")?; + return Ok(()); + } + bail!("codex app-server stdin closed") + } + ClientTransport::WebSocket { socket, url } => { + socket + .send(Message::Text(payload.to_string().into())) + .with_context(|| format!("failed to write websocket message to `{url}`"))?; + Ok(()) + } + } + } + + fn read_payload(&mut self) -> Result { + match &mut self.transport { + ClientTransport::Stdio { stdout, .. } => { + let mut response_line = String::new(); + let bytes = stdout + .read_line(&mut response_line) + .context("failed to read from codex app-server")?; + if bytes == 0 { + bail!("codex app-server closed stdout"); + } + Ok(response_line) + } + ClientTransport::WebSocket { socket, url } => loop { + let frame = socket + .read() + .with_context(|| format!("failed to read websocket message from `{url}`"))?; + match frame { + Message::Text(text) => return Ok(text.to_string()), + Message::Binary(_) | Message::Ping(_) | Message::Pong(_) => continue, + Message::Close(_) => { + bail!("websocket app-server at `{url}` closed the connection") + } + Message::Frame(_) => continue, + } + }, + } + } +} + +fn print_multiline_with_prefix(prefix: &str, payload: &str) { + for line in payload.lines() { + println!("{prefix}{line}"); + } +} + +struct TestClientTracing { + _otel_provider: Option, + traces_enabled: bool, +} + +impl TestClientTracing { + async fn initialize(config_overrides: &[String]) -> Result { + let cli_kv_overrides = CliConfigOverrides { + raw_overrides: config_overrides.to_vec(), + } + .parse_overrides() + .map_err(|e| anyhow::anyhow!("error parsing -c overrides: {e}"))?; + let config = Config::load_with_cli_overrides(cli_kv_overrides) + .await + .context("error loading config")?; + let otel_provider = codex_core::otel_init::build_provider( + &config, + env!("CARGO_PKG_VERSION"), + Some(OTEL_SERVICE_NAME), + DEFAULT_ANALYTICS_ENABLED, + ) + .map_err(|e| anyhow::anyhow!("error loading otel config: {e}"))?; + let traces_enabled = otel_provider + .as_ref() + .and_then(|provider| provider.tracer_provider.as_ref()) + .is_some(); + if let Some(provider) = otel_provider.as_ref() + && traces_enabled + { + let _ = tracing_subscriber::registry() + .with(provider.tracing_layer()) + .try_init(); + } + Ok(Self { + traces_enabled, + _otel_provider: otel_provider, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TraceSummary { + Enabled { url: String }, + Disabled, +} + +impl TraceSummary { + fn capture(traces_enabled: bool) -> Self { + if !traces_enabled { + return Self::Disabled; + } + current_span_w3c_trace_context() + .as_ref() + .and_then(trace_url_from_context) + .map_or(Self::Disabled, |url| Self::Enabled { url }) + } +} + +fn trace_url_from_context(trace: &W3cTraceContext) -> Option { + let traceparent = trace.traceparent.as_deref()?; + let mut parts = traceparent.split('-'); + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(_version), Some(trace_id), Some(_span_id), Some(_trace_flags)) + if trace_id.len() == 32 => + { + Some(format!("go/trace/{trace_id}")) + } + _ => None, + } +} + +fn print_trace_summary(trace_summary: &TraceSummary) { + println!("\n[Datadog trace]"); + match trace_summary { + TraceSummary::Enabled { url } => println!("{url}\n"), + TraceSummary::Disabled => println!("{TRACE_DISABLED_MESSAGE}\n"), + } +} + +impl Drop for CodexClient { + fn drop(&mut self) { + let ClientTransport::Stdio { child, stdin, .. } = &mut self.transport else { + return; + }; + + let _ = stdin.take(); + + if let Ok(Some(status)) = child.try_wait() { + println!("[codex app-server exited: {status}]"); + return; + } + + let deadline = SystemTime::now() + APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT; + loop { + if let Ok(Some(status)) = child.try_wait() { + println!("[codex app-server exited: {status}]"); + return; + } + + if SystemTime::now() >= deadline { + break; + } + + thread::sleep(APP_SERVER_GRACEFUL_SHUTDOWN_POLL_INTERVAL); + } + + let _ = child.kill(); + let _ = child.wait(); + } +} diff --git a/code-rs/app-server-test-client/src/main.rs b/code-rs/app-server-test-client/src/main.rs new file mode 100644 index 00000000000..794bede1c25 --- /dev/null +++ b/code-rs/app-server-test-client/src/main.rs @@ -0,0 +1,7 @@ +use anyhow::Result; +use tokio::runtime::Builder; + +fn main() -> Result<()> { + let runtime = Builder::new_current_thread().enable_all().build()?; + runtime.block_on(codex_app_server_test_client::run()) +} diff --git a/code-rs/app-server-transport/BUILD.bazel b/code-rs/app-server-transport/BUILD.bazel new file mode 100644 index 00000000000..f6ecba68049 --- /dev/null +++ b/code-rs/app-server-transport/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "app-server-transport", + crate_name = "codex_app_server_transport", +) diff --git a/code-rs/app-server-transport/Cargo.toml b/code-rs/app-server-transport/Cargo.toml new file mode 100644 index 00000000000..175890962e7 --- /dev/null +++ b/code-rs/app-server-transport/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "codex-app-server-transport" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_app_server_transport" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true, default-features = false, features = [ + "http1", + "json", + "tokio", + "ws", +] } +base64 = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-api = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-core = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-state = { workspace = true } +codex-uds = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-rustls-provider = { workspace = true } +constant_time_eq = { workspace = true } +futures = { workspace = true } +gethostname = { workspace = true } +hmac = { workspace = true } +jsonwebtoken = { workspace = true } +owo-colors = { workspace = true, features = ["supports-colors"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +time = { workspace = true } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "rt-multi-thread", +] } +tokio-tungstenite = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true, features = ["log"] } +url = { workspace = true } +uuid = { workspace = true, features = ["serde", "v7"] } + +[dev-dependencies] +chrono = { workspace = true } +codex-config = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/code-rs/app-server-transport/src/lib.rs b/code-rs/app-server-transport/src/lib.rs new file mode 100644 index 00000000000..0a5c080acc7 --- /dev/null +++ b/code-rs/app-server-transport/src/lib.rs @@ -0,0 +1,20 @@ +mod outgoing_message; +mod transport; + +pub use outgoing_message::ConnectionId; +pub use outgoing_message::OutgoingError; +pub use outgoing_message::OutgoingMessage; +pub use outgoing_message::OutgoingResponse; +pub use outgoing_message::QueuedOutgoingMessage; +pub use transport::AppServerTransport; +pub use transport::AppServerTransportParseError; +pub use transport::CHANNEL_CAPACITY; +pub use transport::ConnectionOrigin; +pub use transport::RemoteControlHandle; +pub use transport::TransportEvent; +pub use transport::app_server_control_socket_path; +pub use transport::auth; +pub use transport::start_control_socket_acceptor; +pub use transport::start_remote_control; +pub use transport::start_stdio_connection; +pub use transport::start_websocket_acceptor; diff --git a/code-rs/app-server-transport/src/outgoing_message.rs b/code-rs/app-server-transport/src/outgoing_message.rs new file mode 100644 index 00000000000..ff56b9fef94 --- /dev/null +++ b/code-rs/app-server-transport/src/outgoing_message.rs @@ -0,0 +1,58 @@ +use std::fmt; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use serde::Serialize; +use tokio::sync::oneshot; + +/// Stable identifier for a transport connection. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ConnectionId(pub u64); + +impl fmt::Display for ConnectionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Outgoing message from the server to the client. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum OutgoingMessage { + Request(ServerRequest), + /// AppServerNotification is specific to the case where this is run as an + /// "app server" as opposed to an MCP server. + AppServerNotification(ServerNotification), + Response(OutgoingResponse), + Error(OutgoingError), +} + +#[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, +} + +#[derive(Debug)] +pub struct QueuedOutgoingMessage { + pub message: OutgoingMessage, + pub write_complete_tx: Option>, +} + +impl QueuedOutgoingMessage { + pub fn new(message: OutgoingMessage) -> Self { + Self { + message, + write_complete_tx: None, + } + } +} diff --git a/code-rs/app-server-transport/src/transport/auth.rs b/code-rs/app-server-transport/src/transport/auth.rs new file mode 100644 index 00000000000..9ec025f66f0 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/auth.rs @@ -0,0 +1,751 @@ +use anyhow::Context; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::header::AUTHORIZATION; +use clap::Args; +use clap::ValueEnum; +use codex_utils_absolute_path::AbsolutePathBuf; +use constant_time_eq::constant_time_eq_32; +use jsonwebtoken::Algorithm; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::Validation; +use jsonwebtoken::decode; +use serde::Deserialize; +use sha2::Digest; +use sha2::Sha256; +use std::io; +use std::io::ErrorKind; +use std::net::SocketAddr; +use std::path::Path; +use std::path::PathBuf; +use time::OffsetDateTime; + +const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30; +const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32; +const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header"; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Args)] +pub struct AppServerWebsocketAuthArgs { + /// Websocket auth mode for non-loopback listeners. + #[arg(long = "ws-auth", value_name = "MODE", value_enum)] + pub ws_auth: Option, + + /// Absolute path to the capability-token file. + #[arg(long = "ws-token-file", value_name = "PATH")] + pub ws_token_file: Option, + + /// Hex-encoded SHA-256 digest of the capability token. + #[arg(long = "ws-token-sha256", value_name = "HEX")] + pub ws_token_sha256: Option, + + /// Absolute path to the shared secret file for signed JWT bearer tokens. + #[arg(long = "ws-shared-secret-file", value_name = "PATH")] + pub ws_shared_secret_file: Option, + + /// Expected issuer for signed JWT bearer tokens. + #[arg(long = "ws-issuer", value_name = "ISSUER")] + pub ws_issuer: Option, + + /// Expected audience for signed JWT bearer tokens. + #[arg(long = "ws-audience", value_name = "AUDIENCE")] + pub ws_audience: Option, + + /// Maximum clock skew when validating signed JWT bearer tokens. + #[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")] + pub ws_max_clock_skew_seconds: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum WebsocketAuthCliMode { + CapabilityToken, + SignedBearerToken, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AppServerWebsocketAuthSettings { + pub config: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppServerWebsocketAuthConfig { + CapabilityToken { + source: AppServerWebsocketCapabilityTokenSource, + }, + SignedBearerToken { + shared_secret_file: AbsolutePathBuf, + issuer: Option, + audience: Option, + max_clock_skew_seconds: u64, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppServerWebsocketCapabilityTokenSource { + TokenFile { token_file: AbsolutePathBuf }, + TokenSha256 { token_sha256: [u8; 32] }, +} + +#[derive(Clone, Debug, Default)] +pub struct WebsocketAuthPolicy { + pub(crate) mode: Option, +} + +#[derive(Clone, Debug)] +pub(crate) enum WebsocketAuthMode { + CapabilityToken { + token_sha256: [u8; 32], + }, + SignedBearerToken { + shared_secret: Vec, + issuer: Option, + audience: Option, + max_clock_skew_seconds: i64, + }, +} + +#[derive(Debug)] +pub(crate) struct WebsocketAuthError { + status_code: StatusCode, + message: &'static str, +} + +#[derive(Deserialize)] +struct JwtClaims { + exp: i64, + nbf: Option, + iss: Option, + aud: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum JwtAudienceClaim { + Single(String), + Multiple(Vec), +} + +impl WebsocketAuthError { + pub(crate) fn status_code(&self) -> StatusCode { + self.status_code + } + + pub(crate) fn message(&self) -> &'static str { + self.message + } +} + +impl AppServerWebsocketAuthArgs { + pub fn try_into_settings(self) -> anyhow::Result { + let normalize = |value: Option| { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + }; + + let config = match self.ws_auth { + Some(WebsocketAuthCliMode::CapabilityToken) => { + if self.ws_shared_secret_file.is_some() + || self.ws_issuer.is_some() + || self.ws_audience.is_some() + || self.ws_max_clock_skew_seconds.is_some() + { + anyhow::bail!( + "`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`" + ); + } + let source = match (self.ws_token_file, self.ws_token_sha256) { + (Some(_), Some(_)) => { + anyhow::bail!( + "`--ws-token-file` and `--ws-token-sha256` are mutually exclusive" + ); + } + (Some(token_file), None) => { + AppServerWebsocketCapabilityTokenSource::TokenFile { + token_file: absolute_path_arg("--ws-token-file", token_file)?, + } + } + (None, Some(token_sha256)) => { + AppServerWebsocketCapabilityTokenSource::TokenSha256 { + token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?, + } + } + (None, None) => { + anyhow::bail!( + "`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set" + ); + } + }; + Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) + } + Some(WebsocketAuthCliMode::SignedBearerToken) => { + if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() { + anyhow::bail!( + "`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`" + ); + } + let shared_secret_file = self.ws_shared_secret_file.context( + "`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set", + )?; + Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file: absolute_path_arg( + "--ws-shared-secret-file", + shared_secret_file, + )?, + issuer: normalize(self.ws_issuer), + audience: normalize(self.ws_audience), + max_clock_skew_seconds: self + .ws_max_clock_skew_seconds + .unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS), + }) + } + None => { + if self.ws_token_file.is_some() + || self.ws_token_sha256.is_some() + || self.ws_shared_secret_file.is_some() + || self.ws_issuer.is_some() + || self.ws_audience.is_some() + || self.ws_max_clock_skew_seconds.is_some() + { + anyhow::bail!( + "websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`" + ); + } + None + } + }; + + Ok(AppServerWebsocketAuthSettings { config }) + } +} + +pub fn policy_from_settings( + settings: &AppServerWebsocketAuthSettings, +) -> io::Result { + let mode = match settings.config.as_ref() { + Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source { + AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => { + let token = read_trimmed_secret(token_file.as_ref())?; + Some(WebsocketAuthMode::CapabilityToken { + token_sha256: sha256_digest(token.as_bytes()), + }) + } + AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => { + Some(WebsocketAuthMode::CapabilityToken { + token_sha256: *token_sha256, + }) + } + }, + Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file, + issuer, + audience, + max_clock_skew_seconds, + }) => { + let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes(); + validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?; + let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| { + io::Error::new( + ErrorKind::InvalidInput, + "websocket auth clock skew must fit in a signed 64-bit integer", + ) + })?; + Some(WebsocketAuthMode::SignedBearerToken { + shared_secret, + issuer: issuer.clone(), + audience: audience.clone(), + max_clock_skew_seconds, + }) + } + None => None, + }; + + Ok(WebsocketAuthPolicy { mode }) +} + +pub(crate) fn should_warn_about_unauthenticated_non_loopback_listener( + bind_address: SocketAddr, + policy: &WebsocketAuthPolicy, +) -> bool { + !bind_address.ip().is_loopback() && policy.mode.is_none() +} + +pub(crate) fn authorize_upgrade( + headers: &HeaderMap, + policy: &WebsocketAuthPolicy, +) -> Result<(), WebsocketAuthError> { + let Some(mode) = policy.mode.as_ref() else { + return Ok(()); + }; + + let token = bearer_token_from_headers(headers)?; + match mode { + WebsocketAuthMode::CapabilityToken { token_sha256 } => { + let actual_sha256 = sha256_digest(token.as_bytes()); + if constant_time_eq_32(token_sha256, &actual_sha256) { + Ok(()) + } else { + Err(unauthorized("invalid websocket bearer token")) + } + } + WebsocketAuthMode::SignedBearerToken { + shared_secret, + issuer, + audience, + max_clock_skew_seconds, + } => verify_signed_bearer_token( + token, + shared_secret, + issuer.as_deref(), + audience.as_deref(), + *max_clock_skew_seconds, + ), + } +} + +fn verify_signed_bearer_token( + token: &str, + shared_secret: &[u8], + issuer: Option<&str>, + audience: Option<&str>, + max_clock_skew_seconds: i64, +) -> Result<(), WebsocketAuthError> { + let claims = decode_jwt_claims(token, shared_secret)?; + validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds) +} + +fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.required_spec_claims.clear(); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.validate_aud = false; + + decode::(token, &DecodingKey::from_secret(shared_secret), &validation) + .map(|token_data| token_data.claims) + .map_err(|_| unauthorized("invalid websocket jwt")) +} + +fn validate_jwt_claims( + claims: &JwtClaims, + issuer: Option<&str>, + audience: Option<&str>, + max_clock_skew_seconds: i64, +) -> Result<(), WebsocketAuthError> { + let now = OffsetDateTime::now_utc().unix_timestamp(); + if now > claims.exp.saturating_add(max_clock_skew_seconds) { + return Err(unauthorized("expired websocket jwt")); + } + if let Some(nbf) = claims.nbf + && now < nbf.saturating_sub(max_clock_skew_seconds) + { + return Err(unauthorized("websocket jwt is not valid yet")); + } + if let Some(expected_issuer) = issuer + && claims.iss.as_deref() != Some(expected_issuer) + { + return Err(unauthorized("websocket jwt issuer mismatch")); + } + if let Some(expected_audience) = audience + && !audience_matches(claims.aud.as_ref(), expected_audience) + { + return Err(unauthorized("websocket jwt audience mismatch")); + } + + Ok(()) +} + +fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool { + match audience { + Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience, + Some(JwtAudienceClaim::Multiple(actual)) => { + actual.iter().any(|audience| audience == expected_audience) + } + None => false, + } +} + +fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> { + let raw_header = headers + .get(AUTHORIZATION) + .ok_or_else(|| unauthorized("missing websocket bearer token"))?; + let header = raw_header + .to_str() + .map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?; + let Some((scheme, token)) = header.split_once(' ') else { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + }; + if !scheme.eq_ignore_ascii_case("Bearer") { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + } + let token = token.trim(); + if token.is_empty() { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + } + Ok(token) +} + +fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> { + if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!( + "signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes", + path.display() + ), + )); + } + Ok(()) +} + +fn read_trimmed_secret(path: &std::path::Path) -> io::Result { + let raw = std::fs::read_to_string(path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to read websocket auth secret {}: {err}", + path.display() + ), + ) + })?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!("websocket auth secret {} must not be empty", path.display()), + )); + } + Ok(trimmed.to_string()) +} + +fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result { + AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path")) +} + +fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> { + let trimmed = value.trim(); + if trimmed.len() != 64 { + anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"); + } + + let mut digest = [0u8; 32]; + for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() { + let high = hex_nibble(flag_name, pair[0])?; + let low = hex_nibble(flag_name, pair[1])?; + digest[index] = (high << 4) | low; + } + Ok(digest) +} + +fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"), + } +} + +fn sha256_digest(input: &[u8]) -> [u8; 32] { + let mut digest = [0u8; 32]; + digest.copy_from_slice(&Sha256::digest(input)); + digest +} + +fn unauthorized(message: &'static str) -> WebsocketAuthError { + WebsocketAuthError { + status_code: StatusCode::UNAUTHORIZED, + message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use hmac::Hmac; + use hmac::Mac; + use serde_json::json; + + type HmacSha256 = Hmac; + + fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let payload = format!("{header}.{claims_segment}"); + let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap(); + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + format!("{payload}.{signature}") + } + + #[test] + fn warns_about_unauthenticated_non_loopback_listener() { + let policy = WebsocketAuthPolicy::default(); + assert!(should_warn_about_unauthenticated_non_loopback_listener( + "0.0.0.0:8765".parse().unwrap(), + &policy, + )); + assert!(!should_warn_about_unauthenticated_non_loopback_listener( + "127.0.0.1:8765".parse().unwrap(), + &policy, + )); + assert!(!should_warn_about_unauthenticated_non_loopback_listener( + "0.0.0.0:8765".parse().unwrap(), + &WebsocketAuthPolicy { + mode: Some(WebsocketAuthMode::CapabilityToken { + token_sha256: [0u8; 32], + }), + }, + )); + } + + #[test] + fn capability_token_args_require_token_file_or_hash() { + let err = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ..Default::default() + } + .try_into_settings() + .expect_err("capability-token mode should require a token source"); + assert!( + err.to_string().contains("--ws-token-file") + && err.to_string().contains("--ws-token-sha256"), + "unexpected error: {err}" + ); + } + + #[test] + fn capability_token_args_accept_token_hash() { + let settings = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ws_token_sha256: Some("ab".repeat(32)), + ..Default::default() + } + .try_into_settings() + .expect("capability-token hash args should parse"); + + assert_eq!( + settings, + AppServerWebsocketAuthSettings { + config: Some(AppServerWebsocketAuthConfig::CapabilityToken { + source: AppServerWebsocketCapabilityTokenSource::TokenSha256 { + token_sha256: [0xab; 32], + }, + }), + } + ); + } + + #[test] + fn capability_token_args_reject_multiple_token_sources() { + let err = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ws_token_file: Some(PathBuf::from("/tmp/token")), + ws_token_sha256: Some("ab".repeat(32)), + ..Default::default() + } + .try_into_settings() + .expect_err("capability-token mode should reject multiple token sources"); + assert!( + err.to_string().contains("mutually exclusive"), + "unexpected error: {err}" + ); + } + + #[test] + fn capability_token_args_reject_malformed_token_hash() { + let err = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ws_token_sha256: Some("not-a-sha256".to_string()), + ..Default::default() + } + .try_into_settings() + .expect_err("capability-token mode should reject malformed token hashes"); + assert!( + err.to_string().contains("64-character hex"), + "unexpected error: {err}" + ); + } + + #[test] + fn capability_token_hash_policy_authorizes_matching_bearer_token() { + let settings = AppServerWebsocketAuthSettings { + config: Some(AppServerWebsocketAuthConfig::CapabilityToken { + source: AppServerWebsocketCapabilityTokenSource::TokenSha256 { + token_sha256: sha256_digest(b"super-secret-token"), + }, + }), + }; + let policy = policy_from_settings(&settings).expect("hash policy should build"); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer super-secret-token"), + ); + authorize_upgrade(&headers, &policy).expect("matching token should authorize"); + + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer wrong-token"), + ); + let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() { + let err = AppServerWebsocketAuthArgs { + ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), + ..Default::default() + } + .try_into_settings() + .expect_err("mode-specific flags should require --ws-auth"); + assert!( + err.to_string().contains("websocket auth flags require"), + "unexpected error: {err}" + ); + } + + #[test] + fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() { + let settings = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken), + ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), + ws_issuer: Some(" issuer ".to_string()), + ws_audience: Some(" ".to_string()), + ..Default::default() + } + .try_into_settings() + .expect("signed bearer args should parse"); + + assert_eq!( + settings, + AppServerWebsocketAuthSettings { + config: Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret") + .expect("absolute path"), + issuer: Some("issuer".to_string()), + audience: None, + max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS, + }), + } + ); + } + + #[test] + fn signed_bearer_token_verification_rejects_tampering() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + }), + ); + let tampered = token.replace(".eyJleHAi", ".eyJleHBi"); + let err = verify_signed_bearer_token( + &tampered, + shared_secret, + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("tampered jwt should fail"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_token_verification_accepts_valid_token() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "issuer", + "aud": "audience", + }), + ); + verify_signed_bearer_token( + &token, + shared_secret, + Some("issuer"), + Some("audience"), + /*max_clock_skew_seconds*/ 30, + ) + .expect("valid signed token should verify"); + } + + #[test] + fn signed_bearer_token_verification_accepts_multiple_audiences() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "aud": ["other-audience", "audience"], + }), + ); + verify_signed_bearer_token( + &token, + shared_secret, + /*issuer*/ None, + Some("audience"), + /*max_clock_skew_seconds*/ 30, + ) + .expect("jwt audience arrays should verify"); + } + + #[test] + fn signed_bearer_token_verification_rejects_alg_none_tokens() { + let claims_segment = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + })) + .unwrap(), + ); + let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let token = format!("{header_segment}.{claims_segment}."); + let err = verify_signed_bearer_token( + &token, + b"0123456789abcdef0123456789abcdef", + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("alg=none jwt should be rejected"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_token_verification_rejects_missing_exp() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "iss": "issuer", + }), + ); + let err = verify_signed_bearer_token( + &token, + shared_secret, + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("jwt without exp should be rejected"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn validate_signed_bearer_secret_rejects_short_secret() { + let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short") + .expect_err("short shared secret should be rejected"); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string().contains("must be at least 32 bytes"), + "unexpected error: {err}" + ); + } +} diff --git a/code-rs/app-server-transport/src/transport/mod.rs b/code-rs/app-server-transport/src/transport/mod.rs new file mode 100644 index 00000000000..c63a79a0c14 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/mod.rs @@ -0,0 +1,470 @@ +pub mod auth; + +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingError; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::QueuedOutgoingMessage; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_core::config::find_codex_home; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::net::SocketAddr; +use std::path::Path; +use std::str::FromStr; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::warn; + +/// 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. +pub const CHANNEL_CAPACITY: usize = 128; + +mod remote_control; +mod stdio; +mod unix_socket; +#[cfg(test)] +mod unix_socket_tests; +mod websocket; + +pub use remote_control::RemoteControlHandle; +pub use remote_control::start_remote_control; +pub use stdio::start_stdio_connection; +pub use unix_socket::start_control_socket_acceptor; +pub use websocket::start_websocket_acceptor; + +const OVERLOADED_ERROR_CODE: i64 = -32001; + +const APP_SERVER_CONTROL_SOCKET_DIR_NAME: &str = "app-server-control"; +const APP_SERVER_CONTROL_SOCKET_FILE_NAME: &str = "app-server-control.sock"; + +pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result { + AbsolutePathBuf::from_absolute_path( + codex_home + .join(APP_SERVER_CONTROL_SOCKET_DIR_NAME) + .join(APP_SERVER_CONTROL_SOCKET_FILE_NAME), + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AppServerTransport { + Stdio, + UnixSocket { socket_path: AbsolutePathBuf }, + WebSocket { bind_address: SocketAddr }, + Off, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum AppServerTransportParseError { + UnsupportedListenUrl(String), + InvalidUnixSocketPath { listen_url: String, message: String }, + InvalidWebSocketListenUrl(String), +} + +impl std::fmt::Display for AppServerTransportParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!( + f, + "unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`" + ), + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url, + message, + } => write!( + f, + "invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}" + ), + AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!( + f, + "invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`" + ), + } + } +} + +impl std::error::Error for AppServerTransportParseError {} + +impl AppServerTransport { + pub const DEFAULT_LISTEN_URL: &'static str = "stdio://"; + + pub fn from_listen_url(listen_url: &str) -> Result { + if listen_url == Self::DEFAULT_LISTEN_URL { + return Ok(Self::Stdio); + } + + if let Some(raw_socket_path) = listen_url.strip_prefix("unix://") { + let socket_path = if raw_socket_path.is_empty() { + let codex_home = find_codex_home().map_err(|err| { + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url: listen_url.to_string(), + message: format!("failed to resolve CODEX_HOME: {err}"), + } + })?; + app_server_control_socket_path(&codex_home).map_err(|err| { + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url: listen_url.to_string(), + message: err.to_string(), + } + })? + } else { + AbsolutePathBuf::relative_to_current_dir(raw_socket_path).map_err(|err| { + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url: listen_url.to_string(), + message: err.to_string(), + } + })? + }; + return Ok(Self::UnixSocket { socket_path }); + } + + if listen_url == "off" { + return Ok(Self::Off); + } + + if let Some(socket_addr) = listen_url.strip_prefix("ws://") { + let bind_address = socket_addr.parse::().map_err(|_| { + AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string()) + })?; + return Ok(Self::WebSocket { bind_address }); + } + + Err(AppServerTransportParseError::UnsupportedListenUrl( + listen_url.to_string(), + )) + } +} + +impl FromStr for AppServerTransport { + type Err = AppServerTransportParseError; + + fn from_str(s: &str) -> Result { + Self::from_listen_url(s) + } +} + +#[derive(Debug)] +pub enum TransportEvent { + ConnectionOpened { + connection_id: ConnectionId, + origin: ConnectionOrigin, + writer: mpsc::Sender, + disconnect_sender: Option, + }, + ConnectionClosed { + connection_id: ConnectionId, + }, + IncomingMessage { + connection_id: ConnectionId, + message: JSONRPCMessage, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionOrigin { + Stdio, + InProcess, + WebSocket, + RemoteControl, +} + +static CONNECTION_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn next_connection_id() -> ConnectionId { + ConnectionId(CONNECTION_ID_COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +async fn forward_incoming_message( + transport_event_tx: &mpsc::Sender, + writer: &mpsc::Sender, + connection_id: ConnectionId, + payload: &str, +) -> bool { + match serde_json::from_str::(payload) { + Ok(message) => { + enqueue_incoming_message(transport_event_tx, writer, connection_id, message).await + } + Err(err) => { + error!("Failed to deserialize JSONRPCMessage: {err}"); + true + } + } +} + +async fn enqueue_incoming_message( + transport_event_tx: &mpsc::Sender, + writer: &mpsc::Sender, + connection_id: ConnectionId, + message: JSONRPCMessage, +) -> bool { + let event = TransportEvent::IncomingMessage { + connection_id, + message, + }; + match transport_event_tx.try_send(event) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Closed(_)) => false, + Err(mpsc::error::TrySendError::Full(TransportEvent::IncomingMessage { + connection_id, + message: JSONRPCMessage::Request(request), + })) => { + let overload_error = OutgoingMessage::Error(OutgoingError { + id: request.id, + error: JSONRPCErrorError { + code: OVERLOADED_ERROR_CODE, + message: "Server overloaded; retry later.".to_string(), + data: None, + }, + }); + match writer.try_send(QueuedOutgoingMessage::new(overload_error)) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Closed(_)) => false, + Err(mpsc::error::TrySendError::Full(_overload_error)) => { + warn!( + "dropping overload response for connection {:?}: outbound queue is full", + connection_id + ); + true + } + } + } + Err(mpsc::error::TrySendError::Full(event)) => transport_event_tx.send(event).await.is_ok(), + } +} + +fn serialize_outgoing_message(outgoing_message: OutgoingMessage) -> Option { + let value = match serde_json::to_value(outgoing_message) { + Ok(value) => value, + Err(err) => { + error!("Failed to convert OutgoingMessage to JSON value: {err}"); + return None; + } + }; + match serde_json::to_string(&value) { + Ok(json) => Some(json), + Err(err) => { + error!("Failed to serialize JSONRPCMessage: {err}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCNotification; + use codex_app_server_protocol::JSONRPCRequest; + use codex_app_server_protocol::JSONRPCResponse; + use codex_app_server_protocol::RequestId; + use codex_app_server_protocol::ServerNotification; + use pretty_assertions::assert_eq; + use serde_json::json; + use tokio::time::Duration; + use tokio::time::timeout; + + #[test] + fn listen_off_parses_as_off_transport() { + assert_eq!( + AppServerTransport::from_listen_url("off"), + Ok(AppServerTransport::Off) + ); + } + + #[tokio::test] + async fn enqueue_incoming_request_returns_overload_error_when_queue_is_full() { + let connection_id = ConnectionId(42); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let first_message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + transport_event_tx + .send(TransportEvent::IncomingMessage { + connection_id, + message: first_message.clone(), + }) + .await + .expect("queue should accept first message"); + + let request = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(7), + method: "config/read".to_string(), + params: Some(json!({ "includeLayers": false })), + trace: None, + }); + assert!( + enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request).await + ); + + let queued_event = transport_event_rx + .recv() + .await + .expect("first event should stay queued"); + match queued_event { + TransportEvent::IncomingMessage { + connection_id: queued_connection_id, + message, + } => { + assert_eq!(queued_connection_id, connection_id); + assert_eq!(message, first_message); + } + _ => panic!("expected queued incoming message"), + } + + let overload = writer_rx + .recv() + .await + .expect("request should receive overload error"); + let overload_json = + serde_json::to_value(overload.message).expect("serialize overload error"); + assert_eq!( + overload_json, + json!({ + "id": 7, + "error": { + "code": OVERLOADED_ERROR_CODE, + "message": "Server overloaded; retry later." + } + }) + ); + } + + #[tokio::test] + async fn enqueue_incoming_response_waits_instead_of_dropping_when_queue_is_full() { + let connection_id = ConnectionId(42); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); + let (writer_tx, _writer_rx) = mpsc::channel(1); + + let first_message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + transport_event_tx + .send(TransportEvent::IncomingMessage { + connection_id, + message: first_message.clone(), + }) + .await + .expect("queue should accept first message"); + + let response = JSONRPCMessage::Response(JSONRPCResponse { + id: RequestId::Integer(7), + result: json!({"ok": true}), + }); + let transport_event_tx_for_enqueue = transport_event_tx.clone(); + let writer_tx_for_enqueue = writer_tx.clone(); + let enqueue_handle = tokio::spawn(async move { + enqueue_incoming_message( + &transport_event_tx_for_enqueue, + &writer_tx_for_enqueue, + connection_id, + response, + ) + .await + }); + + let queued_event = transport_event_rx + .recv() + .await + .expect("first event should be dequeued"); + match queued_event { + TransportEvent::IncomingMessage { + connection_id: queued_connection_id, + message, + } => { + assert_eq!(queued_connection_id, connection_id); + assert_eq!(message, first_message); + } + _ => panic!("expected queued incoming message"), + } + + let enqueue_result = enqueue_handle.await.expect("enqueue task should not panic"); + assert!(enqueue_result); + + let forwarded_event = transport_event_rx + .recv() + .await + .expect("response should be forwarded instead of dropped"); + match forwarded_event { + TransportEvent::IncomingMessage { + connection_id: queued_connection_id, + message: JSONRPCMessage::Response(JSONRPCResponse { id, result }), + } => { + assert_eq!(queued_connection_id, connection_id); + assert_eq!(id, RequestId::Integer(7)); + assert_eq!(result, json!({"ok": true})); + } + _ => panic!("expected forwarded response message"), + } + } + + #[tokio::test] + async fn enqueue_incoming_request_does_not_block_when_writer_queue_is_full() { + let connection_id = ConnectionId(42); + let (transport_event_tx, _transport_event_rx) = mpsc::channel(1); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + transport_event_tx + .send(TransportEvent::IncomingMessage { + connection_id, + message: JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }), + }) + .await + .expect("transport queue should accept first message"); + + writer_tx + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("writer queue should accept first message"); + + let request = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(7), + method: "config/read".to_string(), + params: Some(json!({ "includeLayers": false })), + trace: None, + }); + + let enqueue_result = timeout( + Duration::from_millis(100), + enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request), + ) + .await + .expect("enqueue should not block while writer queue is full"); + assert!(enqueue_result); + + let queued_outgoing = writer_rx + .recv() + .await + .expect("writer queue should still contain original message"); + let queued_json = + serde_json::to_value(queued_outgoing.message).expect("serialize queued message"); + assert_eq!( + queued_json, + json!({ + "method": "configWarning", + "params": { + "summary": "queued", + "details": null, + }, + }) + ); + } +} diff --git a/code-rs/app-server-transport/src/transport/remote_control/client_tracker.rs b/code-rs/app-server-transport/src/transport/remote_control/client_tracker.rs new file mode 100644 index 00000000000..4639942b080 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/client_tracker.rs @@ -0,0 +1,570 @@ +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::next_connection_id; +use super::protocol::ClientEnvelope; +pub use super::protocol::ClientEvent; +pub use super::protocol::ClientId; +use super::protocol::PongStatus; +use super::protocol::ServerEvent; +use super::protocol::StreamId; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::QueuedOutgoingMessage; +use crate::transport::ConnectionOrigin; +use crate::transport::remote_control::QueuedServerEnvelope; +use codex_app_server_protocol::JSONRPCMessage; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tokio::sync::watch; +use tokio::task::JoinSet; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +const REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +pub(crate) const REMOTE_CONTROL_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Debug)] +pub(crate) struct Stopped; + +struct ClientState { + connection_id: ConnectionId, + disconnect_token: CancellationToken, + last_activity_at: Instant, + last_inbound_seq_id: Option, + status_tx: watch::Sender, +} + +pub(crate) struct ClientTracker { + clients: HashMap<(ClientId, StreamId), ClientState>, + legacy_stream_ids: HashMap, + join_set: JoinSet<(ClientId, StreamId)>, + server_event_tx: mpsc::Sender, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, +} + +impl ClientTracker { + pub(crate) fn new( + server_event_tx: mpsc::Sender, + transport_event_tx: mpsc::Sender, + shutdown_token: &CancellationToken, + ) -> Self { + Self { + clients: HashMap::new(), + legacy_stream_ids: HashMap::new(), + join_set: JoinSet::new(), + server_event_tx, + transport_event_tx, + shutdown_token: shutdown_token.child_token(), + } + } + + pub(crate) async fn bookkeep_join_set(&mut self) -> Option<(ClientId, StreamId)> { + while let Some(join_result) = self.join_set.join_next().await { + let Ok(client_key) = join_result else { + continue; + }; + return Some(client_key); + } + futures::future::pending().await + } + + pub(crate) async fn shutdown(&mut self) { + self.shutdown_token.cancel(); + + while let Some(client_key) = self.clients.keys().next().cloned() { + let _ = self.close_client(&client_key).await; + } + + self.drain_join_set().await; + } + + async fn drain_join_set(&mut self) { + while self.join_set.join_next().await.is_some() {} + } + + pub(crate) async fn handle_message( + &mut self, + client_envelope: ClientEnvelope, + ) -> Result<(), Stopped> { + let ClientEnvelope { + client_id, + event, + stream_id, + seq_id, + cursor: _, + } = client_envelope; + let is_legacy_stream_id = stream_id.is_none(); + let is_initialize = matches!(&event, ClientEvent::ClientMessage { message } if remote_control_message_starts_connection(message)); + let stream_id = match stream_id { + Some(stream_id) => stream_id, + None if is_initialize => { + // TODO(ruslan): delete this fallback once all clients are updated to send stream_id. + self.legacy_stream_ids + .remove(&client_id) + .unwrap_or_else(StreamId::new_random) + } + None => self + .legacy_stream_ids + .get(&client_id) + .cloned() + .unwrap_or_else(|| { + if matches!(&event, ClientEvent::Ping) { + StreamId::new_random() + } else { + StreamId(String::new()) + } + }), + }; + if stream_id.0.is_empty() { + return Ok(()); + } + let client_key = (client_id.clone(), stream_id.clone()); + match event { + ClientEvent::ClientMessage { message } => { + if let Some(seq_id) = seq_id + && let Some(client) = self.clients.get(&client_key) + && client + .last_inbound_seq_id + .is_some_and(|last_seq_id| last_seq_id >= seq_id) + && !is_initialize + { + return Ok(()); + } + + if is_initialize && self.clients.contains_key(&client_key) { + self.close_client(&client_key).await?; + } + + if let Some(connection_id) = self.clients.get_mut(&client_key).map(|client| { + client.last_activity_at = Instant::now(); + if let Some(seq_id) = seq_id { + client.last_inbound_seq_id = Some(seq_id); + } + client.connection_id + }) { + self.send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await?; + return Ok(()); + } + + if !is_initialize { + return Ok(()); + } + + let connection_id = next_connection_id(); + let (writer_tx, writer_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let disconnect_token = self.shutdown_token.child_token(); + self.send_transport_event(TransportEvent::ConnectionOpened { + connection_id, + origin: ConnectionOrigin::RemoteControl, + writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), + }) + .await?; + + let (status_tx, status_rx) = watch::channel(PongStatus::Active); + self.join_set.spawn(Self::run_client_outbound( + client_id.clone(), + stream_id.clone(), + self.server_event_tx.clone(), + writer_rx, + status_rx, + disconnect_token.clone(), + )); + self.clients.insert( + client_key, + ClientState { + connection_id, + disconnect_token, + last_activity_at: Instant::now(), + last_inbound_seq_id: if is_legacy_stream_id { None } else { seq_id }, + status_tx, + }, + ); + if is_legacy_stream_id { + self.legacy_stream_ids.insert(client_id.clone(), stream_id); + } + self.send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await + } + ClientEvent::ClientMessageChunk { .. } | ClientEvent::Ack { .. } => Ok(()), + ClientEvent::Ping => { + if let Some(client) = self.clients.get_mut(&client_key) { + client.last_activity_at = Instant::now(); + let _ = client.status_tx.send(PongStatus::Active); + return Ok(()); + } + + let server_event_tx = self.server_event_tx.clone(); + tokio::spawn(async move { + let server_envelope = QueuedServerEnvelope { + event: ServerEvent::Pong { + status: PongStatus::Unknown, + }, + client_id, + stream_id, + write_complete_tx: None, + }; + let _ = server_event_tx.send(server_envelope).await; + }); + Ok(()) + } + ClientEvent::ClientClosed => self.close_client(&client_key).await, + } + } + + async fn run_client_outbound( + client_id: ClientId, + stream_id: StreamId, + server_event_tx: mpsc::Sender, + mut writer_rx: mpsc::Receiver, + mut status_rx: watch::Receiver, + disconnect_token: CancellationToken, + ) -> (ClientId, StreamId) { + loop { + let (event, write_complete_tx) = tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { + break; + }; + let event = ServerEvent::ServerMessage { + message: Box::new(queued_message.message), + }; + (event, queued_message.write_complete_tx) + } + changed = status_rx.changed() => { + if changed.is_err() { + break; + } + let event = ServerEvent::Pong { status: status_rx.borrow().clone() }; + (event, None) + } + }; + let send_result = tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + send_result = server_event_tx.send(QueuedServerEnvelope { + event, + client_id: client_id.clone(), + stream_id: stream_id.clone(), + write_complete_tx, + }) => send_result, + }; + if send_result.is_err() { + break; + } + } + (client_id, stream_id) + } + + pub(crate) async fn close_expired_clients( + &mut self, + ) -> Result, Stopped> { + let now = Instant::now(); + let expired_client_ids: Vec<(ClientId, StreamId)> = self + .clients + .iter() + .filter_map(|(client_key, client)| { + (!remote_control_client_is_alive(client, now)).then_some(client_key.clone()) + }) + .collect(); + for client_key in &expired_client_ids { + self.close_client(client_key).await?; + } + Ok(expired_client_ids) + } + + pub(super) async fn close_client( + &mut self, + client_key: &(ClientId, StreamId), + ) -> Result<(), Stopped> { + let Some(client) = self.clients.remove(client_key) else { + return Ok(()); + }; + if self + .legacy_stream_ids + .get(&client_key.0) + .is_some_and(|stream_id| stream_id == &client_key.1) + { + self.legacy_stream_ids.remove(&client_key.0); + } + client.disconnect_token.cancel(); + self.send_transport_event(TransportEvent::ConnectionClosed { + connection_id: client.connection_id, + }) + .await + } + + async fn send_transport_event(&self, event: TransportEvent) -> Result<(), Stopped> { + self.transport_event_tx + .send(event) + .await + .map_err(|_| Stopped) + } +} + +fn remote_control_message_starts_connection(message: &JSONRPCMessage) -> bool { + matches!( + message, + JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { method, .. }) + if method == "initialize" + ) +} + +fn remote_control_client_is_alive(client: &ClientState, now: Instant) -> bool { + now.duration_since(client.last_activity_at) < REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outgoing_message::OutgoingMessage; + use crate::transport::remote_control::protocol::ClientEnvelope; + use crate::transport::remote_control::protocol::ClientEvent; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCRequest; + use codex_app_server_protocol::RequestId; + use codex_app_server_protocol::ServerNotification; + use pretty_assertions::assert_eq; + use serde_json::json; + use tokio::time::timeout; + + fn initialize_envelope(client_id: &str) -> ClientEnvelope { + initialize_envelope_with_stream_id(client_id, /*stream_id*/ None) + } + + fn initialize_envelope_with_stream_id( + client_id: &str, + stream_id: Option<&str>, + ) -> ClientEnvelope { + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }), + }, + client_id: ClientId(client_id.to_string()), + stream_id: stream_id.map(|stream_id| StreamId(stream_id.to_string())), + seq_id: Some(0), + cursor: None, + } + } + + #[tokio::test] + async fn cancelled_outbound_task_emits_connection_closed() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + + let (connection_id, disconnect_sender) = match transport_event_rx + .recv() + .await + .expect("connection opened should be sent") + { + TransportEvent::ConnectionOpened { + connection_id, + disconnect_sender: Some(disconnect_sender), + .. + } => (connection_id, disconnect_sender), + other => panic!("expected connection opened, got {other:?}"), + }; + match transport_event_rx + .recv() + .await + .expect("initialize should be forwarded") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + .. + } => assert_eq!(incoming_connection_id, connection_id), + other => panic!("expected incoming initialize, got {other:?}"), + } + + disconnect_sender.cancel(); + let closed_client_id = timeout(Duration::from_secs(1), client_tracker.bookkeep_join_set()) + .await + .expect("bookkeeping should process the closed task") + .expect("closed task should return client id"); + assert_eq!(closed_client_id.0, ClientId("client-1".to_string())); + client_tracker + .close_client(&closed_client_id) + .await + .expect("closed client should emit connection closed"); + + match transport_event_rx + .recv() + .await + .expect("connection closed should be sent") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + + #[tokio::test] + async fn shutdown_cancels_blocked_outbound_forwarding() { + let (server_event_tx, _server_event_rx) = mpsc::channel(1); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx.clone(), transport_event_tx, &shutdown_token); + + server_event_tx + .send(QueuedServerEnvelope { + event: ServerEvent::Pong { + status: PongStatus::Unknown, + }, + client_id: ClientId("queued-client".to_string()), + stream_id: StreamId("queued-stream".to_string()), + write_complete_tx: None, + }) + .await + .expect("server event queue should accept prefill"); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + + let writer = match transport_event_rx + .recv() + .await + .expect("connection opened should be sent") + { + TransportEvent::ConnectionOpened { writer, .. } => writer, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx + .recv() + .await + .expect("initialize should be forwarded"); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("writer should accept queued message"); + + timeout(Duration::from_secs(1), client_tracker.shutdown()) + .await + .expect("shutdown should not hang on blocked server forwarding"); + } + + #[tokio::test] + async fn initialize_with_new_stream_id_opens_new_connection_for_same_client() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("first initialize should open client"); + let first_connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-2"), + )) + .await + .expect("second initialize should open client"); + let second_connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + + assert_ne!(first_connection_id, second_connection_id); + } + + #[tokio::test] + async fn legacy_initialize_without_stream_id_resets_inbound_seq_id() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + client_tracker + .handle_message(ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Notification( + codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }, + ), + }, + client_id: ClientId("client-1".to_string()), + stream_id: None, + seq_id: Some(0), + cursor: None, + }) + .await + .expect("legacy followup should be forwarded"); + + match transport_event_rx.recv().await.expect("followup event") { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + .. + } => assert_eq!(incoming_connection_id, connection_id), + other => panic!("expected incoming message, got {other:?}"), + } + } +} diff --git a/code-rs/app-server-transport/src/transport/remote_control/enroll.rs b/code-rs/app-server-transport/src/transport/remote_control/enroll.rs new file mode 100644 index 00000000000..fb7f727b830 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -0,0 +1,514 @@ +use super::protocol::EnrollRemoteServerRequest; +use super::protocol::EnrollRemoteServerResponse; +use super::protocol::RemoteControlTarget; +use axum::http::HeaderMap; +use codex_api::SharedAuthProvider; +use codex_login::default_client::build_reqwest_client; +use codex_state::RemoteControlEnrollmentRecord; +use codex_state::StateRuntime; +use gethostname::gethostname; +use std::io; +use std::io::ErrorKind; +use tracing::info; +use tracing::warn; + +const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096; + +const REQUEST_ID_HEADER: &str = "x-request-id"; +const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; +const CF_RAY_HEADER: &str = "cf-ray"; +pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlEnrollment { + pub(super) account_id: String, + pub(super) environment_id: String, + pub(super) server_id: String, + pub(super) server_name: String, +} + +pub(super) struct RemoteControlConnectionAuth { + pub(super) auth_provider: SharedAuthProvider, + pub(super) account_id: String, +} + +pub(super) async fn load_persisted_remote_control_enrollment( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, +) -> io::Result> { + let Some(state_db) = state_db else { + return Err(io::Error::new( + ErrorKind::NotFound, + format!( + "remote control enrollment cache unavailable because sqlite state db is disabled: websocket_url={}, account_id={}, app_server_client_name={:?}", + remote_control_target.websocket_url, account_id, app_server_client_name + ), + )); + }; + let enrollment = match state_db + .get_remote_control_enrollment( + &remote_control_target.websocket_url, + account_id, + app_server_client_name, + ) + .await + { + Ok(enrollment) => enrollment, + Err(err) => { + warn!( + "failed to load persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, err={err}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + return Err(io::Error::other(err)); + } + }; + + match enrollment { + Some(enrollment) => { + info!( + "reusing persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.server_id, + enrollment.environment_id + ); + Ok(Some(RemoteControlEnrollment { + account_id: enrollment.account_id, + environment_id: enrollment.environment_id, + server_id: enrollment.server_id, + server_name: enrollment.server_name, + })) + } + None => { + info!( + "no persisted remote control enrollment found: websocket_url={}, account_id={}, app_server_client_name={:?}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + Ok(None) + } + } +} + +pub(super) async fn update_persisted_remote_control_enrollment( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + enrollment: Option<&RemoteControlEnrollment>, +) -> io::Result<()> { + let Some(state_db) = state_db else { + return Err(io::Error::new( + ErrorKind::NotFound, + format!( + "remote control enrollment persistence unavailable because sqlite state db is disabled: websocket_url={}, account_id={}, app_server_client_name={:?}, has_enrollment={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.is_some() + ), + )); + }; + if let &Some(enrollment) = &enrollment + && enrollment.account_id != account_id + { + return Err(io::Error::other(format!( + "enrollment account_id does not match expected account_id `{account_id}`" + ))); + } + + if let Some(enrollment) = enrollment { + state_db + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: remote_control_target.websocket_url.clone(), + account_id: account_id.to_string(), + app_server_client_name: app_server_client_name.map(str::to_string), + server_id: enrollment.server_id.clone(), + environment_id: enrollment.environment_id.clone(), + server_name: enrollment.server_name.clone(), + }) + .await + .map_err(io::Error::other)?; + info!( + "persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.server_id, + enrollment.environment_id + ); + Ok(()) + } else { + let rows_affected = state_db + .delete_remote_control_enrollment( + &remote_control_target.websocket_url, + account_id, + app_server_client_name, + ) + .await + .map_err(io::Error::other)?; + info!( + "cleared persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, rows_affected={rows_affected}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + Ok(()) + } +} + +pub(crate) fn preview_remote_control_response_body(body: &[u8]) -> String { + let body = String::from_utf8_lossy(body); + let trimmed = body.trim(); + if trimmed.is_empty() { + return "".to_string(); + } + if trimmed.len() <= REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES { + return trimmed.to_string(); + } + + let mut cut = REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES; + while !trimmed.is_char_boundary(cut) { + cut = cut.saturating_sub(1); + } + let mut truncated = trimmed[..cut].to_string(); + truncated.push_str("..."); + truncated +} + +pub(crate) fn format_headers(headers: &HeaderMap) -> String { + let request_id_str = headers + .get(REQUEST_ID_HEADER) + .or_else(|| headers.get(OAI_REQUEST_ID_HEADER)) + .map(|value| value.to_str().unwrap_or("").to_owned()) + .unwrap_or_else(|| "".to_owned()); + let cf_ray_str = headers + .get(CF_RAY_HEADER) + .map(|value| value.to_str().unwrap_or("").to_owned()) + .unwrap_or_else(|| "".to_owned()); + format!("request-id: {request_id_str}, cf-ray: {cf_ray_str}") +} + +pub(super) async fn enroll_remote_control_server( + remote_control_target: &RemoteControlTarget, + auth: &RemoteControlConnectionAuth, +) -> io::Result { + let enroll_url = &remote_control_target.enroll_url; + let server_name = gethostname().to_string_lossy().trim().to_string(); + let request = EnrollRemoteServerRequest { + name: server_name.clone(), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + app_server_version: env!("CARGO_PKG_VERSION"), + }; + let client = build_reqwest_client(); + let mut auth_headers = HeaderMap::new(); + auth.auth_provider.add_auth_headers(&mut auth_headers); + let http_request = client + .post(enroll_url) + .timeout(REMOTE_CONTROL_ENROLL_TIMEOUT) + .headers(auth_headers) + .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .json(&request); + + let response = http_request.send().await.map_err(|err| { + io::Error::other(format!( + "failed to enroll remote control server at `{enroll_url}`: {err}" + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control enrollment response from `{enroll_url}`: {err}" + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let headers_str = format_headers(&headers); + let error_kind = if matches!(status.as_u16(), 401 | 403) { + ErrorKind::PermissionDenied + } else { + ErrorKind::Other + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control server enrollment failed at `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}" + ), + )); + } + + let enrollment = serde_json::from_slice::(&body).map_err(|err| { + let headers_str = format_headers(&headers); + io::Error::other(format!( + "failed to parse remote control enrollment response from `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}" + )) + })?; + + Ok(RemoteControlEnrollment { + account_id: auth.account_id.clone(), + environment_id: enrollment.environment_id, + server_id: enrollment.server_id, + server_name, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::remote_control::protocol::normalize_remote_control_url; + use codex_state::StateRuntime; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::io::BufReader; + use tokio::net::TcpListener; + use tokio::net::TcpStream; + use tokio::time::Duration; + use tokio::time::timeout; + + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") + } + + #[tokio::test] + async fn persisted_remote_control_enrollment_round_trips_by_target_and_account() { + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let first_target = normalize_remote_control_url("https://chatgpt.com/remote/control") + .expect("first target should parse"); + let second_target = + normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") + .expect("second target should parse"); + let first_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_first".to_string(), + server_id: "srv_e_first".to_string(), + server_name: "first-server".to_string(), + }; + let second_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_second".to_string(), + server_id: "srv_e_second".to_string(), + server_name: "second-server".to_string(), + }; + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + Some("desktop-client"), + Some(&first_enrollment), + ) + .await + .expect("first enrollment should persist"); + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + Some("desktop-client"), + Some(&second_enrollment), + ) + .await + .expect("second enrollment should persist"); + + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + Some("desktop-client"), + ) + .await + .expect("first enrollment should load"), + Some(first_enrollment.clone()) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-b", + Some("desktop-client"), + ) + .await + .expect("missing account should load"), + None + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + Some("desktop-client"), + ) + .await + .expect("second enrollment should load"), + Some(second_enrollment) + ); + } + + #[tokio::test] + async fn clearing_persisted_remote_control_enrollment_removes_only_matching_entry() { + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let first_target = normalize_remote_control_url("https://chatgpt.com/remote/control") + .expect("first target should parse"); + let second_target = + normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") + .expect("second target should parse"); + let first_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_first".to_string(), + server_id: "srv_e_first".to_string(), + server_name: "first-server".to_string(), + }; + let second_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_second".to_string(), + server_id: "srv_e_second".to_string(), + server_name: "second-server".to_string(), + }; + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + Some(&first_enrollment), + ) + .await + .expect("first enrollment should persist"); + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + /*app_server_client_name*/ None, + Some(&second_enrollment), + ) + .await + .expect("second enrollment should persist"); + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + /*enrollment*/ None, + ) + .await + .expect("matching enrollment should clear"); + + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + ) + .await + .expect("cleared enrollment should load"), + None + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + /*app_server_client_name*/ None, + ) + .await + .expect("remaining enrollment should load"), + Some(second_enrollment) + ); + } + + #[tokio::test] + async fn enroll_remote_control_server_parse_failure_includes_response_body() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = format!( + "http://127.0.0.1:{}/backend-api/", + listener + .local_addr() + .expect("listener should have a local addr") + .port() + ); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let enroll_url = remote_control_target.enroll_url.clone(); + let response_body = json!({ + "error": "not enrolled", + }); + let expected_body = response_body.to_string(); + let server_task = tokio::spawn(async move { + let stream = accept_http_request(&listener).await; + respond_with_json(stream, response_body).await; + }); + + let err = enroll_remote_control_server( + &remote_control_target, + &RemoteControlConnectionAuth { + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + account_id: "account_id".to_string(), + }, + ) + .await + .expect_err("invalid response should fail to parse"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + err.to_string(), + format!( + "failed to parse remote control enrollment response from `{enroll_url}`: HTTP 200 OK, request-id: , cf-ray: , body: {expected_body}, decode error: missing field `server_id` at line 1 column {}", + expected_body.len() + ) + ); + } + + async fn accept_http_request(listener: &TcpListener) -> TcpStream { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + } + + reader.into_inner() + } + + async fn respond_with_json(mut stream: TcpStream, body: serde_json::Value) { + let body = body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); + } +} diff --git a/code-rs/app-server-transport/src/transport/remote_control/mod.rs b/code-rs/app-server-transport/src/transport/remote_control/mod.rs new file mode 100644 index 00000000000..87405efa4f8 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -0,0 +1,126 @@ +mod client_tracker; +mod enroll; +mod protocol; +mod segment; +mod websocket; + +use crate::transport::remote_control::websocket::RemoteControlChannels; +use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; +use crate::transport::remote_control::websocket::RemoteControlWebsocket; + +pub use self::protocol::ClientId; +use self::protocol::ServerEvent; +use self::protocol::StreamId; +use self::protocol::normalize_remote_control_url; +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::next_connection_id; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_login::AuthManager; +use codex_state::StateRuntime; +use std::io; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +pub(super) struct QueuedServerEnvelope { + pub(super) event: ServerEvent, + pub(super) client_id: ClientId, + pub(super) stream_id: StreamId, + pub(super) write_complete_tx: Option>, +} + +#[derive(Clone)] +pub struct RemoteControlHandle { + enabled_tx: Arc>, + status_tx: Arc>, + state_db_available: bool, +} + +impl RemoteControlHandle { + pub fn set_enabled(&self, enabled: bool) { + let requested_enabled = enabled; + let enabled = enabled && self.state_db_available; + if requested_enabled && !self.state_db_available { + warn!("remote control cannot be enabled because sqlite state db is unavailable"); + } + self.enabled_tx.send_if_modified(|state| { + let changed = *state != enabled; + *state = enabled; + changed + }); + } + + pub fn status_receiver(&self) -> watch::Receiver { + self.status_tx.subscribe() + } +} + +pub async fn start_remote_control( + remote_control_url: String, + state_db: Option>, + auth_manager: Arc, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + app_server_client_name_rx: Option>, + initial_enabled: bool, +) -> io::Result<(JoinHandle<()>, RemoteControlHandle)> { + let state_db_available = state_db.is_some(); + let requested_initial_enabled = initial_enabled; + let initial_enabled = initial_enabled && state_db_available; + if requested_initial_enabled && !state_db_available { + warn!("remote control disabled because sqlite state db is unavailable"); + } + let remote_control_target = if initial_enabled { + Some(normalize_remote_control_url(&remote_control_url)?) + } else { + None + }; + + let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let initial_status = RemoteControlStatusChangedNotification { + status: if initial_enabled { + RemoteControlConnectionStatus::Connecting + } else { + RemoteControlConnectionStatus::Disabled + }, + environment_id: None, + }; + let (status_tx, _status_rx) = watch::channel(initial_status); + let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone()); + let join_handle = tokio::spawn(async move { + RemoteControlWebsocket::new( + remote_control_url, + remote_control_target, + state_db, + auth_manager, + RemoteControlChannels { + transport_event_tx, + status_publisher, + }, + shutdown_token, + enabled_rx, + ) + .run(app_server_client_name_rx) + .await; + }); + + Ok(( + join_handle, + RemoteControlHandle { + enabled_tx: Arc::new(enabled_tx), + status_tx: Arc::new(status_tx), + state_db_available, + }, + )) +} + +#[cfg(test)] +mod segment_tests; +#[cfg(test)] +mod tests; diff --git a/code-rs/app-server-transport/src/transport/remote_control/protocol.rs b/code-rs/app-server-transport/src/transport/remote_control/protocol.rs new file mode 100644 index 00000000000..dea5404ab19 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -0,0 +1,277 @@ +use crate::outgoing_message::OutgoingMessage; +use codex_app_server_protocol::JSONRPCMessage; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::io::ErrorKind; +use url::Host; +use url::Url; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlTarget { + pub(super) websocket_url: String, + pub(super) enroll_url: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct EnrollRemoteServerRequest { + pub(super) name: String, + pub(super) os: &'static str, + pub(super) arch: &'static str, + pub(super) app_server_version: &'static str, +} + +#[derive(Debug, Deserialize)] +pub(super) struct EnrollRemoteServerResponse { + pub(super) server_id: String, + pub(super) environment_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ClientId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StreamId(pub String); + +impl StreamId { + pub fn new_random() -> Self { + Self(uuid::Uuid::now_v7().to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientEvent { + ClientMessage { + message: JSONRPCMessage, + }, + ClientMessageChunk { + segment_id: usize, + segment_count: usize, + message_size_bytes: usize, + message_chunk_base64: String, + }, + /// Backend-generated acknowledgement for all server envelopes addressed to + /// `client_id` and `stream_id` whose envelope `seq_id` is less than or equal + /// to this ack's `seq_id`. Chunk acknowledgements carry `segment_id` so the + /// sender can retain only the still-unacked wire chunks on reconnect. + Ack { + #[serde(skip_serializing_if = "Option::is_none")] + segment_id: Option, + }, + Ping, + ClientClosed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) struct ClientEnvelope { + #[serde(flatten)] + pub(crate) event: ClientEvent, + #[serde(rename = "client_id")] + pub(crate) client_id: ClientId, + #[serde(rename = "stream_id", skip_serializing_if = "Option::is_none")] + pub(crate) stream_id: Option, + /// For `Ack`, this is the backend-generated per-stream cursor over + /// `ServerEnvelope.seq_id`. + #[serde(rename = "seq_id", skip_serializing_if = "Option::is_none")] + pub(crate) seq_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PongStatus { + Active, + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerEvent { + ServerMessage { + message: Box, + }, + ServerMessageChunk { + segment_id: usize, + segment_count: usize, + message_size_bytes: usize, + message_chunk_base64: String, + }, + #[allow(dead_code)] + Ack, + Pong { + status: PongStatus, + }, +} + +impl ServerEvent { + pub(crate) fn segment_id(&self) -> Option { + match self { + Self::ServerMessageChunk { segment_id, .. } => Some(*segment_id), + Self::ServerMessage { .. } | Self::Ack | Self::Pong { .. } => None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) struct ServerEnvelope { + #[serde(flatten)] + pub(crate) event: ServerEvent, + #[serde(rename = "client_id")] + pub(crate) client_id: ClientId, + #[serde(rename = "stream_id")] + pub(crate) stream_id: StreamId, + #[serde(rename = "seq_id")] + pub(crate) seq_id: u64, +} + +fn is_allowed_remote_control_chatgpt_host(host: &Option>) -> bool { + let Some(Host::Domain(host)) = *host else { + return false; + }; + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + +fn is_localhost(host: &Option>) -> bool { + match host { + Some(Host::Domain("localhost")) => true, + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + _ => false, + } +} + +pub(super) fn normalize_remote_control_url( + remote_control_url: &str, +) -> io::Result { + let map_url_parse_error = |err: url::ParseError| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`: {err}"), + ) + }; + let map_scheme_error = |_: ()| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!( + "invalid remote control URL `{remote_control_url}`; expected HTTPS URL for chatgpt.com or chatgpt-staging.com, or HTTP/HTTPS URL for localhost" + ), + ) + }; + + let mut remote_control_url = Url::parse(remote_control_url).map_err(map_url_parse_error)?; + if !remote_control_url.path().ends_with('/') { + let normalized_path = format!("{}/", remote_control_url.path()); + remote_control_url.set_path(&normalized_path); + } + + let enroll_url = remote_control_url + .join("wham/remote/control/server/enroll") + .map_err(map_url_parse_error)?; + let mut websocket_url = remote_control_url + .join("wham/remote/control/server") + .map_err(map_url_parse_error)?; + let host = enroll_url.host(); + match enroll_url.scheme() { + "https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => { + websocket_url.set_scheme("wss").map_err(map_scheme_error)?; + } + "http" if is_localhost(&host) => { + websocket_url.set_scheme("ws").map_err(map_scheme_error)?; + } + _ => return Err(map_scheme_error(())), + } + + Ok(RemoteControlTarget { + websocket_url: websocket_url.to_string(), + enroll_url: enroll_url.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn normalize_remote_control_url_accepts_chatgpt_https_urls() { + assert_eq!( + normalize_remote_control_url("https://chatgpt.com/backend-api") + .expect("chatgpt.com URL should normalize"), + RemoteControlTarget { + websocket_url: "wss://chatgpt.com/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "https://chatgpt.com/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + assert_eq!( + normalize_remote_control_url("https://api.chatgpt-staging.com/backend-api") + .expect("chatgpt-staging.com subdomain URL should normalize"), + RemoteControlTarget { + websocket_url: + "wss://api.chatgpt-staging.com/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + } + + #[test] + fn normalize_remote_control_url_accepts_localhost_urls() { + assert_eq!( + normalize_remote_control_url("http://localhost:8080/backend-api") + .expect("localhost http URL should normalize"), + RemoteControlTarget { + websocket_url: "ws://localhost:8080/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "http://localhost:8080/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + assert_eq!( + normalize_remote_control_url("https://localhost:8443/backend-api") + .expect("localhost https URL should normalize"), + RemoteControlTarget { + websocket_url: "wss://localhost:8443/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "https://localhost:8443/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + } + + #[test] + fn normalize_remote_control_url_rejects_unsupported_urls() { + for remote_control_url in [ + "http://chatgpt.com/backend-api", + "http://example.com/backend-api", + "https://example.com/backend-api", + "https://chat.openai.com/backend-api", + "https://chatgpt.com.evil.com/backend-api", + "https://evilchatgpt.com/backend-api", + "https://foo.localhost/backend-api", + ] { + let err = normalize_remote_control_url(remote_control_url) + .expect_err("unsupported URL should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + format!( + "invalid remote control URL `{remote_control_url}`; expected HTTPS URL for chatgpt.com or chatgpt-staging.com, or HTTP/HTTPS URL for localhost" + ) + ); + } + } +} diff --git a/code-rs/app-server-transport/src/transport/remote_control/segment.rs b/code-rs/app-server-transport/src/transport/remote_control/segment.rs new file mode 100644 index 00000000000..ab0d23a8818 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/segment.rs @@ -0,0 +1,449 @@ +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::ServerEnvelope; +use super::protocol::ServerEvent; +use super::protocol::StreamId; +use base64::DecodeSliceError; +use base64::Engine; +use codex_app_server_protocol::JSONRPCMessage; +use std::collections::HashMap; +use std::io; +use std::io::ErrorKind; +use std::io::Write; +use tokio::time::Instant; +use tracing::warn; + +pub(super) const REMOTE_CONTROL_SEGMENT_TARGET_BYTES: usize = 100 * 1024; +pub(super) const REMOTE_CONTROL_SEGMENT_MAX_BYTES: usize = 150 * 1024; +pub(super) const REMOTE_CONTROL_REASSEMBLED_MAX_BYTES: usize = 100 * 1024 * 1024; +pub(super) const REMOTE_CONTROL_SEGMENT_COUNT_MAX: usize = 1024; +const REMOTE_CONTROL_SEGMENT_ASSEMBLY_MAX_COUNT: usize = 128; + +#[derive(Debug)] +struct ClientSegmentAssembly { + stream_id: StreamId, + metadata: ClientSegmentMetadata, + raw: Vec, + next_segment_id: usize, + last_chunk_seen_at: Instant, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClientSegmentMetadata { + seq_id: u64, + segment_count: usize, + message_size_bytes: usize, +} + +#[derive(Default)] +pub(super) struct ClientSegmentReassembler { + assemblies: HashMap, +} + +pub(super) enum ClientSegmentObservation { + Forward(Box), + Pending, + Dropped, +} + +impl ClientSegmentReassembler { + pub(super) fn observe(&mut self, envelope: ClientEnvelope) -> ClientSegmentObservation { + let ClientEvent::ClientMessageChunk { + segment_id, + segment_count, + message_size_bytes, + message_chunk_base64, + } = &envelope.event + else { + return ClientSegmentObservation::Forward(Box::new(envelope)); + }; + let segment_id = *segment_id; + let segment_count = *segment_count; + let message_size_bytes = *message_size_bytes; + + let Some(metadata) = ClientSegmentMetadata::from_envelope(&envelope) else { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping segmented remote-control client envelope without seq_id" + ); + return ClientSegmentObservation::Dropped; + }; + let Some(stream_id) = envelope.stream_id.clone() else { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping segmented remote-control client envelope without stream_id" + ); + return ClientSegmentObservation::Dropped; + }; + if self.should_ignore_chunk(&envelope.client_id, &stream_id, metadata.seq_id, segment_id) { + return ClientSegmentObservation::Dropped; + } + if segment_count == 0 + || segment_count > REMOTE_CONTROL_SEGMENT_COUNT_MAX + || segment_id >= segment_count + || message_size_bytes == 0 + || message_size_bytes > REMOTE_CONTROL_REASSEMBLED_MAX_BYTES + || message_chunk_base64.is_empty() + { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping invalid segmented remote-control client envelope" + ); + self.remove_assembly(&envelope.client_id, &stream_id); + return ClientSegmentObservation::Dropped; + } + + let now = Instant::now(); + match self.assemblies.get(&envelope.client_id) { + Some(assembly) if assembly.stream_id != stream_id => { + warn!( + client_id = envelope.client_id.0.as_str(), + "resetting segmented remote-control client envelope after stream change" + ); + self.assemblies.insert( + envelope.client_id.clone(), + ClientSegmentAssembly { + stream_id: stream_id.clone(), + metadata: metadata.clone(), + raw: Vec::new(), + next_segment_id: 0, + last_chunk_seen_at: now, + }, + ); + } + Some(_) => {} + None => { + self.evict_assemblies_if_full(); + self.assemblies.insert( + envelope.client_id.clone(), + ClientSegmentAssembly { + stream_id: stream_id.clone(), + metadata: metadata.clone(), + raw: Vec::new(), + next_segment_id: 0, + last_chunk_seen_at: now, + }, + ); + } + } + let result = { + let Some(assembly) = self.assemblies.get_mut(&envelope.client_id) else { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping segmented remote-control client envelope without assembly" + ); + return ClientSegmentObservation::Dropped; + }; + if metadata.seq_id < assembly.metadata.seq_id { + AssemblyUpdate::Ignore + } else if assembly.metadata != metadata { + warn!( + client_id = envelope.client_id.0.as_str(), + "resetting segmented remote-control client envelope after metadata mismatch" + ); + AssemblyUpdate::Drop + } else if segment_id < assembly.next_segment_id { + AssemblyUpdate::Pending + } else if segment_id != assembly.next_segment_id { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping out-of-order segmented remote-control client envelope" + ); + AssemblyUpdate::Drop + } else { + assembly.last_chunk_seen_at = now; + let chunk_start = assembly.raw.len(); + let decoded_chunk_len = base64::decoded_len_estimate(message_chunk_base64.len()); + let chunk_end = usize::min( + message_size_bytes, + chunk_start.saturating_add(decoded_chunk_len), + ); + assembly.raw.resize(chunk_end, 0); + match base64::engine::general_purpose::STANDARD.decode_slice( + message_chunk_base64.as_bytes(), + &mut assembly.raw[chunk_start..], + ) { + Ok(decoded_chunk_len) => { + assembly.raw.truncate(chunk_start + decoded_chunk_len); + assembly.next_segment_id += 1; + if assembly.next_segment_id < segment_count { + AssemblyUpdate::Pending + } else if assembly.raw.len() != message_size_bytes { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping reassembled remote-control client envelope with mismatched size" + ); + AssemblyUpdate::Drop + } else { + match serde_json::from_slice::(&assembly.raw) { + Ok(message) => AssemblyUpdate::Complete(message), + Err(err) => { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping invalid reassembled remote-control client envelope: {err}" + ); + AssemblyUpdate::Drop + } + } + } + } + Err(DecodeSliceError::OutputSliceTooSmall) => { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping segmented remote-control client envelope after size overflow" + ); + AssemblyUpdate::Drop + } + Err(err) => { + warn!( + client_id = envelope.client_id.0.as_str(), + "dropping segmented remote-control client envelope with invalid base64: {err}" + ); + AssemblyUpdate::Drop + } + } + } + }; + + match result { + AssemblyUpdate::Pending => ClientSegmentObservation::Pending, + AssemblyUpdate::Ignore => ClientSegmentObservation::Dropped, + AssemblyUpdate::Drop => { + self.remove_assembly(&envelope.client_id, &stream_id); + ClientSegmentObservation::Dropped + } + AssemblyUpdate::Complete(message) => { + self.remove_assembly(&envelope.client_id, &stream_id); + ClientSegmentObservation::Forward(Box::new(ClientEnvelope { + event: ClientEvent::ClientMessage { message }, + ..envelope + })) + } + } + } + + pub(super) fn invalidate_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) { + self.remove_assembly(client_id, stream_id); + } + + pub(super) fn invalidate_client(&mut self, client_id: &ClientId) { + self.assemblies.remove(client_id); + } + + pub(super) fn should_ignore_chunk( + &self, + client_id: &ClientId, + stream_id: &StreamId, + seq_id: u64, + segment_id: usize, + ) -> bool { + self.assemblies.get(client_id).is_some_and(|assembly| { + assembly.stream_id == *stream_id + && (seq_id < assembly.metadata.seq_id + || (seq_id == assembly.metadata.seq_id + && segment_id < assembly.next_segment_id)) + }) + } + + fn remove_assembly(&mut self, client_id: &ClientId, stream_id: &StreamId) { + if self + .assemblies + .get(client_id) + .is_some_and(|assembly| &assembly.stream_id == stream_id) + { + self.assemblies.remove(client_id); + } + } + + fn evict_assemblies_if_full(&mut self) { + while self.assemblies.len() >= REMOTE_CONTROL_SEGMENT_ASSEMBLY_MAX_COUNT { + let Some(client_id) = self + .assemblies + .iter() + .min_by_key(|(_, assembly)| assembly.last_chunk_seen_at) + .map(|(client_id, _)| client_id.clone()) + else { + return; + }; + self.assemblies.remove(&client_id); + } + } +} + +enum AssemblyUpdate { + Pending, + Ignore, + Drop, + Complete(JSONRPCMessage), +} + +impl ClientSegmentMetadata { + fn from_envelope(envelope: &ClientEnvelope) -> Option { + let ClientEvent::ClientMessageChunk { + segment_count, + message_size_bytes, + .. + } = &envelope.event + else { + return None; + }; + Some(Self { + seq_id: envelope.seq_id?, + segment_count: *segment_count, + message_size_bytes: *message_size_bytes, + }) + } +} + +pub(super) fn split_server_envelope_for_transport( + envelope: ServerEnvelope, +) -> io::Result> { + if !matches!(envelope.event, ServerEvent::ServerMessage { .. }) { + return Ok(vec![envelope]); + } + + let envelope_size_bytes = serialized_len(&envelope)?; + if envelope_size_bytes <= REMOTE_CONTROL_SEGMENT_MAX_BYTES { + return Ok(vec![envelope]); + } + + let ServerEvent::ServerMessage { message } = envelope.event.clone() else { + unreachable!("server message variant checked above"); + }; + let raw = serde_json::to_vec(message.as_ref()).map_err(io::Error::other)?; + let message_size_bytes = raw.len(); + if message_size_bytes > REMOTE_CONTROL_REASSEMBLED_MAX_BYTES { + warn!("dropping remote-control server envelope that exceeds reassembled size limit"); + return Ok(Vec::new()); + } + + let minimal_segment_count = + usize::min(message_size_bytes.max(1), REMOTE_CONTROL_SEGMENT_COUNT_MAX); + let minimal_chunk = &raw[..usize::min(raw.len(), 1)]; + if serialized_chunk_len( + &envelope, + /*segment_id*/ 0, + minimal_segment_count, + message_size_bytes, + minimal_chunk, + )? > REMOTE_CONTROL_SEGMENT_MAX_BYTES + { + warn!("dropping remote-control server envelope that cannot fit within segment size limit"); + return Ok(Vec::new()); + } + + let mut segment_count = usize::max( + 2, + message_size_bytes.div_ceil(REMOTE_CONTROL_SEGMENT_TARGET_BYTES), + ); + loop { + let chunk_size = usize::max(1, message_size_bytes.div_ceil(segment_count)); + segment_count = message_size_bytes.div_ceil(chunk_size); + let segments_fit = raw + .chunks(chunk_size) + .enumerate() + .all(|(segment_id, chunk)| { + serialized_chunk_len( + &envelope, + segment_id, + segment_count, + message_size_bytes, + chunk, + ) + .is_ok_and(|size| size <= REMOTE_CONTROL_SEGMENT_MAX_BYTES) + }); + if segments_fit { + return raw + .chunks(chunk_size) + .enumerate() + .map(|(segment_id, chunk)| { + build_chunk_envelope( + &envelope, + segment_id, + segment_count, + message_size_bytes, + chunk, + ) + }) + .collect(); + } + if chunk_size == 1 { + warn!( + "dropping remote-control server envelope that cannot fit within segment size limit" + ); + return Ok(Vec::new()); + } + let next_segment_count = segment_count + 1; + let next_chunk_size = usize::max(1, message_size_bytes.div_ceil(next_segment_count)); + segment_count = if next_chunk_size == chunk_size { + message_size_bytes + } else { + next_segment_count + }; + } +} + +fn serialized_chunk_len( + envelope: &ServerEnvelope, + segment_id: usize, + segment_count: usize, + message_size_bytes: usize, + chunk: &[u8], +) -> io::Result { + serialized_len(&build_chunk_envelope( + envelope, + segment_id, + segment_count, + message_size_bytes, + chunk, + )?) +} + +#[derive(Default)] +struct CountingWriter { + len: usize, +} + +impl Write for CountingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.len += buf.len(); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn serialized_len(value: &impl serde::Serialize) -> io::Result { + let mut writer = CountingWriter::default(); + serde_json::to_writer(&mut writer, value).map_err(io::Error::other)?; + Ok(writer.len) +} + +fn build_chunk_envelope( + envelope: &ServerEnvelope, + segment_id: usize, + segment_count: usize, + message_size_bytes: usize, + chunk: &[u8], +) -> io::Result { + if segment_count > REMOTE_CONTROL_SEGMENT_COUNT_MAX { + return Err(io::Error::new( + ErrorKind::InvalidData, + "remote-control segment count exceeds maximum", + )); + } + Ok(ServerEnvelope { + event: ServerEvent::ServerMessageChunk { + segment_id, + segment_count, + message_size_bytes, + message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk), + }, + client_id: envelope.client_id.clone(), + stream_id: envelope.stream_id.clone(), + seq_id: envelope.seq_id, + }) +} diff --git a/code-rs/app-server-transport/src/transport/remote_control/segment_tests.rs b/code-rs/app-server-transport/src/transport/remote_control/segment_tests.rs new file mode 100644 index 00000000000..dc15bdf8ba1 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/segment_tests.rs @@ -0,0 +1,386 @@ +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::ServerEnvelope; +use super::protocol::ServerEvent; +use super::protocol::StreamId; +use super::segment::ClientSegmentObservation; +use super::segment::ClientSegmentReassembler; +use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; +use super::segment::split_server_envelope_for_transport; +use crate::outgoing_message::OutgoingMessage; +use base64::Engine; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::ServerNotification; +use pretty_assertions::assert_eq; + +#[test] +fn reassembles_client_message_chunks() { + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let client_id = ClientId("client-1".to_string()); + let stream_id = Some(StreamId("stream-1".to_string())); + let mut reassembler = ClientSegmentReassembler::default(); + + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id.clone(), + /*seq_id*/ 7, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Pending + )); + let reassembled = match reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id, + /*seq_id*/ 7, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + )) { + ClientSegmentObservation::Forward(reassembled) => *reassembled, + ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => { + panic!("message should reassemble") + } + }; + assert_eq!(reassembled.client_id, client_id); + assert_eq!( + reassembled.stream_id, + Some(StreamId("stream-1".to_string())) + ); + assert_eq!(reassembled.seq_id, Some(7)); + assert_eq!(reassembled.cursor, None); + match reassembled.event { + ClientEvent::ClientMessage { + message: reassembled_message, + } => assert_eq!(reassembled_message, message), + other => panic!("expected client message, got {other:?}"), + } +} + +#[test] +fn splits_large_server_messages_into_wire_chunks() { + let envelope = ServerEnvelope { + event: ServerEvent::ServerMessage { + message: Box::new(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "x".repeat(REMOTE_CONTROL_SEGMENT_MAX_BYTES), + details: None, + path: None, + range: None, + }), + )), + }, + client_id: ClientId("client-1".to_string()), + stream_id: StreamId("stream-1".to_string()), + seq_id: 9, + }; + + let segments = split_server_envelope_for_transport(envelope).expect("split should succeed"); + + assert!(segments.len() > 1); + assert!( + segments + .iter() + .all(|segment| matches!(segment.event, ServerEvent::ServerMessageChunk { .. })) + ); + assert!(segments.iter().all(|segment| segment.seq_id == 9)); + assert!(segments.iter().all(|segment| { + serde_json::to_vec(segment) + .expect("segment should serialize") + .len() + <= REMOTE_CONTROL_SEGMENT_MAX_BYTES + })); +} + +#[test] +fn invalidates_incomplete_stream_assemblies() { + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let client_id = ClientId("client-1".to_string()); + let stream_id = StreamId("stream-1".to_string()); + let mut reassembler = ClientSegmentReassembler::default(); + + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + Some(stream_id.clone()), + /*seq_id*/ 7, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Pending + )); + reassembler.invalidate_stream(&client_id, &stream_id); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id, + Some(stream_id), + /*seq_id*/ 7, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + )), + ClientSegmentObservation::Dropped + )); +} + +#[test] +fn resets_incomplete_client_assembly_when_stream_changes() { + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let client_id = ClientId("client-1".to_string()); + let first_stream_id = StreamId("stream-1".to_string()); + let second_stream_id = StreamId("stream-2".to_string()); + let mut reassembler = ClientSegmentReassembler::default(); + + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + Some(first_stream_id.clone()), + /*seq_id*/ 7, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Pending + )); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + Some(second_stream_id.clone()), + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Pending + )); + let reassembled = match reassembler.observe(chunk_envelope( + client_id.clone(), + Some(second_stream_id), + /*seq_id*/ 8, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + )) { + ClientSegmentObservation::Forward(reassembled) => *reassembled, + ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => { + panic!("replacement stream should reassemble") + } + }; + assert_eq!( + reassembled.stream_id, + Some(StreamId("stream-2".to_string())) + ); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id, + Some(first_stream_id), + /*seq_id*/ 7, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + )), + ClientSegmentObservation::Dropped + )); +} + +#[test] +fn ignores_stale_chunks_without_dropping_newer_assembly() { + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let client_id = ClientId("client-1".to_string()); + let stream_id = Some(StreamId("stream-1".to_string())); + let mut reassembler = ClientSegmentReassembler::default(); + + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id.clone(), + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Pending + )); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id.clone(), + /*seq_id*/ 7, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id, + stream_id, + /*seq_id*/ 8, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + )), + ClientSegmentObservation::Forward(_) + )); +} + +#[test] +fn ignores_invalid_stale_chunks_without_dropping_newer_assembly() { + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let client_id = ClientId("client-1".to_string()); + let stream_id = Some(StreamId("stream-1".to_string())); + let mut reassembler = ClientSegmentReassembler::default(); + + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id.clone(), + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Pending + )); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id.clone(), + /*seq_id*/ 7, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + b"", + )), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id, + stream_id, + /*seq_id*/ 8, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + )), + ClientSegmentObservation::Forward(_) + )); +} + +#[test] +fn ignores_invalid_duplicate_chunks_without_dropping_current_assembly() { + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let client_id = ClientId("client-1".to_string()); + let stream_id = Some(StreamId("stream-1".to_string())); + let mut reassembler = ClientSegmentReassembler::default(); + + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id.clone(), + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + )), + ClientSegmentObservation::Pending + )); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id.clone(), + stream_id.clone(), + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + b"", + )), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + reassembler.observe(chunk_envelope( + client_id, + stream_id, + /*seq_id*/ 8, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + )), + ClientSegmentObservation::Forward(_) + )); +} + +fn chunk_envelope( + client_id: ClientId, + stream_id: Option, + seq_id: u64, + segment_id: usize, + segment_count: usize, + message_size_bytes: usize, + chunk: &[u8], +) -> ClientEnvelope { + ClientEnvelope { + event: ClientEvent::ClientMessageChunk { + segment_id, + segment_count, + message_size_bytes, + message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk), + }, + client_id, + stream_id, + seq_id: Some(seq_id), + cursor: None, + } +} diff --git a/code-rs/app-server-transport/src/transport/remote_control/tests.rs b/code-rs/app-server-transport/src/transport/remote_control/tests.rs new file mode 100644 index 00000000000..5fd3caa401b --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -0,0 +1,1625 @@ +use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::RemoteControlEnrollment; +use super::enroll::load_persisted_remote_control_enrollment; +use super::enroll::update_persisted_remote_control_enrollment; +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::StreamId; +use super::protocol::normalize_remote_control_url; +use super::websocket::REMOTE_CONTROL_PROTOCOL_VERSION; +use super::*; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::QueuedOutgoingMessage; +use crate::transport::CHANNEL_CAPACITY; +use crate::transport::ConnectionOrigin; +use crate::transport::TransportEvent; +use base64::Engine; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_app_server_protocol::ServerNotification; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::test_support::auth_manager_from_auth; +use codex_core::test_support::auth_manager_from_auth_with_home; +use codex_login::AuthDotJson; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::save_auth; +use codex_login::token_data::TokenData; +use codex_login::token_data::parse_chatgpt_jwt_claims; +use codex_state::StateRuntime; +use futures::SinkExt; +use futures::StreamExt; +use gethostname::gethostname; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio::time::Duration; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::accept_hdr_async; +use tokio_tungstenite::tungstenite; +use tokio_util::sync::CancellationToken; + +fn remote_control_auth_manager() -> Arc { + auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) +} + +fn remote_control_auth_manager_with_home(codex_home: &TempDir) -> Arc { + auth_manager_from_auth_with_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_home.path().to_path_buf(), + ) +} + +fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { + #[derive(serde::Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + "chatgpt_account_id": "account_id" + } + }); + let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = b64(&serde_json::to_vec(&header).expect("header should serialize")); + let payload_b64 = b64(&serde_json::to_vec(&payload).expect("payload should serialize")); + let fake_jwt = format!("{header_b64}.{payload_b64}.sig"); + + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: parse_chatgpt_jwt_claims(&fake_jwt).expect("fake jwt should parse"), + access_token: "Access Token".to_string(), + refresh_token: "refresh-token".to_string(), + account_id: account_id.map(str::to_string), + }), + last_refresh: Some(chrono::Utc::now()), + agent_identity: None, + } +} + +async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") +} + +fn remote_control_url_for_listener(listener: &TcpListener) -> String { + let addr = listener + .local_addr() + .expect("listener should have a local addr"); + format!("http://{addr}/backend-api/") +} + +async fn expect_remote_control_status( + status_rx: &mut watch::Receiver, + expected_status: Option, + expected_environment_id: Option<&str>, +) { + timeout(Duration::from_secs(5), status_rx.changed()) + .await + .expect("remote control status event should arrive in time") + .expect("remote control status watch should remain open"); + let status = status_rx.borrow(); + if let Some(expected_status) = expected_status { + assert_eq!(status.status, expected_status); + } + assert_eq!(status.environment_id.as_deref(), expected_environment_id); +} + +async fn expect_remote_control_status_snapshot( + status_rx: &mut watch::Receiver, + expected_status: RemoteControlStatusChangedNotification, +) { + if *status_rx.borrow() == expected_status { + return; + } + + let expected_status_for_wait = expected_status.clone(); + let result = timeout(Duration::from_secs(5), async { + loop { + status_rx + .changed() + .await + .expect("remote control status watch should remain open"); + if *status_rx.borrow() == expected_status_for_wait { + return; + } + } + }) + .await; + assert!( + result.is_ok(), + "remote control status snapshot should arrive in time; expected {expected_status:?}, latest {:?}", + status_rx.borrow().clone() + ); +} + +#[tokio::test] +async fn remote_control_transport_manages_virtual_clients_and_routes_messages() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut websocket = accept_remote_control_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_test"), + ) + .await; + + let client_id = ClientId("client-1".to_string()); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "unknown", + }) + ); + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Notification( + codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }, + ), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + assert!( + timeout(Duration::from_millis(100), transport_event_rx.recv()) + .await + .is_err(), + "non-initialize client messages should be ignored before connection creation" + ); + + let initialize_message = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message.clone(), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(1), + cursor: None, + }, + ) + .await; + + let (connection_id, writer) = match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { + connection_id, + origin, + writer, + .. + } => { + assert_eq!(origin, ConnectionOrigin::RemoteControl); + (connection_id, writer) + } + other => panic!("expected connection open event, got {other:?}"), + }; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, initialize_message); + } + other => panic!("expected initialize incoming message, got {other:?}"), + } + + let followup_message = + JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: followup_message.clone(), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(2), + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("followup message should arrive in time") + .expect("followup message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, followup_message); + } + other => panic!("expected followup incoming message, got {other:?}"), + } + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "active", + }) + ); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": "client-1", + "seq_id": 2, + "message": { + "method": "configWarning", + "params": { + "summary": "test", + "details": null, + } + } + }) + ); + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientClosed, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection close should arrive in time") + .expect("connection close should exist") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => { + assert_eq!(closed_connection_id, connection_id); + } + other => panic!("expected connection close event, got {other:?}"), + } + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id, + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "unknown", + }) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_transport_reconnects_after_disconnect() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + first_websocket + .close(None) + .await + .expect("first websocket should close"); + drop(first_websocket); + + let mut second_websocket = accept_remote_control_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_test"), + ) + .await; + send_client_event( + &mut second_websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(2), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }), + }, + client_id: ClientId("client-2".to_string()), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("reconnected initialize should arrive in time") + .expect("reconnected initialize should exist") + { + TransportEvent::ConnectionOpened { .. } => {} + other => panic!("expected connection open after reconnect, got {other:?}"), + } + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() { + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + "https://internal.example.com/backend-api/".to_string(), + /*state_db*/ None, + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ false, + ) + .await + .expect("disabled remote control should not validate the URL at startup"); + + shutdown_token.cancel(); + timeout(Duration::from_secs(1), remote_task) + .await + .expect("remote control task should stop") + .expect("remote control task should join"); +} + +#[tokio::test] +async fn remote_control_start_allows_missing_auth_when_enabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + auth_manager, + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start before ChatGPT auth is available"); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should wait for auth before connecting"); + + shutdown_token.cancel(); + timeout(Duration::from_secs(1), remote_task) + .await + .expect("remote control task should stop") + .expect("remote control task should join"); +} + +#[tokio::test] +async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + /*state_db*/ None, + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start disabled without sqlite state db"); + let mut status_rx = remote_handle.status_receiver(); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + environment_id: None, + } + ); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should not connect without sqlite state db"); + + remote_handle.set_enabled(/*enabled*/ true); + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should remain disabled without sqlite state db"); + timeout(Duration::from_millis(20), status_rx.changed()) + .await + .expect_err("status should remain disabled without sqlite state db"); + + shutdown_token.cancel(); + timeout(Duration::from_secs(1), remote_task) + .await + .expect("remote control task should stop") + .expect("remote control task should join"); +} + +#[tokio::test] +async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + expect_remote_control_status_snapshot( + &mut status_rx, + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connected, + environment_id: Some("env_test".to_string()), + }, + ) + .await; + + remote_handle.set_enabled(/*enabled*/ false); + expect_remote_control_status_snapshot( + &mut status_rx, + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + environment_id: None, + }, + ) + .await; + timeout(Duration::from_secs(1), first_websocket.next()) + .await + .expect("disabling remote control should close the websocket"); + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("disabled remote control should not reconnect"); + + remote_handle.set_enabled(/*enabled*/ true); + expect_remote_control_status_snapshot( + &mut status_rx, + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + environment_id: Some("env_test".to_string()), + }, + ) + .await; + let mut second_websocket = accept_remote_control_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_test"), + ) + .await; + second_websocket + .close(None) + .await + .expect("second websocket should close"); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let enroll_request = accept_http_request(&listener).await; + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_test"), + ) + .await; + + let client_id = ClientId("client-1".to_string()); + let initialize_message = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message, + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + let writer = match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { writer, .. } => writer, + other => panic!("expected connection open event, got {other:?}"), + }; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { .. } => {} + other => panic!("expected initialize incoming message, got {other:?}"), + } + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "stale".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + let (server_event, stream_id) = read_server_event_with_stream_id(&mut first_websocket).await; + assert_eq!( + server_event, + json!({ + "type": "server_message", + "client_id": "client-1", + "seq_id": 1, + "message": { + "method": "configWarning", + "params": { + "summary": "stale", + "details": null, + } + } + }) + ); + + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::Ack { segment_id: None }, + client_id: client_id.clone(), + stream_id: Some(stream_id), + seq_id: Some(1), + cursor: None, + }, + ) + .await; + + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::ClientClosed, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection close should arrive in time") + .expect("connection close should exist") + { + TransportEvent::ConnectionClosed { .. } => {} + other => panic!("expected connection close event, got {other:?}"), + } + + first_websocket + .close(None) + .await + .expect("first websocket should close"); + drop(first_websocket); + + let mut second_websocket = accept_remote_control_connection(&listener).await; + send_client_event( + &mut second_websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id, + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut second_websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "unknown", + }) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_http_mode_enrolls_before_connecting() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + assert_eq!( + enroll_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + enroll_request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + assert_eq!( + serde_json::from_str::(&enroll_request.body) + .expect("enroll body should deserialize"), + json!({ + "name": expected_server_name, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "app_server_version": env!("CARGO_PKG_VERSION"), + }) + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + + let (handshake_request, mut websocket) = + accept_remote_control_backend_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_test"), + ) + .await; + assert_eq!( + handshake_request.path, + "/backend-api/wham/remote/control/server" + ); + assert_eq!( + handshake_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + handshake_request + .headers + .get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&"srv_e_test".to_string()) + ); + assert_eq!( + handshake_request.headers.get("x-codex-name"), + Some(&base64::engine::general_purpose::STANDARD.encode(&expected_server_name)) + ); + assert_eq!( + handshake_request.headers.get("x-codex-protocol-version"), + Some(&REMOTE_CONTROL_PROTOCOL_VERSION.to_string()) + ); + + let backend_client_id = ClientId("backend-test-client".to_string()); + let writer = { + let initialize_message = + JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(11), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-backend-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message.clone(), + }, + client_id: backend_client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + let (connection_id, writer) = + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { + connection_id, + writer, + .. + } => (connection_id, writer), + other => panic!("expected connection open event, got {other:?}"), + }; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, initialize_message); + } + other => panic!("expected initialize incoming message, got {other:?}"), + } + writer + }; + + writer + .send(QueuedOutgoingMessage::new(OutgoingMessage::Response( + crate::outgoing_message::OutgoingResponse { + id: codex_app_server_protocol::RequestId::Integer(11), + result: json!({ + "userAgent": "codex-test-agent" + }), + }, + ))) + .await + .expect("remote writer should accept initialize response"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": backend_client_id.0.clone(), + "seq_id": 1, + "message": { + "id": 11, + "result": { + "userAgent": "codex-test-agent", + } + } + }) + ); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "backend".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": backend_client_id.0.clone(), + "seq_id": 2, + "message": { + "method": "configWarning", + "params": { + "summary": "backend", + "details": null, + } + } + }) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let persisted_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_persisted".to_string(), + server_id: "srv_e_persisted".to_string(), + server_name: "persisted-server".to_string(), + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&persisted_enrollment), + ) + .await + .expect("persisted enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.path, + "/backend-api/wham/remote/control/server" + ); + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&persisted_enrollment.server_id) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("persisted enrollment should load"), + Some(persisted_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let app_server_client_name = "stdio-client"; + let persisted_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_persisted".to_string(), + server_id: "srv_e_persisted".to_string(), + server_name: "persisted-server".to_string(), + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + Some(app_server_client_name), + Some(&persisted_enrollment), + ) + .await + .expect("persisted enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let (app_server_client_name_tx, app_server_client_name_rx) = oneshot::channel::(); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + Some(app_server_client_name_rx), + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should wait for the stdio client name"); + + let _ = app_server_client_name_tx.send(app_server_client_name.to_string()); + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&persisted_enrollment.server_id) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_waits_for_account_id_before_enrolling() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(/*account_id*/ None), + AuthCredentialsStoreMode::File, + ) + .expect("auth without account id should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let expected_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_ready".to_string(), + server_id: "srv_e_ready".to_string(), + server_name: expected_server_name, + }; + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + auth_manager, + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start before account id is available"); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should wait for account id before enrolling"); + + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(Some("account_id")), + AuthCredentialsStoreMode::File, + ) + .expect("auth with account id should save"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ + "server_id": expected_enrollment.server_id, + "environment_id": expected_enrollment.environment_id, + }), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&expected_enrollment.server_id) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let stale_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_stale".to_string(), + server_id: "srv_e_stale".to_string(), + server_name: "stale-server".to_string(), + }; + let refreshed_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: expected_server_name, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let websocket_request = accept_http_request(&listener).await; + assert_eq!( + websocket_request.request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + assert_eq!( + websocket_request.headers.get("x-codex-server-id"), + Some(&stale_enrollment.server_id) + ); + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_stale"), + ) + .await; + respond_with_status(websocket_request.stream, "404 Not Found", "").await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + /*expected_environment_id*/ None, + ) + .await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ + "server_id": refreshed_enrollment.server_id, + "environment_id": refreshed_enrollment.environment_id, + }), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_refreshed"), + ) + .await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&refreshed_enrollment.server_id) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("refreshed enrollment should load"), + Some(refreshed_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[derive(Debug)] +struct CapturedHttpRequest { + stream: TcpStream, + request_line: String, + headers: BTreeMap, + body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CapturedWebSocketRequest { + path: String, + headers: BTreeMap, +} + +async fn accept_remote_control_connection(listener: &TcpListener) -> WebSocketStream { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("remote control should connect in time") + .expect("listener accept should succeed"); + accept_async(stream) + .await + .expect("websocket handshake should succeed") +} + +async fn accept_http_request(listener: &TcpListener) -> CapturedHttpRequest { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + let request_line = request_line.trim_end_matches("\r\n").to_string(); + + let mut headers = BTreeMap::new(); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + let line = line.trim_end_matches("\r\n"); + let (name, value) = line.split_once(':').expect("header should contain colon"); + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let mut body = vec![0; content_length]; + reader + .read_exact(&mut body) + .await + .expect("request body should read"); + + CapturedHttpRequest { + stream: reader.into_inner(), + request_line, + headers, + body: String::from_utf8(body).expect("body should be utf-8"), + } +} + +async fn respond_with_json(mut stream: TcpStream, body: serde_json::Value) { + let body = body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); +} + +async fn respond_with_status(stream: TcpStream, status: &str, body: &str) { + respond_with_status_and_headers(stream, status, &[], body).await; +} + +async fn respond_with_status_and_headers( + mut stream: TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &str, +) { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n{body}", + body.len(), + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); +} + +async fn accept_remote_control_backend_connection( + listener: &TcpListener, +) -> (CapturedWebSocketRequest, WebSocketStream) { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("websocket request should arrive in time") + .expect("listener accept should succeed"); + let captured_request = Arc::new(std::sync::Mutex::new(None::)); + let captured_request_for_callback = captured_request.clone(); + let websocket = accept_hdr_async( + stream, + move |request: &tungstenite::handshake::server::Request, + response: tungstenite::handshake::server::Response| { + let headers = request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + value + .to_str() + .expect("header should be valid utf-8") + .to_string(), + ) + }) + .collect::>(); + *captured_request_for_callback + .lock() + .expect("capture lock should acquire") = Some(CapturedWebSocketRequest { + path: request.uri().path().to_string(), + headers, + }); + Ok(response) + }, + ) + .await + .expect("websocket handshake should succeed"); + let captured_request = captured_request + .lock() + .expect("capture lock should acquire") + .clone() + .expect("websocket request should be captured"); + (captured_request, websocket) +} + +async fn send_client_event( + websocket: &mut WebSocketStream, + client_envelope: ClientEnvelope, +) { + let payload = serde_json::to_string(&client_envelope).expect("client event should serialize"); + websocket + .send(tungstenite::Message::Text(payload.into())) + .await + .expect("client event should send"); +} + +async fn read_server_event(websocket: &mut WebSocketStream) -> serde_json::Value { + read_server_event_with_stream_id(websocket).await.0 +} + +async fn read_server_event_with_stream_id( + websocket: &mut WebSocketStream, +) -> (serde_json::Value, StreamId) { + loop { + let frame = timeout(Duration::from_secs(5), websocket.next()) + .await + .expect("server event should arrive in time") + .expect("websocket should stay open") + .expect("websocket frame should be readable"); + match frame { + tungstenite::Message::Text(text) => { + let mut event: serde_json::Value = + serde_json::from_str(text.as_ref()).expect("server event should deserialize"); + let stream_id = event + .as_object_mut() + .and_then(|event| event.remove("stream_id")) + .expect("stream_id should be present"); + let stream_id = stream_id + .as_str() + .expect("stream_id should be a string") + .to_string(); + return (event, StreamId(stream_id)); + } + tungstenite::Message::Ping(payload) => { + websocket + .send(tungstenite::Message::Pong(payload)) + .await + .expect("websocket pong should send"); + } + tungstenite::Message::Pong(_) => {} + tungstenite::Message::Close(frame) => { + panic!("unexpected websocket close frame: {frame:?}"); + } + tungstenite::Message::Binary(_) => { + panic!("unexpected binary websocket frame"); + } + tungstenite::Message::Frame(_) => {} + } + } +} diff --git a/code-rs/app-server-transport/src/transport/remote_control/websocket.rs b/code-rs/app-server-transport/src/transport/remote_control/websocket.rs new file mode 100644 index 00000000000..f7b49b72ec3 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -0,0 +1,2563 @@ +use crate::transport::TransportEvent; +use crate::transport::remote_control::client_tracker::ClientTracker; +use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL; +use crate::transport::remote_control::enroll::RemoteControlConnectionAuth; +use crate::transport::remote_control::enroll::RemoteControlEnrollment; +use crate::transport::remote_control::enroll::enroll_remote_control_server; +use crate::transport::remote_control::enroll::format_headers; +use crate::transport::remote_control::enroll::load_persisted_remote_control_enrollment; +use crate::transport::remote_control::enroll::preview_remote_control_response_body; +use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment; + +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::RemoteControlTarget; +use super::protocol::ServerEnvelope; +use super::protocol::StreamId; +use super::segment::ClientSegmentObservation; +use super::segment::ClientSegmentReassembler; +use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; +use super::segment::split_server_envelope_for_transport; +use axum::http::HeaderValue; +use base64::Engine; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_core::util::backoff; +use codex_login::AuthManager; +use codex_login::UnauthorizedRecovery; +use codex_state::StateRuntime; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use futures::SinkExt; +use futures::StreamExt; +use futures::stream::SplitSink; +use futures::stream::SplitStream; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio::time::MissedTickBehavior; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "3"; +pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor"; +const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(10); +const REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(60); +const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(1); + +struct BoundedOutboundBuffer { + buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque>, + used_tx: watch::Sender, +} + +impl BoundedOutboundBuffer { + fn new() -> (Self, watch::Receiver) { + let (used_tx, used_rx) = watch::channel(0); + let buffer = Self { + buffer_by_stream: HashMap::new(), + used_tx, + }; + (buffer, used_rx) + } + + fn insert(&mut self, server_envelope: &ServerEnvelope) { + self.buffer_by_stream + .entry(( + server_envelope.client_id.clone(), + server_envelope.stream_id.clone(), + )) + .or_default() + .push_back(server_envelope.clone()); + self.used_tx.send_modify(|used| *used += 1); + } + + fn ack( + &mut self, + client_id: &ClientId, + stream_id: &StreamId, + acked_seq_id: u64, + acked_segment_id: Option, + ) { + let key = (client_id.clone(), stream_id.clone()); + let Some(buffer) = self.buffer_by_stream.get_mut(&key) else { + return; + }; + let acked_cursor = (acked_seq_id, acked_segment_id.unwrap_or(usize::MAX)); + buffer.retain(|server_envelope| { + let envelope_cursor = ( + server_envelope.seq_id, + server_envelope.event.segment_id().unwrap_or_default(), + ); + let is_acked = envelope_cursor <= acked_cursor; + if is_acked { + self.used_tx.send_modify(|used| *used -= 1); + } + !is_acked + }); + if buffer.is_empty() { + self.buffer_by_stream.remove(&key); + } + } + + fn server_envelopes(&self) -> impl Iterator { + self.buffer_by_stream + .values() + .flat_map(|buffer| buffer.iter()) + } +} + +struct WebsocketState { + outbound_buffer: BoundedOutboundBuffer, + subscribe_cursor: Option, + next_seq_id_by_stream: HashMap<(ClientId, StreamId), u64>, + last_completed_client_chunk_seq_id_by_stream: HashMap<(ClientId, Option), u64>, + client_segment_reassembler: ClientSegmentReassembler, +} + +impl WebsocketState { + fn observe_client_message( + &mut self, + client_envelope: ClientEnvelope, + wire_size_bytes: usize, + ) -> ClientSegmentObservation { + let client_message_key = Self::client_message_key(&client_envelope); + if let Some((key, seq_id)) = client_message_key.as_ref() + && self + .last_completed_client_chunk_seq_id_by_stream + .get(key) + .is_some_and(|last_seq_id| last_seq_id >= seq_id) + { + return ClientSegmentObservation::Dropped; + } + if let ( + Some((_, seq_id)), + Some(stream_id), + ClientEvent::ClientMessageChunk { segment_id, .. }, + ) = ( + client_message_key.as_ref(), + client_envelope.stream_id.as_ref(), + &client_envelope.event, + ) && self.client_segment_reassembler.should_ignore_chunk( + &client_envelope.client_id, + stream_id, + *seq_id, + *segment_id, + ) { + return ClientSegmentObservation::Dropped; + } + if client_message_key.is_some() && wire_size_bytes > REMOTE_CONTROL_SEGMENT_MAX_BYTES { + warn!( + client_id = client_envelope.client_id.0.as_str(), + "dropping oversized segmented remote-control client envelope" + ); + if let Some(stream_id) = client_envelope.stream_id.as_ref() { + self.client_segment_reassembler + .invalidate_stream(&client_envelope.client_id, stream_id); + } + return ClientSegmentObservation::Dropped; + } + + let observation = self.client_segment_reassembler.observe(client_envelope); + if matches!(observation, ClientSegmentObservation::Forward(_)) + && let Some((key, seq_id)) = client_message_key + { + self.last_completed_client_chunk_seq_id_by_stream + .insert(key, seq_id); + } + observation + } + + fn invalidate_client_message_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) { + self.last_completed_client_chunk_seq_id_by_stream + .remove(&(client_id.clone(), Some(stream_id.clone()))); + } + + fn invalidate_client_message_client(&mut self, client_id: &ClientId) { + self.last_completed_client_chunk_seq_id_by_stream + .retain(|(cursor_client_id, _), _| cursor_client_id != client_id); + } + + fn client_message_key( + client_envelope: &ClientEnvelope, + ) -> Option<((ClientId, Option), u64)> { + let seq_id = match (&client_envelope.event, client_envelope.seq_id) { + (ClientEvent::ClientMessageChunk { .. }, Some(seq_id)) => seq_id, + _ => return None, + }; + Some(( + ( + client_envelope.client_id.clone(), + client_envelope.stream_id.clone(), + ), + seq_id, + )) + } +} + +pub(crate) struct RemoteControlWebsocket { + remote_control_url: String, + remote_control_target: Option, + state_db: Option>, + auth_manager: Arc, + status_publisher: RemoteControlStatusPublisher, + shutdown_token: CancellationToken, + reconnect_attempt: u64, + enrollment: Option, + auth_recovery: UnauthorizedRecovery, + client_tracker: Arc>, + state: Arc>, + server_event_rx: Arc>>, + used_rx: watch::Receiver, + enabled_rx: watch::Receiver, +} + +enum ConnectOutcome { + Connected(Box>>), + Disabled, + Shutdown, +} + +pub(super) struct RemoteControlChannels { + pub(super) transport_event_tx: mpsc::Sender, + pub(super) status_publisher: RemoteControlStatusPublisher, +} + +#[derive(Clone)] +pub(super) struct RemoteControlStatusPublisher { + tx: watch::Sender, +} + +impl RemoteControlStatusPublisher { + pub(super) fn new(tx: watch::Sender) -> Self { + Self { tx } + } + + fn publish_status(&self, connection_status: RemoteControlConnectionStatus) { + self.tx.send_if_modified(|status| { + let next_status = RemoteControlStatusChangedNotification { + status: connection_status, + environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { + None + } else { + status.environment_id.clone() + }, + }; + if *status == next_status { + return false; + } + + *status = next_status; + true + }); + } + + fn publish_environment_id(&self, environment_id: Option) { + self.tx.send_if_modified(|status| { + if status.status == RemoteControlConnectionStatus::Disabled { + return false; + } + let next_status = RemoteControlStatusChangedNotification { + status: status.status, + environment_id, + }; + if *status == next_status { + return false; + } + + *status = next_status; + true + }); + } +} + +#[derive(Clone, Copy)] +pub(super) struct RemoteControlConnectOptions<'a> { + subscribe_cursor: Option<&'a str>, + app_server_client_name: Option<&'a str>, +} + +impl RemoteControlWebsocket { + pub(crate) fn new( + remote_control_url: String, + remote_control_target: Option, + state_db: Option>, + auth_manager: Arc, + channels: RemoteControlChannels, + shutdown_token: CancellationToken, + enabled_rx: watch::Receiver, + ) -> Self { + let shutdown_token = shutdown_token.child_token(); + let (server_event_tx, server_event_rx) = mpsc::channel(super::CHANNEL_CAPACITY); + let client_tracker = ClientTracker::new( + server_event_tx, + channels.transport_event_tx, + &shutdown_token, + ); + let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let auth_recovery = auth_manager.unauthorized_recovery(); + + Self { + remote_control_url, + remote_control_target, + state_db, + auth_manager, + status_publisher: channels.status_publisher, + shutdown_token, + reconnect_attempt: 0, + enrollment: None, + auth_recovery, + client_tracker: Arc::new(Mutex::new(client_tracker)), + state: Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + })), + server_event_rx: Arc::new(Mutex::new(server_event_rx)), + used_rx, + enabled_rx, + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "remote-control client shutdown must serialize tracker state" + )] + pub(crate) async fn run( + mut self, + app_server_client_name_rx: Option>, + ) { + let app_server_client_name = match self + .wait_for_app_server_client_name(app_server_client_name_rx) + .await + { + Ok(app_server_client_name) => app_server_client_name, + Err(_) => { + self.client_tracker.lock().await.shutdown().await; + return; + } + }; + + loop { + if !self.wait_until_enabled().await { + break; + } + + let shutdown_token = self.shutdown_token.child_token(); + let websocket_connection = match self + .connect(&shutdown_token, app_server_client_name.as_deref()) + .await + { + ConnectOutcome::Connected(websocket_connection) => *websocket_connection, + ConnectOutcome::Disabled => { + self.status_publisher + .publish_status(RemoteControlConnectionStatus::Disabled); + continue; + } + ConnectOutcome::Shutdown => break, + }; + + self.run_connection(websocket_connection, shutdown_token) + .await; + } + + self.client_tracker.lock().await.shutdown().await; + } + + async fn wait_for_app_server_client_name( + &self, + app_server_client_name_rx: Option>, + ) -> Result, ()> { + match app_server_client_name_rx { + Some(app_server_client_name_rx) => { + tokio::select! { + _ = self.shutdown_token.cancelled() => Err(()), + app_server_client_name = app_server_client_name_rx => match app_server_client_name { + Ok(app_server_client_name) => Ok(Some(app_server_client_name)), + Err(_) => Err(()), + }, + } + } + None => Ok(None), + } + } + + async fn wait_until_enabled(&mut self) -> bool { + tokio::select! { + _ = self.shutdown_token.cancelled() => false, + enabled = self.enabled_rx.wait_for(|enabled| *enabled) => enabled.is_ok(), + } + } + + async fn connect( + &mut self, + shutdown_token: &CancellationToken, + app_server_client_name: Option<&str>, + ) -> ConnectOutcome { + self.status_publisher + .publish_status(RemoteControlConnectionStatus::Connecting); + let remote_control_target = match self.remote_control_target.as_ref() { + Some(remote_control_target) => remote_control_target.clone(), + None => match super::protocol::normalize_remote_control_url(&self.remote_control_url) { + Ok(remote_control_target) => { + self.remote_control_target = Some(remote_control_target.clone()); + remote_control_target + } + Err(err) => { + self.status_publisher + .publish_status(RemoteControlConnectionStatus::Errored); + warn!("remote control is enabled but the URL is invalid: {err}"); + tokio::select! { + _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, + changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + return ConnectOutcome::Disabled; + } + } + } + }, + }; + + loop { + let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); + let connect_options = RemoteControlConnectOptions { + subscribe_cursor: subscribe_cursor.as_deref(), + app_server_client_name, + }; + let connect_result = tokio::select! { + _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, + changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + return ConnectOutcome::Disabled; + } + connect_result = connect_remote_control_websocket( + &remote_control_target, + self.state_db.as_deref(), + &self.auth_manager, + &mut self.auth_recovery, + &mut self.enrollment, + connect_options, + &self.status_publisher, + ) => connect_result, + }; + + match connect_result { + Ok((websocket_connection, response)) => { + if !*self.enabled_rx.borrow() { + return ConnectOutcome::Disabled; + } + self.reconnect_attempt = 0; + self.auth_recovery = self.auth_manager.unauthorized_recovery(); + self.status_publisher + .publish_status(RemoteControlConnectionStatus::Connected); + info!( + "connected to app-server remote control websocket: {}, {}", + remote_control_target.websocket_url, + format_headers(response.headers()) + ); + return ConnectOutcome::Connected(Box::new(websocket_connection)); + } + Err(err) => { + if !*self.enabled_rx.borrow() { + return ConnectOutcome::Disabled; + } + let reconnect_delay = if err.kind() == ErrorKind::WouldBlock { + REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL + } else { + self.status_publisher + .publish_status(RemoteControlConnectionStatus::Errored); + warn!( + "failed to connect to app-server remote control websocket: {}, err: {}", + remote_control_target.websocket_url, err + ); + let reconnect_delay = backoff(self.reconnect_attempt); + self.reconnect_attempt += 1; + reconnect_delay + }; + tokio::select! { + _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, + changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + return ConnectOutcome::Disabled; + } + _ = tokio::time::sleep(reconnect_delay) => {} + } + } + } + } + } + + async fn run_connection( + &self, + websocket_connection: WebSocketStream>, + shutdown_token: CancellationToken, + ) { + let (websocket_writer, websocket_reader) = websocket_connection.split(); + let mut join_set = tokio::task::JoinSet::new(); + + join_set.spawn(Self::run_server_writer( + self.state.clone(), + self.server_event_rx.clone(), + self.used_rx.clone(), + websocket_writer, + REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL, + shutdown_token.clone(), + )); + join_set.spawn(Self::run_websocket_reader( + self.client_tracker.clone(), + self.state.clone(), + websocket_reader, + REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT, + shutdown_token.clone(), + )); + + let mut enabled_rx = self.enabled_rx.clone(); + tokio::select! { + _ = shutdown_token.cancelled() => {} + changed = enabled_rx.wait_for(|enabled| !*enabled) => { + if changed.is_ok() { + self.status_publisher + .publish_status(RemoteControlConnectionStatus::Disabled); + } + } + _ = join_set.join_next() => {} + }; + shutdown_token.cancel(); + + join_set.join_all().await; + } + + async fn run_server_writer( + state: Arc>, + server_event_rx: Arc>>, + used_rx: watch::Receiver, + websocket_writer: SplitSink< + WebSocketStream>, + tungstenite::Message, + >, + ping_interval: std::time::Duration, + shutdown_token: CancellationToken, + ) { + let result = Self::run_server_writer_inner( + state, + server_event_rx, + used_rx, + websocket_writer, + ping_interval, + shutdown_token, + ) + .await; + if let Err(err) = result { + warn!("remote control websocket writer disconnected, err: {err}"); + } else { + warn!("remote control websocket writer was stopped"); + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "remote-control server event receiver is shared across reconnects" + )] + async fn run_server_writer_inner( + state: Arc>, + server_event_rx: Arc>>, + mut used_rx: watch::Receiver, + mut websocket_writer: SplitSink< + WebSocketStream>, + tungstenite::Message, + >, + ping_interval: std::time::Duration, + shutdown_token: CancellationToken, + ) -> io::Result<()> { + let server_envelopes = state + .lock() + .await + .outbound_buffer + .server_envelopes() + .cloned() + .collect::>(); + for server_envelope in server_envelopes { + let payload = match serde_json::to_string(&server_envelope) { + Ok(payload) => payload, + Err(err) => { + error!("failed to serialize remote-control server event: {err}"); + continue; + } + }; + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + }; + } + + let mut ping_interval = + tokio::time::interval_at(tokio::time::Instant::now() + ping_interval, ping_interval); + ping_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + let mut server_event_rx = server_event_rx.lock().await; + loop { + let outbound_has_capacity = *used_rx.borrow() < super::CHANNEL_CAPACITY; + let queued_server_envelope = tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + _ = ping_interval.tick() => { + if let Err(err) = websocket_writer + .send(tungstenite::Message::Ping(Vec::new().into())) + .await + { + return Err(io::Error::other(err)); + } + continue; + } + wait_result = used_rx.changed(), if !outbound_has_capacity => + { + if wait_result.is_err() { + return Err(io::Error::new( + ErrorKind::UnexpectedEof, + "outbound buffer usage channel closed", + )); + } + continue; + } + recv_result = server_event_rx.recv(), if outbound_has_capacity => { + match recv_result { + Some(queued_server_envelope) => queued_server_envelope, + None => { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "server event channel closed")); + } + } + } + }; + let (payloads, write_complete_tx) = { + let mut state = state.lock().await; + let seq_key = ( + queued_server_envelope.client_id.clone(), + queued_server_envelope.stream_id.clone(), + ); + let seq_id = *state + .next_seq_id_by_stream + .entry(seq_key.clone()) + .or_insert(1); + + let server_envelope = ServerEnvelope { + event: queued_server_envelope.event, + client_id: queued_server_envelope.client_id, + seq_id, + stream_id: queued_server_envelope.stream_id, + }; + let server_envelopes = match split_server_envelope_for_transport(server_envelope) { + Ok(server_envelopes) => server_envelopes, + Err(err) => { + error!("failed to split remote-control server event: {err}"); + continue; + } + }; + let mut payloads = Vec::with_capacity(server_envelopes.len()); + for server_envelope in server_envelopes { + let payload = match serde_json::to_string(&server_envelope) { + Ok(payload) => payload, + Err(err) => { + error!("failed to serialize remote-control server event: {err}"); + continue; + } + }; + state.outbound_buffer.insert(&server_envelope); + payloads.push(payload); + } + state + .next_seq_id_by_stream + .insert(seq_key, seq_id.saturating_add(1)); + + (payloads, queued_server_envelope.write_complete_tx) + }; + + for payload in payloads { + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + } + } + if let Some(write_complete_tx) = write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + } + + async fn run_websocket_reader( + client_tracker: Arc>, + state: Arc>, + websocket_reader: SplitStream>>, + pong_timeout: std::time::Duration, + shutdown_token: CancellationToken, + ) { + let result = Self::run_websocket_reader_inner( + client_tracker, + state, + websocket_reader, + pong_timeout, + shutdown_token, + ) + .await; + if let Err(err) = result { + warn!("remote control websocket reader disconnected, err: {err}"); + } else { + warn!("remote control websocket reader was stopped"); + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "remote-control client tracking must stay serialized while processing inbound events" + )] + async fn run_websocket_reader_inner( + client_tracker: Arc>, + state: Arc>, + mut websocket_reader: SplitStream>>, + pong_timeout: std::time::Duration, + shutdown_token: CancellationToken, + ) -> io::Result<()> { + let mut client_tracker = client_tracker.lock().await; + let mut idle_sweep_interval = tokio::time::interval(REMOTE_CONTROL_IDLE_SWEEP_INTERVAL); + idle_sweep_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + let pong_deadline = tokio::time::sleep(pong_timeout); + tokio::pin!(pong_deadline); + + loop { + let incoming_message = tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + _ = &mut pong_deadline => { + return Err(io::Error::new( + ErrorKind::TimedOut, + "remote control websocket pong timeout", + )); + } + client_key = client_tracker.bookkeep_join_set() => { + let Some(client_key) = client_key else { + continue; + }; + if client_tracker.close_client(&client_key).await.is_err() { + return Ok(()); + } + state + .lock() + .await + .client_segment_reassembler + .invalidate_stream(&client_key.0, &client_key.1); + state + .lock() + .await + .invalidate_client_message_stream(&client_key.0, &client_key.1); + continue; + } + _ = idle_sweep_interval.tick() => { + match client_tracker.close_expired_clients().await { + Ok(client_keys) => { + let mut websocket_state = state.lock().await; + for (client_id, stream_id) in client_keys { + websocket_state + .client_segment_reassembler + .invalidate_stream(&client_id, &stream_id); + websocket_state + .invalidate_client_message_stream(&client_id, &stream_id); + } + } + Err(_) => return Ok(()), + } + continue; + } + incoming_message = websocket_reader.next() => { + match incoming_message { + Some(incoming_message) => incoming_message, + None => return Err(io::Error::new(ErrorKind::UnexpectedEof, "websocket stream ended")), + } + } + }; + let (client_envelope, wire_size_bytes) = match incoming_message { + Ok(tungstenite::Message::Text(text)) => { + let wire_size_bytes = text.len(); + match serde_json::from_str::(&text) { + Ok(client_envelope) => (client_envelope, wire_size_bytes), + Err(err) => { + warn!("failed to deserialize remote-control client event: {err}"); + continue; + } + } + } + Ok(tungstenite::Message::Pong(_)) => { + pong_deadline + .as_mut() + .reset(tokio::time::Instant::now() + pong_timeout); + continue; + } + Ok(tungstenite::Message::Ping(_)) | Ok(tungstenite::Message::Frame(_)) => continue, + Ok(tungstenite::Message::Binary(_)) => { + warn!("dropping unsupported binary remote-control websocket message"); + continue; + } + Ok(tungstenite::Message::Close(_)) => { + return Err(io::Error::new( + ErrorKind::ConnectionAborted, + "websocket disconnected", + )); + } + Err(err) => { + return Err(io::Error::new( + ErrorKind::InvalidData, + format!("failed to read from websocket: {err}"), + )); + } + }; + + let observation = { + let mut websocket_state = state.lock().await; + websocket_state.observe_client_message(client_envelope, wire_size_bytes) + }; + let client_envelope = match observation { + ClientSegmentObservation::Forward(client_envelope) => *client_envelope, + ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => continue, + }; + + { + let mut websocket_state = state.lock().await; + if let Some(cursor) = client_envelope.cursor.as_deref() { + websocket_state.subscribe_cursor = Some(cursor.to_string()); + } + if let ClientEvent::Ack { segment_id } = &client_envelope.event + && let Some(acked_seq_id) = client_envelope.seq_id + && let Some(stream_id) = client_envelope.stream_id.as_ref() + { + websocket_state.outbound_buffer.ack( + &client_envelope.client_id, + stream_id, + acked_seq_id, + *segment_id, + ); + } + } + + let closed_client = + matches!(&client_envelope.event, ClientEvent::ClientClosed).then(|| { + ( + client_envelope.client_id.clone(), + client_envelope.stream_id.clone(), + ) + }); + if client_tracker + .handle_message(client_envelope) + .await + .is_err() + { + return Ok(()); + } + if let Some((client_id, stream_id)) = closed_client { + let mut websocket_state = state.lock().await; + if let Some(stream_id) = stream_id { + websocket_state + .client_segment_reassembler + .invalidate_stream(&client_id, &stream_id); + websocket_state.invalidate_client_message_stream(&client_id, &stream_id); + } else { + websocket_state + .client_segment_reassembler + .invalidate_client(&client_id); + websocket_state.invalidate_client_message_client(&client_id); + } + } + } + } +} + +fn set_remote_control_header( + headers: &mut tungstenite::http::HeaderMap, + name: &'static str, + value: &str, +) -> io::Result<()> { + let header_value = HeaderValue::from_str(value).map_err(|err| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control header `{name}`: {err}"), + ) + })?; + headers.insert(name, header_value); + Ok(()) +} + +fn build_remote_control_websocket_request( + websocket_url: &str, + enrollment: &RemoteControlEnrollment, + auth: &RemoteControlConnectionAuth, + subscribe_cursor: Option<&str>, +) -> io::Result> { + let mut request = websocket_url.into_client_request().map_err(|err| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control websocket URL `{websocket_url}`: {err}"), + ) + })?; + let headers = request.headers_mut(); + set_remote_control_header(headers, "x-codex-server-id", &enrollment.server_id)?; + set_remote_control_header( + headers, + "x-codex-name", + &base64::engine::general_purpose::STANDARD.encode(&enrollment.server_name), + )?; + set_remote_control_header( + headers, + "x-codex-protocol-version", + REMOTE_CONTROL_PROTOCOL_VERSION, + )?; + let mut auth_headers = tungstenite::http::HeaderMap::new(); + auth.auth_provider.add_auth_headers(&mut auth_headers); + headers.extend(auth_headers); + set_remote_control_header(headers, REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)?; + if let Some(subscribe_cursor) = subscribe_cursor { + set_remote_control_header( + headers, + REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER, + subscribe_cursor, + )?; + } + Ok(request) +} + +pub(crate) async fn load_remote_control_auth( + auth_manager: &Arc, +) -> io::Result { + let mut reloaded = false; + let auth = loop { + let Some(auth) = auth_manager.auth().await else { + if reloaded { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication", + )); + } + auth_manager.reload().await; + reloaded = true; + continue; + }; + if !auth.uses_codex_backend() { + break auth; + } + if auth.get_account_id().is_none() && !reloaded { + auth_manager.reload().await; + reloaded = true; + continue; + } + break auth; + }; + + if !auth.uses_codex_backend() { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication; API key auth is not supported", + )); + } + + Ok(RemoteControlConnectionAuth { + auth_provider: codex_model_provider::auth_provider_from_auth(&auth), + account_id: auth.get_account_id().ok_or_else(|| { + io::Error::new( + ErrorKind::WouldBlock, + "remote control enrollment is waiting for a ChatGPT account id", + ) + })?, + }) +} + +pub(super) async fn connect_remote_control_websocket( + remote_control_target: &RemoteControlTarget, + state_db: Option<&StateRuntime>, + auth_manager: &Arc, + auth_recovery: &mut UnauthorizedRecovery, + enrollment: &mut Option, + connect_options: RemoteControlConnectOptions<'_>, + status_publisher: &RemoteControlStatusPublisher, +) -> io::Result<( + WebSocketStream>, + tungstenite::http::Response<()>, +)> { + ensure_rustls_crypto_provider(); + + let Some(state_db) = state_db else { + *enrollment = None; + return Err(io::Error::new( + ErrorKind::NotFound, + "remote control requires sqlite state db", + )); + }; + + let auth = match load_remote_control_auth(auth_manager).await { + Ok(auth) => auth, + Err(err) => { + if err.kind() == ErrorKind::PermissionDenied { + *enrollment = None; + status_publisher.publish_environment_id(/*environment_id*/ None); + } + return Err(err); + } + }; + let enrollment_account_id = enrollment.as_ref().map(|enrollment| &enrollment.account_id); + if enrollment_account_id.is_some_and(|account_id| account_id != &auth.account_id) { + info!( + "clearing in-memory remote control enrollment because account id changed: websocket_url={}, previous_account_id={:?}, current_account_id={:?}", + remote_control_target.websocket_url, + enrollment + .as_ref() + .map(|enrollment| enrollment.account_id.as_str()), + auth.account_id + ); + *enrollment = None; + status_publisher.publish_environment_id(/*environment_id*/ None); + } + + if let Some(enrollment) = enrollment.as_ref() { + status_publisher.publish_environment_id(Some(enrollment.environment_id.clone())); + } + + if enrollment.is_none() { + let loaded_enrollment = load_persisted_remote_control_enrollment( + Some(state_db), + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + ) + .await?; + if let Some(loaded_enrollment) = loaded_enrollment.as_ref() { + status_publisher.publish_environment_id(Some(loaded_enrollment.environment_id.clone())); + } + *enrollment = loaded_enrollment; + } + + if enrollment.is_none() { + info!( + "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", + remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id + ); + let new_enrollment = match enroll_remote_control_server(remote_control_target, &auth).await + { + Ok(new_enrollment) => new_enrollment, + Err(err) + if err.kind() == ErrorKind::PermissionDenied + && recover_remote_control_auth(auth_recovery).await => + { + return Err(io::Error::other(format!( + "{err}; retrying after auth recovery" + ))); + } + Err(err) => return Err(err), + }; + if let Err(err) = update_persisted_remote_control_enrollment( + Some(state_db), + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + Some(&new_enrollment), + ) + .await + { + return Err(io::Error::other(format!( + "failed to persist remote control enrollment in sqlite state db: {err}" + ))); + } + info!( + "created new remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + new_enrollment.account_id, + new_enrollment.server_id, + new_enrollment.environment_id + ); + status_publisher.publish_environment_id(Some(new_enrollment.environment_id.clone())); + *enrollment = Some(new_enrollment); + } + + let enrollment_ref = enrollment.as_ref().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + let request = build_remote_control_websocket_request( + &remote_control_target.websocket_url, + enrollment_ref, + &auth, + connect_options.subscribe_cursor, + )?; + + match connect_async(request).await { + Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), + Err(err) => { + match &err { + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + info!( + "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + auth.account_id, + enrollment_ref.server_id, + enrollment_ref.environment_id + ); + if let Err(clear_err) = update_persisted_remote_control_enrollment( + Some(state_db), + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!( + "failed to clear stale remote control enrollment in sqlite state db: {clear_err}" + ); + } + *enrollment = None; + status_publisher.publish_environment_id(/*environment_id*/ None); + } + tungstenite::Error::Http(response) + if matches!(response.status().as_u16(), 401 | 403) => + { + if recover_remote_control_auth(auth_recovery).await { + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; retrying after auth recovery", + response.status() + ))); + } + } + _ => {} + } + Err(io::Error::other( + format_remote_control_websocket_connect_error( + &remote_control_target.websocket_url, + &err, + ), + )) + } + } +} + +async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool { + if !auth_recovery.has_next() { + return false; + } + + let mode = auth_recovery.mode_name(); + let step = auth_recovery.step_name(); + match auth_recovery.next().await { + Ok(step_result) => { + info!( + "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", + step_result.auth_state_changed() + ); + true + } + Err(err) => { + warn!("remote control websocket auth recovery failed: mode={mode}, step={step}: {err}"); + false + } + } +} + +fn format_remote_control_websocket_connect_error( + websocket_url: &str, + err: &tungstenite::Error, +) -> String { + let mut message = + format!("failed to connect app-server remote control websocket `{websocket_url}`: {err}"); + let tungstenite::Error::Http(response) = err else { + return message; + }; + + message.push_str(&format!(", {}", format_headers(response.headers()))); + if let Some(body) = response.body().as_ref() + && !body.is_empty() + { + let body_preview = preview_remote_control_response_body(body); + message.push_str(&format!(", body: {body_preview}")); + } + + message +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outgoing_message::OutgoingMessage; + use crate::transport::remote_control::ServerEvent; + use crate::transport::remote_control::protocol::StreamId; + use crate::transport::remote_control::protocol::normalize_remote_control_url; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCMessage; + use codex_app_server_protocol::JSONRPCNotification; + use codex_app_server_protocol::ServerNotification; + use codex_config::types::AuthCredentialsStoreMode; + use codex_core::test_support::auth_manager_from_auth; + use codex_login::AuthDotJson; + use codex_login::CodexAuth; + use codex_login::save_auth; + use codex_login::token_data::TokenData; + use codex_login::token_data::parse_chatgpt_jwt_claims; + use codex_state::StateRuntime; + use futures::StreamExt; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::io::BufReader; + use tokio::net::TcpListener; + use tokio::net::TcpStream; + use tokio::sync::mpsc; + use tokio::time::Duration; + use tokio::time::timeout; + use tokio_tungstenite::accept_async; + + // Windows Bazel CI can take longer than a few seconds for the websocket + // client connection attempt to reach the local test listener. + #[cfg(windows)] + const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30); + #[cfg(not(windows))] + const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5); + + fn remote_control_status_channel() -> ( + RemoteControlStatusPublisher, + watch::Receiver, + ) { + let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + environment_id: None, + }); + (RemoteControlStatusPublisher::new(status_tx), status_rx) + } + + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") + } + + fn remote_control_auth_manager() -> Arc { + auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + } + + fn remote_control_url_for_listener(listener: &TcpListener) -> String { + let addr = listener + .local_addr() + .expect("listener should have a local addr"); + format!("http://{addr}/backend-api/") + } + + fn remote_control_auth_dot_json(access_token: &str) -> AuthDotJson { + #[derive(serde::Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + "chatgpt_account_id": "account_id" + } + }); + let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = b64(&serde_json::to_vec(&header).expect("header should serialize")); + let payload_b64 = b64(&serde_json::to_vec(&payload).expect("payload should serialize")); + let fake_jwt = format!("{header_b64}.{payload_b64}.sig"); + + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: parse_chatgpt_jwt_claims(&fake_jwt).expect("fake jwt should parse"), + access_token: access_token.to_string(), + refresh_token: "refresh-token".to_string(), + account_id: Some("account_id".to_string()), + }), + last_refresh: Some(Utc::now()), + agent_identity: None, + } + } + + #[tokio::test] + async fn connect_remote_control_websocket_includes_http_error_details() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let expected_error = format!( + "failed to connect app-server remote control websocket `{}`: HTTP error: 503 Service Unavailable, request-id: , cf-ray: , body: upstream unavailable", + remote_control_target.websocket_url + ); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + respond_with_status_and_headers( + stream, + "503 Service Unavailable", + &[("x-trace-id", "trace-503"), ("x-region", "us-east-1")], + "upstream unavailable", + ) + .await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = remote_control_auth_manager(); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + let (status_publisher, status_rx) = remote_control_status_channel(); + + let err = match connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + RemoteControlConnectOptions { + subscribe_cursor: None, + app_server_client_name: None, + }, + &status_publisher, + ) + .await + { + Ok(_) => panic!("http error response should fail the websocket connect"), + Err(err) => err, + }; + + server_task.await.expect("server task should succeed"); + assert_eq!(err.to_string(), expected_error); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + environment_id: Some("env_test".to_string()), + } + ); + } + + #[tokio::test] + async fn connect_remote_control_websocket_recovers_after_unauthorized_reload() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("stale-token"), + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + let (status_publisher, status_rx) = remote_control_status_channel(); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("fresh-token"), + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await; + }); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + RemoteControlConnectOptions { + subscribe_cursor: None, + app_server_client_name: None, + }, + &status_publisher, + ) + .await + .expect_err("unauthorized response should fail the websocket connect"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + environment_id: Some("env_test".to_string()), + } + ); + assert_eq!( + err.to_string(), + "remote control websocket auth failed with HTTP 401 Unauthorized; retrying after auth recovery" + ); + assert_eq!( + auth_manager + .auth() + .await + .expect("auth should remain available") + .get_token() + .expect("token should be readable"), + "fresh-token" + ); + } + + #[tokio::test] + async fn connect_remote_control_websocket_recovers_after_unauthorized_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let enroll_url = remote_control_target.enroll_url.clone(); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("stale-token"), + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = None; + let (status_publisher, status_rx) = remote_control_status_channel(); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("fresh-token"), + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + RemoteControlConnectOptions { + subscribe_cursor: None, + app_server_client_name: None, + }, + &status_publisher, + ) + .await + .expect_err("unauthorized enrollment should fail the websocket connect"); + + server_task.await.expect("server task should succeed"); + assert!( + !status_rx + .has_changed() + .expect("remote control status watch should remain open") + ); + assert_eq!( + err.to_string(), + format!( + "remote control server enrollment failed at `{enroll_url}`: HTTP 401 Unauthorized, request-id: , cf-ray: , body: unauthorized; retrying after auth recovery" + ) + ); + assert_eq!( + auth_manager + .auth() + .await + .expect("auth should remain available") + .get_token() + .expect("token should be readable"), + "fresh-token" + ); + } + + #[tokio::test] + async fn connect_remote_control_websocket_requires_sqlite_state_db() { + let remote_control_target = normalize_remote_control_url("http://127.0.0.1:9/backend-api/") + .expect("target should parse"); + let auth_manager = remote_control_auth_manager(); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + let (status_publisher, _status_rx) = remote_control_status_channel(); + + let err = connect_remote_control_websocket( + &remote_control_target, + /*state_db*/ None, + &auth_manager, + &mut auth_recovery, + &mut enrollment, + RemoteControlConnectOptions { + subscribe_cursor: None, + app_server_client_name: None, + }, + &status_publisher, + ) + .await + .expect_err("missing sqlite state db should fail remote control"); + + assert_eq!(err.kind(), ErrorKind::NotFound); + assert_eq!(err.to_string(), "remote control requires sqlite state db"); + assert_eq!(enrollment, None); + } + + #[tokio::test] + async fn connect_remote_control_websocket_requires_chatgpt_auth() { + let remote_control_target = normalize_remote_control_url("http://127.0.0.1:9/backend-api/") + .expect("target should parse"); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + let (status_publisher, mut status_rx) = remote_control_status_channel(); + status_publisher.publish_environment_id(Some("env_test".to_string())); + status_rx + .changed() + .await + .expect("remote control status watch should remain open"); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + RemoteControlConnectOptions { + subscribe_cursor: None, + app_server_client_name: None, + }, + &status_publisher, + ) + .await + .expect_err("missing auth should fail remote control"); + + assert_eq!(err.kind(), ErrorKind::PermissionDenied); + assert_eq!( + err.to_string(), + "remote control requires ChatGPT authentication" + ); + assert_eq!(enrollment, None); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + environment_id: None, + } + ); + } + + #[tokio::test] + async fn run_remote_control_websocket_loop_shutdown_cancels_reconnect_backoff() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + drop(listener); + + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let (transport_event_tx, transport_event_rx) = mpsc::channel(1); + drop(transport_event_rx); + let (status_publisher, _status_rx) = remote_control_status_channel(); + let shutdown_token = CancellationToken::new(); + let (_enabled_tx, enabled_rx) = watch::channel(true); + let websocket_task = tokio::spawn({ + let shutdown_token = shutdown_token.clone(); + async move { + RemoteControlWebsocket::new( + remote_control_url, + Some(remote_control_target), + /*state_db*/ None, + remote_control_auth_manager(), + RemoteControlChannels { + transport_event_tx, + status_publisher, + }, + shutdown_token, + enabled_rx, + ) + .run(/*app_server_client_name_rx*/ None) + .await + } + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + shutdown_token.cancel(); + + timeout(Duration::from_millis(100), websocket_task) + .await + .expect("shutdown should cancel reconnect backoff") + .expect("websocket task should join"); + } + + #[tokio::test] + async fn publish_status_if_changed_sends_only_status_changes() { + let (status_publisher, mut status_rx) = remote_control_status_channel(); + + status_publisher.publish_environment_id(/*environment_id*/ None); + assert!( + timeout(Duration::from_millis(20), status_rx.changed()) + .await + .is_err() + ); + + status_publisher.publish_environment_id(Some("env_first".to_string())); + status_rx + .changed() + .await + .expect("remote control status watch should remain open"); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + environment_id: Some("env_first".to_string()), + } + ); + + status_publisher.publish_environment_id(Some("env_first".to_string())); + assert!( + timeout(Duration::from_millis(20), status_rx.changed()) + .await + .is_err() + ); + + status_publisher.publish_status(RemoteControlConnectionStatus::Connected); + status_rx + .changed() + .await + .expect("remote control status watch should remain open"); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connected, + environment_id: Some("env_first".to_string()), + } + ); + + status_publisher.publish_environment_id(/*environment_id*/ None); + status_rx + .changed() + .await + .expect("remote control status watch should remain open"); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connected, + environment_id: None, + } + ); + + status_publisher.publish_environment_id(Some("env_disabled".to_string())); + status_publisher.publish_status(RemoteControlConnectionStatus::Disabled); + status_rx + .changed() + .await + .expect("remote control status watch should remain open"); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + environment_id: None, + } + ); + + status_publisher.publish_environment_id(Some("env_disabled".to_string())); + assert!( + timeout(Duration::from_millis(20), status_rx.changed()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn run_server_writer_inner_sends_periodic_ping_frames() { + let (client_stream, mut server_stream) = connected_websocket_pair().await; + let (websocket_writer, _websocket_reader) = client_stream.split(); + let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let state = Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + })); + let (_server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY); + let server_event_rx = Arc::new(Mutex::new(server_event_rx)); + let shutdown_token = CancellationToken::new(); + let writer_task = tokio::spawn(RemoteControlWebsocket::run_server_writer_inner( + state, + server_event_rx, + used_rx, + websocket_writer, + Duration::from_millis(20), + shutdown_token.clone(), + )); + + let message = timeout(Duration::from_secs(5), server_stream.next()) + .await + .expect("ping frame should arrive in time") + .expect("server websocket should stay open") + .expect("ping frame should read"); + assert!(matches!(message, tungstenite::Message::Ping(_))); + + shutdown_token.cancel(); + writer_task + .await + .expect("writer task should join") + .expect("writer should stop cleanly"); + } + + #[tokio::test] + async fn run_server_writer_inner_assigns_contiguous_seq_ids_per_stream() { + let (client_stream, mut server_stream) = connected_websocket_pair().await; + let (websocket_writer, _websocket_reader) = client_stream.split(); + let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let state = Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + })); + let (server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY); + let server_event_rx = Arc::new(Mutex::new(server_event_rx)); + let shutdown_token = CancellationToken::new(); + let writer_task = tokio::spawn(RemoteControlWebsocket::run_server_writer_inner( + state, + server_event_rx, + used_rx, + websocket_writer, + Duration::from_secs(60), + shutdown_token.clone(), + )); + + let client_id = ClientId("client-1".to_string()); + let first_stream = StreamId("stream-1".to_string()); + let second_stream = StreamId("stream-2".to_string()); + for stream_id in [&first_stream, &second_stream, &first_stream] { + server_event_tx + .send(super::super::QueuedServerEnvelope { + event: ServerEvent::Pong { + status: crate::transport::remote_control::protocol::PongStatus::Active, + }, + client_id: client_id.clone(), + stream_id: stream_id.clone(), + write_complete_tx: None, + }) + .await + .expect("server event should queue"); + } + + assert_eq!( + read_server_text_event(&mut server_stream).await, + serde_json::json!({ + "type": "pong", + "client_id": "client-1", + "stream_id": "stream-1", + "seq_id": 1, + "status": "active", + }) + ); + assert_eq!( + read_server_text_event(&mut server_stream).await, + serde_json::json!({ + "type": "pong", + "client_id": "client-1", + "stream_id": "stream-2", + "seq_id": 1, + "status": "active", + }) + ); + assert_eq!( + read_server_text_event(&mut server_stream).await, + serde_json::json!({ + "type": "pong", + "client_id": "client-1", + "stream_id": "stream-1", + "seq_id": 2, + "status": "active", + }) + ); + + shutdown_token.cancel(); + writer_task + .await + .expect("writer task should join") + .expect("writer should stop cleanly"); + } + + #[tokio::test] + async fn run_websocket_reader_inner_times_out_without_pong_frames() { + let (client_stream, _server_stream) = connected_websocket_pair().await; + let (_websocket_writer, websocket_reader) = client_stream.split(); + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let state = Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + })); + let (server_event_tx, _server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel(super::super::CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let client_tracker = Arc::new(Mutex::new(ClientTracker::new( + server_event_tx, + transport_event_tx, + &shutdown_token, + ))); + + let err = timeout( + Duration::from_secs(5), + RemoteControlWebsocket::run_websocket_reader_inner( + client_tracker, + state, + websocket_reader, + Duration::from_millis(100), + shutdown_token, + ), + ) + .await + .expect("reader should time out waiting for pong") + .expect_err("missing pong should fail the websocket reader"); + + assert_eq!(err.kind(), ErrorKind::TimedOut); + assert_eq!(err.to_string(), "remote control websocket pong timeout"); + } + + #[test] + fn outbound_buffer_acks_by_stream_id() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_1 = ClientId("client-1".to_string()); + let client_2 = ClientId("client-2".to_string()); + let stream_1 = StreamId("stream-1".to_string()); + + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-1", + /*seq_id*/ 1, + "first-client-old-stream", + )); + outbound_buffer.insert(&server_envelope( + &client_2, + "stream-1", + /*seq_id*/ 2, + "second-client", + )); + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-2", + /*seq_id*/ 3, + "first-client-new-stream", + )); + + outbound_buffer.ack( + &client_1, &stream_1, /*acked_seq_id*/ 3, /*acked_segment_id*/ None, + ); + + let mut retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| { + ( + server_envelope.client_id.0.as_str(), + server_envelope.stream_id.0.as_str(), + server_envelope.seq_id, + ) + }) + .collect::>(); + retained.sort_unstable(); + assert_eq!( + retained, + vec![("client-1", "stream-2", 3), ("client-2", "stream-1", 2)] + ); + assert_eq!(*used_rx.borrow(), 2); + } + + #[test] + fn outbound_buffer_retains_unacked_messages_until_ack_advances() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_1 = ClientId("client-1".to_string()); + let client_2 = ClientId("client-2".to_string()); + let stream_1 = StreamId("stream-1".to_string()); + + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-1", + /*seq_id*/ 1, + "first-old", + )); + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-2", + /*seq_id*/ 2, + "first-new", + )); + outbound_buffer.insert(&server_envelope( + &client_2, "stream-1", /*seq_id*/ 3, "second", + )); + + outbound_buffer.ack( + &client_1, &stream_1, /*acked_seq_id*/ 1, /*acked_segment_id*/ None, + ); + + let mut retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| { + ( + server_envelope.client_id.0.as_str(), + server_envelope.stream_id.0.as_str(), + server_envelope.seq_id, + ) + }) + .collect::>(); + retained.sort_unstable(); + assert_eq!( + retained, + vec![("client-1", "stream-2", 2), ("client-2", "stream-1", 3)] + ); + assert_eq!(*used_rx.borrow(), 2); + } + + #[test] + fn outbound_buffer_advances_segmented_acks_by_wire_cursor() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_id = ClientId("client-1".to_string()); + let stream_id = StreamId("stream-1".to_string()); + + outbound_buffer.insert(&server_chunk_envelope( + &client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 0, + )); + outbound_buffer.insert(&server_chunk_envelope( + &client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 1, + )); + + outbound_buffer.ack( + &client_id, + &stream_id, + /*acked_seq_id*/ 4, + /*acked_segment_id*/ Some(1), + ); + + let retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| server_envelope.event.segment_id()) + .collect::>(); + assert_eq!(retained, Vec::>::new()); + assert_eq!(*used_rx.borrow(), 0); + } + + #[test] + fn outbound_buffer_treats_segmentless_acks_as_seq_level_acks() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_id = ClientId("client-1".to_string()); + let stream_id = StreamId("stream-1".to_string()); + + outbound_buffer.insert(&server_chunk_envelope( + &client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 0, + )); + outbound_buffer.insert(&server_chunk_envelope( + &client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 1, + )); + + outbound_buffer.ack( + &client_id, &stream_id, /*acked_seq_id*/ 4, /*acked_segment_id*/ None, + ); + + let retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| server_envelope.event.segment_id()) + .collect::>(); + assert_eq!(retained, Vec::>::new()); + assert_eq!(*used_rx.borrow(), 0); + } + + #[test] + fn websocket_state_drops_duplicate_client_chunks_while_pending() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let first_chunk = client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"x", + ); + let second_chunk = client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"y", + ); + + assert!(matches!( + observe_client_message(&mut state, first_chunk.clone()), + ClientSegmentObservation::Pending + )); + assert!(matches!( + observe_client_message(&mut state, first_chunk.clone()), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + observe_client_message(&mut state, second_chunk), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + observe_client_message(&mut state, first_chunk), + ClientSegmentObservation::Pending + )); + } + + #[test] + fn websocket_state_drops_replayed_client_chunks_after_completion() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let first_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 4, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + ); + let second_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 4, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + ); + + assert!(matches!( + observe_client_message(&mut state, first_chunk.clone()), + ClientSegmentObservation::Pending + )); + assert!(matches!( + observe_client_message(&mut state, second_chunk), + ClientSegmentObservation::Forward(_) + )); + assert!(matches!( + observe_client_message(&mut state, first_chunk), + ClientSegmentObservation::Dropped + )); + } + + #[test] + fn websocket_state_allows_replay_after_rejected_out_of_order_chunk() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let first_chunk = client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"x", + ); + let second_chunk = client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"y", + ); + + assert!(matches!( + observe_client_message(&mut state, second_chunk), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + observe_client_message(&mut state, first_chunk), + ClientSegmentObservation::Pending + )); + } + + #[test] + fn websocket_state_allows_replay_after_later_chunk_drops() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let first_chunk = client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"x", + ); + let invalid_second_chunk = client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"", + ); + + assert!(matches!( + observe_client_message(&mut state, first_chunk.clone()), + ClientSegmentObservation::Pending + )); + assert!(matches!( + observe_client_message(&mut state, invalid_second_chunk), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + observe_client_message(&mut state, first_chunk), + ClientSegmentObservation::Pending + )); + } + + #[test] + fn websocket_state_drops_oversized_client_chunk_frames() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let chunk = client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0, + /*segment_count*/ 1, /*message_size_bytes*/ 1, b"x", + ); + + assert!(matches!( + state.observe_client_message(chunk, REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1), + ClientSegmentObservation::Dropped + )); + } + + #[test] + fn websocket_state_ignores_oversized_stale_chunks_without_dropping_newer_assembly() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let first_newer_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + ); + let oversized_stale_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 7, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + ); + let second_newer_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 8, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + ); + + assert!(matches!( + observe_client_message(&mut state, first_newer_chunk), + ClientSegmentObservation::Pending + )); + assert!(matches!( + state.observe_client_message( + oversized_stale_chunk, + REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1, + ), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + observe_client_message(&mut state, second_newer_chunk), + ClientSegmentObservation::Forward(_) + )); + } + + #[test] + fn websocket_state_ignores_oversized_duplicate_chunks_without_dropping_current_assembly() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let first_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + ); + let oversized_duplicate_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 8, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + ); + let second_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 8, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + ); + + assert!(matches!( + observe_client_message(&mut state, first_chunk), + ClientSegmentObservation::Pending + )); + assert!(matches!( + state.observe_client_message( + oversized_duplicate_chunk, + REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1, + ), + ClientSegmentObservation::Dropped + )); + assert!(matches!( + observe_client_message(&mut state, second_chunk), + ClientSegmentObservation::Forward(_) + )); + } + + #[test] + fn websocket_state_clears_chunk_cursor_when_stream_is_invalidated() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let client_id = ClientId("client-1".to_string()); + let stream_id = StreamId("stream-1".to_string()); + + assert!(matches!( + observe_client_message( + &mut state, + client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"x", + ) + ), + ClientSegmentObservation::Pending + )); + state.invalidate_client_message_stream(&client_id, &stream_id); + state + .client_segment_reassembler + .invalidate_stream(&client_id, &stream_id); + + assert!(matches!( + observe_client_message( + &mut state, + client_chunk_envelope( + "client-1", "stream-1", /*seq_id*/ 1, /*segment_id*/ 0, + /*segment_count*/ 2, /*message_size_bytes*/ 2, b"x", + ) + ), + ClientSegmentObservation::Pending + )); + } + + fn server_envelope( + client_id: &ClientId, + stream_id: &str, + seq_id: u64, + summary: &str, + ) -> ServerEnvelope { + ServerEnvelope { + event: ServerEvent::ServerMessage { + message: Box::new(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: summary.to_string(), + details: None, + path: None, + range: None, + }), + )), + }, + client_id: client_id.clone(), + stream_id: StreamId(stream_id.to_string()), + seq_id, + } + } + + fn server_chunk_envelope( + client_id: &ClientId, + stream_id: &str, + seq_id: u64, + segment_id: usize, + ) -> ServerEnvelope { + ServerEnvelope { + event: ServerEvent::ServerMessageChunk { + segment_id, + segment_count: 2, + message_size_bytes: 2, + message_chunk_base64: String::new(), + }, + client_id: client_id.clone(), + stream_id: StreamId(stream_id.to_string()), + seq_id, + } + } + + fn client_chunk_envelope( + client_id: &str, + stream_id: &str, + seq_id: u64, + segment_id: usize, + segment_count: usize, + message_size_bytes: usize, + chunk: &[u8], + ) -> ClientEnvelope { + ClientEnvelope { + event: ClientEvent::ClientMessageChunk { + segment_id, + segment_count, + message_size_bytes, + message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk), + }, + client_id: ClientId(client_id.to_string()), + stream_id: Some(StreamId(stream_id.to_string())), + seq_id: Some(seq_id), + cursor: None, + } + } + + fn observe_client_message( + state: &mut WebsocketState, + envelope: ClientEnvelope, + ) -> ClientSegmentObservation { + let wire_size_bytes = serde_json::to_vec(&envelope) + .expect("client envelope should serialize") + .len(); + state.observe_client_message(envelope, wire_size_bytes) + } + + async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) { + let (stream, _) = timeout(TEST_HTTP_ACCEPT_TIMEOUT, listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + } + + ( + reader.into_inner(), + request_line.trim_end_matches("\r\n").to_string(), + ) + } + + async fn connected_websocket_pair() -> ( + WebSocketStream>, + WebSocketStream, + ) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let connect_task = tokio::spawn(connect_async(format!( + "ws://{}", + listener + .local_addr() + .expect("listener should have a local addr") + ))); + let (server_stream, _) = listener + .accept() + .await + .expect("server should accept client"); + let server_stream = accept_async(server_stream) + .await + .expect("server websocket handshake should succeed"); + let (client_stream, _) = connect_task + .await + .expect("client connect task should join") + .expect("client websocket handshake should succeed"); + + (client_stream, server_stream) + } + + async fn read_server_text_event( + server_stream: &mut WebSocketStream, + ) -> serde_json::Value { + let message = timeout(Duration::from_secs(5), server_stream.next()) + .await + .expect("server event should arrive in time") + .expect("server websocket should stay open") + .expect("server event should read"); + let tungstenite::Message::Text(text) = message else { + panic!("expected text event, got {message:?}"); + }; + serde_json::from_str(text.as_ref()).expect("server event should deserialize") + } + + async fn respond_with_status_and_headers( + mut stream: TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &str, + ) { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n{body}", + body.len(), + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); + } +} diff --git a/code-rs/app-server-transport/src/transport/stdio.rs b/code-rs/app-server-transport/src/transport/stdio.rs new file mode 100644 index 00000000000..2d30296cd07 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/stdio.rs @@ -0,0 +1,113 @@ +use super::CHANNEL_CAPACITY; +use super::ConnectionOrigin; +use super::TransportEvent; +use super::forward_incoming_message; +use super::next_connection_id; +use super::serialize_outgoing_message; +use crate::outgoing_message::QueuedOutgoingMessage; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCRequest; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use tokio::io; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tracing::debug; +use tracing::error; +use tracing::info; + +pub async fn start_stdio_connection( + transport_event_tx: mpsc::Sender, + stdio_handles: &mut Vec>, + initialize_client_name_tx: oneshot::Sender, +) -> IoResult<()> { + let connection_id = next_connection_id(); + let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let writer_tx_for_reader = writer_tx.clone(); + transport_event_tx + .send(TransportEvent::ConnectionOpened { + connection_id, + origin: ConnectionOrigin::Stdio, + writer: writer_tx, + disconnect_sender: None, + }) + .await + .map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?; + + let transport_event_tx_for_reader = transport_event_tx.clone(); + stdio_handles.push(tokio::spawn(async move { + let stdin = io::stdin(); + let reader = BufReader::new(stdin); + let mut lines = reader.lines(); + let mut initialize_client_name_tx = Some(initialize_client_name_tx); + + loop { + match lines.next_line().await { + Ok(Some(line)) => { + if let Some(client_name) = stdio_initialize_client_name(&line) + && let Some(initialize_client_name_tx) = initialize_client_name_tx.take() + { + let _ = initialize_client_name_tx.send(client_name); + } + if !forward_incoming_message( + &transport_event_tx_for_reader, + &writer_tx_for_reader, + connection_id, + &line, + ) + .await + { + break; + } + } + Ok(None) => break, + Err(err) => { + error!("Failed reading stdin: {err}"); + break; + } + } + } + + let _ = transport_event_tx_for_reader + .send(TransportEvent::ConnectionClosed { connection_id }) + .await; + debug!("stdin reader finished (EOF)"); + })); + + stdio_handles.push(tokio::spawn(async move { + let mut stdout = io::stdout(); + while let Some(queued_message) = writer_rx.recv().await { + let Some(mut json) = serialize_outgoing_message(queued_message.message) else { + continue; + }; + json.push('\n'); + if let Err(err) = stdout.write_all(json.as_bytes()).await { + error!("Failed to write to stdout: {err}"); + break; + } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + info!("stdout writer exited (channel closed)"); + })); + + Ok(()) +} + +fn stdio_initialize_client_name(line: &str) -> Option { + let message = serde_json::from_str::(line).ok()?; + let JSONRPCMessage::Request(JSONRPCRequest { method, params, .. }) = message else { + return None; + }; + if method != "initialize" { + return None; + } + let params = serde_json::from_value::(params?).ok()?; + Some(params.client_info.name) +} diff --git a/code-rs/app-server-transport/src/transport/unix_socket.rs b/code-rs/app-server-transport/src/transport/unix_socket.rs new file mode 100644 index 00000000000..f75d3fe99af --- /dev/null +++ b/code-rs/app-server-transport/src/transport/unix_socket.rs @@ -0,0 +1,165 @@ +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::path::Path; + +use super::TransportEvent; +use crate::transport::websocket::run_websocket_connection; +use codex_uds::UnixListener; +use codex_uds::UnixStream; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::StreamExt; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::time::Duration; +use tokio_tungstenite::accept_async; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +#[cfg(unix)] +const CONTROL_SOCKET_MODE: u32 = 0o600; + +pub async fn start_control_socket_acceptor( + socket_path: AbsolutePathBuf, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, +) -> IoResult> { + prepare_control_socket_path(socket_path.as_path()).await?; + let listener = UnixListener::bind(socket_path.as_path()).await?; + let socket_guard = ControlSocketFileGuard { socket_path }; + set_control_socket_permissions(socket_guard.socket_path.as_path()).await?; + info!( + socket_path = %socket_guard.socket_path.display(), + "app-server control socket listening" + ); + + Ok(tokio::spawn(run_control_socket_acceptor( + listener, + transport_event_tx, + shutdown_token, + socket_guard, + ))) +} + +async fn run_control_socket_acceptor( + mut listener: UnixListener, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + socket_guard: ControlSocketFileGuard, +) { + let _socket_guard = socket_guard; + loop { + let stream = tokio::select! { + _ = shutdown_token.cancelled() => { + break; + } + result = listener.accept() => { + match result { + Ok(stream) => stream, + Err(err) => { + if matches!( + err.kind(), + ErrorKind::ConnectionAborted | ErrorKind::ConnectionReset | ErrorKind::Interrupted + ) { + warn!("recoverable control socket accept error: {err}"); + continue; + } + error!("control socket accept error: {err}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + } + } + }; + + let transport_event_tx = transport_event_tx.clone(); + tokio::spawn(async move { + let websocket_stream = match accept_async(stream).await { + Ok(websocket_stream) => websocket_stream, + Err(err) => { + warn!("failed to upgrade control socket websocket connection: {err}"); + return; + } + }; + let (websocket_writer, websocket_reader) = websocket_stream.split(); + run_websocket_connection(websocket_writer, websocket_reader, transport_event_tx).await; + }); + } + info!("control socket acceptor shutting down"); +} + +async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { + if let Some(parent) = socket_path.parent() { + codex_uds::prepare_private_socket_directory(parent).await?; + } + + match UnixStream::connect(socket_path).await { + Ok(_stream) => { + return Err(std::io::Error::new( + ErrorKind::AddrInUse, + format!( + "app-server control socket is already in use at {}", + socket_path.display() + ), + )); + } + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) if err.kind() == ErrorKind::ConnectionRefused => {} + Err(err) => { + if !socket_path.exists() { + return Ok(()); + } + return Err(err); + } + } + + if !socket_path.try_exists()? { + return Ok(()); + } + + if !codex_uds::is_stale_socket_path(socket_path).await? { + return Err(std::io::Error::new( + ErrorKind::AlreadyExists, + format!( + "app-server control socket path exists and is not a socket: {}", + socket_path.display() + ), + )); + } + tokio::fs::remove_file(socket_path).await +} + +#[cfg(unix)] +async fn set_control_socket_permissions(socket_path: &Path) -> IoResult<()> { + use std::os::unix::fs::PermissionsExt; + + tokio::fs::set_permissions( + socket_path, + std::fs::Permissions::from_mode(CONTROL_SOCKET_MODE), + ) + .await +} + +#[cfg(not(unix))] +async fn set_control_socket_permissions(_socket_path: &Path) -> IoResult<()> { + Ok(()) +} + +struct ControlSocketFileGuard { + socket_path: AbsolutePathBuf, +} + +impl Drop for ControlSocketFileGuard { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(self.socket_path.as_path()) + && err.kind() != ErrorKind::NotFound + { + warn!( + socket_path = %self.socket_path.display(), + %err, + "failed to remove app-server control socket file" + ); + } + } +} diff --git a/code-rs/app-server-transport/src/transport/unix_socket_tests.rs b/code-rs/app-server-transport/src/transport/unix_socket_tests.rs new file mode 100644 index 00000000000..0b7dec0a236 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/unix_socket_tests.rs @@ -0,0 +1,201 @@ +use super::AppServerTransport; +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::app_server_control_socket_path; +use super::start_control_socket_acceptor; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_core::config::find_codex_home; +use codex_uds::UnixStream; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use std::io::Result as IoResult; +use std::path::Path; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio::time::timeout; +use tokio_tungstenite::client_async; +use tokio_tungstenite::tungstenite::Bytes; +use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_util::sync::CancellationToken; + +#[test] +fn listen_unix_socket_parses_as_unix_socket_transport() { + assert_eq!( + AppServerTransport::from_listen_url("unix://"), + Ok(AppServerTransport::UnixSocket { + socket_path: default_control_socket_path() + }) + ); +} + +#[test] +fn listen_unix_socket_accepts_absolute_custom_path() { + assert_eq!( + AppServerTransport::from_listen_url("unix:///tmp/codex.sock"), + Ok(AppServerTransport::UnixSocket { + socket_path: absolute_path("/tmp/codex.sock") + }) + ); +} + +#[test] +fn listen_unix_socket_accepts_relative_custom_path() { + assert_eq!( + AppServerTransport::from_listen_url("unix://codex.sock"), + Ok(AppServerTransport::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock") + .expect("relative path should resolve") + }) + ); +} + +#[tokio::test] +async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_and_pings() { + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let socket_path = test_socket_path(temp_dir.path()); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let accept_handle = start_control_socket_acceptor( + socket_path.clone(), + transport_event_tx, + shutdown_token.clone(), + ) + .await + .expect("control socket acceptor should start"); + + let stream = connect_to_socket(socket_path.as_path()) + .await + .expect("client should connect"); + let (mut websocket, response) = client_async("ws://localhost/rpc", stream) + .await + .expect("websocket upgrade should complete"); + assert_eq!(response.status().as_u16(), 101); + + let opened = timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("connection opened event should arrive") + .expect("connection opened event"); + let connection_id = match opened { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + _ => panic!("expected connection opened event"), + }; + + let notification = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + websocket + .send(WebSocketMessage::Text( + serde_json::to_string(¬ification) + .expect("notification should serialize") + .into(), + )) + .await + .expect("notification should send"); + + let incoming = timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("incoming message event should arrive") + .expect("incoming message event"); + assert_eq!( + match incoming { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => (incoming_connection_id, message), + _ => panic!("expected incoming message event"), + }, + (connection_id, notification) + ); + + websocket + .send(WebSocketMessage::Ping(Bytes::from_static(b"check"))) + .await + .expect("ping should send"); + let pong = timeout(Duration::from_secs(1), websocket.next()) + .await + .expect("pong should arrive") + .expect("pong frame") + .expect("pong should be valid"); + assert_eq!(pong, WebSocketMessage::Pong(Bytes::from_static(b"check"))); + + websocket.close(None).await.expect("close should send"); + let closed = timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("connection closed event should arrive") + .expect("connection closed event"); + assert!(matches!( + closed, + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } if closed_connection_id == connection_id + )); + + shutdown_token.cancel(); + accept_handle.await.expect("acceptor should join"); + assert_socket_path_removed(socket_path.as_path()); +} + +#[cfg(unix)] +#[tokio::test] +async fn control_socket_file_is_private_after_bind() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let socket_path = test_socket_path(temp_dir.path()); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let accept_handle = start_control_socket_acceptor( + socket_path.clone(), + transport_event_tx, + shutdown_token.clone(), + ) + .await + .expect("control socket acceptor should start"); + + let metadata = tokio::fs::metadata(socket_path.as_path()) + .await + .expect("socket metadata should exist"); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + + shutdown_token.cancel(); + accept_handle.await.expect("acceptor should join"); +} + +fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") +} + +fn default_control_socket_path() -> AbsolutePathBuf { + let codex_home = find_codex_home().expect("codex home"); + app_server_control_socket_path(&codex_home).expect("default control socket path") +} + +fn test_socket_path(temp_dir: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path( + temp_dir + .join("app-server-control") + .join("app-server-control.sock"), + ) + .expect("socket path should resolve") +} + +async fn connect_to_socket(socket_path: &Path) -> IoResult { + UnixStream::connect(socket_path).await +} + +#[cfg(unix)] +fn assert_socket_path_removed(socket_path: &Path) { + assert!(!socket_path.exists()); +} + +#[cfg(windows)] +fn assert_socket_path_removed(_socket_path: &Path) { + // uds_windows uses a regular filesystem path as its rendezvous point, + // but there is no Unix socket filesystem node to assert on. +} diff --git a/code-rs/app-server-transport/src/transport/websocket.rs b/code-rs/app-server-transport/src/transport/websocket.rs new file mode 100644 index 00000000000..627197c29b8 --- /dev/null +++ b/code-rs/app-server-transport/src/transport/websocket.rs @@ -0,0 +1,388 @@ +use super::CHANNEL_CAPACITY; +use super::ConnectionOrigin; +use super::TransportEvent; +use super::auth::WebsocketAuthPolicy; +use super::auth::authorize_upgrade; +use super::auth::should_warn_about_unauthenticated_non_loopback_listener; +use super::forward_incoming_message; +use super::next_connection_id; +use super::serialize_outgoing_message; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::QueuedOutgoingMessage; +use axum::Router; +use axum::body::Body; +use axum::body::Bytes; +use axum::extract::ConnectInfo; +use axum::extract::State; +use axum::extract::ws::Message as AxumWebSocketMessage; +use axum::extract::ws::WebSocketUpgrade; +use axum::http::HeaderMap; +use axum::http::Request; +use axum::http::StatusCode; +use axum::http::header::ORIGIN; +use axum::middleware; +use axum::middleware::Next; +use axum::response::IntoResponse; +use axum::response::Response; +use axum::routing::any; +use axum::routing::get; +use futures::SinkExt; +use futures::StreamExt; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use owo_colors::Style; +use std::io::Result as IoResult; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::Message as TungsteniteWebSocketMessage; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +/// WebSocket clients can briefly lag behind normal turn output bursts while the +/// writer task is healthy, so give them more headroom than internal channels. +const WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY: usize = 32 * 1024; +const _: () = assert!(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY > CHANNEL_CAPACITY); + +fn colorize(text: &str, style: Style) -> String { + text.if_supports_color(Stream::Stderr, |value| value.style(style)) + .to_string() +} + +#[allow(clippy::print_stderr)] +fn print_websocket_startup_banner(addr: SocketAddr) { + let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan()); + let listening_label = colorize("listening on:", Style::new().dimmed()); + let listen_url = colorize(&format!("ws://{addr}"), Style::new().green()); + let ready_label = colorize("readyz:", Style::new().dimmed()); + let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green()); + let health_label = colorize("healthz:", Style::new().dimmed()); + let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green()); + let note_label = colorize("note:", Style::new().dimmed()); + eprintln!("{title}"); + eprintln!(" {listening_label} {listen_url}"); + eprintln!(" {ready_label} {ready_url}"); + eprintln!(" {health_label} {health_url}"); + if addr.ip().is_loopback() { + eprintln!( + " {note_label} binds localhost only (use SSH port-forwarding for remote access)" + ); + } else { + eprintln!( + " {note_label} websocket auth is opt-in in this build; configure `--ws-auth ...` before real remote use" + ); + } +} + +#[derive(Clone)] +struct WebSocketListenerState { + transport_event_tx: mpsc::Sender, + auth_policy: Arc, +} + +async fn health_check_handler() -> StatusCode { + StatusCode::OK +} + +async fn reject_requests_with_origin_header( + request: Request, + next: Next, +) -> Result { + if request.headers().contains_key(ORIGIN) { + warn!( + method = %request.method(), + uri = %request.uri(), + "rejecting websocket listener request with Origin header" + ); + Err(StatusCode::FORBIDDEN) + } else { + Ok(next.run(request).await) + } +} + +async fn websocket_upgrade_handler( + websocket: WebSocketUpgrade, + ConnectInfo(peer_addr): ConnectInfo, + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) { + warn!( + %peer_addr, + message = err.message(), + "rejecting websocket client during upgrade" + ); + return (err.status_code(), err.message()).into_response(); + } + info!(%peer_addr, "websocket client connected"); + websocket + .on_upgrade(move |stream| async move { + let (websocket_writer, websocket_reader) = stream.split(); + run_websocket_connection(websocket_writer, websocket_reader, state.transport_event_tx) + .await; + }) + .into_response() +} + +pub async fn start_websocket_acceptor( + bind_address: SocketAddr, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + auth_policy: WebsocketAuthPolicy, +) -> IoResult> { + if should_warn_about_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { + warn!( + %bind_address, + "starting non-loopback websocket listener without auth; websocket auth is opt-in for now and will become the default in a future release" + ); + } + let listener = TcpListener::bind(bind_address).await?; + let local_addr = listener.local_addr()?; + print_websocket_startup_banner(local_addr); + info!("app-server websocket listening on ws://{local_addr}"); + + let router = Router::new() + .route("/readyz", get(health_check_handler)) + .route("/healthz", get(health_check_handler)) + .fallback(any(websocket_upgrade_handler)) + .layer(middleware::from_fn(reject_requests_with_origin_header)) + .with_state(WebSocketListenerState { + transport_event_tx, + auth_policy: Arc::new(auth_policy), + }); + let server = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + shutdown_token.cancelled().await; + }); + Ok(tokio::spawn(async move { + if let Err(err) = server.await { + error!("websocket acceptor failed: {err}"); + } + info!("websocket acceptor shutting down"); + })) +} + +pub(crate) async fn run_websocket_connection( + websocket_writer: impl futures::sink::Sink + Send + 'static, + websocket_reader: impl futures::stream::Stream> + Send + 'static, + transport_event_tx: mpsc::Sender, +) where + M: AppServerWebSocketMessage + Send + 'static, + SinkError: Send + 'static, + StreamError: std::fmt::Display + Send + 'static, +{ + let connection_id = next_connection_id(); + let (writer_tx, writer_rx) = + mpsc::channel::(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY); + let writer_tx_for_reader = writer_tx.clone(); + let disconnect_token = CancellationToken::new(); + if transport_event_tx + .send(TransportEvent::ConnectionOpened { + connection_id, + origin: ConnectionOrigin::WebSocket, + writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), + }) + .await + .is_err() + { + return; + } + + let (writer_control_tx, writer_control_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let mut outbound_task = tokio::spawn(run_websocket_outbound_loop( + websocket_writer, + writer_rx, + writer_control_rx, + disconnect_token.clone(), + )); + let mut inbound_task = tokio::spawn(run_websocket_inbound_loop( + websocket_reader, + transport_event_tx.clone(), + writer_tx_for_reader, + writer_control_tx, + connection_id, + disconnect_token.clone(), + )); + + tokio::select! { + _ = &mut outbound_task => { + disconnect_token.cancel(); + inbound_task.abort(); + } + _ = &mut inbound_task => { + disconnect_token.cancel(); + outbound_task.abort(); + } + } + + let _ = transport_event_tx + .send(TransportEvent::ConnectionClosed { connection_id }) + .await; +} + +pub(crate) enum IncomingWebSocketMessage { + Text(String), + Binary, + Ping(Bytes), + Pong, + Close, +} + +/// Converts concrete WebSocket message types into the small message surface the +/// app-server transport needs, and constructs the only outbound frames it +/// sends directly. +pub(crate) trait AppServerWebSocketMessage: Sized { + fn text(text: String) -> Self; + fn pong(payload: Bytes) -> Self; + fn into_incoming(self) -> Option; +} + +impl AppServerWebSocketMessage for AxumWebSocketMessage { + fn text(text: String) -> Self { + Self::Text(text.into()) + } + + fn pong(payload: Bytes) -> Self { + Self::Pong(payload) + } + + fn into_incoming(self) -> Option { + Some(match self { + Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()), + Self::Binary(_) => IncomingWebSocketMessage::Binary, + Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload), + Self::Pong(_) => IncomingWebSocketMessage::Pong, + Self::Close(_) => IncomingWebSocketMessage::Close, + }) + } +} + +impl AppServerWebSocketMessage for TungsteniteWebSocketMessage { + fn text(text: String) -> Self { + Self::Text(text.into()) + } + + fn pong(payload: Bytes) -> Self { + Self::Pong(payload) + } + + fn into_incoming(self) -> Option { + Some(match self { + Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()), + Self::Binary(_) => IncomingWebSocketMessage::Binary, + Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload), + Self::Pong(_) => IncomingWebSocketMessage::Pong, + Self::Close(_) => IncomingWebSocketMessage::Close, + Self::Frame(_) => return None, + }) + } +} + +async fn run_websocket_outbound_loop( + websocket_writer: impl futures::sink::Sink + Send + 'static, + mut writer_rx: mpsc::Receiver, + mut writer_control_rx: mpsc::Receiver, + disconnect_token: CancellationToken, +) where + M: AppServerWebSocketMessage + Send + 'static, + SinkError: Send + 'static, +{ + tokio::pin!(websocket_writer); + loop { + tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + message = writer_control_rx.recv() => { + let Some(message) = message else { + break; + }; + if websocket_writer.send(message).await.is_err() { + break; + } + } + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { + break; + }; + let Some(json) = serialize_outgoing_message(queued_message.message) else { + continue; + }; + if websocket_writer.send(M::text(json)).await.is_err() { + break; + } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + } + } +} + +async fn run_websocket_inbound_loop( + websocket_reader: impl futures::stream::Stream> + Send + 'static, + transport_event_tx: mpsc::Sender, + writer_tx_for_reader: mpsc::Sender, + writer_control_tx: mpsc::Sender, + connection_id: ConnectionId, + disconnect_token: CancellationToken, +) where + M: AppServerWebSocketMessage + Send + 'static, + StreamError: std::fmt::Display + Send + 'static, +{ + tokio::pin!(websocket_reader); + loop { + tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + incoming_message = websocket_reader.next() => { + match incoming_message { + Some(Ok(message)) => match message.into_incoming() { + Some(IncomingWebSocketMessage::Text(text)) => { + if !forward_incoming_message( + &transport_event_tx, + &writer_tx_for_reader, + connection_id, + &text, + ) + .await + { + break; + } + } + Some(IncomingWebSocketMessage::Ping(payload)) => { + match writer_control_tx.try_send(M::pong(payload)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("websocket control queue full while replying to ping; closing connection"); + break; + } + } + } + Some(IncomingWebSocketMessage::Pong) => {} + Some(IncomingWebSocketMessage::Close) => break, + Some(IncomingWebSocketMessage::Binary) => { + warn!("dropping unsupported binary websocket message"); + } + None => {} + }, + None => break, + Some(Err(err)) => { + warn!("websocket receive error: {err}"); + break; + } + } + } + } + } +} diff --git a/code-rs/app-server/BUILD.bazel b/code-rs/app-server/BUILD.bazel new file mode 100644 index 00000000000..6765141bdc4 --- /dev/null +++ b/code-rs/app-server/BUILD.bazel @@ -0,0 +1,21 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "app-server", + crate_name = "codex_app_server", + integration_test_timeout = "long", + test_shard_counts = { + # Note app-server-all-test has a large number of integration tests, so + # even a single shard can be quite slow. When there is a legitimate + # test failure in a shard, it will still get run 3x in total, which + # can cause us to exhaust our CI timeout if the shard happens to run + # long. Using a higher shard count for app-server-all-test should help + # mitigate this risk. + "app-server-all-test": 16, + "app-server-unit-tests": 8, + }, + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], + test_tags = ["no-sandbox"], +) diff --git a/code-rs/app-server/Cargo.toml b/code-rs/app-server/Cargo.toml index 1a17e7c8e27..b4a5e64a89c 100644 --- a/code-rs/app-server/Cargo.toml +++ b/code-rs/app-server/Cargo.toml @@ -1,53 +1,121 @@ [package] -edition = "2024" -name = "code-app-server" -version = { workspace = true } +name = "codex-app-server" +version.workspace = true +edition.workspace = true +license.workspace = true [[bin]] -name = "code-app-server" +name = "codex-app-server" path = "src/main.rs" +[[bin]] +name = "codex-app-server-test-notify-capture" +path = "src/bin/notify_capture.rs" + [lib] -name = "code_app_server" +name = "codex_app_server" path = "src/lib.rs" +doctest = false [lints] workspace = true [dependencies] anyhow = { workspace = true } +async-trait = { workspace = true } +base64 = { workspace = true } +axum = { workspace = true, default-features = false, features = [ + "http1", + "json", + "tokio", + "ws", +] } +codex-analytics = { workspace = true } +codex-arg0 = { workspace = true } +codex-cloud-requirements = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-core-plugins = { workspace = true } +codex-exec-server = { workspace = true } +codex-external-agent-migration = { workspace = true } +codex-external-agent-sessions = { workspace = true } +codex-features = { workspace = true } +codex-git-utils = { workspace = true } +codex-hooks = { workspace = true } +codex-otel = { workspace = true } +codex-plugin = { workspace = true } +codex-shell-command = { workspace = true } +codex-utils-cli = { workspace = true } +codex-utils-pty = { workspace = true } +codex-backend-client = { workspace = true } +codex-file-search = { workspace = true } +codex-chatgpt = { workspace = true } +codex-login = { workspace = true } +codex-memories-write = { workspace = true } +codex-mcp = { workspace = true } +codex-model-provider = { workspace = true } +codex-models-manager = { workspace = true } +codex-protocol = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-app-server-transport = { workspace = true } +codex-feedback = { workspace = true } +codex-rmcp-client = { workspace = true } +codex-rollout = { workspace = true } +codex-sandboxing = { workspace = true } +codex-state = { workspace = true } +codex-thread-store = { workspace = true } +codex-tools = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-json-to-toml = { workspace = true } +chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } -code-arg0 = { workspace = true } -code-common = { workspace = true, features = ["cli"] } -code-core = { workspace = true } -code-file-search = { workspace = true } -code-login = { workspace = true } -code-app-server-protocol = { workspace = true } -code-protocol = { workspace = true } -code-utils-absolute-path = { workspace = true } -code-utils-json-to-toml = { workspace = true } futures = { 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 } -owo-colors = { workspace = true, features = ["supports-colors"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha1 = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +toml = { workspace = true } +toml_edit = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", - "net", "process", "rt-multi-thread", "signal", ] } -tokio-tungstenite = { version = "0.23", default-features = true, features = ["rustls-tls-webpki-roots"] } +tokio-util = { workspace = true } tracing = { workspace = true, features = ["log"] } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -toml = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] -assert_cmd = { workspace = true } -reqwest = { workspace = true } +app_test_support = { workspace = true } +axum = { workspace = true, default-features = false, features = [ + "http1", + "json", + "tokio", +] } +base64 = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-utils-cargo-bin = { workspace = true } +core_test_support = { workspace = true } +flate2 = { workspace = true } +hmac = { workspace = true } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } +pretty_assertions = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls"] } +rmcp = { workspace = true, default-features = false, features = [ + "elicitation", + "server", + "transport-streamable-http-server", +] } +serial_test = { workspace = true } +sha2 = { workspace = true } +shlex = { workspace = true } +tar = { workspace = true } +tokio-tungstenite = { workspace = true } +tracing-opentelemetry = { workspace = true } +url = { workspace = true } +wiremock = { workspace = true } diff --git a/code-rs/app-server/README.md b/code-rs/app-server/README.md new file mode 100644 index 00000000000..01982d7ee51 --- /dev/null +++ b/code-rs/app-server/README.md @@ -0,0 +1,1955 @@ +# codex-app-server + +`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). + +## Table of Contents + +- [Protocol](#protocol) +- [Message Schema](#message-schema) +- [Core Primitives](#core-primitives) +- [Lifecycle Overview](#lifecycle-overview) +- [Initialization](#initialization) +- [API Overview](#api-overview) +- [Events](#events) +- [Approvals](#approvals) +- [Skills](#skills) +- [Apps](#apps) +- [Auth endpoints](#auth-endpoints) +- [Experimental API Opt-in](#experimental-api-opt-in) + +## Protocol + +Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports bidirectional communication using JSON-RPC 2.0 messages (with the `"jsonrpc":"2.0"` header omitted on the wire). + +Supported transports: + +- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) +- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) +- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake +- off (`--listen off`): do not expose a local transport + +When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: + +- `GET /readyz` returns `200 OK` once the listener is accepting new connections. +- `GET /healthz` returns `200 OK` when no `Origin` header is present. +- Any request carrying an `Origin` header is rejected with `403 Forbidden`. + +Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. + +The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy` +opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock` +by default, or to `--sock PATH` when provided, and proxies bytes between that socket and stdin/stdout. +The proxied stream carries the websocket HTTP Upgrade handshake followed by websocket frames. + +Security note: + +- Loopback websocket listeners (`ws://127.0.0.1:PORT`) remain appropriate for localhost and SSH port-forwarding workflows. +- Non-loopback websocket listeners currently allow unauthenticated connections by default during rollout. If you expose one remotely, configure websocket auth explicitly now. +- Supported auth modes are app-server flags: + - `--ws-auth capability-token --ws-token-file /absolute/path` + - `--ws-auth capability-token --ws-token-sha256 HEX` + - `--ws-auth signed-bearer-token --ws-shared-secret-file /absolute/path` for HMAC-signed JWT/JWS bearer tokens, with optional `--ws-issuer`, `--ws-audience`, `--ws-max-clock-skew-seconds` +- Clients present the credential as `Authorization: Bearer ` during the websocket handshake. Auth is enforced before JSON-RPC `initialize`. +- When starting `codex app-server` manually, prefer `--ws-token-file` over passing raw bearer tokens on the command line. Store a high-entropy token in a file readable only by your user, then have your client present that token in the websocket `Authorization` header. +- `--ws-token-sha256` is intended for clients that keep the raw token in a separate local secret store and only need the server to know the SHA-256 verifier. The hash may appear in process listings, but it is not sufficient to authenticate; clients still need the original raw token. Only use this mode with randomly generated high-entropy tokens, not passwords or other guessable values. + +Tracing/log output: + +- `RUST_LOG` controls log filtering/verbosity. +- Set `LOG_FORMAT=json` to emit app-server tracing logs to `stderr` as JSON (one event per line). + +Backpressure behavior: + +- The server uses bounded queues between transport ingress, request processing, and outbound writes. +- When request ingress is saturated, new requests are rejected with a JSON-RPC error code `-32001` and message `"Server overloaded; retry later."`. +- Clients should treat this as retryable and use exponential backoff with jitter. + +## Message Schema + +Currently, you can dump a TypeScript version of the schema using `codex app-server generate-ts`, or a JSON Schema bundle via `codex app-server generate-json-schema`. Each output is specific to the version of Codex you used to run the command, so the generated artifacts are guaranteed to match that version. + +``` +codex app-server generate-ts --out DIR +codex app-server generate-json-schema --out DIR +``` + +## Core Primitives + +The API exposes three top level primitives representing an interaction between a user and Codex: + +- **Thread**: A conversation between a user and the Codex agent. Each thread contains multiple turns. +- **Turn**: One turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items. +- **Item**: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. Example items include user message, agent reasoning, agent message, shell command, file edit, etc. + +Use the thread APIs to create, list, or archive conversations. Drive a conversation with turn APIs and stream progress via turn notifications. + +## Lifecycle Overview + +- Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. +- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. + The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or experimental `permissions` profile selection, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. +- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). +- Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. + +## Initialization + +Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services, `codexHome` for the server's Codex home directory, and `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error. + +`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. + +Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. + +**Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If +you are developing a new Codex integration that is intended for enterprise use, please contact us to get it +added to a known clients list. For more context: https://chatgpt.com/admin/api-reference#tag/Logs:-Codex + +Example (from OpenAI's official VSCode extension): + +```json +{ + "method": "initialize", + "id": 0, + "params": { + "clientInfo": { + "name": "codex_vscode", + "title": "Codex VS Code Extension", + "version": "0.1.0" + } + } +} +``` + +Example with notification opt-out: + +```json +{ + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "my_client", + "title": "My Client", + "version": "0.1.0" + }, + "capabilities": { + "experimentalApi": true, + "optOutNotificationMethods": ["thread/started", "item/agentMessage/delta"] + } + } +} +``` + +## API Overview + +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. +- `thread/loaded/list` — list the thread ids currently loaded in memory. +- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. +- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. +- `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. +- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. +- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. +- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. +- `thread/goal/set` — create, replace, or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. Supplying a new `objective` replaces the goal and resets usage accounting. Supplying the current non-terminal objective or omitting `objective` updates the existing goal’s status and/or token budget while preserving usage. +- `thread/goal/get` — fetch the current persisted goal for a materialized thread; returns `goal: null` when no goal exists. +- `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. +- `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. +- `thread/goal/cleared` — notification emitted whenever a thread goal is removed. +- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). +- `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. +- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`. +- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. +- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. +- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. +- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. +- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. +- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. +- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. +- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. +- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. +- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. +- `thread/realtime/appendText` — append text input to the active realtime session (experimental); returns `{}`. +- `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. +- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. +- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `command/exec/write` — write base64-decoded stdin bytes to a running `command/exec` session or close stdin; returns `{}`. +- `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`. +- `command/exec/terminate` — terminate a running `command/exec` session by `processId`; returns `{}`. +- `command/exec/outputDelta` — notification emitted for base64-encoded stdout/stderr chunks from a streaming `command/exec` session. +- `process/spawn` — experimental; spawn a standalone process without the Codex sandbox on the host where the app server is running; returns after the process starts and emits `process/outputDelta` and `process/exited` notifications. +- `process/writeStdin` — experimental; write base64-decoded stdin bytes to a running `process/spawn` session or close stdin; returns `{}`. +- `process/resizePty` — experimental; resize a running PTY-backed `process/spawn` session by `processHandle`; returns `{}`. +- `process/kill` — experimental; terminate a running `process/spawn` session by `processHandle`; returns `{}`. +- `process/outputDelta` — experimental; notification emitted for base64-encoded stdout/stderr chunks from a streaming `process/spawn` session. +- `process/exited` — experimental; notification emitted when a `process/spawn` session exits. +- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`. +- `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`. +- `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`. +- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `isSymlink`, `createdAtMs`, and `modifiedAtMs`. +- `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path. +- `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`. +- `fs/copy` — copy between absolute paths; directory copies require `recursive: true`. +- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. +- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. +- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. +- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. +- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. +- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `hooks/list` — list discovered hooks for one or more `cwd` values. +- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. +- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. +- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. +- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). +- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). +- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. +- `skills/changed` — notification emitted when watched local skill files change. +- `app/list` — list available apps. +- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. +- `skills/config/write` — write user-level skill config by name or absolute path. +- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). +- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). +- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. +- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). +- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. +- `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. +- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. +- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. +- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. +- `config/read` — fetch the effective config on disk after resolving config layering. +- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). +- `config/value/write` — write a single config key/value to the user's config.toml on disk. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. + +### Example: Start or resume a thread + +Start a fresh thread when you need a new Codex conversation. + +```json +{ "method": "thread/start", "id": 10, "params": { + // Optionally set config settings. If not specified, will use the user's + // current config settings. + "model": "gpt-5.1-codex", + "cwd": "/Users/me/project", + "approvalPolicy": "never", + "sandbox": "workspaceWrite", + // Prefer experimental profile selection: + // "permissions": { "type": "profile", "id": ":workspace" } + // Do not send both "sandbox" and "permissions". + "personality": "friendly", + "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) + "sessionStartSource": "startup", // optional: "startup" (default) or "clear" + // Experimental: requires opt-in + "dynamicTools": [ + { + "name": "lookup_ticket", + "description": "Fetch a ticket by id", + "deferLoading": true, + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + } + ], +} } +{ "id": 10, "result": { + "thread": { + "id": "thr_123", + "preview": "", + "modelProvider": "openai", + "createdAt": 1730910000 + } +} } +{ "method": "thread/started", "params": { "thread": { … } } } +``` + +Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string. + +To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. + +By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. + +By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead. + +Example: + +```json +{ "method": "thread/resume", "id": 11, "params": { + "threadId": "thr_123", + "personality": "friendly" +} } +{ "id": 11, "result": { "thread": { "id": "thr_123", … } } } + +{ "method": "thread/resume", "id": 12, "params": { + "threadId": "thr_123", + "excludeTurns": true +} } +{ "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } +``` + +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: + +```json +{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } +{ "id": 12, "result": { "thread": { "id": "thr_456", "sessionId": "thr_456", … } } } +{ "method": "thread/started", "params": { "thread": { … } } } +``` + +Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage. + +### Example: List threads (with pagination & filters) + +`thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of: + +- `cursor` — opaque string from a prior response; omit for the first page. +- `limit` — server defaults to a reasonable page size if unset. +- `sortKey` — `created_at` (default) or `updated_at`. +- `sortDirection` — `desc` (default) or `asc`. +- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. +- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). +- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). +- `cwd` — restrict results to threads whose session cwd exactly matches this path, or one of these paths when an array is provided. Relative paths are resolved against the app-server process cwd before matching. +- `useStateDbOnly` — when `true`, return from the state DB without scanning JSONL rollouts to repair metadata. Omit or pass `false` to preserve the default scan-and-repair behavior. +- `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). +- Responses include `nextCursor` to continue in the same direction and `backwardsCursor` to pass as `cursor` when reversing `sortDirection`. +- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. + +Example: + +```json +{ "method": "thread/list", "id": 20, "params": { + "cursor": null, + "limit": 25, + "cwd": ["/Users/me/project", "/Users/me/project-worktree"], + "sortKey": "created_at" +} } +{ "id": 20, "result": { + "data": [ + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } + ], + "nextCursor": "opaque-token-or-null", + "backwardsCursor": "opaque-token-or-null" +} } +``` + +When `nextCursor` is `null`, you’ve reached the final page. + +### Example: List loaded threads + +`thread/loaded/list` returns thread ids currently loaded in memory. This is useful when you want to check which sessions are active without scanning rollouts on disk. + +```json +{ "method": "thread/loaded/list", "id": 21 } +{ "id": 21, "result": { + "data": ["thr_123", "thr_456"] +} } +``` + +### Example: Track thread status changes + +`thread/status/changed` is emitted whenever a loaded thread's status changes after it has already been introduced to the client: + +- Includes `threadId` and the new `status`. +- Status can be `notLoaded`, `idle`, `systemError`, or `active` (with `activeFlags`; `active` implies running). +- `thread/start`, `thread/fork`, and detached review threads do not emit a separate initial `thread/status/changed`; their `thread/started` notification already carries the current `thread.status`. + +```json +{ + "method": "thread/status/changed", + "params": { + "threadId": "thr_123", + "status": { "type": "active", "activeFlags": [] } + } +} +``` + +### Example: Unsubscribe from a loaded thread + +`thread/unsubscribe` removes the current connection's subscription to a thread. The response status is one of: + +- `unsubscribed` when the connection was subscribed and is now removed. +- `notSubscribed` when the connection was not subscribed to that thread. +- `notLoaded` when the thread is not loaded. + +If this was the last subscriber, the server does not unload the thread immediately. It unloads the thread after the thread has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed` and a `thread/status/changed` transition to `notLoaded`. + +```json +{ "method": "thread/unsubscribe", "id": 22, "params": { "threadId": "thr_123" } } +{ "id": 22, "result": { "status": "unsubscribed" } } +``` + +Later, after the idle unload timeout: + +```json +{ "method": "thread/status/changed", "params": { + "threadId": "thr_123", + "status": { "type": "notLoaded" } +} } +{ "method": "thread/closed", "params": { "threadId": "thr_123" } } +``` + +### Example: Read a thread + +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. + +```json +{ "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } +{ "id": 22, "result": { + "thread": { "id": "thr_123", "status": { "type": "notLoaded" }, "turns": [] } +} } +``` + +```json +{ "method": "thread/read", "id": 23, "params": { "threadId": "thr_123", "includeTurns": true } } +{ "id": 23, "result": { + "thread": { "id": "thr_123", "status": { "type": "notLoaded" }, "turns": [ ... ] } +} } +``` + +### Example: List thread turns (experimental) + +Use `thread/turns/list` with `capabilities.experimentalApi = true` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page. + +Every returned `Turn` includes `itemsView`, which tells clients whether the `items` array was omitted intentionally (`notLoaded`), contains only summary items (`summary`), or contains every item available from persisted app-server history (`full`). Pass `itemsView` to choose the returned detail level; omitted `itemsView` defaults to `"summary"`. + +```json +{ "method": "thread/turns/list", "id": 24, "params": { + "threadId": "thr_123", + "limit": 50, + "sortDirection": "desc", + "itemsView": "summary" +} } +{ "id": 24, "result": { + "data": [ ... ], + "nextCursor": "older-turns-cursor-or-null", + "backwardsCursor": "newer-turns-cursor-or-null" +} } +``` + +`thread/turns/items/list` is the planned hydration API for fetching full items for one turn: + +```json +{ "method": "thread/turns/items/list", "id": 25, "params": { + "threadId": "thr_123", + "turnId": "turn_456", + "limit": 100, + "sortDirection": "asc" +} } +``` + +This method currently returns JSON-RPC `-32601` with message `thread/turns/items/list is not supported yet`. + +### Example: Update stored thread metadata + +Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value. + +```json +{ "method": "thread/metadata/update", "id": 24, "params": { + "threadId": "thr_123", + "gitInfo": { "branch": "feature/sidebar-pr" } +} } +{ "id": 24, "result": { + "thread": { + "id": "thr_123", + "gitInfo": { "sha": null, "branch": "feature/sidebar-pr", "originUrl": null } + } +} } + +{ "method": "thread/metadata/update", "id": 25, "params": { + "threadId": "thr_123", + "gitInfo": { "branch": null } +} } +{ "id": 25, "result": { + "thread": { + "id": "thr_123", + "gitInfo": null + } +} } +``` + +Experimental: use `thread/memoryMode/set` to change whether a thread remains eligible for future memory generation. + +```json +{ "method": "thread/memoryMode/set", "id": 26, "params": { + "threadId": "thr_123", + "mode": "disabled" +} } +{ "id": 26, "result": {} } +``` + +Experimental: use `memory/reset` to clear local memory artifacts and sqlite-backed memory stage data for the current Codex home. This preserves existing thread memory modes; use `thread/memoryMode/set` separately when a thread's future memory eligibility should change. + +```json +{ "method": "memory/reset", "id": 27 } +{ "id": 27, "result": {} } +``` + +### Example: Set and update a thread goal + +Use `thread/goal/set` with an `objective` to create or replace the current goal for a materialized thread. Supplying a new objective resets `tokensUsed`, `timeUsedSeconds`, and `createdAt`. Supplying the current non-terminal objective, or omitting `objective`, updates the existing goal’s status or token budget while preserving usage history. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. + +```json +{ "method": "thread/goal/set", "id": 27, "params": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "tokenBudget": 200000 +} } +{ "id": 27, "result": { "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "active", + "tokenBudget": 200000, + "tokensUsed": 0, + "timeUsedSeconds": 0, + "createdAt": 1776272400, + "updatedAt": 1776272400 +} } } +{ "method": "thread/goal/updated", "params": { "threadId": "thr_123", "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "active", + "tokenBudget": 200000, + "tokensUsed": 0, + "timeUsedSeconds": 0, + "createdAt": 1776272400, + "updatedAt": 1776272400 +} } } +``` + +```json +{ "method": "thread/goal/set", "id": 28, "params": { + "threadId": "thr_123", + "status": "paused" +} } +{ "id": 28, "result": { "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "paused", + "tokenBudget": 200000, + "tokensUsed": 10000, + "timeUsedSeconds": 60, + "createdAt": 1776272400, + "updatedAt": 1776272460 +} } } +``` + +Use `thread/goal/get` to read the current goal without changing it. + +```json +{ "method": "thread/goal/get", "id": 29, "params": { "threadId": "thr_123" } } +{ "id": 29, "result": { "goal": null } } +``` + +Use `thread/goal/clear` to remove the current goal. + +```json +{ "method": "thread/goal/clear", "id": 30, "params": { "threadId": "thr_123" } } +{ "id": 30, "result": { "cleared": true } } +{ "method": "thread/goal/cleared", "params": { "threadId": "thr_123" } } +``` + +### Example: Archive a thread + +Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory and attempt to move any spawned descendant thread rollouts. + +```json +{ "method": "thread/archive", "id": 21, "params": { "threadId": "thr_b" } } +{ "id": 21, "result": {} } +{ "method": "thread/archived", "params": { "threadId": "thr_b" } } +``` + +An archived thread will not appear in `thread/list` unless `archived` is set to `true`. + +### Example: Unarchive a thread + +Use `thread/unarchive` to move an archived rollout back into the sessions directory. + +```json +{ "method": "thread/unarchive", "id": 24, "params": { "threadId": "thr_b" } } +{ "id": 24, "result": { "thread": { "id": "thr_b" } } } +{ "method": "thread/unarchived", "params": { "threadId": "thr_b" } } +``` + +### Example: Trigger thread compaction + +Use `thread/compact/start` to trigger manual history compaction for a thread. The request returns immediately with `{}`. + +Progress is emitted as standard `turn/*` and `item/*` notifications on the same `threadId`. Clients should expect a single compaction item: + +- `item/started` with `item: { "type": "contextCompaction", ... }` +- `item/completed` with the same `contextCompaction` item id + +While compaction is running, the thread is effectively in a turn so clients should surface progress UI based on the notifications. + +```json +{ "method": "thread/compact/start", "id": 25, "params": { "threadId": "thr_b" } } +{ "id": 25, "result": {} } +``` + +### Example: Run a thread shell command + +Use `thread/shellCommand` for the TUI `!` workflow. The request returns immediately with `{}`. +This API runs unsandboxed with full access; it does not inherit the thread +sandbox policy. + +If the thread already has an active turn, the command runs as an auxiliary action on that turn. In that case, progress is emitted as standard `item/*` notifications on the existing turn and the formatted output is injected into the turn’s message stream: + +- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }` +- zero or more `item/commandExecution/outputDelta` +- `item/completed` with the same `commandExecution` item id + +If the thread does not already have an active turn, the server starts a standalone turn for the shell command. In that case clients should expect: + +- `turn/started` +- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }` +- zero or more `item/commandExecution/outputDelta` +- `item/completed` with the same `commandExecution` item id +- `turn/completed` + +```json +{ "method": "thread/shellCommand", "id": 26, "params": { "threadId": "thr_b", "command": "git status --short" } } +{ "id": 26, "result": {} } +``` + +### Example: Start a turn (send user input) + +Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: + +- `{"type":"text","text":"Explain this diff"}` +- `{"type":"image","url":"https://…png"}` +- `{"type":"localImage","path":"/tmp/screenshot.png"}` + +You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. + +`approvalsReviewer` accepts: + +- `"user"` — default. Review approval requests directly in the client. +- `"auto_review"` — route approval requests to a carefully prompted subagent, which gathers relevant context and applies a risk-based decision framework before approving or denying the request. The legacy value `"guardian_subagent"` is still accepted for compatibility. + +```json +{ "method": "turn/start", "id": 30, "params": { + "threadId": "thr_123", + "input": [ { "type": "text", "text": "Run tests" } ], + // Below are optional config overrides + "cwd": "/Users/me/project", + // Experimental: turn-scoped environment selection. + "environments": [ + { "environmentId": "local", "cwd": "/Users/me/project" } + ], + "approvalPolicy": "unlessTrusted", + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": ["/Users/me/project"], + "networkAccess": true + }, + // Prefer experimental profile selection: + // "permissions": { "type": "profile", "id": ":workspace" } + // Do not send both "sandboxPolicy" and "permissions". + "model": "gpt-5.1-codex", + "effort": "medium", + "summary": "concise", + "personality": "friendly", + // Optional JSON Schema to constrain the final assistant message for this turn. + "outputSchema": { + "type": "object", + "properties": { "answer": { "type": "string" } }, + "required": ["answer"], + "additionalProperties": false + } +} } +{ "id": 30, "result": { "turn": { + "id": "turn_456", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + +### Example: Start a turn (invoke a skill) + +Invoke a skill explicitly by including `$` in the text input and adding a `skill` input item alongside it. + +```json +{ "method": "turn/start", "id": 33, "params": { + "threadId": "thr_123", + "input": [ + { "type": "text", "text": "$skill-creator Add a new skill for triaging flaky CI and include step-by-step usage." }, + { "type": "skill", "name": "skill-creator", "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" } + ] +} } +{ "id": 33, "result": { "turn": { + "id": "turn_457", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + +### Example: Start a turn (invoke an app) + +Invoke an app by including `$` in the text input and adding a `mention` input item with the app id in `app://` form. + +```json +{ "method": "turn/start", "id": 34, "params": { + "threadId": "thr_123", + "input": [ + { "type": "text", "text": "$demo-app Summarize the latest updates." }, + { "type": "mention", "name": "Demo App", "path": "app://demo-app" } + ] +} } +{ "id": 34, "result": { "turn": { + "id": "turn_458", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + +### Example: Start a turn (invoke a plugin) + +Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/list`. + +```json +{ "method": "turn/start", "id": 35, "params": { + "threadId": "thr_123", + "input": [ + { "type": "text", "text": "@sample Summarize the latest updates." }, + { "type": "mention", "name": "Sample Plugin", "path": "plugin://sample@test" } + ] +} } +{ "id": 35, "result": { "turn": { + "id": "turn_459", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + +### Example: Inject raw history items + +Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. + +```json +{ "method": "thread/inject_items", "id": 36, "params": { + "threadId": "thr_123", + "items": [ + { + "type": "message", + "role": "assistant", + "content": [{ "type": "output_text", "text": "Previously computed context." }] + } + ] +} } +{ "id": 36, "result": {} } +``` + +### Example: Start realtime with WebRTC + +Use `thread/realtime/start` with `transport.type: "webrtc"` when a browser or webview owns the `RTCPeerConnection` and app-server should create the server-side realtime session. The transport `sdp` must be the offer SDP produced by `RTCPeerConnection.createOffer()`, not a hand-written or minimal SDP string. + +The offer should include the media sections the client wants to negotiate. For the standard realtime UI flow, create the audio track/transceiver and the `oai-events` data channel before calling `createOffer()`: + +```javascript +const pc = new RTCPeerConnection(); + +audioElement.autoplay = true; +pc.ontrack = (event) => { + audioElement.srcObject = event.streams[0]; +}; + +const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); +pc.addTrack(mediaStream.getAudioTracks()[0], mediaStream); +pc.createDataChannel("oai-events"); + +const offer = await pc.createOffer(); +await pc.setLocalDescription(offer); +``` + +Then send `offer.sdp` to app-server. Core uses `experimental_realtime_ws_backend_prompt` for the backend instructions and the thread conversation id as the default Realtime API session identifier. This `realtimeSessionId` value refers to the upstream Realtime API session, not a Codex session/thread-group id. The start response is `{}`; the remote answer SDP arrives later as `thread/realtime/sdp` and should be passed to `setRemoteDescription()`: + +```json +{ "method": "thread/realtime/start", "id": 40, "params": { + "threadId": "thr_123", + "outputModality": "audio", + "prompt": "You are on a call.", + "realtimeSessionId": null, + "transport": { "type": "webrtc", "sdp": "v=0\r\no=..." } +} } +{ "id": 40, "result": {} } +{ "method": "thread/realtime/sdp", "params": { + "threadId": "thr_123", + "sdp": "v=0\r\no=..." +} } +``` + +Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null` or +`prompt: ""` when the session should start without that default backend prompt. + +```javascript +await pc.setRemoteDescription({ + type: "answer", + sdp: notification.params.sdp, +}); +``` + +### Example: Interrupt an active turn + +You can cancel a running Turn with `turn/interrupt`. + +```json +{ "method": "turn/interrupt", "id": 31, "params": { + "threadId": "thr_123", + "turnId": "turn_456" +} } +{ "id": 31, "result": {} } +``` + +The server requests cancellation of the active turn, then emits a `turn/completed` event with `status: "interrupted"`. This does not terminate background terminals; use `thread/backgroundTerminals/clean` when you explicitly want to stop those shells. Rely on the `turn/completed` event to know when turn interruption has finished. + +### Example: Clean background terminals + +Use `thread/backgroundTerminals/clean` to terminate all running background terminals associated with a thread. This method is experimental and requires `capabilities.experimentalApi = true`. + +```json +{ "method": "thread/backgroundTerminals/clean", "id": 35, "params": { + "threadId": "thr_123" +} } +{ "id": 35, "result": {} } +``` + +### Example: Steer an active turn + +Use `turn/steer` to append additional user input to the currently active regular turn. This does +not emit `turn/started` and does not accept turn context overrides. + +```json +{ "method": "turn/steer", "id": 32, "params": { + "threadId": "thr_123", + "input": [ { "type": "text", "text": "Actually focus on failing tests first." } ], + "expectedTurnId": "turn_456" +} } +{ "id": 32, "result": { "turnId": "turn_456" } } +``` + +`expectedTurnId` is required. If there is no active turn, `expectedTurnId` does not match the +active turn, or the active turn kind does not accept same-turn steering (for example review or +manual compaction), the request fails with an `invalid request` error. + +### Example: Request a code review + +Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed: + +- `{"type":"uncommittedChanges"}` — staged, unstaged, and untracked files. +- `{"type":"baseBranch","branch":"main"}` — diff against the provided branch’s upstream (see prompt for the exact `git merge-base`/`git diff` instructions Codex will run). +- `{"type":"commit","sha":"abc1234","title":"Optional subject"}` — review a specific commit. +- `{"type":"custom","instructions":"Free-form reviewer instructions"}` — fallback prompt equivalent to the legacy manual review request. +- `delivery` (`"inline"` or `"detached"`, default `"inline"`) — where the review runs: + - `"inline"`: run the review as a new turn on the existing thread. The response’s `reviewThreadId` equals the original `threadId`, and no new `thread/started` notification is emitted. + - `"detached"`: fork a new review thread from the parent conversation and run the review there. The response’s `reviewThreadId` is the id of this new review thread, and the server emits a `thread/started` notification for it before streaming review items. + +Example request/response: + +```json +{ "method": "review/start", "id": 40, "params": { + "threadId": "thr_123", + "delivery": "inline", + "target": { "type": "commit", "sha": "1234567deadbeef", "title": "Polish tui colors" } +} } +{ "id": 40, "result": { + "turn": { + "id": "turn_900", + "status": "inProgress", + "items": [ + { "type": "userMessage", "id": "turn_900", "content": [ { "type": "text", "text": "Review commit 1234567: Polish tui colors" } ] } + ], + "error": null + }, + "reviewThreadId": "thr_123" +} } +``` + +For a detached review, use `"delivery": "detached"`. The response is the same shape, but `reviewThreadId` will be the id of the new review thread (different from the original `threadId`). The server also emits a `thread/started` notification for that new thread before streaming the review turn. + +Codex streams the usual `turn/started` notification followed by an `item/started` +with an `enteredReviewMode` item so clients can show progress: + +```json +{ + "method": "item/started", + "params": { + "item": { + "type": "enteredReviewMode", + "id": "turn_900", + "review": "current changes" + } + } +} +``` + +When the reviewer finishes, the server emits `item/started` and `item/completed` +containing an `exitedReviewMode` item with the final review text: + +```json +{ + "method": "item/completed", + "params": { + "item": { + "type": "exitedReviewMode", + "id": "turn_900", + "review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..." + } + } +} +``` + +The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client. + +### Example: One-off command execution + +Run a standalone command (argv vector) in the server’s sandbox without creating a thread or turn: + +```json +{ "method": "command/exec", "id": 32, "params": { + "command": ["ls", "-la"], + "processId": "ls-1", // optional string; required for streaming and ability to terminate the process + "cwd": "/Users/me/project", // optional; defaults to server cwd + "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names + "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true + "permissionProfile": { // optional; defaults to user config + "type": "managed", + "fileSystem": { "type": "restricted", "entries": [ + { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, + { "path": { "type": "special", "value": { "kind": "project_roots", "subpath": null } }, "access": "write" } + ] }, + "network": { "enabled": false } + }, + "outputBytesCap": 1048576, // optional; per-stream capture cap + "disableOutputCap": false, // optional; cannot be combined with outputBytesCap + "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout + "disableTimeout": false // optional; cannot be combined with timeoutMs +} } +{ "id": 32, "result": { + "exitCode": 0, + "stdout": "...", + "stderr": "" +} } +``` + +- Prefer using `process/spawn` when you want an explicitly unsandboxed process execution API with immediate spawn acknowledgement, handle-based control, output notifications, and an exit notification. +- For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. + +Notes: + +- Empty `command` arrays are rejected. +- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. +- `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. +- When omitted, `timeoutMs` falls back to the server default. +- When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. +- `disableOutputCap: true` disables stdout/stderr capture truncation for that `command/exec` request. It cannot be combined with `outputBytesCap`. +- `disableTimeout: true` disables the timeout entirely for that `command/exec` request. It cannot be combined with `timeoutMs`. +- `processId` is optional for buffered execution. When omitted, Codex generates an internal id for lifecycle tracking, but `tty`, `streamStdin`, and `streamStdoutStderr` must stay disabled and follow-up `command/exec/write` / `command/exec/terminate` calls are not available for that command. +- `size` is only valid when `tty: true`. It sets the initial PTY size in character cells. +- Buffered Windows sandbox execution accepts `processId` for correlation, but `command/exec/write` and `command/exec/terminate` are still unsupported for those requests. +- Buffered Windows sandbox execution also requires the default output cap; custom `outputBytesCap` and `disableOutputCap` are unsupported there. +- `tty`, `streamStdin`, and `streamStdoutStderr` are optional booleans. Legacy requests that omit them continue to use buffered execution. +- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`. +- `tty` and `streamStdin` do not disable the timeout on their own; omit `timeoutMs` to use the server default timeout, or set `disableTimeout: true` to keep the process alive until exit or explicit termination. +- `outputBytesCap` applies independently to `stdout` and `stderr`, and streamed bytes are not duplicated into the final response. +- The `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted. +- `command/exec/outputDelta` notifications are connection-scoped. If the originating connection closes, the server terminates the process. + +Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes: + +```json +{ "method": "command/exec", "id": 33, "params": { + "command": ["bash", "-i"], + "processId": "bash-1", + "tty": true, + "outputBytesCap": 32768 +} } +{ "method": "command/exec/outputDelta", "params": { + "processId": "bash-1", + "stream": "stdout", + "deltaBase64": "YmFzaC00LjQkIA==", + "capReached": false +} } +{ "method": "command/exec/write", "id": 34, "params": { + "processId": "bash-1", + "deltaBase64": "cHdkCg==" +} } +{ "id": 34, "result": {} } +{ "method": "command/exec/write", "id": 35, "params": { + "processId": "bash-1", + "closeStdin": true +} } +{ "id": 35, "result": {} } +{ "method": "command/exec/resize", "id": 36, "params": { + "processId": "bash-1", + "size": { "rows": 48, "cols": 160 } +} } +{ "id": 36, "result": {} } +{ "method": "command/exec/terminate", "id": 37, "params": { + "processId": "bash-1" +} } +{ "id": 37, "result": {} } +{ "id": 33, "result": { + "exitCode": 137, + "stdout": "", + "stderr": "" +} } +``` + +- `command/exec/write` accepts either `deltaBase64`, `closeStdin`, or both. +- Clients may supply a connection-scoped string `processId` in `command/exec`; `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` only accept those client-supplied string ids. +- `command/exec/outputDelta.processId` is always the client-supplied string id from the original `command/exec` request. +- `command/exec/outputDelta.stream` is `stdout` or `stderr`. PTY mode multiplexes terminal output through `stdout`. +- `command/exec/outputDelta.capReached` is `true` on the final streamed chunk for a stream when `outputBytesCap` truncates that stream; later output on that stream is dropped. +- `command/exec.params.env` overrides the server-computed environment per key; set a key to `null` to unset an inherited variable. +- `command/exec/resize` is only supported for PTY-backed `command/exec` sessions. + +### Example: Process lifecycle execution + +Use `process/spawn` to start a standalone argv-based process without the Codex sandbox on the host where the app server is running. The `process/*` API is experimental and requires `initialize.params.capabilities.experimentalApi: true`. The spawn response means the process has started and the `processHandle` is registered; completion is reported later through `process/exited`. + +```json +{ "method": "process/spawn", "id": 40, "params": { + "command": ["cargo", "check"], + "processHandle": "cargo-check-1", + "cwd": "/Users/me/project", // required absolute path + "env": { "RUST_LOG": null }, // optional; override or unset app-server env vars + "outputBytesCap": 1048576, // optional; omit for default, null disables + "timeoutMs": 10000 // optional; omit for default, null disables +} } +{ "id": 40, "result": {} } +{ "method": "process/exited", "params": { + "processHandle": "cargo-check-1", + "exitCode": 0, + "stdout": "...", + "stdoutCapReached": false, + "stderr": "", + "stderrCapReached": false +} } +``` + +For interactive or streaming processes, set `tty: true` or `streamStdoutStderr: true` and route output notifications by `processHandle`: + +```json +{ "method": "process/spawn", "id": 41, "params": { + "command": ["bash", "-i"], + "processHandle": "bash-1", + "cwd": "/Users/me/project", + "tty": true, + "size": { "rows": 40, "cols": 120 }, + "outputBytesCap": null, + "timeoutMs": null +} } +{ "id": 41, "result": {} } +{ "method": "process/outputDelta", "params": { + "processHandle": "bash-1", + "stream": "stdout", + "deltaBase64": "YmFzaC00LjQkIA==", + "capReached": false +} } +{ "method": "process/writeStdin", "id": 42, "params": { + "processHandle": "bash-1", + "deltaBase64": "cHdkCg==" +} } +{ "id": 42, "result": {} } +{ "method": "process/resizePty", "id": 43, "params": { + "processHandle": "bash-1", + "size": { "rows": 48, "cols": 160 } +} } +{ "id": 43, "result": {} } +{ "method": "process/kill", "id": 44, "params": { + "processHandle": "bash-1" +} } +{ "id": 44, "result": {} } +{ "method": "process/exited", "params": { + "processHandle": "bash-1", + "exitCode": 137, + "stdout": "", + "stdoutCapReached": false, + "stderr": "", + "stderrCapReached": false +} } +``` + +- Empty `command` arrays and empty `processHandle` strings are rejected. +- `cwd` is required and must be absolute. +- `process/spawn` is intentionally unsandboxed and does not define sandbox-selection fields such as `sandboxPolicy` or `permissionProfile`. +- Duplicate active `processHandle` values are rejected on the same connection; the same handle can be reused after the prior process exits. +- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`. +- `process/writeStdin` accepts either `deltaBase64`, `closeStdin`, or both. +- When omitted, `timeoutMs` and `outputBytesCap` fall back to server defaults. Set either field to `null` to disable that limit for terminal-style sessions. +- `outputBytesCap` applies independently to `stdout` and `stderr`; `process/exited.stdoutCapReached` and `stderrCapReached` report whether each stream reached the cap. Streamed bytes are not duplicated into `process/exited`. +- `process/outputDelta` and `process/exited` notifications are connection-scoped. If the originating connection closes, the server terminates the process. + +### Example: Filesystem utilities + +These methods operate on absolute paths on the host filesystem and cover reading, writing, directory traversal, copying, removal, and change notifications. + +All filesystem paths in this section must be absolute. + +```json +{ "method": "fs/createDirectory", "id": 40, "params": { + "path": "/tmp/example/nested", + "recursive": true +} } +{ "id": 40, "result": {} } +{ "method": "fs/writeFile", "id": 41, "params": { + "path": "/tmp/example/nested/note.txt", + "dataBase64": "aGVsbG8=" +} } +{ "id": 41, "result": {} } +{ "method": "fs/getMetadata", "id": 42, "params": { + "path": "/tmp/example/nested/note.txt" +} } +{ "id": 42, "result": { + "isDirectory": false, + "isFile": true, + "isSymlink": false, + "createdAtMs": 1730910000000, + "modifiedAtMs": 1730910000000 +} } +{ "method": "fs/readFile", "id": 43, "params": { + "path": "/tmp/example/nested/note.txt" +} } +{ "id": 43, "result": { + "dataBase64": "aGVsbG8=" +} } +``` + +- `fs/getMetadata` returns whether the path resolves to a directory or regular file, whether the path itself is a symlink, plus `createdAtMs` and `modifiedAtMs` in Unix milliseconds. If a timestamp is unavailable on the current platform, that field is `0`. +- `fs/createDirectory` defaults `recursive` to `true` when omitted. +- `fs/remove` defaults both `recursive` and `force` to `true` when omitted. +- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`. +- `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped. + +### Example: Filesystem watch + +`fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations. + +```json +{ "method": "fs/watch", "id": 44, "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "path": "/Users/me/project/.git/HEAD" +} } +{ "id": 44, "result": { + "path": "/Users/me/project/.git/HEAD" +} } +{ "method": "fs/changed", "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": ["/Users/me/project/.git/HEAD"] +} } +{ "method": "fs/unwatch", "id": 45, "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1" +} } +{ "id": 45, "result": {} } +``` + +## Events + +Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications. + +Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`. + +Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics. + +Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session. + +### Notification opt-out + +Clients can suppress specific notifications per connection by sending exact method names in `initialize.params.capabilities.optOutNotificationMethods`. + +- Exact-match only: `item/agentMessage/delta` suppresses only that method. +- Unknown method names are ignored. +- Applies to app-server typed notifications such as `thread/*`, `turn/*`, `item/*`, and `rawResponseItem/*`. +- Does not apply to requests/responses/errors. + +Examples: + +- Opt out of thread lifecycle notifications: `thread/started` +- Opt out of streamed agent text deltas: `item/agentMessage/delta` + +### Fuzzy file search events (experimental) + +The fuzzy file search session API emits per-query notifications: + +- `fuzzyFileSearch/sessionUpdated` — `{ sessionId, query, files }` with the current matching files for the active query. +- `fuzzyFileSearch/sessionCompleted` — `{ sessionId, query }` once indexing/matching for that query has completed. + +### Thread realtime events (experimental) + +The thread realtime API emits thread-scoped notifications for session lifecycle and streaming media: + +- `thread/realtime/started` — `{ threadId, realtimeSessionId }` once realtime starts for the thread (experimental). `realtimeSessionId` is the upstream Realtime API session identifier, not a Codex session/thread-group id. +- `thread/realtime/itemAdded` — `{ threadId, item }` for raw non-audio realtime items that do not have a dedicated typed app-server notification, including `handoff_request` (experimental). `item` is forwarded as raw JSON while the upstream websocket item schema remains unstable. +- `thread/realtime/transcript/delta` — `{ threadId, role, delta }` for live realtime transcript deltas (experimental). +- `thread/realtime/transcript/done` — `{ threadId, role, text }` when realtime emits the final full text for a transcript part (experimental). +- `thread/realtime/outputAudio/delta` — `{ threadId, audio }` for streamed output audio chunks (experimental). `audio` uses camelCase fields (`data`, `sampleRate`, `numChannels`, `samplesPerChannel`). +- `thread/realtime/error` — `{ threadId, message }` when realtime encounters a transport or backend error (experimental). +- `thread/realtime/closed` — `{ threadId, reason }` when the realtime transport closes (experimental). + +Because audio is intentionally separate from `ThreadItem`, clients can opt out of `thread/realtime/outputAudio/delta` independently with `optOutNotificationMethods`. + +### Windows sandbox setup events + +- `windowsSandbox/setupCompleted` — `{ mode, success, error }` after a `windowsSandbox/setupStart` request finishes. + +### MCP server startup events + +- `mcpServer/startupStatus/updated` — `{ name, status, error }` when app-server observes an MCP server startup transition. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`. + +### Turn events + +The app-server streams JSON-RPC notifications while a turn is running. Each turn emits `turn/started` when it begins running and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. + +- `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`. +- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. +- `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. +- `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. +- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). +- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. + +Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. + +#### Items + +`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: + +- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). +- `agentMessage` — `{id, text}` containing the accumulated agent reply. +- `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). +- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). +- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. +- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. +- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. +- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. +- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. +- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. +- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description. +- `exitedReviewMode` — `{id, review}` emitted when the reviewer finishes; `review` is the full plain-text review (usually, overall notes plus bullet point findings). +- `contextCompaction` — `{id}` emitted when codex compacts the conversation history. This can happen automatically. +- `compacted` - `{threadId, turnId}` when codex compacts the conversation history. This can happen automatically. **Deprecated:** Use `contextCompaction` instead. + +All items emit shared lifecycle events: + +- `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas. +- `item/completed` — sends the final `item` once that work itself finishes (for example, after a tool call or message completes); treat this as the authoritative execution/result state. +- `item/autoApprovalReview/started` — [UNSTABLE] temporary auto-review notification carrying `{threadId, turnId, targetItemId, review, action}` when approval auto-review begins. This shape is expected to change soon. +- `item/autoApprovalReview/completed` — [UNSTABLE] temporary auto-review notification carrying `{threadId, turnId, targetItemId, review, action}` when approval auto-review resolves. This shape is expected to change soon. + +`review` is [UNSTABLE] and currently has `{status, riskLevel?, userAuthorization?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `riskLevel` is one of `"low"`, `"medium"`, `"high"`, or `"critical"` when present. `userAuthorization` is one of `"unknown"`, `"low"`, `"medium"`, or `"high"` when present. `action` is a tagged union with `type: "command" | "execve" | "applyPatch" | "networkAccess" | "mcpToolCall"`. Command-like actions include a `source` discriminator (`"shell"` or `"unifiedExec"`). These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the auto-review app protocol is still being designed. + +There are additional item-specific events: + +#### agentMessage + +- `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply. + +#### plan + +- `item/plan/delta` — streams proposed plan content for plan items (experimental); concatenate `delta` values for the same plan `itemId`. These deltas correspond to the `` block. + +#### reasoning + +- `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens. +- `item/reasoning/summaryPartAdded` — marks the boundary between reasoning summary sections for an `itemId`; subsequent `summaryTextDelta` entries share the same `summaryIndex`. +- `item/reasoning/textDelta` — streams raw reasoning text (only applicable for e.g. open source models); use `contentIndex` to group deltas that belong together before showing them in the UI. + +#### commandExecution + +- `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item. + Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded. + +#### fileChange + +- `item/fileChange/patchUpdated` - when `features.apply_patch_streaming_events` is enabled, streams structured file-change snapshots parsed from the model-generated patch before it is executed. +- `item/fileChange/outputDelta` - deprecated legacy protocol entry for `apply_patch` text output; retained for compatibility but no longer emitted by the server. + +### Errors + +`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo?, additionalDetails? } }` payload as `turn.status: "failed"` and may precede that terminal notification. + +`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values: + +- `ContextWindowExceeded` +- `UsageLimitExceeded` +- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx +- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream +- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion +- `ResponseTooManyFailedAttempts { httpStatusCode? }` +- `ActiveTurnNotSteerable { turnKind }`: `turn/start` or `turn/steer` was submitted while the + current active turn was not steerable, for example `/review` or manual `/compact` +- `BadRequest` +- `Unauthorized` +- `SandboxError` +- `InternalServerError` +- `Other`: all unclassified errors + +When an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. + +## Approvals + +Certain actions (shell commands or modifying files) may require explicit user approval depending on the user's config. When `turn/start` is used, the app-server drives an approval flow by sending a server-initiated JSON-RPC request to the client. The client must respond to tell Codex whether to proceed. UIs should present these requests inline with the active turn so users can review the proposed command or diff before choosing. + +- Requests include `threadId` and `turnId`—use them to scope UI state to the active conversation. +- Respond with a single `{ "decision": ... }` payload. Command approvals support `accept`, `acceptForSession`, `acceptWithExecpolicyAmendment`, `applyNetworkPolicyAmendment`, `decline`, or `cancel`. The server resumes or declines the work and ends the item with `item/completed`. + +### Command execution approvals + +Order of messages: + +1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. +3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. +4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. +5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. + +### File change approvals + +Order of messages: + +1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user. +2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, an optional `reason`, and may include unstable `grantRoot` when the agent is asking for session-scoped write access under a specific root. +3. Client response — `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. +4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. +5. `item/completed` — returns the same `fileChange` item with `status` updated to `completed`, `failed`, or `declined` after the patch attempt. Rely on this to show success/failure and finalize the diff state in your UI. + +UI guidance for IDEs: surface an approval dialog as soon as the request arrives. The turn will proceed after the server receives a response to the approval request. The terminal `item/completed` notification will be sent with the appropriate status. + +### request_user_input + +When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. + +### MCP server elicitations + +MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`. + +Order of messages: + +1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either: + - a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }` + - a URL request: `{ "mode": "url", "message": "...", "url": "...", "elicitationId": "..." }` +2. Client response — `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`. +3. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. + +`turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. + +For MCP tool approval elicitations, form request `meta` includes +`codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, +`persist: "always"`, or `persist: ["session", "always"]` to advertise whether +the client can offer session-scoped and/or persistent approval choices. + +### Permission requests + +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs. + +```json +{ + "method": "item/permissions/requestApproval", + "id": 61, + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "cwd": "/Users/me/project", + "reason": "Select a workspace root", + "permissions": { + "fileSystem": { + "write": ["/Users/me/project", "/Users/me/shared"] + } + } + } +} +``` + +The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session; omitted or `"turn"` keeps the existing turn-scoped behavior: + +```json +{ + "id": 61, + "result": { + "scope": "session", + "permissions": { + "fileSystem": { + "write": ["/Users/me/project"] + } + } + } +} +``` + +Only the granted subset matters on the wire. Any permissions omitted from `result.permissions` are treated as denied. Any permissions not present in the original request are ignored by the server. + +Within the same turn, granted permissions are sticky: later shell-like tool calls can automatically reuse the granted subset without reissuing a separate permission request. + +If the session approval policy uses `Granular` with `request_permissions: false`, standalone `request_permissions` tool calls are auto-denied and no `item/permissions/requestApproval` prompt is sent. Inline `with_additional_permissions` command requests remain controlled by `sandbox_approval`, and any previously granted permissions remain sticky for later shell-like calls in the same turn. + +### Dynamic tool calls (experimental) + +`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. + +Dynamic tool identifiers follow the same constraints as Responses function tools: + +- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters. +- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters. +- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`. + +Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. + +When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: + +```json +{ + "method": "item/tool/call", + "id": 60, + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "callId": "call_123", + "tool": "lookup_ticket", + "arguments": { "id": "ABC-123" } + } +} +``` + +The server also emits item lifecycle notifications around the request: + +1. `item/started` with `item.type = "dynamicToolCall"`, `status = "inProgress"`, plus `tool` and `arguments`. +2. `item/tool/call` request. +3. Client response. +4. `item/completed` with `item.type = "dynamicToolCall"`, final `status`, and the returned `contentItems`/`success`. + +The client must respond with content items. Use `inputText` for text and `inputImage` for image URLs/data URLs: + +```json +{ + "id": 60, + "result": { + "contentItems": [ + { "type": "inputText", "text": "Ticket ABC-123 is open." }, + { "type": "inputImage", "imageUrl": "data:image/png;base64,AAA" } + ], + "success": true + } +} +``` + +## Skills + +Invoke a skill by including `$` in the text input. Add a `skill` input item (recommended) so the backend injects full skill instructions instead of relying on the model to resolve the name. + +```json +{ + "method": "turn/start", + "id": 101, + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "$skill-creator Add a new skill for triaging flaky CI." + }, + { + "type": "skill", + "name": "skill-creator", + "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" + } + ] + } +} +``` + +If you omit the `skill` item, the model will still parse the `$` marker and try to locate the skill, which can add latency. + +Example: + +``` +$skill-creator Add a new skill for triaging flaky CI and include step-by-step usage. +``` + +Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). +`skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk. +The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed. + +```json +{ "method": "skills/list", "id": 25, "params": { + "cwds": ["/Users/me/project", "/Users/me/other-project"], + "forceReload": true +} } +{ "id": 25, "result": { + "data": [{ + "cwd": "/Users/me/project", + "skills": [ + { + "name": "skill-creator", + "description": "Create or update a Codex skill", + "enabled": true, + "interface": { + "displayName": "Skill Creator", + "shortDescription": "Create or update a Codex skill", + "iconSmall": "icon.svg", + "iconLarge": "icon-large.svg", + "brandColor": "#111111", + "defaultPrompt": "Add a new skill for triaging flaky CI." + } + } + ], + "errors": [] + }] +} } +``` + +```json +{ + "method": "skills/changed", + "params": {} +} +``` + +To enable or disable a skill by absolute path: + +```json +{ + "method": "skills/config/write", + "id": 26, + "params": { + "path": "/Users/alice/.codex/skills/skill-creator/SKILL.md", + "name": null, + "enabled": false + } +} +``` + +To enable or disable a skill by name: + +```json +{ + "method": "skills/config/write", + "id": 27, + "params": { + "path": null, + "name": "github:yeet", + "enabled": false + } +} +``` + +Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response. + +Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading. + +For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. + +```json +{ + "method": "hooks/list", + "id": 28, + "params": { + "cwds": ["/Users/me/project"] + } +} +``` + +```json +{ + "id": 28, + "result": { + "data": [{ + "cwd": "/Users/me/project", + "hooks": [{ + "key": "/Users/me/.codex/config.toml:pre_tool_use:0:0", + "eventName": "pre_tool_use", + "handlerType": "command", + "isManaged": false, + "matcher": "Bash", + "command": "python3 /Users/me/hook.py", + "timeoutSec": 5, + "statusMessage": "running hook", + "sourcePath": "/Users/me/.codex/config.toml", + "source": "user", + "pluginId": null, + "displayOrder": 0, + "enabled": true, + "currentHash": "sha256:...", + "trustStatus": "untrusted" + }], + "warnings": [], + "errors": [] + }] + } +} +``` + +To disable a non-managed hook, upsert a state entry at `hooks.state` with `config/batchWrite`: + +```json +{ + "method": "config/batchWrite", + "id": 29, + "params": { + "edits": [{ + "keyPath": "hooks.state", + "value": { + "/Users/me/.codex/config.toml:pre_tool_use:0:0": { + "enabled": false + } + }, + "mergeStrategy": "upsert" + }], + "reloadUserConfig": true + } +} +``` + +To re-enable it, upsert the same hook key with `"enabled": true`. +## Apps + +Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. + +```json +{ "method": "app/list", "id": 50, "params": { + "cursor": null, + "limit": 50, + "threadId": "thr_123", + "forceRefetch": false +} } +{ "id": 50, "result": { + "data": [ + { + "id": "demo-app", + "name": "Demo App", + "description": "Example connector for documentation.", + "logoUrl": "https://example.com/demo-app.png", + "logoUrlDark": null, + "distributionChannel": null, + "branding": null, + "appMetadata": null, + "labels": null, + "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", + "isAccessible": true, + "isEnabled": true + } + ], + "nextCursor": null +} } +``` + +When `threadId` is provided, app feature gating (`Feature::Apps`) is evaluated using that thread's config snapshot. When omitted, the latest global config is used. + +`app/list` returns after both accessible apps and directory apps are loaded. Set `forceRefetch: true` to bypass app caches and fetch fresh data from sources. Cache entries are only replaced when those refetches succeed. + +The server also emits `app/list/updated` notifications whenever either source (accessible apps or directory apps) finishes loading. Each notification includes the latest merged app list. + +```json +{ + "method": "app/list/updated", + "params": { + "data": [ + { + "id": "demo-app", + "name": "Demo App", + "description": "Example connector for documentation.", + "logoUrl": "https://example.com/demo-app.png", + "logoUrlDark": null, + "distributionChannel": null, + "branding": null, + "appMetadata": null, + "labels": null, + "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", + "isAccessible": true, + "isEnabled": true + } + ] + } +} +``` + +Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/list`. + +Example: + +``` +$demo-app Pull the latest updates from the team. +``` + +```json +{ + "method": "turn/start", + "id": 51, + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "$demo-app Pull the latest updates from the team." + }, + { "type": "mention", "name": "Demo App", "path": "app://demo-app" } + ] + } +} +``` + +## Auth endpoints + +The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. + +### Authentication modes + +Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`. + +- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. +- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. + +### API Overview + +- `account/read` — fetch current account info; optionally refresh tokens. +- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`). +- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). +- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. +- `account/logout` — sign out; triggers `account/updated`. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. +- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). +- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. +- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. +- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. +- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`. + +### 1) Check auth state + +Request: + +```json +{ "method": "account/read", "id": 1, "params": { "refreshToken": false } } +``` + +Response examples: + +```json +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models) +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) +{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } +``` + +Field notes: + +- `refreshToken` (bool): set `true` to force a token refresh. +- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. + +### 2) Log in with an API key + +1. Send: + ```json + { + "method": "account/login/start", + "id": 2, + "params": { "type": "apiKey", "apiKey": "sk-…" } + } + ``` +2. Expect: + ```json + { "id": 2, "result": { "type": "apiKey" } } + ``` +3. Notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "apikey", "planType": null } } + ``` + +### 3) Log in with ChatGPT (browser flow) + +1. Start: + ```json + { "method": "account/login/start", "id": 3, "params": { "type": "chatgpt" } } + { "id": 3, "result": { "type": "chatgpt", "loginId": "", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A%2Fauth%2Fcallback" } } + ``` +2. Open `authUrl` in a browser; the app-server hosts the local callback. +3. Wait for notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } + ``` + +### 4) Log in with ChatGPT (device code flow) + +1. Start: + ```json + { "method": "account/login/start", "id": 4, "params": { "type": "chatgptDeviceCode" } } + { "id": 4, "result": { "type": "chatgptDeviceCode", "loginId": "", "verificationUrl": "https://auth.openai.com/codex/device", "userCode": "ABCD-1234" } } + ``` +2. Show `verificationUrl` and `userCode` to the user; the frontend owns the UX. +3. Wait for notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } + ``` + +### 5) Cancel a ChatGPT login + +```json +{ "method": "account/login/cancel", "id": 5, "params": { "loginId": "" } } +{ "method": "account/login/completed", "params": { "loginId": "", "success": false, "error": "…" } } +``` + +### 6) Logout + +```json +{ "method": "account/logout", "id": 6 } +{ "id": 6, "result": {} } +{ "method": "account/updated", "params": { "authMode": null, "planType": null } } +``` + +### 7) Rate limits (ChatGPT) + +```json +{ "method": "account/rateLimits/read", "id": 7 } +{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } } +{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } +``` + +Field notes: + +- `usedPercent` is current usage within the OpenAI quota window. +- `windowDurationMins` is the quota window length. +- `resetsAt` is a Unix timestamp (seconds) for the next reset. +- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. + +### 8) Notify a workspace owner about a limit + +```json +{ "method": "account/sendAddCreditsNudgeEmail", "id": 8, "params": { "creditType": "credits" } } +{ "id": 8, "result": { "status": "sent" } } +``` + +Use `creditType: "credits"` when workspace credits are depleted, or `creditType: "usage_limit"` when the workspace usage limit has been reached. If the owner was already notified recently, the response status is `cooldown_active`. + +## Experimental API Opt-in + +Some app-server methods and fields are intentionally gated behind an experimental capability with no backwards-compatible guarantees. This lets clients choose between: + +- Stable surface only (default): no opt-in, no experimental methods/fields exposed. +- Experimental surface: opt in during `initialize`. + +### Generating stable vs experimental client schemas + +`codex app-server` schema generation defaults to the stable API surface (experimental fields and methods filtered out). Pass `--experimental` to include experimental methods/fields in generated TypeScript or JSON schema: + +```bash +# Stable-only output (default) +codex app-server generate-ts --out DIR +codex app-server generate-json-schema --out DIR + +# Include experimental API surface +codex app-server generate-ts --out DIR --experimental +codex app-server generate-json-schema --out DIR --experimental +``` + +### How clients opt in at runtime + +Set `capabilities.experimentalApi` to `true` in your single `initialize` request: + +```json +{ + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "my_client", + "title": "My Client", + "version": "0.1.0" + }, + "capabilities": { + "experimentalApi": true + } + } +} +``` + +Then send the standard `initialized` notification and proceed normally. + +Notes: + +- If `capabilities` is omitted, `experimentalApi` is treated as `false`. +- This setting is negotiated once at initialization time for the process lifetime (re-initializing is rejected with `"Already initialized"`). + +### What happens without opt-in + +If a request uses an experimental method or sets an experimental field without opting in, app-server rejects it with a JSON-RPC error. The message is: + +` requires experimentalApi capability` + +Examples of descriptor strings: + +- `mock/experimentalMethod` (method-level gate) +- `thread/start.mockExperimentalField` (field-level gate) +- `askForApproval.granular` (enum-variant gate, for `approvalPolicy: { "granular": ... }`) + +### For maintainers: Adding experimental fields and methods + +Use this checklist when introducing a field/method that should only be available when the client opts into experimental APIs. + +At runtime, clients must send `initialize` with `capabilities.experimentalApi = true` to use experimental methods or fields. + +1. Annotate the field in the protocol type (usually `app-server-protocol/src/protocol/v2.rs`) with: + ```rust + #[experimental("thread/start.myField")] + pub my_field: Option, + ``` +2. Ensure the params type derives `ExperimentalApi` so field-level gating can be detected at runtime. + +3. In `app-server-protocol/src/protocol/common.rs`, keep the method stable and use `inspect_params: true` when only some fields are experimental (like `thread/start`). If the entire method is experimental, annotate the method variant with `#[experimental("method/name")]`. + +Enum variants can be gated too: + +```rust +#[derive(ExperimentalApi)] +enum AskForApproval { + #[experimental("askForApproval.granular")] + Granular { /* ... */ }, +} +``` + +If a stable field contains a nested type that may itself be experimental, mark +the field with `#[experimental(nested)]` so `ExperimentalApi` bubbles the nested +reason up through the containing type: + +```rust +#[derive(ExperimentalApi)] +struct ProfileV2 { + #[experimental(nested)] + approval_policy: Option, +} +``` + +For server-initiated request payloads, annotate the field the same way so schema generation treats it as experimental, and make sure app-server omits that field when the client did not opt into `experimentalApi`. + +4. Regenerate protocol fixtures: + + ```bash + just write-app-server-schema + # Include experimental API fields/methods in fixtures. + just write-app-server-schema --experimental + ``` + +5. Verify the protocol crate: + + ```bash + cargo test -p codex-app-server-protocol + ``` diff --git a/code-rs/app-server/src/analytics_utils.rs b/code-rs/app-server/src/analytics_utils.rs new file mode 100644 index 00000000000..24ed12d2ad3 --- /dev/null +++ b/code-rs/app-server/src/analytics_utils.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/code-rs/app-server/src/app_server_tracing.rs b/code-rs/app-server/src/app_server_tracing.rs new file mode 100644 index 00000000000..6e8133740f9 --- /dev/null +++ b/code-rs/app-server/src/app_server_tracing.rs @@ -0,0 +1,180 @@ +//! Tracing helpers shared by socket and in-process app-server entry points. +//! +//! The in-process path intentionally reuses the same span shape as JSON-RPC +//! transports so request telemetry stays comparable across stdio, websocket, +//! and embedded callers. [`typed_request_span`] is the in-process counterpart +//! of [`request_span`] and stamps `rpc.transport` as `"in-process"` while +//! deriving client identity from the typed [`ClientRequest`] rather than +//! from a parsed JSON envelope. + +use crate::message_processor::ConnectionSessionState; +use crate::outgoing_message::ConnectionId; +use crate::transport::AppServerTransport; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCRequest; +use codex_otel::set_parent_from_context; +use codex_otel::set_parent_from_w3c_trace_context; +use codex_otel::traceparent_context_from_env; +use codex_protocol::protocol::W3cTraceContext; +use tracing::Span; +use tracing::field; +use tracing::info_span; + +pub(crate) fn request_span( + request: &JSONRPCRequest, + transport: &AppServerTransport, + connection_id: ConnectionId, + session: &ConnectionSessionState, +) -> Span { + let initialize_client_info = initialize_client_info(request); + let method = request.method.as_str(); + let span = app_server_request_span_template( + method, + transport_name(transport), + &request.id, + connection_id, + ); + + record_client_info( + &span, + client_name(initialize_client_info.as_ref(), session), + client_version(initialize_client_info.as_ref(), session), + ); + + let parent_trace = request.trace.as_ref().and_then(|trace| { + trace.traceparent.as_ref()?; + Some(W3cTraceContext { + traceparent: trace.traceparent.clone(), + tracestate: trace.tracestate.clone(), + }) + }); + attach_parent_context(&span, method, &request.id, parent_trace.as_ref()); + + span +} + +/// Builds tracing span metadata for typed in-process requests. +/// +/// This mirrors `request_span` semantics while stamping transport as +/// `in-process` and deriving client info either from initialize params or +/// from existing connection session state. +pub(crate) fn typed_request_span( + request: &ClientRequest, + connection_id: ConnectionId, + session: &ConnectionSessionState, +) -> Span { + let method = request.method(); + let span = app_server_request_span_template(&method, "in-process", request.id(), connection_id); + + let client_info = initialize_client_info_from_typed_request(request); + record_client_info( + &span, + client_info + .map(|(client_name, _)| client_name) + .or(session.app_server_client_name()), + client_info + .map(|(_, client_version)| client_version) + .or(session.client_version()), + ); + + attach_parent_context(&span, &method, request.id(), /*parent_trace*/ None); + span +} + +fn transport_name(transport: &AppServerTransport) -> &'static str { + match transport { + AppServerTransport::Stdio => "stdio", + AppServerTransport::UnixSocket { .. } => "unix_socket", + AppServerTransport::WebSocket { .. } => "websocket", + AppServerTransport::Off => "off", + } +} + +fn app_server_request_span_template( + method: &str, + transport: &'static str, + request_id: &impl std::fmt::Display, + connection_id: ConnectionId, +) -> Span { + info_span!( + "app_server.request", + otel.kind = "server", + otel.name = method, + rpc.system = "jsonrpc", + rpc.method = method, + rpc.transport = transport, + rpc.request_id = %request_id, + app_server.connection_id = %connection_id, + app_server.api_version = "v2", + app_server.client_name = field::Empty, + app_server.client_version = field::Empty, + turn.id = field::Empty, + ) +} + +fn record_client_info(span: &Span, client_name: Option<&str>, client_version: Option<&str>) { + if let Some(client_name) = client_name { + span.record("app_server.client_name", client_name); + } + if let Some(client_version) = client_version { + span.record("app_server.client_version", client_version); + } +} + +fn attach_parent_context( + span: &Span, + method: &str, + request_id: &impl std::fmt::Display, + parent_trace: Option<&W3cTraceContext>, +) { + if let Some(trace) = parent_trace { + if !set_parent_from_w3c_trace_context(span, trace) { + tracing::warn!( + rpc_method = method, + rpc_request_id = %request_id, + "ignoring invalid inbound request trace carrier" + ); + } + } else if let Some(context) = traceparent_context_from_env() { + set_parent_from_context(span, context); + } +} + +fn client_name<'a>( + initialize_client_info: Option<&'a InitializeParams>, + session: &'a ConnectionSessionState, +) -> Option<&'a str> { + if let Some(params) = initialize_client_info { + return Some(params.client_info.name.as_str()); + } + session.app_server_client_name() +} + +fn client_version<'a>( + initialize_client_info: Option<&'a InitializeParams>, + session: &'a ConnectionSessionState, +) -> Option<&'a str> { + if let Some(params) = initialize_client_info { + return Some(params.client_info.version.as_str()); + } + session.client_version() +} + +fn initialize_client_info(request: &JSONRPCRequest) -> Option { + if request.method != "initialize" { + return None; + } + let params = request.params.clone()?; + serde_json::from_value(params).ok() +} + +fn initialize_client_info_from_typed_request(request: &ClientRequest) -> Option<(&str, &str)> { + match request { + ClientRequest::Initialize { params, .. } => Some(( + params.client_info.name.as_str(), + params.client_info.version.as_str(), + )), + _ => None, + } +} diff --git a/code-rs/app-server/src/bespoke_event_handling.rs b/code-rs/app-server/src/bespoke_event_handling.rs new file mode 100644 index 00000000000..1f2f289b05b --- /dev/null +++ b/code-rs/app-server/src/bespoke_event_handling.rs @@ -0,0 +1,3816 @@ +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::outgoing_message::ClientRequestResult; +use crate::outgoing_message::ThreadScopedOutgoingMessageSender; +use crate::request_processors::populate_thread_turns_from_history; +use crate::request_processors::thread_from_stored_thread; +use crate::server_request_error::is_turn_transition_server_request_error; +use crate::thread_state::ThreadState; +use crate::thread_state::TurnSummary; +use crate::thread_state::resolve_server_request_on_thread_listener; +use crate::thread_status::ThreadWatchActiveGuard; +use crate::thread_status::ThreadWatchManager; +use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; +use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; +use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo; +use codex_app_server_protocol::CommandAction as V2ParsedCommand; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalParams; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::DeprecationNoticeNotification; +use codex_app_server_protocol::DynamicToolCallParams; +use codex_app_server_protocol::DynamicToolCallStatus; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileChangeRequestApprovalParams; +use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile; +use codex_app_server_protocol::GuardianWarningNotification; +use codex_app_server_protocol::HookCompletedNotification; +use codex_app_server_protocol::HookStartedNotification; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::McpServerStartupState; +use codex_app_server_protocol::McpServerStatusUpdatedNotification; +use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelVerificationNotification; +use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; +use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; +use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction; +use codex_app_server_protocol::PermissionsRequestApprovalParams; +use codex_app_server_protocol::PermissionsRequestApprovalResponse; +use codex_app_server_protocol::RawResponseItemCompletedNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::SkillsChangedNotification; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadRealtimeClosedNotification; +use codex_app_server_protocol::ThreadRealtimeErrorNotification; +use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; +use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification; +use codex_app_server_protocol::ThreadRealtimeSdpNotification; +use codex_app_server_protocol::ThreadRealtimeStartedNotification; +use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification; +use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; +use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnDiffUpdatedNotification; +use codex_app_server_protocol::TurnError; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnPlanStep; +use codex_app_server_protocol::TurnPlanUpdatedNotification; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WarningNotification; +use codex_app_server_protocol::build_item_from_guardian_event; +use codex_app_server_protocol::guardian_auto_approval_review_notification; +use codex_app_server_protocol::item_event_to_server_notification; +use codex_core::CodexThread; +use codex_core::ThreadManager; +use codex_core::review_format::format_review_findings_block; +use codex_core::review_prompts; +use codex_protocol::ThreadId; +use codex_protocol::items::parse_hook_prompt_message; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; +use codex_sandboxing::policy_transforms::intersect_permission_profiles; +use codex_shell_command::parse_command::shlex_join; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use tokio::sync::Mutex; +use tokio::sync::oneshot; +use tracing::error; + +enum CommandExecutionApprovalPresentation { + Network(V2NetworkApprovalContext), + Command(CommandExecutionCompletionItem), +} + +#[derive(Debug, PartialEq)] +struct CommandExecutionCompletionItem { + command: String, + cwd: AbsolutePathBuf, + command_actions: Vec, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn apply_bespoke_event_handling( + event: Event, + conversation_id: ThreadId, + conversation: Arc, + thread_manager: Arc, + outgoing: ThreadScopedOutgoingMessageSender, + thread_state: Arc>, + thread_watch_manager: ThreadWatchManager, + thread_list_state_permit: Arc, + fallback_model_provider: String, +) { + let Event { + id: event_turn_id, + msg, + } = event; + match msg { + EventMsg::TurnStarted(payload) => { + // While not technically necessary as it was already done on TurnComplete, be extra cautios and abort any pending server requests. + outgoing.abort_pending_server_requests().await; + thread_watch_manager + .note_turn_started(&conversation_id.to_string()) + .await; + let turn = { + let state = thread_state.lock().await; + let mut turn = state.active_turn_snapshot().unwrap_or_else(|| Turn { + id: payload.turn_id.clone(), + items: Vec::new(), + items_view: TurnItemsView::NotLoaded, + error: None, + status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, + }); + turn.items.clear(); + turn.items_view = TurnItemsView::NotLoaded; + turn + }; + let notification = TurnStartedNotification { + thread_id: conversation_id.to_string(), + turn, + }; + outgoing + .send_server_notification(ServerNotification::TurnStarted(notification)) + .await; + } + EventMsg::TurnComplete(turn_complete_event) => { + // All per-thread requests are bound to a turn, so abort them. + outgoing.abort_pending_server_requests().await; + respond_to_pending_interrupts(&thread_state, &outgoing).await; + let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); + thread_watch_manager + .note_turn_completed(&conversation_id.to_string(), turn_failed) + .await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; + } + EventMsg::SkillsUpdateAvailable => { + outgoing + .send_server_notification(ServerNotification::SkillsChanged( + SkillsChangedNotification {}, + )) + .await; + } + EventMsg::McpStartupUpdate(update) => { + let (status, error) = match update.status { + codex_protocol::protocol::McpStartupStatus::Starting => { + (McpServerStartupState::Starting, None) + } + codex_protocol::protocol::McpStartupStatus::Ready => { + (McpServerStartupState::Ready, None) + } + codex_protocol::protocol::McpStartupStatus::Failed { error } => { + (McpServerStartupState::Failed, Some(error)) + } + codex_protocol::protocol::McpStartupStatus::Cancelled => { + (McpServerStartupState::Cancelled, None) + } + }; + let notification = McpServerStatusUpdatedNotification { + name: update.server, + status, + error, + }; + outgoing + .send_server_notification(ServerNotification::McpServerStatusUpdated(notification)) + .await; + } + EventMsg::Warning(warning_event) => { + let notification = WarningNotification { + thread_id: Some(conversation_id.to_string()), + message: warning_event.message, + }; + outgoing + .send_server_notification(ServerNotification::Warning(notification)) + .await; + } + EventMsg::GuardianWarning(warning_event) => { + let notification = GuardianWarningNotification { + thread_id: conversation_id.to_string(), + message: warning_event.message, + }; + outgoing + .send_server_notification(ServerNotification::GuardianWarning(notification)) + .await; + } + EventMsg::GuardianAssessment(assessment) => { + let pending_command_execution = match build_item_from_guardian_event( + &assessment, + CommandExecutionStatus::InProgress, + ) { + Some(ThreadItem::CommandExecution { + id, + command, + cwd, + command_actions, + .. + }) => Some(( + id, + CommandExecutionCompletionItem { + command, + cwd, + command_actions, + }, + )), + Some(_) | None => None, + }; + let assessment_turn_id = if assessment.turn_id.is_empty() { + event_turn_id.clone() + } else { + assessment.turn_id.clone() + }; + if assessment.status == codex_protocol::protocol::GuardianAssessmentStatus::InProgress + && let Some((target_item_id, completion_item)) = pending_command_execution.as_ref() + { + start_command_execution_item( + &conversation_id, + assessment_turn_id.clone(), + target_item_id.clone(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + } + let notification = guardian_auto_approval_review_notification( + &conversation_id, + &event_turn_id, + &assessment, + ); + outgoing.send_server_notification(notification).await; + let completion_status = match assessment.status { + codex_protocol::protocol::GuardianAssessmentStatus::Denied + | codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { + Some(CommandExecutionStatus::Declined) + } + codex_protocol::protocol::GuardianAssessmentStatus::TimedOut => { + Some(CommandExecutionStatus::Failed) + } + codex_protocol::protocol::GuardianAssessmentStatus::InProgress + | codex_protocol::protocol::GuardianAssessmentStatus::Approved => None, + }; + if let Some(completion_status) = completion_status + && let Some((target_item_id, completion_item)) = pending_command_execution + { + complete_command_execution_item( + &conversation_id, + assessment_turn_id, + target_item_id, + completion_item.command, + completion_item.cwd, + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions, + completion_status, + &outgoing, + &thread_state, + ) + .await; + } + } + EventMsg::ModelReroute(event) => { + let notification = ModelReroutedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + from_model: event.from_model, + to_model: event.to_model, + reason: event.reason.into(), + }; + outgoing + .send_server_notification(ServerNotification::ModelRerouted(notification)) + .await; + } + EventMsg::ModelVerification(event) => { + let notification = ModelVerificationNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + verifications: event.verifications.into_iter().map(Into::into).collect(), + }; + outgoing + .send_server_notification(ServerNotification::ModelVerification(notification)) + .await; + } + EventMsg::RealtimeConversationStarted(event) => { + let notification = ThreadRealtimeStartedNotification { + thread_id: conversation_id.to_string(), + realtime_session_id: event.realtime_session_id, + version: event.version, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeStarted(notification)) + .await; + } + EventMsg::RealtimeConversationSdp(event) => { + let notification = ThreadRealtimeSdpNotification { + thread_id: conversation_id.to_string(), + sdp: event.sdp, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeSdp(notification)) + .await; + } + EventMsg::RealtimeConversationRealtime(event) => match event.payload { + RealtimeEvent::SessionUpdated { .. } => {} + RealtimeEvent::InputAudioSpeechStarted(event) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item: serde_json::json!({ + "type": "input_audio_buffer.speech_started", + "item_id": event.item_id, + }), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } + RealtimeEvent::InputTranscriptDelta(event) => { + let notification = ThreadRealtimeTranscriptDeltaNotification { + thread_id: conversation_id.to_string(), + role: "user".to_string(), + delta: event.delta, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDelta( + notification, + )) + .await; + } + RealtimeEvent::InputTranscriptDone(event) => { + let notification = ThreadRealtimeTranscriptDoneNotification { + thread_id: conversation_id.to_string(), + role: "user".to_string(), + text: event.text, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDone( + notification, + )) + .await; + } + RealtimeEvent::OutputTranscriptDelta(event) => { + let notification = ThreadRealtimeTranscriptDeltaNotification { + thread_id: conversation_id.to_string(), + role: "assistant".to_string(), + delta: event.delta, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDelta( + notification, + )) + .await; + } + RealtimeEvent::OutputTranscriptDone(event) => { + let notification = ThreadRealtimeTranscriptDoneNotification { + thread_id: conversation_id.to_string(), + role: "assistant".to_string(), + text: event.text, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDone( + notification, + )) + .await; + } + RealtimeEvent::AudioOut(audio) => { + let notification = ThreadRealtimeOutputAudioDeltaNotification { + thread_id: conversation_id.to_string(), + audio: audio.into(), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeOutputAudioDelta( + notification, + )) + .await; + } + RealtimeEvent::ResponseCreated(_) => {} + RealtimeEvent::ResponseCancelled(event) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item: serde_json::json!({ + "type": "response.cancelled", + "response_id": event.response_id, + }), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } + RealtimeEvent::ResponseDone(_) => {} + RealtimeEvent::ConversationItemAdded(item) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } + RealtimeEvent::ConversationItemDone { .. } | RealtimeEvent::NoopRequested(_) => {} + RealtimeEvent::HandoffRequested(handoff) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item: serde_json::json!({ + "type": "handoff_request", + "handoff_id": handoff.handoff_id, + "item_id": handoff.item_id, + "input_transcript": handoff.input_transcript, + "active_transcript": handoff.active_transcript, + }), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } + RealtimeEvent::Error(message) => { + let notification = ThreadRealtimeErrorNotification { + thread_id: conversation_id.to_string(), + message, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeError(notification)) + .await; + } + }, + EventMsg::RealtimeConversationClosed(event) => { + let notification = ThreadRealtimeClosedNotification { + thread_id: conversation_id.to_string(), + reason: event.reason, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeClosed(notification)) + .await; + } + EventMsg::ApplyPatchApprovalRequest(event) => { + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let item_id = event.call_id.clone(); + + let params = FileChangeRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: event.turn_id.clone(), + item_id: item_id.clone(), + started_at_ms: event.started_at_ms, + reason: event.reason.clone(), + grant_root: event.grant_root.clone(), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::FileChangeRequestApproval(params)) + .await; + tokio::spawn(async move { + on_file_change_request_approval_response( + item_id, + pending_request_id, + rx, + conversation, + thread_state.clone(), + permission_guard, + ) + .await; + }); + } + EventMsg::ExecApprovalRequest(ev) => { + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let available_decisions = ev + .effective_available_decisions() + .into_iter() + .map(CommandExecutionApprovalDecision::from) + .collect::>(); + let ExecApprovalRequestEvent { + call_id, + approval_id, + turn_id, + started_at_ms, + command, + cwd, + reason, + network_approval_context, + proposed_execpolicy_amendment, + proposed_network_policy_amendments, + additional_permissions, + parsed_cmd, + .. + } = ev; + let command_actions = parsed_cmd + .iter() + .cloned() + .map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd)) + .collect::>(); + let presentation = if let Some(network_approval_context) = + network_approval_context.map(V2NetworkApprovalContext::from) + { + CommandExecutionApprovalPresentation::Network(network_approval_context) + } else { + let command_string = shlex_join(&command); + let completion_item = CommandExecutionCompletionItem { + command: command_string, + cwd: cwd.clone(), + command_actions: command_actions.clone(), + }; + CommandExecutionApprovalPresentation::Command(completion_item) + }; + let (network_approval_context, command, cwd, command_actions, completion_item) = + match presentation { + CommandExecutionApprovalPresentation::Network(network_approval_context) => { + (Some(network_approval_context), None, None, None, None) + } + CommandExecutionApprovalPresentation::Command(completion_item) => ( + None, + Some(completion_item.command.clone()), + Some(completion_item.cwd.clone()), + Some(completion_item.command_actions.clone()), + Some(completion_item), + ), + }; + if approval_id.is_none() + && let Some(completion_item) = completion_item.as_ref() + { + start_command_execution_item( + &conversation_id, + event_turn_id.clone(), + call_id.clone(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + } + let proposed_execpolicy_amendment_v2 = + proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); + let proposed_network_policy_amendments_v2 = + proposed_network_policy_amendments.map(|amendments| { + amendments + .into_iter() + .map(V2NetworkPolicyAmendment::from) + .collect() + }); + let additional_permissions = + additional_permissions.map(V2AdditionalPermissionProfile::from); + + let params = CommandExecutionRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: turn_id.clone(), + item_id: call_id.clone(), + started_at_ms, + approval_id: approval_id.clone(), + reason, + network_approval_context, + command, + cwd, + command_actions, + additional_permissions, + proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, + proposed_network_policy_amendments: proposed_network_policy_amendments_v2, + available_decisions: Some(available_decisions), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::CommandExecutionRequestApproval( + params, + )) + .await; + tokio::spawn(async move { + on_command_execution_request_approval_response( + event_turn_id, + conversation_id, + approval_id, + call_id, + completion_item, + pending_request_id, + rx, + conversation, + outgoing, + thread_state.clone(), + permission_guard, + ) + .await; + }); + } + EventMsg::RequestUserInput(request) => { + let user_input_guard = thread_watch_manager + .note_user_input_requested(&conversation_id.to_string()) + .await; + let questions = request + .questions + .into_iter() + .map(|question| ToolRequestUserInputQuestion { + id: question.id, + header: question.header, + question: question.question, + is_other: question.is_other, + is_secret: question.is_secret, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| ToolRequestUserInputOption { + label: option.label, + description: option.description, + }) + .collect() + }), + }) + .collect(); + let params = ToolRequestUserInputParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + item_id: request.call_id, + questions, + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput(params)) + .await; + tokio::spawn(async move { + on_request_user_input_response( + event_turn_id, + pending_request_id, + rx, + conversation, + thread_state, + user_input_guard, + ) + .await; + }); + } + EventMsg::ElicitationRequest(request) => { + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let turn_id = match request.turn_id.clone() { + Some(turn_id) => Some(turn_id), + None => { + let state = thread_state.lock().await; + state.active_turn_snapshot().map(|turn| turn.id) + } + }; + let server_name = request.server_name.clone(); + let request_body = match request.request.try_into() { + Ok(request_body) => request_body, + Err(err) => { + error!( + error = %err, + server_name, + request_id = ?request.id, + "failed to parse typed MCP elicitation schema" + ); + if let Err(err) = conversation + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: codex_protocol::approvals::ElicitationAction::Cancel, + content: None, + meta: None, + }) + .await + { + error!("failed to submit ResolveElicitation: {err}"); + } + return; + } + }; + let params = McpServerElicitationRequestParams { + thread_id: conversation_id.to_string(), + turn_id, + server_name: request.server_name.clone(), + request: request_body, + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::McpServerElicitationRequest(params)) + .await; + tokio::spawn(async move { + on_mcp_server_elicitation_response( + request.server_name, + request.id, + pending_request_id, + rx, + conversation, + thread_state, + permission_guard, + ) + .await; + }); + } + EventMsg::RequestPermissions(request) => { + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let requested_permissions = request.permissions.clone(); + let request_cwd = match request.cwd.clone() { + Some(cwd) => cwd, + None => conversation.config_snapshot().await.cwd, + }; + let params = PermissionsRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id.clone(), + item_id: request.call_id.clone(), + started_at_ms: request.started_at_ms, + cwd: request_cwd.clone(), + reason: request.reason, + permissions: request.permissions.into(), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) + .await; + let pending_response = PendingRequestPermissionsResponse { + call_id: request.call_id, + requested_permissions, + request_cwd, + pending_request_id, + receiver: rx, + request_permissions_guard: permission_guard, + }; + tokio::spawn(async move { + on_request_permissions_response(pending_response, conversation, thread_state).await; + }); + } + EventMsg::DynamicToolCallRequest(request) => { + let call_id = request.call_id; + let turn_id = request.turn_id; + let namespace = request.namespace; + let tool = request.tool; + let arguments = request.arguments; + let item = ThreadItem::DynamicToolCall { + id: call_id.clone(), + namespace: namespace.clone(), + tool: tool.clone(), + arguments: arguments.clone(), + status: DynamicToolCallStatus::InProgress, + content_items: None, + success: None, + duration_ms: None, + }; + let notification = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: turn_id.clone(), + started_at_ms: request.started_at_ms, + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + let params = DynamicToolCallParams { + thread_id: conversation_id.to_string(), + turn_id: turn_id.clone(), + call_id: call_id.clone(), + namespace, + tool: tool.clone(), + arguments: arguments.clone(), + }; + let (_pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::DynamicToolCall(params)) + .await; + tokio::spawn(async move { + crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; + }); + } + EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => { + // Deprecated MCP tool-call events are still fanned out for legacy clients. + // App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead. + } + msg @ (EventMsg::DynamicToolCallResponse(_) + | EventMsg::CollabAgentSpawnBegin(_) + | EventMsg::CollabAgentSpawnEnd(_) + | EventMsg::CollabAgentInteractionBegin(_) + | EventMsg::CollabAgentInteractionEnd(_) + | EventMsg::CollabWaitingBegin(_) + | EventMsg::CollabWaitingEnd(_) + | EventMsg::CollabCloseBegin(_) + | EventMsg::CollabResumeBegin(_) + | EventMsg::CollabResumeEnd(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::PlanDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::AgentReasoningSectionBreak(_)) => { + let notification = item_event_to_server_notification( + msg, + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + EventMsg::CollabCloseEnd(end_event) => { + if thread_manager + .get_thread(end_event.receiver_thread_id) + .await + .is_err() + { + thread_watch_manager + .remove_thread(&end_event.receiver_thread_id.to_string()) + .await; + } + let notification = item_event_to_server_notification( + EventMsg::CollabCloseEnd(end_event), + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + EventMsg::ContextCompacted(..) => { + // Core still fans out this deprecated event for legacy clients; + // v2 clients receive the canonical ContextCompaction item instead. + } + EventMsg::DeprecationNotice(event) => { + let notification = DeprecationNoticeNotification { + summary: event.summary, + details: event.details, + }; + outgoing + .send_server_notification(ServerNotification::DeprecationNotice(notification)) + .await; + } + EventMsg::TokenCount(token_count_event) => { + handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) + .await; + } + EventMsg::Error(ev) => { + thread_watch_manager + .note_system_error(&conversation_id.to_string()) + .await; + + let message = ev.message.clone(); + let codex_error_info = ev.codex_error_info.clone(); + // If this error belongs to an in-flight `thread/rollback` request, fail that request + // (and clear pending state) so subsequent rollbacks are unblocked. + // + // Don't send a notification for this error. + if matches!( + codex_error_info, + Some(CoreCodexErrorInfo::ThreadRollbackFailed) + ) { + return handle_thread_rollback_failed( + conversation_id, + message, + &thread_state, + &outgoing, + ) + .await; + }; + + if !ev.affects_turn_status() { + return; + } + + let turn_error = TurnError { + message: ev.message, + codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from), + additional_details: None, + }; + handle_error(conversation_id, turn_error.clone(), &thread_state).await; + outgoing + .send_server_notification(ServerNotification::Error(ErrorNotification { + error: turn_error.clone(), + will_retry: false, + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + })) + .await; + } + EventMsg::StreamError(ev) => { + // We don't need to update the turn summary store for stream errors as they are intermediate error states for retries, + // but we notify the client. + let turn_error = TurnError { + message: ev.message, + codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from), + additional_details: ev.additional_details, + }; + outgoing + .send_server_notification(ServerNotification::Error(ErrorNotification { + error: turn_error, + will_retry: true, + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + })) + .await; + } + EventMsg::ViewImageToolCall(_) => {} + EventMsg::EnteredReviewMode(review_request) => { + let review = review_request + .user_facing_hint + .unwrap_or_else(|| review_prompts::user_facing_hint(&review_request.target)); + let item = ThreadItem::EnteredReviewMode { + id: event_turn_id.clone(), + review, + }; + let started = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + item: item.clone(), + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(started)) + .await; + let completed = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(completed)) + .await; + } + msg @ (EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::PatchApplyUpdated(_) + | EventMsg::TerminalInteraction(_)) => { + let notification = item_event_to_server_notification( + msg, + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + EventMsg::HookStarted(event) => { + let notification = HookStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: event.turn_id, + run: event.run.into(), + }; + outgoing + .send_server_notification(ServerNotification::HookStarted(notification)) + .await; + } + EventMsg::HookCompleted(event) => { + let notification = HookCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: event.turn_id, + run: event.run.into(), + }; + outgoing + .send_server_notification(ServerNotification::HookCompleted(notification)) + .await; + } + EventMsg::ExitedReviewMode(review_event) => { + let review = match review_event.review_output { + Some(output) => render_review_output_text(&output), + None => REVIEW_FALLBACK_MESSAGE.to_string(), + }; + let item = ThreadItem::ExitedReviewMode { + id: event_turn_id.clone(), + review, + }; + let started = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + item: item.clone(), + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(started)) + .await; + let completed = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(completed)) + .await; + } + EventMsg::RawResponseItem(raw_response_item_event) => { + maybe_emit_hook_prompt_item_completed( + conversation_id, + &event_turn_id, + &raw_response_item_event.item, + &outgoing, + ) + .await; + maybe_emit_raw_response_item_completed( + conversation_id, + &event_turn_id, + raw_response_item_event.item, + &outgoing, + ) + .await; + } + EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => { + // Core still fans out these deprecated events for legacy clients; + // v2 clients receive the canonical FileChange item instead. + } + EventMsg::ExecCommandBegin(exec_command_begin_event) => { + if matches!( + exec_command_begin_event.source, + codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction + ) { + // TerminalInteraction is the v2 surface for unified exec + // stdin/poll events. Suppress the legacy CommandExecution + // item so clients do not render the same wait twice. + return; + } + let item_id = exec_command_begin_event.call_id.clone(); + let first_start = { + let mut state = thread_state.lock().await; + state + .turn_summary + .command_execution_started + .insert(item_id.clone()) + }; + if first_start { + let notification = item_event_to_server_notification( + EventMsg::ExecCommandBegin(exec_command_begin_event), + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + } + EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { + let notification = item_event_to_server_notification( + EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event), + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + EventMsg::ExecCommandEnd(exec_command_end_event) => { + let call_id = exec_command_end_event.call_id.clone(); + { + let mut state = thread_state.lock().await; + state + .turn_summary + .command_execution_started + .remove(&call_id); + } + if matches!( + exec_command_end_event.source, + codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction + ) { + // The paired begin event is suppressed above; keep the + // completion out of v2 as well so no orphan legacy item is + // emitted for unified exec interactions. + return; + } + let notification = item_event_to_server_notification( + EventMsg::ExecCommandEnd(exec_command_end_event), + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + // If this is a TurnAborted, reply to any pending interrupt requests. + EventMsg::TurnAborted(turn_aborted_event) => { + // All per-thread requests are bound to a turn, so abort them. + outgoing.abort_pending_server_requests().await; + respond_to_pending_interrupts(&thread_state, &outgoing).await; + + thread_watch_manager + .note_turn_interrupted(&conversation_id.to_string()) + .await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; + } + EventMsg::ThreadRolledBack(_rollback_event) => { + let pending = { + let mut state = thread_state.lock().await; + state.pending_rollbacks.take() + }; + + if let Some(request_id) = pending { + let _thread_list_state_permit = match thread_list_state_permit.acquire().await { + Ok(permit) => permit, + Err(err) => { + outgoing + .send_error( + request_id, + internal_error(format!( + "failed to acquire thread list state permit: {err}" + )), + ) + .await; + return; + } + }; + let fallback_cwd = conversation.config_snapshot().await.cwd; + let stored_thread = match conversation + .read_thread( + /*include_archived*/ true, /*include_history*/ true, + ) + .await + { + Ok(stored_thread) => stored_thread, + Err(err) => { + outgoing + .send_error( + request_id.clone(), + internal_error(format!( + "failed to read thread {conversation_id} after rollback: {err}" + )), + ) + .await; + return; + } + }; + let loaded_status = thread_watch_manager + .loaded_status_for_thread(&conversation_id.to_string()) + .await; + let response = match thread_rollback_response_from_stored_thread( + stored_thread, + conversation.session_configured().session_id.to_string(), + fallback_model_provider.as_str(), + &fallback_cwd, + loaded_status, + ) { + Ok(response) => response, + Err(err) => { + outgoing + .send_error(request_id.clone(), internal_error(err)) + .await; + return; + } + }; + + outgoing.send_response(request_id, response).await; + } + } + EventMsg::ThreadGoalUpdated(thread_goal_event) => { + let notification = ThreadGoalUpdatedNotification { + thread_id: thread_goal_event.thread_id.to_string(), + turn_id: thread_goal_event.turn_id, + goal: thread_goal_event.goal.clone().into(), + }; + outgoing + .send_global_server_notification(ServerNotification::ThreadGoalUpdated( + notification, + )) + .await; + } + EventMsg::TurnDiff(turn_diff_event) => { + handle_turn_diff(conversation_id, &event_turn_id, turn_diff_event, &outgoing).await; + } + EventMsg::PlanUpdate(plan_update_event) => { + handle_turn_plan_update( + conversation_id, + &event_turn_id, + plan_update_event, + &outgoing, + ) + .await; + } + EventMsg::ShutdownComplete => { + thread_watch_manager + .note_thread_shutdown(&conversation_id.to_string()) + .await; + } + + _ => {} + } +} + +async fn handle_turn_diff( + conversation_id: ThreadId, + event_turn_id: &str, + turn_diff_event: TurnDiffEvent, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let notification = TurnDiffUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.to_string(), + diff: turn_diff_event.unified_diff, + }; + outgoing + .send_server_notification(ServerNotification::TurnDiffUpdated(notification)) + .await; +} + +async fn handle_turn_plan_update( + conversation_id: ThreadId, + event_turn_id: &str, + plan_update_event: UpdatePlanArgs, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + // `update_plan` is a todo/checklist tool; it is not related to plan-mode updates + let notification = TurnPlanUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.to_string(), + explanation: plan_update_event.explanation, + plan: plan_update_event + .plan + .into_iter() + .map(TurnPlanStep::from) + .collect(), + }; + outgoing + .send_server_notification(ServerNotification::TurnPlanUpdated(notification)) + .await; +} + +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + +async fn emit_turn_completed_with_status( + conversation_id: ThreadId, + event_turn_id: String, + turn_completion_metadata: TurnCompletionMetadata, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let notification = TurnCompletedNotification { + thread_id: conversation_id.to_string(), + turn: Turn { + id: event_turn_id, + items: vec![], + items_view: TurnItemsView::NotLoaded, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, + }, + }; + outgoing + .send_server_notification(ServerNotification::TurnCompleted(notification)) + .await; +} + +#[allow(clippy::too_many_arguments)] +async fn start_command_execution_item( + conversation_id: &ThreadId, + turn_id: String, + item_id: String, + command: String, + cwd: AbsolutePathBuf, + command_actions: Vec, + source: CommandExecutionSource, + outgoing: &ThreadScopedOutgoingMessageSender, + thread_state: &Arc>, +) -> bool { + let first_start = { + let mut state = thread_state.lock().await; + state + .turn_summary + .command_execution_started + .insert(item_id.clone()) + }; + if first_start { + let notification = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id, + started_at_ms: now_unix_timestamp_ms(), + item: ThreadItem::CommandExecution { + id: item_id, + command, + cwd, + process_id: None, + source, + status: CommandExecutionStatus::InProgress, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }, + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + } + first_start +} + +#[allow(clippy::too_many_arguments)] +async fn complete_command_execution_item( + conversation_id: &ThreadId, + turn_id: String, + item_id: String, + command: String, + cwd: AbsolutePathBuf, + process_id: Option, + source: CommandExecutionSource, + command_actions: Vec, + status: CommandExecutionStatus, + outgoing: &ThreadScopedOutgoingMessageSender, + thread_state: &Arc>, +) { + let should_emit = thread_state + .lock() + .await + .turn_summary + .command_execution_started + .remove(&item_id); + if !should_emit { + return; + } + + let item = ThreadItem::CommandExecution { + id: item_id, + command, + cwd, + process_id, + source, + status, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + let notification = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id, + completed_at_ms: now_unix_timestamp_ms(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; +} + +async fn maybe_emit_raw_response_item_completed( + conversation_id: ThreadId, + turn_id: &str, + item: codex_protocol::models::ResponseItem, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let notification = RawResponseItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: turn_id.to_string(), + item, + }; + outgoing + .send_server_notification(ServerNotification::RawResponseItemCompleted(notification)) + .await; +} + +pub(crate) async fn maybe_emit_hook_prompt_item_completed( + conversation_id: ThreadId, + turn_id: &str, + item: &codex_protocol::models::ResponseItem, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let codex_protocol::models::ResponseItem::Message { + role, content, id, .. + } = item + else { + return; + }; + + if role != "user" { + return; + } + + let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else { + return; + }; + + let notification = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: turn_id.to_string(), + completed_at_ms: now_unix_timestamp_ms(), + item: ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(codex_app_server_protocol::HookPromptFragment::from) + .collect(), + }, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; +} + +async fn find_and_remove_turn_summary( + _conversation_id: ThreadId, + thread_state: &Arc>, +) -> TurnSummary { + let mut state = thread_state.lock().await; + std::mem::take(&mut state.turn_summary) +} + +async fn handle_turn_complete( + conversation_id: ThreadId, + event_turn_id: String, + turn_complete_event: TurnCompleteEvent, + outgoing: &ThreadScopedOutgoingMessageSender, + thread_state: &Arc>, +) { + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; + + let (status, error) = match turn_summary.last_error { + Some(error) => (TurnStatus::Failed, Some(error)), + None => (TurnStatus::Completed, None), + }; + + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; +} + +async fn handle_turn_interrupted( + conversation_id: ThreadId, + event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, + outgoing: &ThreadScopedOutgoingMessageSender, + thread_state: &Arc>, +) { + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; + + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, + outgoing, + ) + .await; +} + +async fn handle_thread_rollback_failed( + _conversation_id: ThreadId, + message: String, + thread_state: &Arc>, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let pending_rollback = thread_state.lock().await.pending_rollbacks.take(); + + if let Some(request_id) = pending_rollback { + outgoing + .send_error(request_id, invalid_request(message)) + .await; + } +} + +fn thread_rollback_response_from_stored_thread( + stored_thread: codex_thread_store::StoredThread, + session_id: String, + fallback_model_provider: &str, + fallback_cwd: &AbsolutePathBuf, + loaded_status: ThreadStatus, +) -> std::result::Result { + let thread_id = stored_thread.thread_id; + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd); + thread.session_id = session_id; + let Some(history) = history else { + return Err(format!( + "thread {thread_id} did not include persisted history after rollback" + )); + }; + populate_thread_turns_from_history(&mut thread, &history.items, /*active_turn*/ None); + thread.status = loaded_status; + Ok(ThreadRollbackResponse { thread }) +} + +async fn respond_to_pending_interrupts( + thread_state: &Arc>, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let pending = { + let mut state = thread_state.lock().await; + std::mem::take(&mut state.pending_interrupts) + }; + + for request_id in pending { + outgoing + .send_response(request_id, TurnInterruptResponse {}) + .await; + } +} + +async fn handle_token_count_event( + conversation_id: ThreadId, + turn_id: String, + token_count_event: TokenCountEvent, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + let TokenCountEvent { info, rate_limits } = token_count_event; + if let Some(token_usage) = info.map(ThreadTokenUsage::from) { + let notification = ThreadTokenUsageUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id, + token_usage, + }; + outgoing + .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) + .await; + } + if let Some(rate_limits) = rate_limits { + outgoing + .send_server_notification(ServerNotification::AccountRateLimitsUpdated( + AccountRateLimitsUpdatedNotification { + rate_limits: rate_limits.into(), + }, + )) + .await; + } +} + +async fn handle_error( + _conversation_id: ThreadId, + error: TurnError, + thread_state: &Arc>, +) { + let mut state = thread_state.lock().await; + state.turn_summary.last_error = Some(error); +} + +async fn on_request_user_input_response( + event_turn_id: String, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, + user_input_guard: ThreadWatchActiveGuard, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(user_input_guard); + let value = match response { + Ok(Ok(value)) => value, + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + return; + } + Err(err) => { + error!("request failed: {err:?}"); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + return; + } + }; + + let response = + serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize ToolRequestUserInputResponse: {err}"); + ToolRequestUserInputResponse { + answers: HashMap::new(), + } + }); + let response = CoreRequestUserInputResponse { + answers: response + .answers + .into_iter() + .map(|(id, answer)| { + ( + id, + CoreRequestUserInputAnswer { + answers: answer.answers, + }, + ) + }) + .collect(), + }; + + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } +} + +async fn on_mcp_server_elicitation_response( + server_name: String, + request_id: codex_protocol::mcp::RequestId, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, + permission_guard: ThreadWatchActiveGuard, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(permission_guard); + let response = mcp_server_elicitation_response_from_client_result(response); + + if let Err(err) = conversation + .submit(Op::ResolveElicitation { + server_name, + request_id, + decision: response.action.to_core(), + content: response.content, + meta: response.meta, + }) + .await + { + error!("failed to submit ResolveElicitation: {err}"); + } +} + +fn mcp_server_elicitation_response_from_client_result( + response: std::result::Result, +) -> McpServerElicitationRequestResponse { + match response { + Ok(Ok(value)) => serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize McpServerElicitationRequestResponse: {err}"); + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + meta: None, + } + }), + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => { + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Cancel, + content: None, + meta: None, + } + } + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + meta: None, + } + } + Err(err) => { + error!("request failed: {err:?}"); + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + meta: None, + } + } + } +} + +async fn on_request_permissions_response( + pending_response: PendingRequestPermissionsResponse, + conversation: Arc, + thread_state: Arc>, +) { + let PendingRequestPermissionsResponse { + call_id, + requested_permissions, + request_cwd, + pending_request_id, + receiver, + request_permissions_guard, + } = pending_response; + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(request_permissions_guard); + let Some(response) = request_permissions_response_from_client_result( + requested_permissions, + response, + request_cwd.as_path(), + ) else { + return; + }; + + if let Err(err) = conversation + .submit(Op::RequestPermissionsResponse { + id: call_id, + response, + }) + .await + { + error!("failed to submit RequestPermissionsResponse: {err}"); + } +} + +struct PendingRequestPermissionsResponse { + call_id: String, + requested_permissions: CoreRequestPermissionProfile, + request_cwd: AbsolutePathBuf, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + request_permissions_guard: ThreadWatchActiveGuard, +} + +fn request_permissions_response_from_client_result( + requested_permissions: CoreRequestPermissionProfile, + response: std::result::Result, + cwd: &std::path::Path, +) -> Option { + let value = match response { + Ok(Ok(value)) => value, + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return None, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + }); + } + Err(err) => { + error!("request failed: {err:?}"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + }); + } + }; + + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize PermissionsRequestApprovalResponse: {err}"); + PermissionsRequestApprovalResponse { + permissions: V2GrantedPermissionProfile::default(), + scope: codex_app_server_protocol::PermissionGrantScope::Turn, + strict_auto_review: None, + } + }); + let strict_auto_review = response.strict_auto_review.unwrap_or(false); + if strict_auto_review + && matches!( + response.scope, + codex_app_server_protocol::PermissionGrantScope::Session + ) + { + error!("strict auto review is only supported for turn-scoped permission grants"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + }); + } + let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.into(); + let permissions = if granted_permissions.is_empty() { + CoreRequestPermissionProfile::default() + } else { + intersect_permission_profiles(requested_permissions.into(), granted_permissions, cwd).into() + }; + Some(CoreRequestPermissionsResponse { + permissions, + scope: response.scope.to_core(), + strict_auto_review, + }) +} + +const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; + +fn render_review_output_text(output: &ReviewOutputEvent) -> String { + let mut sections = Vec::new(); + let explanation = output.overall_explanation.trim(); + if !explanation.is_empty() { + sections.push(explanation.to_string()); + } + if !output.findings.is_empty() { + let findings = format_review_findings_block(&output.findings, /*selection*/ None); + let trimmed = findings.trim(); + if !trimmed.is_empty() { + sections.push(trimmed.to_string()); + } + } + if sections.is_empty() { + REVIEW_FALLBACK_MESSAGE.to_string() + } else { + sections.join("\n\n") + } +} + +fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision { + match decision { + FileChangeApprovalDecision::Accept => ReviewDecision::Approved, + FileChangeApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession, + FileChangeApprovalDecision::Decline => ReviewDecision::Denied, + FileChangeApprovalDecision::Cancel => ReviewDecision::Abort, + } +} + +#[allow(clippy::too_many_arguments)] +async fn on_file_change_request_approval_response( + item_id: String, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + codex: Arc, + thread_state: Arc>, + permission_guard: ThreadWatchActiveGuard, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(permission_guard); + let decision = match response { + Ok(Ok(value)) => { + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize FileChangeRequestApprovalResponse: {err}"); + FileChangeRequestApprovalResponse { + decision: FileChangeApprovalDecision::Decline, + } + }); + + map_file_change_approval_decision(response.decision) + } + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + ReviewDecision::Denied + } + Err(err) => { + error!("request failed: {err:?}"); + ReviewDecision::Denied + } + }; + + if let Err(err) = codex + .submit(Op::PatchApproval { + id: item_id, + decision, + }) + .await + { + error!("failed to submit PatchApproval: {err}"); + } +} + +#[allow(clippy::too_many_arguments)] +async fn on_command_execution_request_approval_response( + event_turn_id: String, + conversation_id: ThreadId, + approval_id: Option, + item_id: String, + completion_item: Option, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + outgoing: ThreadScopedOutgoingMessageSender, + thread_state: Arc>, + permission_guard: ThreadWatchActiveGuard, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(permission_guard); + let (decision, completion_status) = match response { + Ok(Ok(value)) => { + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}"); + CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Decline, + } + }); + + let decision = response.decision; + + let (decision, completion_status) = match decision { + CommandExecutionApprovalDecision::Accept => (ReviewDecision::Approved, None), + CommandExecutionApprovalDecision::AcceptForSession => { + (ReviewDecision::ApprovedForSession, None) + } + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => ( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.into_core(), + }, + None, + ), + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => { + let completion_status = match network_policy_amendment.action { + V2NetworkPolicyRuleAction::Allow => None, + V2NetworkPolicyRuleAction::Deny => Some(CommandExecutionStatus::Declined), + }; + ( + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into_core(), + }, + completion_status, + ) + } + CommandExecutionApprovalDecision::Decline => ( + ReviewDecision::Denied, + Some(CommandExecutionStatus::Declined), + ), + CommandExecutionApprovalDecision::Cancel => ( + ReviewDecision::Abort, + Some(CommandExecutionStatus::Declined), + ), + }; + (decision, completion_status) + } + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + (ReviewDecision::Denied, Some(CommandExecutionStatus::Failed)) + } + Err(err) => { + error!("request failed: {err:?}"); + (ReviewDecision::Denied, Some(CommandExecutionStatus::Failed)) + } + }; + + let suppress_subcommand_completion_item = { + // For regular shell/unified_exec approvals, approval_id is null. + // For zsh-fork subcommand approvals, approval_id is present and + // item_id points to the parent command item. + if approval_id.is_some() { + let state = thread_state.lock().await; + state + .turn_summary + .command_execution_started + .contains(&item_id) + } else { + false + } + }; + + if let Some(status) = completion_status + && !suppress_subcommand_completion_item + && let Some(completion_item) = completion_item + { + complete_command_execution_item( + &conversation_id, + event_turn_id.clone(), + item_id.clone(), + completion_item.command, + completion_item.cwd, + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions, + status, + &outgoing, + &thread_state, + ) + .await; + } + + if let Err(err) = conversation + .submit(Op::ExecApproval { + id: approval_id.unwrap_or_else(|| item_id.clone()), + turn_id: Some(event_turn_id), + decision, + }) + .await + { + error!("failed to submit ExecApproval: {err}"); + } +} + +fn now_unix_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CHANNEL_CAPACITY; + use crate::outgoing_message::ConnectionId; + use crate::outgoing_message::OutgoingEnvelope; + use crate::outgoing_message::OutgoingMessage; + use crate::outgoing_message::OutgoingMessageSender; + use anyhow::Result; + use anyhow::anyhow; + use anyhow::bail; + use chrono::Utc; + use codex_app_server_protocol::AutoReviewDecisionSource; + use codex_app_server_protocol::GuardianApprovalReviewStatus; + use codex_app_server_protocol::JSONRPCErrorError; + use codex_app_server_protocol::TurnPlanStepStatus; + use codex_login::CodexAuth; + use codex_protocol::items::HookPromptFragment; + use codex_protocol::items::build_hook_prompt_message; + use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; + use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::plan_tool::PlanItemArg; + use codex_protocol::plan_tool::StepStatus; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::CreditsSnapshot; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::GuardianAssessmentEvent; + use codex_protocol::protocol::GuardianAssessmentStatus; + use codex_protocol::protocol::RateLimitSnapshot; + use codex_protocol::protocol::RateLimitWindow; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::TokenUsage; + use codex_protocol::protocol::TokenUsageInfo; + use codex_protocol::protocol::UserMessageEvent; + use codex_thread_store::StoredThread; + use codex_thread_store::StoredThreadHistory; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use core_test_support::load_default_config_for_test; + use pretty_assertions::assert_eq; + use serde_json::json; + use tempfile::TempDir; + use tokio::sync::Mutex; + use tokio::sync::mpsc; + + fn new_thread_state() -> Arc> { + Arc::new(Mutex::new(ThreadState::default())) + } + + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + + async fn recv_broadcast_message( + rx: &mut mpsc::Receiver, + ) -> Result { + let envelope = rx + .recv() + .await + .ok_or_else(|| anyhow!("should send one message"))?; + match envelope { + OutgoingEnvelope::Broadcast { message } => Ok(message), + OutgoingEnvelope::ToConnection { message, .. } => Ok(message), + } + } + + #[test] + fn rollback_response_rebuilds_pathless_thread_from_stored_history() -> Result<()> { + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000789")?; + let created_at = Utc::now(); + let history_items = vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "before rollback".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "after rollback".to_string(), + phase: None, + memory_citation: None, + })), + ]; + let stored_thread = StoredThread { + thread_id, + rollout_path: None, + forked_from_id: None, + preview: "fallback preview".to_string(), + name: Some("Rollback thread".to_string()), + model_provider: "openai".to_string(), + model: None, + reasoning_effort: None, + created_at, + updated_at: created_at, + archived_at: None, + cwd: test_path_buf("/tmp").abs().into(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + thread_source: None, + agent_nickname: None, + agent_role: None, + agent_path: None, + git_info: None, + approval_mode: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + token_usage: None, + first_user_message: Some("before rollback".to_string()), + history: Some(StoredThreadHistory { + thread_id, + items: history_items, + }), + }; + let fallback_cwd = test_path_buf("/tmp").abs(); + + let response = thread_rollback_response_from_stored_thread( + stored_thread, + thread_id.to_string(), + "fallback-provider", + &fallback_cwd, + ThreadStatus::NotLoaded, + ) + .expect("rollback response should rebuild from stored history"); + + assert_eq!(response.thread.id, thread_id.to_string()); + assert_eq!(response.thread.path, None); + assert_eq!(response.thread.preview, "before rollback"); + assert_eq!(response.thread.name.as_deref(), Some("Rollback thread")); + assert_eq!(response.thread.status, ThreadStatus::NotLoaded); + assert_eq!(response.thread.turns.len(), 1); + assert_eq!(response.thread.turns[0].items.len(), 2); + Ok(()) + } + + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { + CommandExecutionCompletionItem { + command: command.to_string(), + cwd: test_path_buf("/tmp").abs(), + command_actions: vec![V2ParsedCommand::Unknown { + command: command.to_string(), + }], + } + } + + fn guardian_command_assessment( + id: &str, + turn_id: &str, + status: GuardianAssessmentStatus, + ) -> GuardianAssessmentEvent { + let (risk_level, user_authorization, rationale) = match status { + GuardianAssessmentStatus::InProgress => (None, None, None), + GuardianAssessmentStatus::Approved => ( + Some(codex_protocol::protocol::GuardianRiskLevel::Low), + Some(codex_protocol::protocol::GuardianUserAuthorization::High), + Some("looks safe".to_string()), + ), + GuardianAssessmentStatus::Denied => ( + Some(codex_protocol::protocol::GuardianRiskLevel::High), + Some(codex_protocol::protocol::GuardianUserAuthorization::Low), + Some("too risky".to_string()), + ), + GuardianAssessmentStatus::TimedOut => { + (None, None, Some("review timed out".to_string())) + } + GuardianAssessmentStatus::Aborted => (None, None, None), + }; + GuardianAssessmentEvent { + id: format!("review-{id}"), + target_item_id: Some(id.to_string()), + turn_id: turn_id.to_string(), + started_at_ms: 1_000, + completed_at_ms: (!matches!(status, GuardianAssessmentStatus::InProgress)) + .then_some(1_042), + status, + risk_level, + user_authorization, + rationale, + decision_source: if matches!(status, GuardianAssessmentStatus::InProgress) { + None + } else { + Some(codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent) + }, + action: serde_json::from_value(json!({ + "type": "command", + "source": "shell", + "command": format!("rm -f /tmp/{id}.sqlite"), + "cwd": test_path_buf("/tmp"), + })) + .expect("guardian action"), + } + } + + struct GuardianAssessmentTestContext { + conversation_id: ThreadId, + conversation: Arc, + thread_manager: Arc, + outgoing: ThreadScopedOutgoingMessageSender, + thread_state: Arc>, + thread_watch_manager: ThreadWatchManager, + } + + impl GuardianAssessmentTestContext { + async fn apply_guardian_assessment_event(&self, assessment: GuardianAssessmentEvent) { + let event_turn_id = assessment.turn_id.clone(); + apply_bespoke_event_handling( + Event { + id: event_turn_id, + msg: EventMsg::GuardianAssessment(assessment), + }, + self.conversation_id, + self.conversation.clone(), + self.thread_manager.clone(), + self.outgoing.clone(), + self.thread_state.clone(), + self.thread_watch_manager.clone(), + Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), + "test-provider".to_string(), + ) + .await; + } + } + + #[test] + fn guardian_assessment_started_uses_event_turn_id_fallback() { + let conversation_id = ThreadId::new(); + let action = codex_protocol::protocol::GuardianAssessmentAction::Command { + source: codex_protocol::protocol::GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: test_path_buf("/tmp").abs(), + }; + let notification = guardian_auto_approval_review_notification( + &conversation_id, + "turn-from-event", + &GuardianAssessmentEvent { + id: "review-1".to_string(), + target_item_id: Some("item-1".to_string()), + turn_id: String::new(), + started_at_ms: 1_000, + completed_at_ms: None, + status: codex_protocol::protocol::GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: action.clone(), + }, + ); + + match notification { + ServerNotification::ItemGuardianApprovalReviewStarted(payload) => { + assert_eq!(payload.thread_id, conversation_id.to_string()); + assert_eq!(payload.turn_id, "turn-from-event"); + assert_eq!(payload.started_at_ms, 1_000); + assert_eq!(payload.review_id, "review-1"); + assert_eq!(payload.target_item_id.as_deref(), Some("item-1")); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::InProgress + ); + assert_eq!(payload.review.risk_level, None); + assert_eq!(payload.review.user_authorization, None); + assert_eq!(payload.review.rationale, None); + assert_eq!(payload.action, action.into()); + } + other => panic!("unexpected notification: {other:?}"), + } + } + + #[test] + fn guardian_assessment_completed_emits_review_payload() { + let conversation_id = ThreadId::new(); + let action = codex_protocol::protocol::GuardianAssessmentAction::Command { + source: codex_protocol::protocol::GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: test_path_buf("/tmp").abs(), + }; + let notification = guardian_auto_approval_review_notification( + &conversation_id, + "turn-from-event", + &GuardianAssessmentEvent { + id: "review-2".to_string(), + target_item_id: Some("item-2".to_string()), + turn_id: "turn-from-assessment".to_string(), + started_at_ms: 1_000, + completed_at_ms: Some(1_042), + status: codex_protocol::protocol::GuardianAssessmentStatus::Denied, + risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), + user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), + rationale: Some("too risky".to_string()), + decision_source: Some( + codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + ), + action: action.clone(), + }, + ); + + match notification { + ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => { + assert_eq!(payload.thread_id, conversation_id.to_string()); + assert_eq!(payload.turn_id, "turn-from-assessment"); + assert_eq!(payload.started_at_ms, 1_000); + assert_eq!(payload.completed_at_ms, 1_042); + assert_eq!(payload.review_id, "review-2"); + assert_eq!(payload.target_item_id.as_deref(), Some("item-2")); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); + assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied); + assert_eq!( + payload.review.risk_level, + Some(codex_app_server_protocol::GuardianRiskLevel::High) + ); + assert_eq!( + payload.review.user_authorization, + Some(codex_app_server_protocol::GuardianUserAuthorization::Low) + ); + assert_eq!(payload.review.rationale.as_deref(), Some("too risky")); + assert_eq!(payload.action, action.into()); + } + other => panic!("unexpected notification: {other:?}"), + } + } + + #[test] + fn guardian_assessment_aborted_emits_completed_review_payload() { + let conversation_id = ThreadId::new(); + let action = codex_protocol::protocol::GuardianAssessmentAction::NetworkAccess { + target: "api.openai.com:443".to_string(), + host: "api.openai.com".to_string(), + protocol: codex_protocol::protocol::NetworkApprovalProtocol::Https, + port: 443, + }; + let notification = guardian_auto_approval_review_notification( + &conversation_id, + "turn-from-event", + &GuardianAssessmentEvent { + id: "review-3".to_string(), + target_item_id: None, + turn_id: "turn-from-assessment".to_string(), + started_at_ms: 1_000, + completed_at_ms: Some(1_042), + status: codex_protocol::protocol::GuardianAssessmentStatus::Aborted, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: Some( + codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + ), + action: action.clone(), + }, + ); + + match notification { + ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => { + assert_eq!(payload.thread_id, conversation_id.to_string()); + assert_eq!(payload.turn_id, "turn-from-assessment"); + assert_eq!(payload.review_id, "review-3"); + assert_eq!(payload.target_item_id, None); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); + assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Aborted); + assert_eq!(payload.review.risk_level, None); + assert_eq!(payload.review.user_authorization, None); + assert_eq!(payload.review.rationale, None); + assert_eq!(payload.action, action.into()); + } + other => panic!("unexpected notification: {other:?}"), + } + } + + #[tokio::test] + async fn command_execution_started_helper_emits_once() -> Result<()> { + let conversation_id = ThreadId::new(); + let thread_state = new_thread_state(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + let completion_item = command_execution_completion_item("printf hi"); + + let first_start = start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + assert!(first_start); + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => { + assert_eq!(payload.thread_id, conversation_id.to_string()); + assert_eq!(payload.turn_id, "turn-1"); + assert_eq!( + payload.item, + ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: completion_item.command.clone(), + cwd: completion_item.cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: completion_item.command_actions.clone(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); + } + other => bail!("unexpected message: {other:?}"), + } + + let second_start = start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + assert!(!second_start); + assert!(rx.try_recv().is_err(), "duplicate start should not emit"); + Ok(()) + } + + #[tokio::test] + async fn complete_command_execution_item_emits_declined_once_for_pending_command() -> Result<()> + { + let conversation_id = ThreadId::new(); + let thread_state = new_thread_state(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + let completion_item = command_execution_completion_item("printf hi"); + + start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + let _started = recv_broadcast_message(&mut rx).await?; + + complete_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions.clone(), + CommandExecutionStatus::Declined, + &outgoing, + &thread_state, + ) + .await; + + let completed = recv_broadcast_message(&mut rx).await?; + match completed { + OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) => { + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution completion"); + }; + assert_eq!(id, "cmd-1"); + assert_eq!(status, CommandExecutionStatus::Declined); + } + other => bail!("unexpected message: {other:?}"), + } + + complete_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command, + completion_item.cwd, + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions, + CommandExecutionStatus::Declined, + &outgoing, + &thread_state, + ) + .await; + assert!( + rx.try_recv().is_err(), + "completion should not emit after the pending item is cleared" + ); + Ok(()) + } + + #[tokio::test] + async fn guardian_command_execution_notifications_wrap_review_lifecycle() -> Result<()> { + let codex_home = TempDir::new()?; + let config = load_default_config_for_test(&codex_home).await; + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ), + ); + let codex_core::NewThread { + thread_id: conversation_id, + thread: conversation, + .. + } = thread_manager.start_thread(config.clone()).await?; + let thread_state = new_thread_state(); + let thread_watch_manager = ThreadWatchManager::new(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + let guardian_context = GuardianAssessmentTestContext { + conversation_id, + conversation: conversation.clone(), + thread_manager: thread_manager.clone(), + outgoing: outgoing.clone(), + thread_state: thread_state.clone(), + thread_watch_manager: thread_watch_manager.clone(), + }; + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-approved", + "turn-guardian-approved", + GuardianAssessmentStatus::InProgress, + )) + .await; + let first = recv_broadcast_message(&mut rx).await?; + match first { + OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => { + assert_eq!(payload.turn_id, "turn-guardian-approved"); + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution item"); + }; + assert_eq!(id, "cmd-guardian-approved"); + assert_eq!(status, CommandExecutionStatus::InProgress); + } + other => bail!("unexpected message: {other:?}"), + } + let second = recv_broadcast_message(&mut rx).await?; + match second { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewStarted(payload), + ) => { + assert_eq!(payload.review_id, "review-cmd-guardian-approved"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-approved") + ); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::InProgress + ); + } + other => bail!("unexpected message: {other:?}"), + } + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-approved", + "turn-guardian-approved", + GuardianAssessmentStatus::Approved, + )) + .await; + let third = recv_broadcast_message(&mut rx).await?; + match third { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewCompleted(payload), + ) => { + assert_eq!(payload.review_id, "review-cmd-guardian-approved"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-approved") + ); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::Approved + ); + } + other => bail!("unexpected message: {other:?}"), + } + assert!( + rx.try_recv().is_err(), + "approved review should not complete the command item" + ); + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-denied", + "turn-guardian-denied", + GuardianAssessmentStatus::InProgress, + )) + .await; + let fourth = recv_broadcast_message(&mut rx).await?; + match fourth { + OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => { + assert_eq!(payload.turn_id, "turn-guardian-denied"); + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution item"); + }; + assert_eq!(id, "cmd-guardian-denied"); + assert_eq!(status, CommandExecutionStatus::InProgress); + } + other => bail!("unexpected message: {other:?}"), + } + let fifth = recv_broadcast_message(&mut rx).await?; + match fifth { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewStarted(payload), + ) => { + assert_eq!(payload.review_id, "review-cmd-guardian-denied"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-denied") + ); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::InProgress + ); + } + other => bail!("unexpected message: {other:?}"), + } + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-denied", + "turn-guardian-denied", + GuardianAssessmentStatus::Denied, + )) + .await; + let sixth = recv_broadcast_message(&mut rx).await?; + match sixth { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewCompleted(payload), + ) => { + assert_eq!(payload.review_id, "review-cmd-guardian-denied"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-denied") + ); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); + assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied); + } + other => bail!("unexpected message: {other:?}"), + } + let seventh = recv_broadcast_message(&mut rx).await?; + match seventh { + OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) => { + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution completion"); + }; + assert_eq!(id, "cmd-guardian-denied"); + assert_eq!(status, CommandExecutionStatus::Declined); + } + other => bail!("unexpected message: {other:?}"), + } + + let mut missing_target = guardian_command_assessment( + "cmd-guardian-missing-target", + "turn-guardian-missing-target", + GuardianAssessmentStatus::InProgress, + ); + missing_target.target_item_id = None; + guardian_context + .apply_guardian_assessment_event(missing_target) + .await; + let eighth = recv_broadcast_message(&mut rx).await?; + match eighth { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewStarted(payload), + ) => { + assert_eq!(payload.review_id, "review-cmd-guardian-missing-target"); + assert_eq!(payload.target_item_id, None); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::InProgress + ); + } + other => bail!("unexpected message: {other:?}"), + } + + assert!(rx.try_recv().is_err(), "no extra messages expected"); + conversation.shutdown_and_wait().await?; + Ok(()) + } + + #[test] + fn file_change_accept_for_session_maps_to_approved_for_session() { + let decision = + map_file_change_approval_decision(FileChangeApprovalDecision::AcceptForSession); + assert_eq!(decision, ReviewDecision::ApprovedForSession); + } + + #[test] + fn mcp_server_elicitation_turn_transition_error_maps_to_cancel() { + let error = JSONRPCErrorError { + code: -1, + message: "client request resolved because the turn state was changed".to_string(), + data: Some(serde_json::json!({ "reason": "turnTransition" })), + }; + + let response = mcp_server_elicitation_response_from_client_result(Ok(Err(error))); + + assert_eq!( + response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Cancel, + content: None, + meta: None, + } + ); + } + + #[test] + fn request_permissions_turn_transition_error_is_ignored() { + let error = JSONRPCErrorError { + code: -1, + message: "client request resolved because the turn state was changed".to_string(), + data: Some(serde_json::json!({ "reason": "turnTransition" })), + }; + + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile::default(), + Ok(Err(error)), + std::env::current_dir().expect("current dir").as_path(), + ); + + assert_eq!(response, None); + } + + #[test] + fn request_permissions_response_accepts_partial_network_and_file_system_grants() { + let input_path = if cfg!(target_os = "windows") { + r"C:\tmp\input" + } else { + "/tmp/input" + }; + let output_path = if cfg!(target_os = "windows") { + r"C:\tmp\output" + } else { + "/tmp/output" + }; + let ignored_path = if cfg!(target_os = "windows") { + r"C:\tmp\ignored" + } else { + "/tmp/ignored" + }; + let absolute_path = |path: &str| { + AbsolutePathBuf::try_from(std::path::PathBuf::from(path)).expect("absolute path") + }; + let requested_permissions = CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path(input_path)]), + Some(vec![absolute_path(output_path)]), + )), + }; + let cases = vec![ + ( + serde_json::json!({}), + CoreRequestPermissionProfile::default(), + ), + ( + serde_json::json!({ + "network": { + "enabled": true, + }, + }), + CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + ..CoreRequestPermissionProfile::default() + }, + ), + ( + serde_json::json!({ + "fileSystem": { + "write": [output_path], + }, + }), + CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![absolute_path(output_path)]), + )), + ..CoreRequestPermissionProfile::default() + }, + ), + ( + serde_json::json!({ + "fileSystem": { + "read": [input_path], + "write": [output_path, ignored_path], + }, + "macos": { + "calendar": true, + }, + }), + CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path(input_path)]), + Some(vec![absolute_path(output_path)]), + )), + ..CoreRequestPermissionProfile::default() + }, + ), + ]; + + let cwd = std::env::current_dir().expect("current dir"); + for (granted_permissions, expected_permissions) in cases { + let response = request_permissions_response_from_client_result( + requested_permissions.clone(), + Ok(Ok(serde_json::json!({ + "permissions": granted_permissions, + }))), + cwd.as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: expected_permissions, + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + } + ); + } + } + + #[test] + fn request_permissions_response_preserves_session_scope() { + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile::default(), + Ok(Ok(serde_json::json!({ + "scope": "session", + "permissions": {}, + }))), + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: CoreRequestPermissionProfile::default(), + scope: CorePermissionGrantScope::Session, + strict_auto_review: false, + } + ); + } + + #[test] + fn request_permissions_response_rejects_session_scoped_strict_auto_review() { + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile::default(), + Ok(Ok(serde_json::json!({ + "scope": "session", + "strictAutoReview": true, + "permissions": { + "network": { + "enabled": true, + }, + }, + }))), + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: CoreRequestPermissionProfile::default(), + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + } + ); + } + + #[test] + fn request_permissions_response_preserves_turn_scoped_strict_auto_review() { + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + Ok(Ok(serde_json::json!({ + "strictAutoReview": true, + "permissions": { + "network": { + "enabled": true, + }, + }, + }))), + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("response should be accepted"); + + assert_eq!(response.scope, CorePermissionGrantScope::Turn); + assert!(response.strict_auto_review); + } + + #[test] + fn request_permissions_response_accepts_explicit_child_grant_for_requested_cwd_scope() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute cwd"); + let child = cwd.join("child"); + let requested_permissions = CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + ..Default::default() + }; + + let response = request_permissions_response_from_client_result( + requested_permissions, + Ok(Ok(serde_json::json!({ + "permissions": { + "fileSystem": { + "write": [child], + }, + }, + }))), + cwd.as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response.permissions, + CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child]), + )), + ..Default::default() + } + ); + } + + #[test] + fn request_permissions_response_rejects_child_grant_outside_requested_cwd_scope() { + let temp_dir = TempDir::new().expect("temp dir"); + let request_cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("request-cwd")) + .expect("absolute request cwd"); + let later_cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("later-cwd")) + .expect("absolute later cwd"); + let later_child = later_cwd.join("child"); + let requested_permissions = CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + ..Default::default() + }; + + let response = request_permissions_response_from_client_result( + requested_permissions, + Ok(Ok(serde_json::json!({ + "permissions": { + "fileSystem": { + "write": [later_child], + }, + }, + }))), + request_cwd.as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response.permissions, + CoreRequestPermissionProfile::default() + ); + } + + #[test] + fn request_permissions_response_ignores_broader_cwd_grant_for_requested_child_path() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute cwd"); + let child = cwd.join("child"); + let requested_permissions = CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child]), + )), + ..Default::default() + }; + + let response = request_permissions_response_from_client_result( + requested_permissions, + Ok(Ok(serde_json::json!({ + "permissions": { + "fileSystem": { + "entries": [{ + "path": { + "type": "special", + "value": { + "kind": "project_roots", + "subpath": null + } + }, + "access": "write" + }], + }, + }, + }))), + cwd.as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response.permissions, + CoreRequestPermissionProfile::default() + ); + } + + #[tokio::test] + async fn test_handle_error_records_message() -> Result<()> { + let conversation_id = ThreadId::new(); + let thread_state = new_thread_state(); + + handle_error( + conversation_id, + TurnError { + message: "boom".to_string(), + codex_error_info: Some(V2CodexErrorInfo::InternalServerError), + additional_details: None, + }, + &thread_state, + ) + .await; + + let turn_summary = find_and_remove_turn_summary(conversation_id, &thread_state).await; + assert_eq!( + turn_summary.last_error, + Some(TurnError { + message: "boom".to_string(), + codex_error_info: Some(V2CodexErrorInfo::InternalServerError), + additional_details: None, + }) + ); + Ok(()) + } + + #[tokio::test] + async fn turn_started_omits_active_snapshot_items() -> Result<()> { + let codex_home = TempDir::new()?; + let config = load_default_config_for_test(&codex_home).await; + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ), + ); + let codex_core::NewThread { + thread_id: conversation_id, + thread: conversation, + .. + } = thread_manager.start_thread(config.clone()).await?; + let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event( + "turn-1", + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + ); + state.track_current_turn_event( + "turn-1", + &EventMsg::UserMessage(codex_protocol::protocol::UserMessageEvent { + message: "already tracked".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }), + ); + } + let thread_watch_manager = ThreadWatchManager::new(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + + apply_bespoke_event_handling( + Event { + id: "turn-1".to_string(), + msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }, + conversation_id, + conversation, + thread_manager, + outgoing, + thread_state, + thread_watch_manager, + Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), + "test-provider".to_string(), + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnStarted(n)) => { + assert_eq!(n.turn.id, "turn-1"); + assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded); + assert!(n.turn.items.is_empty()); + } + other => bail!("unexpected message: {other:?}"), + } + Ok(()) + } + + #[tokio::test] + async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> { + let conversation_id = ThreadId::new(); + let event_turn_id = "complete1".to_string(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event( + &event_turn_id, + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + ); + state.track_current_turn_event( + &event_turn_id, + &EventMsg::TurnComplete(turn_complete_event(&event_turn_id)), + ); + } + + handle_turn_complete( + conversation_id, + event_turn_id.clone(), + turn_complete_event(&event_turn_id), + &outgoing, + &thread_state, + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { + assert_eq!(n.turn.id, event_turn_id); + assert_eq!(n.turn.status, TurnStatus::Completed); + assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded); + assert!(n.turn.items.is_empty()); + assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } + + #[tokio::test] + async fn test_handle_turn_interrupted_emits_interrupted_with_error() -> Result<()> { + let conversation_id = ThreadId::new(); + let event_turn_id = "interrupt1".to_string(); + let thread_state = new_thread_state(); + handle_error( + conversation_id, + TurnError { + message: "oops".to_string(), + codex_error_info: None, + additional_details: None, + }, + &thread_state, + ) + .await; + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + + handle_turn_interrupted( + conversation_id, + event_turn_id.clone(), + turn_aborted_event(&event_turn_id), + &outgoing, + &thread_state, + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { + assert_eq!(n.turn.id, event_turn_id); + assert_eq!(n.turn.status, TurnStatus::Interrupted); + assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } + + #[tokio::test] + async fn test_handle_turn_complete_emits_failed_with_error() -> Result<()> { + let conversation_id = ThreadId::new(); + let event_turn_id = "complete_err1".to_string(); + let thread_state = new_thread_state(); + handle_error( + conversation_id, + TurnError { + message: "bad".to_string(), + codex_error_info: Some(V2CodexErrorInfo::Other), + additional_details: None, + }, + &thread_state, + ) + .await; + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + + handle_turn_complete( + conversation_id, + event_turn_id.clone(), + turn_complete_event(&event_turn_id), + &outgoing, + &thread_state, + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { + assert_eq!(n.turn.id, event_turn_id); + assert_eq!(n.turn.status, TurnStatus::Failed); + assert_eq!( + n.turn.error, + Some(TurnError { + message: "bad".to_string(), + codex_error_info: Some(V2CodexErrorInfo::Other), + additional_details: None, + }) + ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } + + #[tokio::test] + async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + let update = UpdatePlanArgs { + explanation: Some("need plan".to_string()), + plan: vec![ + PlanItemArg { + step: "first".to_string(), + status: StepStatus::Pending, + }, + PlanItemArg { + step: "second".to_string(), + status: StepStatus::Completed, + }, + ], + }; + + let conversation_id = ThreadId::new(); + + handle_turn_plan_update(conversation_id, "turn-123", update, &outgoing).await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnPlanUpdated(n)) => { + assert_eq!(n.thread_id, conversation_id.to_string()); + assert_eq!(n.turn_id, "turn-123"); + assert_eq!(n.explanation.as_deref(), Some("need plan")); + assert_eq!(n.plan.len(), 2); + assert_eq!(n.plan[0].step, "first"); + assert_eq!(n.plan[0].status, TurnPlanStepStatus::Pending); + assert_eq!(n.plan[1].step, "second"); + assert_eq!(n.plan[1].status, TurnPlanStepStatus::Completed); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } + + #[tokio::test] + async fn test_handle_token_count_event_emits_usage_and_rate_limits() -> Result<()> { + let conversation_id = ThreadId::new(); + let turn_id = "turn-123".to_string(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + + let info = TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 100, + cached_input_tokens: 25, + output_tokens: 50, + reasoning_output_tokens: 9, + total_tokens: 200, + }, + last_token_usage: TokenUsage { + input_tokens: 10, + cached_input_tokens: 5, + output_tokens: 7, + reasoning_output_tokens: 1, + total_tokens: 23, + }, + model_context_window: Some(4096), + }; + let rate_limits = RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 42.5, + window_minutes: Some(15), + resets_at: Some(1700000000), + }), + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("5".to_string()), + }), + plan_type: None, + rate_limit_reached_type: None, + }; + + handle_token_count_event( + conversation_id, + turn_id.clone(), + TokenCountEvent { + info: Some(info), + rate_limits: Some(rate_limits), + }, + &outgoing, + ) + .await; + + let first = recv_broadcast_message(&mut rx).await?; + match first { + OutgoingMessage::AppServerNotification( + ServerNotification::ThreadTokenUsageUpdated(payload), + ) => { + assert_eq!(payload.thread_id, conversation_id.to_string()); + assert_eq!(payload.turn_id, turn_id); + let usage = payload.token_usage; + assert_eq!(usage.total.total_tokens, 200); + assert_eq!(usage.total.cached_input_tokens, 25); + assert_eq!(usage.last.output_tokens, 7); + assert_eq!(usage.model_context_window, Some(4096)); + } + other => bail!("unexpected notification: {other:?}"), + } + + let second = recv_broadcast_message(&mut rx).await?; + match second { + OutgoingMessage::AppServerNotification( + ServerNotification::AccountRateLimitsUpdated(payload), + ) => { + assert_eq!(payload.rate_limits.limit_id.as_deref(), Some("codex")); + assert_eq!(payload.rate_limits.limit_name, None); + assert!(payload.rate_limits.primary.is_some()); + assert!(payload.rate_limits.credits.is_some()); + } + other => bail!("unexpected notification: {other:?}"), + } + Ok(()) + } + + #[tokio::test] + async fn test_handle_token_count_event_without_usage_info() -> Result<()> { + let conversation_id = ThreadId::new(); + let turn_id = "turn-456".to_string(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + + handle_token_count_event( + conversation_id, + turn_id.clone(), + TokenCountEvent { + info: None, + rate_limits: None, + }, + &outgoing, + ) + .await; + + assert!( + rx.try_recv().is_err(), + "no notifications should be emitted when token usage info is absent" + ); + Ok(()) + } + + #[tokio::test] + async fn test_handle_turn_complete_emits_error_multiple_turns() -> Result<()> { + // Conversation A will have two turns; Conversation B will have one turn. + let conversation_a = ThreadId::new(); + let conversation_b = ThreadId::new(); + let thread_state = new_thread_state(); + + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + + // Turn 1 on conversation A + let a_turn1 = "a_turn1".to_string(); + handle_error( + conversation_a, + TurnError { + message: "a1".to_string(), + codex_error_info: Some(V2CodexErrorInfo::BadRequest), + additional_details: None, + }, + &thread_state, + ) + .await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; + + // Turn 1 on conversation B + let b_turn1 = "b_turn1".to_string(); + handle_error( + conversation_b, + TurnError { + message: "b1".to_string(), + codex_error_info: None, + additional_details: None, + }, + &thread_state, + ) + .await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; + + // Turn 2 on conversation A + let a_turn2 = "a_turn2".to_string(); + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; + + // Verify: A turn 1 + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { + assert_eq!(n.turn.id, a_turn1); + assert_eq!(n.turn.status, TurnStatus::Failed); + assert_eq!( + n.turn.error, + Some(TurnError { + message: "a1".to_string(), + codex_error_info: Some(V2CodexErrorInfo::BadRequest), + additional_details: None, + }) + ); + } + other => bail!("unexpected message: {other:?}"), + } + + // Verify: B turn 1 + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { + assert_eq!(n.turn.id, b_turn1); + assert_eq!(n.turn.status, TurnStatus::Failed); + assert_eq!( + n.turn.error, + Some(TurnError { + message: "b1".to_string(), + codex_error_info: None, + additional_details: None, + }) + ); + } + other => bail!("unexpected message: {other:?}"), + } + + // Verify: A turn 2 + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { + assert_eq!(n.turn.id, a_turn2); + assert_eq!(n.turn.status, TurnStatus::Completed); + assert_eq!(n.turn.error, None); + } + other => bail!("unexpected message: {other:?}"), + } + + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } + + #[tokio::test] + async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + let unified_diff = "--- a\n+++ b\n".to_string(); + let conversation_id = ThreadId::new(); + + handle_turn_diff( + conversation_id, + "turn-1", + TurnDiffEvent { + unified_diff: unified_diff.clone(), + }, + &outgoing, + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnDiffUpdated( + notification, + )) => { + assert_eq!(notification.thread_id, conversation_id.to_string()); + assert_eq!(notification.turn_id, "turn-1"); + assert_eq!(notification.diff, unified_diff); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } + + #[tokio::test] + async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let conversation_id = ThreadId::new(); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + let item = build_hook_prompt_message(&[ + HookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"), + HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"), + ]) + .expect("hook prompt message"); + + maybe_emit_hook_prompt_item_completed(conversation_id, "turn-1", &item, &outgoing).await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted( + notification, + )) => { + assert_eq!(notification.thread_id, conversation_id.to_string()); + assert_eq!(notification.turn_id, "turn-1"); + assert_eq!( + notification.item, + ThreadItem::HookPrompt { + id: notification.item.id().to_string(), + fragments: vec![ + codex_app_server_protocol::HookPromptFragment { + text: "Retry with tests.".into(), + hook_run_id: "hook-run-1".into(), + }, + codex_app_server_protocol::HookPromptFragment { + text: "Then summarize cleanly.".into(), + hook_run_id: "hook-run-2".into(), + }, + ], + } + ); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); + Ok(()) + } +} diff --git a/code-rs/app-server/src/bin/notify_capture.rs b/code-rs/app-server/src/bin/notify_capture.rs new file mode 100644 index 00000000000..7217e263104 --- /dev/null +++ b/code-rs/app-server/src/bin/notify_capture.rs @@ -0,0 +1,44 @@ +use std::env; +use std::fs; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; + +fn main() -> Result<()> { + let mut args = env::args_os(); + let _program = args.next(); + let output_path = PathBuf::from( + args.next() + .ok_or_else(|| anyhow!("expected output path as first argument"))?, + ); + let payload = args + .next() + .ok_or_else(|| anyhow!("expected payload as final argument"))?; + + if args.next().is_some() { + bail!("expected payload as final argument"); + } + + let payload = payload.to_string_lossy(); + let temp_path = PathBuf::from(format!("{}.tmp", output_path.display())); + let mut file = File::create(&temp_path) + .with_context(|| format!("failed to create {}", temp_path.display()))?; + file.write_all(payload.as_bytes()) + .with_context(|| format!("failed to write {}", temp_path.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", temp_path.display()))?; + fs::rename(&temp_path, &output_path).with_context(|| { + format!( + "failed to move {} into {}", + temp_path.display(), + output_path.display() + ) + })?; + + Ok(()) +} diff --git a/code-rs/app-server/src/bin/test_notify_capture.rs b/code-rs/app-server/src/bin/test_notify_capture.rs new file mode 100644 index 00000000000..b3d96b85454 --- /dev/null +++ b/code-rs/app-server/src/bin/test_notify_capture.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use anyhow::anyhow; +use std::env; +use std::path::PathBuf; + +fn main() -> Result<()> { + let mut args = env::args_os().skip(1); + let output_path = PathBuf::from( + args.next() + .ok_or_else(|| anyhow!("missing output path argument"))?, + ); + let payload = args + .next() + .ok_or_else(|| anyhow!("missing payload argument"))? + .into_string() + .map_err(|_| anyhow!("payload must be valid UTF-8"))?; + + let temp_path = output_path.with_extension("json.tmp"); + std::fs::write(&temp_path, payload)?; + std::fs::rename(&temp_path, &output_path)?; + + Ok(()) +} diff --git a/code-rs/app-server/src/code_message_processor.rs b/code-rs/app-server/src/code_message_processor.rs deleted file mode 100644 index b1ac75a75fe..00000000000 --- a/code-rs/app-server/src/code_message_processor.rs +++ /dev/null @@ -1,2412 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -use code_app_server_protocol::Account as V2Account; -use code_app_server_protocol::CancelLoginAccountParams; -use code_app_server_protocol::CancelLoginAccountResponse; -use code_app_server_protocol::CancelLoginAccountStatus; -use code_app_server_protocol::GetAccountRateLimitsResponse; -use code_app_server_protocol::GetAccountResponse; -use code_app_server_protocol::LoginAccountParams; -use code_app_server_protocol::LoginAccountResponse; -use code_app_server_protocol::LogoutAccountResponse; -use code_app_server_protocol::ToolRequestUserInputOption; -use code_app_server_protocol::ToolRequestUserInputParams; -use code_app_server_protocol::ToolRequestUserInputQuestion; -use code_app_server_protocol::ToolRequestUserInputResponse; -use code_core::AuthManager; -use code_core::CodexConversation; -use code_core::ConversationManager; -use code_core::NewConversation; -use code_core::RolloutRecorder; -use code_core::Cursor; -use code_core::config::Config; -use code_core::config::ConfigOverrides; -use code_core::config::ConfigToml; -use code_core::config_edit::{CONFIG_KEY_EFFORT, CONFIG_KEY_MODEL}; -use code_core::exec; -use code_core::exec_env; -use code_core::get_platform_sandbox; -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 code_login::CLIENT_ID; -use code_login::ServerOptions; -use code_login::ShutdownHandle; -use code_login::run_login_server; -use tokio::sync::Mutex; -use tokio::sync::oneshot; -use tokio::time::Duration; -use tokio::time::timeout; -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::ConnectionId; -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_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; -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::DynamicToolCallParams; -use code_protocol::mcp_protocol::DynamicToolCallResponse; -use code_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD; -use code_protocol::mcp_protocol::DYNAMIC_TOOL_CALL_METHOD; -use code_protocol::request_user_input::RequestUserInputAnswer; -use code_protocol::request_user_input::RequestUserInputResponse; -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::GetAuthStatusParams; -use code_protocol::mcp_protocol::GetAuthStatusResponse; -use code_protocol::mcp_protocol::GetUserAgentResponse; -use code_protocol::mcp_protocol::GetUserSavedConfigResponse; -use code_protocol::mcp_protocol::ListConversationsParams; -use code_protocol::mcp_protocol::ListConversationsResponse; -use code_protocol::mcp_protocol::LoginApiKeyParams; -use code_protocol::mcp_protocol::LoginApiKeyResponse; -use code_protocol::mcp_protocol::NewConversationParams; -use code_protocol::mcp_protocol::NewConversationResponse; -use code_protocol::mcp_protocol::ResumeConversationParams; -use code_protocol::mcp_protocol::ResumeConversationResponse; -use code_protocol::mcp_protocol::ArchiveConversationParams; -use code_protocol::mcp_protocol::ArchiveConversationResponse; -use code_protocol::mcp_protocol::RemoveConversationListenerParams; -use code_protocol::mcp_protocol::RemoveConversationSubscriptionResponse; -use code_protocol::mcp_protocol::SetDefaultModelParams; -use code_protocol::mcp_protocol::SetDefaultModelResponse; -use code_protocol::mcp_protocol::SendUserMessageParams; -use code_protocol::mcp_protocol::SendUserMessageResponse; -use code_protocol::mcp_protocol::SendUserTurnParams; -use code_protocol::mcp_protocol::SendUserTurnResponse; -use code_protocol::mcp_protocol::UserInfoResponse; -use code_protocol::mcp_protocol::ExecOneOffCommandParams; -use code_protocol::mcp_protocol::ExecArbitraryCommandResponse; -use code_protocol::mcp_protocol::ConversationSummary; -use code_protocol::mcp_protocol::UserSavedConfig; -use code_protocol::mcp_protocol::Profile; -use code_protocol::mcp_protocol::SandboxSettings; -use code_protocol::mcp_protocol::Tools; -use code_protocol::mcp_protocol::LoginChatGptResponse; -use code_protocol::mcp_protocol::CancelLoginChatGptParams; -use code_protocol::mcp_protocol::CancelLoginChatGptResponse; -use code_protocol::mcp_protocol::LogoutChatGptResponse; -use code_protocol::account::PlanType; -use code_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; -use code_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; -use code_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; - -// Removed deprecated ChatGPT login support scaffolding - -const TOOL_REQUEST_USER_INPUT_METHOD: &str = "item/tool/requestUserInput"; - -struct ConversationListenerRegistration { - owner_connection_id: ConnectionId, - cancel_tx: oneshot::Sender<()>, -} - -struct ActiveLogin { - login_id: Uuid, - shutdown_handle: ShutdownHandle, -} - -/// Handles JSON-RPC messages for Codex conversations. -pub struct CodexMessageProcessor { - auth_manager: Arc, - conversation_manager: Arc, - outgoing: Arc, - code_linux_sandbox_exe: Option, - config: Arc, - conversation_listeners: HashMap, - active_login: Arc>>, - // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. - pending_interrupts: Arc>>>, - #[allow(dead_code)] - pending_fuzzy_searches: Arc>>>, -} - -impl CodexMessageProcessor { - pub fn new( - auth_manager: Arc, - conversation_manager: Arc, - outgoing: Arc, - code_linux_sandbox_exe: Option, - config: Arc, - ) -> Self { - Self { - auth_manager, - conversation_manager, - outgoing, - code_linux_sandbox_exe, - config, - conversation_listeners: HashMap::new(), - active_login: Arc::new(Mutex::new(None)), - 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) { - self.process_request_for_connection(ConnectionId(0), request) - .await; - } - - pub(crate) async fn process_request_for_connection( - &mut self, - connection_id: ConnectionId, - request: ClientRequest, - ) { - match request { - ClientRequest::Initialize { request_id, .. } => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - 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::ListConversations { request_id, params } => { - self.list_conversations(request_id, params).await; - } - ClientRequest::ResumeConversation { request_id, params } => { - self.resume_conversation(request_id, params).await; - } - ClientRequest::ArchiveConversation { request_id, params } => { - self.archive_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(connection_id, request_id, params) - .await; - } - ClientRequest::RemoveConversationListener { request_id, params } => { - self.remove_conversation_listener(connection_id, request_id, params) - .await; - } - ClientRequest::SendUserTurn { request_id, params } => { - self.send_user_turn_compat(request_id, params).await; - } - ClientRequest::FuzzyFileSearch { request_id, params } => { - self.fuzzy_file_search(request_id, params).await; - } - ClientRequest::LoginChatGpt { request_id, .. } => { - self.login_chatgpt_v1(request_id).await; - } - ClientRequest::LoginApiKey { request_id, params } => { - self.login_api_key(request_id, params).await; - } - ClientRequest::CancelLoginChatGpt { request_id, params } => { - self.cancel_login_chatgpt_v1(request_id, params).await; - } - ClientRequest::LogoutChatGpt { request_id, .. } => { - self.logout_chatgpt_v1(request_id).await; - } - ClientRequest::GetAuthStatus { request_id, params } => { - self.get_auth_status(request_id, params).await; - } - ClientRequest::GetUserSavedConfig { request_id, .. } => { - self.get_user_saved_config(request_id).await; - } - ClientRequest::SetDefaultModel { request_id, params } => { - self.set_default_model(request_id, params).await; - } - ClientRequest::GetUserAgent { request_id, .. } => { - self.get_user_agent(request_id).await; - } - ClientRequest::UserInfo { request_id, .. } => { - self.user_info(request_id).await; - } - ClientRequest::GitDiffToRemote { request_id, params } => { - self.git_diff_to_origin(request_id, params.cwd).await; - } - ClientRequest::ExecOneOffCommand { request_id, params } => { - self.exec_one_off_command(request_id, params).await; - } - } - } - - pub(crate) async fn on_connection_closed(&mut self, connection_id: ConnectionId) { - let subscription_ids: Vec = self - .conversation_listeners - .iter() - .filter_map(|(subscription_id, registration)| { - if registration.owner_connection_id == connection_id { - Some(*subscription_id) - } else { - None - } - }) - .collect(); - - for subscription_id in subscription_ids { - if let Some(registration) = self.conversation_listeners.remove(&subscription_id) { - let _ = registration.cancel_tx.send(()); - } - } - } - - pub(crate) async fn get_account_response_v2( - &self, - refresh_token: bool, - ) -> Result { - let requires_openai_auth = self.config.model_provider.requires_openai_auth; - - self.refresh_token_if_requested(refresh_token).await; - - if !requires_openai_auth { - return Ok(GetAccountResponse { - account: None, - requires_openai_auth, - }); - } - - let account = match self.auth_manager.auth() { - Some(auth) if auth.mode == code_app_server_protocol::AuthMode::ApiKey => { - Some(V2Account::ApiKey {}) - } - Some(auth) if auth.mode.is_chatgpt() => { - let email = auth - .get_token_data() - .await - .ok() - .and_then(|token_data| token_data.id_token.email); - let plan_type = parse_plan_type(auth.get_plan_type()); - - match email { - Some(email) => Some(V2Account::Chatgpt { email, plan_type }), - None => { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "email is required for chatgpt authentication".to_string(), - data: None, - }); - } - } - } - _ => None, - }; - - Ok(GetAccountResponse { - account, - requires_openai_auth, - }) - } - - async fn refresh_token_if_requested(&self, refresh_token: bool) { - if !refresh_token { - return; - } - - if self - .auth_manager - .auth() - .as_ref() - .is_some_and(|auth| auth.mode == code_app_server_protocol::AuthMode::ChatgptAuthTokens) - { - return; - } - - let _ = self.auth_manager.refresh_token_classified().await; - } - - pub(crate) async fn login_account_v2( - &self, - params: LoginAccountParams, - ) -> Result { - match params { - LoginAccountParams::ApiKey { api_key } => { - let api_key = api_key.trim(); - if api_key.is_empty() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "apiKey is required".to_string(), - data: None, - }); - } - - if let Err(err) = code_core::auth::login_with_api_key(&self.config.code_home, api_key) { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to persist api key: {err}"), - data: None, - }); - } - - self.auth_manager.reload(); - Ok(LoginAccountResponse::ApiKey {}) - } - LoginAccountParams::Chatgpt => self.start_chatgpt_login_v2().await, - LoginAccountParams::ChatgptAuthTokens { - access_token, - chatgpt_account_id, - chatgpt_plan_type, - } => { - code_core::auth::login_with_chatgpt_auth_tokens( - &self.config.code_home, - &access_token, - &chatgpt_account_id, - chatgpt_plan_type.as_deref(), - ) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to persist chatgpt auth tokens: {err}"), - data: None, - })?; - - self.auth_manager.reload(); - Ok(LoginAccountResponse::ChatgptAuthTokens {}) - } - } - } - - async fn start_chatgpt_login_v2(&self) -> Result { - let mut options = ServerOptions::new( - self.config.code_home.clone(), - CLIENT_ID.to_string(), - self.config.responses_originator_header.clone(), - ); - options.open_browser = false; - - let server = run_login_server(options).map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start login server: {err}"), - data: None, - })?; - - let login_id = Uuid::new_v4(); - let auth_url = server.auth_url.clone(); - let shutdown_handle = server.cancel_handle(); - - { - let mut active_login = self.active_login.lock().await; - if let Some(existing) = active_login.take() { - existing.shutdown_handle.shutdown(); - } - *active_login = Some(ActiveLogin { - login_id, - shutdown_handle: shutdown_handle.clone(), - }); - } - - let active_login = Arc::clone(&self.active_login); - let auth_manager = Arc::clone(&self.auth_manager); - tokio::spawn(async move { - let login_result = timeout(Duration::from_secs(300), server.block_until_done()).await; - match login_result { - Ok(Ok(())) => { - auth_manager.reload(); - } - Ok(Err(err)) => { - tracing::warn!("chatgpt login failed: {err}"); - } - Err(_elapsed) => { - shutdown_handle.shutdown(); - } - } - - let mut active_login = active_login.lock().await; - if active_login.as_ref().map(|entry| entry.login_id) == Some(login_id) { - *active_login = None; - } - }); - - Ok(LoginAccountResponse::Chatgpt { - login_id: login_id.to_string(), - auth_url, - }) - } - - pub(crate) async fn cancel_login_account_v2( - &self, - params: CancelLoginAccountParams, - ) -> Result { - let login_id = Uuid::parse_str(¶ms.login_id).map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid login id: {}", params.login_id), - data: None, - })?; - - let status = self.cancel_active_login(login_id).await; - Ok(CancelLoginAccountResponse { status }) - } - - async fn cancel_active_login(&self, login_id: Uuid) -> CancelLoginAccountStatus { - let mut active_login = self.active_login.lock().await; - if active_login.as_ref().map(|entry| entry.login_id) == Some(login_id) { - if let Some(existing) = active_login.take() { - existing.shutdown_handle.shutdown(); - } - CancelLoginAccountStatus::Canceled - } else { - CancelLoginAccountStatus::NotFound - } - } - - pub(crate) async fn logout_account_v2(&self) -> Result { - { - let mut active_login = self.active_login.lock().await; - if let Some(existing) = active_login.take() { - existing.shutdown_handle.shutdown(); - } - } - - self.auth_manager.logout().map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("logout failed: {err}"), - data: None, - })?; - Ok(LogoutAccountResponse {}) - } - - pub(crate) fn get_account_rate_limits_v2( - &self, - ) -> Result { - let Some(auth) = self.auth_manager.auth() else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "account authentication required to read rate limits".to_string(), - data: None, - }); - }; - - if !auth.mode.is_chatgpt() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "chatgpt authentication required to read rate limits".to_string(), - data: None, - }); - } - - let snapshots = code_core::account_usage::list_rate_limit_snapshots(&self.config.code_home) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to read rate limit snapshots: {err}"), - data: None, - })?; - let selected = select_rate_limit_snapshot(auth.get_account_id(), snapshots).ok_or_else(|| { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "no rate limit snapshot available".to_string(), - data: None, - } - })?; - - let snapshot = selected.snapshot.clone().ok_or_else(|| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "no rate limit snapshot available".to_string(), - data: None, - })?; - - let plan_type = selected.plan.clone().map(|value| parse_plan_type(Some(value))); - let rate_limits = rate_limit_snapshot_from_event(&snapshot, plan_type); - let mut rate_limits_by_limit_id = HashMap::new(); - rate_limits_by_limit_id.insert(selected.account_id, rate_limits.clone().into()); - - Ok(GetAccountRateLimitsResponse { - rate_limits: rate_limits.into(), - rate_limits_by_limit_id: Some(rate_limits_by_limit_id), - }) - } - - 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 = 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, - final_output_json_schema: None, - }) - .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 = 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, - final_output_json_schema: None, - }) - .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, - owner_connection_id: ConnectionId, - 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, - ConversationListenerRegistration { - owner_connection_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_to_connection( - owner_connection_id, - OutgoingNotification { - method, - params: Some(params.into()), - }, - ) - .await; - - apply_bespoke_event_handling( - event.clone(), - conversation_id, - owner_connection_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, - requester_connection_id: ConnectionId, - request_id: RequestId, - params: RemoveConversationListenerParams, - ) { - let RemoveConversationListenerParams { subscription_id } = params; - match self.conversation_listeners.remove(&subscription_id) { - Some(registration) => { - if registration.owner_connection_id != requester_connection_id { - // Keep ownership scoped to the client that created the listener. - self.conversation_listeners - .insert(subscription_id, registration); - 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; - return; - } - - // Signal the spawned task to exit and acknowledge. - let _ = registration.cancel_tx.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 list_conversations(&self, request_id: RequestId, params: ListConversationsParams) { - let page_size = params.page_size.unwrap_or(50).min(200); - let cursor: Option = match params.cursor { - Some(cursor) => match serde_json::from_value::(serde_json::Value::String(cursor)) { - Ok(cursor) => Some(cursor), - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "invalid cursor".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }, - None => None, - }; - - let page = match RolloutRecorder::list_conversations( - &self.config.code_home, - page_size, - cursor.as_ref(), - &[], - ) - .await - { - Ok(page) => page, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to list conversations: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let mut out = Vec::new(); - for item in page.items { - let conversation_id = match conversation_id_from_rollout_path(&item.path) { - Some(id) => id, - None => continue, - }; - let preview = snippet_from_rollout_tail(&item.tail).unwrap_or_default(); - out.push(ConversationSummary { - conversation_id, - path: item.path, - preview, - timestamp: item.created_at, - }); - } - - let next_cursor = page.next_cursor.and_then(|cursor| { - serde_json::to_value(cursor) - .ok() - .and_then(|v| v.as_str().map(|s| s.to_string())) - }); - - self.outgoing - .send_response( - request_id, - ListConversationsResponse { - items: out, - next_cursor, - }, - ) - .await; - } - - async fn resume_conversation(&self, request_id: RequestId, params: ResumeConversationParams) { - let overrides = params.overrides.unwrap_or_default(); - let config = match derive_config_from_params(overrides, 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 - .resume_conversation_from_rollout( - config, - params.path, - Arc::clone(&self.auth_manager), - ) - .await - { - Ok(NewConversation { - conversation_id, - session_configured, - .. - }) => { - self.outgoing - .send_response( - request_id, - ResumeConversationResponse { - conversation_id, - model: session_configured.model, - initial_messages: None, - }, - ) - .await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error resuming conversation: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn archive_conversation( - &self, - request_id: RequestId, - params: ArchiveConversationParams, - ) { - let ArchiveConversationParams { - conversation_id, - rollout_path, - } = params; - - if self - .conversation_manager - .get_conversation(conversation_id) - .await - .is_ok() - { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "cannot archive an active conversation".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - let catalog = code_core::SessionCatalog::new(self.config.code_home.clone()); - match catalog - .archive_conversation(uuid::Uuid::from(conversation_id), &rollout_path) - .await - { - Ok(true) => { - self.outgoing - .send_response(request_id, ArchiveConversationResponse {}) - .await; - } - Ok(false) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "conversation not found".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to archive conversation: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn login_chatgpt_v1(&self, request_id: RequestId) { - match self.start_chatgpt_login_v2().await { - Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { - let login_id = match Uuid::parse_str(&login_id) { - Ok(login_id) => login_id, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("invalid login id generated by server: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - self.outgoing - .send_response(request_id, LoginChatGptResponse { login_id, auth_url }) - .await; - } - Ok(_) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "unexpected login response type".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn cancel_login_chatgpt_v1( - &self, - request_id: RequestId, - params: CancelLoginChatGptParams, - ) { - let status = self.cancel_active_login(params.login_id).await; - match status { - CancelLoginAccountStatus::Canceled => { - self.outgoing - .send_response(request_id, CancelLoginChatGptResponse {}) - .await; - } - CancelLoginAccountStatus::NotFound => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("login id not found: {}", params.login_id), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn logout_chatgpt_v1(&self, request_id: RequestId) { - match self.logout_account_v2().await { - Ok(_) => { - self.outgoing - .send_response(request_id, LogoutChatGptResponse {}) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn login_api_key(&self, request_id: RequestId, params: LoginApiKeyParams) { - let api_key = params.api_key.trim(); - if api_key.is_empty() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "api_key is required".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - if let Err(err) = code_core::auth::login_with_api_key(&self.config.code_home, api_key) { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to persist api key: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - self.auth_manager.reload(); - self.outgoing - .send_response(request_id, LoginApiKeyResponse {}) - .await; - } - - async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) { - let requires_openai_auth = self.config.model_provider.requires_openai_auth; - let include_token = params.include_token.unwrap_or(false); - - self.refresh_token_if_requested(params.refresh_token.unwrap_or(false)) - .await; - - let auth = self.auth_manager.auth(); - let mut auth_method = auth.as_ref().map(|a| map_auth_mode_to_wire(a.mode)); - let mut auth_token = None; - - if !requires_openai_auth { - auth_method = None; - } else if include_token { - if let Some(auth) = auth.as_ref() { - let permanent_refresh_failure = - self.auth_manager.refresh_failure_for_auth(auth).is_some(); - if !permanent_refresh_failure && let Ok(token) = auth.get_token().await { - if !token.trim().is_empty() { - auth_token = Some(token); - } - } - } - } - - self.outgoing - .send_response( - request_id, - GetAuthStatusResponse { - auth_method, - auth_token, - requires_openai_auth: Some(requires_openai_auth), - }, - ) - .await; - } - - async fn get_user_saved_config(&self, request_id: RequestId) { - let cfg: ConfigToml = match code_core::config::load_config_as_toml_with_cli_overrides( - &self.config.code_home, - Vec::new(), - ) { - Ok(cfg) => cfg, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to load config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let config = UserSavedConfig { - approval_policy: cfg.approval_policy.map(map_ask_for_approval_to_wire), - sandbox_mode: cfg.sandbox_mode, - sandbox_settings: cfg.sandbox_workspace_write.as_ref().map(|s| SandboxSettings { - writable_roots: s.writable_roots.clone(), - network_access: Some(s.network_access), - exclude_tmpdir_env_var: Some(s.exclude_tmpdir_env_var), - exclude_slash_tmp: Some(s.exclude_slash_tmp), - }), - model: cfg.model, - model_reasoning_effort: cfg - .model_reasoning_effort - .map(map_reasoning_effort_to_wire), - model_reasoning_summary: cfg - .model_reasoning_summary - .map(map_reasoning_summary_to_wire), - model_verbosity: cfg.model_text_verbosity.map(map_verbosity_to_wire), - tools: cfg.tools.map(|t| Tools { - web_search: t.web_search, - view_image: t.view_image, - }), - profile: cfg.profile, - profiles: cfg - .profiles - .into_iter() - .map(|(name, profile)| { - ( - name, - Profile { - model: profile.model, - model_provider: profile.model_provider, - approval_policy: profile - .approval_policy - .map(map_ask_for_approval_to_wire), - model_reasoning_effort: profile - .model_reasoning_effort - .map(map_reasoning_effort_to_wire), - model_reasoning_summary: profile - .model_reasoning_summary - .map(map_reasoning_summary_to_wire), - model_verbosity: profile - .model_text_verbosity - .map(map_verbosity_to_wire), - chatgpt_base_url: profile.chatgpt_base_url, - }, - ) - }) - .collect(), - }; - - self.outgoing - .send_response(request_id, GetUserSavedConfigResponse { config }) - .await; - } - - async fn set_default_model(&self, request_id: RequestId, params: SetDefaultModelParams) { - let effort_value = params.reasoning_effort.map(|effort| match effort { - code_protocol::config_types::ReasoningEffort::None => "minimal".to_string(), - _ => effort.to_string(), - }); - let model_value = params.model; - - let effort_ref = effort_value.as_deref(); - let model_ref = model_value.as_deref(); - - let overrides = [ - (&[CONFIG_KEY_MODEL][..], model_ref), - (&[CONFIG_KEY_EFFORT][..], effort_ref), - ]; - - if let Err(err) = code_core::config_edit::persist_overrides_and_clear_if_none( - &self.config.code_home, - self.config.active_profile.as_deref(), - &overrides, - ) - .await - { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to persist config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - self.outgoing - .send_response(request_id, SetDefaultModelResponse {}) - .await; - } - - async fn get_user_agent(&self, request_id: RequestId) { - let originator = self.config.responses_originator_header.trim(); - let user_agent = code_core::default_client::get_code_user_agent( - (!originator.is_empty()).then_some(originator), - ); - self.outgoing - .send_response(request_id, GetUserAgentResponse { user_agent }) - .await; - } - - async fn user_info(&self, request_id: RequestId) { - let mut alleged_user_email = None; - if let Some(auth) = self.auth_manager.auth() { - if auth.mode.is_chatgpt() { - alleged_user_email = auth - .get_token_data() - .await - .ok() - .and_then(|t| t.id_token.email); - } - } - self.outgoing - .send_response(request_id, UserInfoResponse { alleged_user_email }) - .await; - } - - async fn exec_one_off_command(&self, request_id: RequestId, params: ExecOneOffCommandParams) { - if params.command.is_empty() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "command is required".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - if params.sandbox_policy.is_some() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "sandbox_policy override is not supported".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone()); - let env = exec_env::create_env(&self.config.shell_environment_policy); - - let exec_params = exec::ExecParams { - command: params.command, - shell_script: None, - cwd, - timeout_ms: params.timeout_ms, - env, - with_escalated_permissions: None, - justification: None, - }; - let sandbox_type = get_platform_sandbox().unwrap_or(exec::SandboxType::None); - - match exec::process_exec_tool_call( - exec_params, - sandbox_type, - &self.config.sandbox_policy, - self.config.cwd.as_path(), - &self.config.code_linux_sandbox_exe, - None, - ) - .await - { - Ok(output) => { - let exec::ExecToolCallOutput { - exit_code, - stdout, - stderr, - .. - } = output; - self.outgoing - .send_response( - request_id, - ExecArbitraryCommandResponse { - exit_code, - stdout: stdout.text, - stderr: stderr.text, - }, - ) - .await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("exec failed: {err}"), - 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: code_protocol::mcp_protocol::GitSha::new(&value.sha.0), - 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 = 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, - final_output_json_schema: None, - }) - .await; - - // Acknowledge. - self.outgoing.send_response(request_id, SendUserTurnResponse {}).await; - } -} - -async fn apply_bespoke_event_handling( - event: Event, - conversation_id: ConversationId, - owner_connection_id: ConnectionId, - conversation: Arc, - outgoing: Arc, - _pending_interrupts: Arc>>>, -) { - 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 = 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, - } - } - }; - (p, mapped) - }) - .collect(); - - let params = ApplyPatchApprovalParams { - conversation_id, - call_id: call_id.clone(), - file_changes, - reason, - grant_root, - }; - let value = serde_json::to_value(¶ms).unwrap_or_default(); - let rx = outgoing - .send_request_to_connection(owner_connection_id, 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, - approval_id, - turn_id, - command, - cwd, - reason, - network_approval_context: _, - additional_permissions, - }) => { - let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); - let params = ExecCommandApprovalParams { - conversation_id, - call_id: call_id.clone(), - approval_id, - command, - cwd, - reason, - additional_permissions, - }; - let value = serde_json::to_value(¶ms).unwrap_or_default(); - let rx = outgoing - .send_request_to_connection(owner_connection_id, EXEC_COMMAND_APPROVAL_METHOD, Some(value)) - .await; - - // TODO(mbolin): Enforce a timeout so this task does not live indefinitely? - let approval_id = effective_approval_id; // correlate by approval_id/call_id, not event_id - tokio::spawn(async move { - on_exec_approval_response(approval_id, Some(turn_id), rx, conversation).await; - }); - } - EventMsg::DynamicToolCallRequest(request) => { - let call_id = request.call_id; - let params = DynamicToolCallParams { - conversation_id, - turn_id: request.turn_id, - call_id: call_id.clone(), - namespace: request.namespace, - tool: request.tool, - arguments: request.arguments, - }; - let value = serde_json::to_value(¶ms).unwrap_or_default(); - let rx = outgoing - .send_request_to_connection(owner_connection_id, DYNAMIC_TOOL_CALL_METHOD, Some(value)) - .await; - - tokio::spawn(async move { - on_dynamic_tool_call_response(call_id, rx, conversation).await; - }); - } - EventMsg::RequestUserInput(request) => { - let request_turn_id = request.turn_id; - let params = ToolRequestUserInputParams { - thread_id: conversation_id.to_string(), - turn_id: request_turn_id.clone(), - item_id: request.call_id, - questions: request - .questions - .into_iter() - .map(|question| ToolRequestUserInputQuestion { - id: question.id, - header: question.header, - question: question.question, - is_other: question.is_other, - is_secret: question.is_secret, - options: question.options.map(|options| { - options - .into_iter() - .map(|option| ToolRequestUserInputOption { - label: option.label, - description: option.description, - }) - .collect() - }), - }) - .collect(), - }; - let value = serde_json::to_value(¶ms).unwrap_or_default(); - let rx = outgoing - .send_request_to_connection( - owner_connection_id, - TOOL_REQUEST_USER_INPUT_METHOD, - Some(value), - ) - .await; - - tokio::spawn(async move { - on_request_user_input_response(request_turn_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, -) -> std::io::Result { - let NewConversationParams { - model, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: cli_overrides, - base_instructions, - include_plan_tool, - dynamic_tools, - .. - } = 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, - dynamic_tools, - compact_prompt_override: None, - compact_prompt_override_file: 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, - codex: Arc, -) { - 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::(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_dynamic_tool_call_response( - call_id: String, - receiver: tokio::sync::oneshot::Receiver, - conversation: Arc, -) { - let response = receiver.await; - let value = match response { - Ok(value) => value, - Err(err) => { - error!("request failed: {err:?}"); - let fallback = CoreDynamicToolResponse { - content_items: vec![code_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputText { - text: "dynamic tool request failed".to_string(), - }], - success: false, - }; - if let Err(err) = conversation - .submit(Op::DynamicToolResponse { - id: call_id.clone(), - response: fallback, - }) - .await - { - error!("failed to submit DynamicToolResponse: {err}"); - } - return; - } - }; - - let response = serde_json::from_value::(value).unwrap_or_else(|err| { - error!("failed to deserialize DynamicToolCallResponse: {err}"); - DynamicToolCallResponse { - output: "dynamic tool response was invalid".to_string(), - success: false, - } - }); - - let response = CoreDynamicToolResponse { - content_items: vec![ - code_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputText { - text: response.output, - }, - ], - success: response.success, - }; - if let Err(err) = conversation - .submit(Op::DynamicToolResponse { - id: call_id, - response, - }) - .await - { - error!("failed to submit DynamicToolResponse: {err}"); - } -} - -async fn on_request_user_input_response( - turn_id: String, - receiver: tokio::sync::oneshot::Receiver, - conversation: Arc, -) { - let response = receiver.await; - let value = match response { - Ok(value) => value, - Err(err) => { - error!("request failed: {err:?}"); - let empty = RequestUserInputResponse { - answers: HashMap::new(), - }; - if let Err(err) = conversation - .submit(Op::UserInputAnswer { - id: turn_id, - response: empty, - }) - .await - { - error!("failed to submit UserInputAnswer: {err}"); - } - return; - } - }; - - let response = - serde_json::from_value::(value).unwrap_or_else(|err| { - error!("failed to deserialize ToolRequestUserInputResponse: {err}"); - ToolRequestUserInputResponse { - answers: HashMap::new(), - } - }); - - let response = map_tool_request_user_input_response(response); - if let Err(err) = conversation - .submit(Op::UserInputAnswer { - id: turn_id, - response, - }) - .await - { - error!("failed to submit UserInputAnswer: {err}"); - } -} - -fn map_tool_request_user_input_response( - response: ToolRequestUserInputResponse, -) -> RequestUserInputResponse { - RequestUserInputResponse { - answers: response - .answers - .into_iter() - .map(|(id, answer)| { - ( - id, - RequestUserInputAnswer { - answers: answer.answers, - }, - ) - }) - .collect(), - } -} - -async fn on_exec_approval_response( - approval_id: String, - approval_turn_id: Option, - receiver: tokio::sync::oneshot::Receiver, - conversation: Arc, -) { - let response = receiver.await; - let value = match response { - Ok(value) => value, - Err(err) => { - tracing::error!("request failed: {err:?}"); - // When the owning connection disconnects, callbacks are dropped. - // Submit a conservative deny so the run can progress. - if let Err(submit_err) = conversation - .submit(Op::ExecApproval { - id: approval_id.clone(), - turn_id: approval_turn_id.clone(), - decision: core_protocol::ReviewDecision::Denied, - }) - .await - { - error!("failed to submit denied ExecApproval after request failure: {submit_err}"); - } - return; - } - }; - - // Try to deserialize `value` and then make the appropriate call to `codex`. - let response = - serde_json::from_value::(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, - turn_id: approval_turn_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::ApprovedExecpolicyAmendment { .. } => { - 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, - } -} - -trait IntoWireAuthMode { - fn into_wire(self) -> code_protocol::mcp_protocol::AuthMode; -} - -impl IntoWireAuthMode for code_app_server_protocol::AuthMode { - fn into_wire(self) -> code_protocol::mcp_protocol::AuthMode { - match self { - code_app_server_protocol::AuthMode::ApiKey => { - code_protocol::mcp_protocol::AuthMode::ApiKey - } - code_app_server_protocol::AuthMode::Chatgpt => { - code_protocol::mcp_protocol::AuthMode::ChatGPT - } - code_app_server_protocol::AuthMode::ChatgptAuthTokens => { - code_protocol::mcp_protocol::AuthMode::ChatgptAuthTokens - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use code_app_server_protocol::AuthMode; - use code_core::auth::CodexAuth; - use code_core::auth::RefreshTokenError; - use code_core::config::ConfigOverrides; - use code_protocol::mcp_protocol::RemoveConversationListenerParams; - use code_protocol::protocol::SessionSource; - use mcp_types::RequestId; - use serde_json::from_value; - use tokio::sync::mpsc; - - fn make_processor_for_tests() -> (CodexMessageProcessor, mpsc::UnboundedReceiver) { - let (outgoing_tx, outgoing_rx) = mpsc::unbounded_channel(); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); - let config = Arc::new( - Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default()) - .expect("load default config"), - ); - 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, - )); - - ( - CodexMessageProcessor::new( - auth_manager, - conversation_manager, - outgoing, - None, - config, - ), - outgoing_rx, - ) - } - - fn make_processor_with_auth_for_tests( - auth_manager: Arc, - ) -> ( - CodexMessageProcessor, - mpsc::UnboundedReceiver, - ) { - let (outgoing_tx, outgoing_rx) = mpsc::unbounded_channel(); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); - let config = Arc::new( - Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default()) - .expect("load default config"), - ); - let conversation_manager = Arc::new(ConversationManager::new( - auth_manager.clone(), - SessionSource::Mcp, - )); - - ( - CodexMessageProcessor::new( - auth_manager, - conversation_manager, - outgoing, - None, - config, - ), - outgoing_rx, - ) - } - - #[tokio::test] - async fn remove_conversation_listener_enforces_owner_connection() { - let (mut processor, mut outgoing_rx) = make_processor_for_tests(); - - let subscription_id = Uuid::new_v4(); - let (cancel_tx, mut cancel_rx) = oneshot::channel(); - processor.conversation_listeners.insert( - subscription_id, - ConversationListenerRegistration { - owner_connection_id: ConnectionId(1), - cancel_tx, - }, - ); - - processor - .remove_conversation_listener( - ConnectionId(2), - RequestId::Integer(10), - RemoveConversationListenerParams { subscription_id }, - ) - .await; - - let message = outgoing_rx - .recv() - .await - .expect("error response should be sent"); - match message { - crate::outgoing_message::OutgoingMessage::Error(err) => { - assert_eq!(err.id, RequestId::Integer(10)); - assert!(err.error.message.contains("subscription not found")); - } - _ => panic!("expected error response"), - } - - assert!( - processor.conversation_listeners.contains_key(&subscription_id), - "listener should remain registered for original owner" - ); - - processor - .remove_conversation_listener( - ConnectionId(1), - RequestId::Integer(11), - RemoveConversationListenerParams { subscription_id }, - ) - .await; - - let message = outgoing_rx - .recv() - .await - .expect("success response should be sent"); - match message { - crate::outgoing_message::OutgoingMessage::Response(response) => { - assert_eq!(response.id, RequestId::Integer(11)); - } - _ => panic!("expected success response"), - } - - assert!( - processor.conversation_listeners.get(&subscription_id).is_none(), - "listener should be removed by owner" - ); - assert_eq!(cancel_rx.try_recv(), Ok(())); - } - - #[tokio::test] - async fn get_auth_status_omits_token_after_permanent_refresh_failure() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let auth_manager = AuthManager::from_auth_for_testing(auth.clone()); - auth_manager.seed_refresh_failure_for_testing( - &auth, - RefreshTokenError::permanent("refresh token already used"), - ); - - let (processor, mut outgoing_rx) = make_processor_with_auth_for_tests(auth_manager); - processor - .get_auth_status( - RequestId::Integer(42), - GetAuthStatusParams { - include_token: Some(true), - refresh_token: Some(false), - }, - ) - .await; - - let message = outgoing_rx - .recv() - .await - .expect("auth status response should be sent"); - let response = match message { - crate::outgoing_message::OutgoingMessage::Response(response) => response, - _ => panic!("expected response message"), - }; - let status: GetAuthStatusResponse = - from_value(response.result).expect("valid getAuthStatus payload"); - assert_eq!( - status, - GetAuthStatusResponse { - auth_method: Some(code_protocol::mcp_protocol::AuthMode::ChatGPT), - auth_token: None, - requires_openai_auth: Some(true), - } - ); - } - - #[test] - fn parse_plan_type_is_case_insensitive() { - assert_eq!(parse_plan_type(Some("Pro".to_string())), PlanType::Pro); - assert_eq!( - parse_plan_type(Some("BUSINESS".to_string())), - PlanType::Business - ); - assert_eq!(parse_plan_type(Some("mystery".to_string())), PlanType::Unknown); - assert_eq!(parse_plan_type(None), PlanType::Unknown); - } - - #[test] - fn select_rate_limit_snapshot_prefers_matching_account() { - let snapshots = vec![ - code_core::account_usage::StoredRateLimitSnapshot { - account_id: "acct-a".to_string(), - plan: Some("pro".to_string()), - snapshot: None, - observed_at: None, - primary_next_reset_at: None, - secondary_next_reset_at: None, - last_usage_limit_hit_at: None, - }, - code_core::account_usage::StoredRateLimitSnapshot { - account_id: "acct-b".to_string(), - plan: Some("plus".to_string()), - snapshot: None, - observed_at: None, - primary_next_reset_at: None, - secondary_next_reset_at: None, - last_usage_limit_hit_at: None, - }, - ]; - - let selected = select_rate_limit_snapshot(Some("acct-b".to_string()), snapshots) - .expect("snapshot should be selected"); - assert_eq!(selected.account_id, "acct-b"); - } - - #[test] - fn rate_limit_snapshot_from_event_maps_windows() { - let event = code_core::protocol::RateLimitSnapshotEvent { - primary_used_percent: 11.0, - secondary_used_percent: 22.0, - primary_to_secondary_ratio_percent: 50.0, - primary_window_minutes: 60, - secondary_window_minutes: 1440, - primary_reset_after_seconds: Some(12), - secondary_reset_after_seconds: Some(34), - rate_limit_reached_type: None, - }; - - let snapshot = rate_limit_snapshot_from_event(&event, Some(PlanType::Pro)); - assert_eq!(snapshot.plan_type, Some(PlanType::Pro)); - assert_eq!( - snapshot.primary.as_ref().and_then(|window| window.window_minutes), - Some(60) - ); - assert_eq!( - snapshot - .secondary - .as_ref() - .and_then(|window| window.window_minutes), - Some(1440) - ); - } - - #[test] - fn map_tool_request_user_input_response_preserves_answers() { - let response = ToolRequestUserInputResponse { - answers: std::collections::HashMap::from([( - "question_id".to_string(), - code_app_server_protocol::ToolRequestUserInputAnswer { - answers: vec!["selected".to_string()], - }, - )]), - }; - - let mapped = map_tool_request_user_input_response(response); - assert_eq!( - mapped - .answers - .get("question_id") - .expect("question_id should exist") - .answers, - vec!["selected".to_string()] - ); - } -} - -impl IntoWireAuthMode for code_protocol::mcp_protocol::AuthMode { - fn into_wire(self) -> code_protocol::mcp_protocol::AuthMode { - self - } -} - -fn map_auth_mode_to_wire(mode: M) -> code_protocol::mcp_protocol::AuthMode { - mode.into_wire() -} - -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::Reject(config) => { - core_protocol::AskForApproval::Reject(core_protocol::RejectConfig { - sandbox_approval: config.sandbox_approval, - rules: config.rules, - skill_approval: config.skill_approval, - request_permissions: config.request_permissions, - mcp_elicitations: config.mcp_elicitations, - }) - } - code_protocol::protocol::AskForApproval::Never => core_protocol::AskForApproval::Never, - } -} - -fn map_ask_for_approval_to_wire(a: core_protocol::AskForApproval) -> code_protocol::protocol::AskForApproval { - match a { - core_protocol::AskForApproval::UnlessTrusted => code_protocol::protocol::AskForApproval::UnlessTrusted, - core_protocol::AskForApproval::OnFailure => code_protocol::protocol::AskForApproval::OnFailure, - core_protocol::AskForApproval::OnRequest => code_protocol::protocol::AskForApproval::OnRequest, - core_protocol::AskForApproval::Reject(config) => { - code_protocol::protocol::AskForApproval::Reject(code_protocol::protocol::RejectConfig { - sandbox_approval: config.sandbox_approval, - rules: config.rules, - skill_approval: config.skill_approval, - request_permissions: config.request_permissions, - mcp_elicitations: config.mcp_elicitations, - }) - } - core_protocol::AskForApproval::Never => code_protocol::protocol::AskForApproval::Never, - } -} - -fn map_reasoning_effort_to_wire( - effort: code_core::config_types::ReasoningEffort, -) -> code_protocol::config_types::ReasoningEffort { - match effort { - code_core::config_types::ReasoningEffort::Minimal => { - code_protocol::config_types::ReasoningEffort::Minimal - } - code_core::config_types::ReasoningEffort::Low => code_protocol::config_types::ReasoningEffort::Low, - code_core::config_types::ReasoningEffort::Medium => { - code_protocol::config_types::ReasoningEffort::Medium - } - code_core::config_types::ReasoningEffort::High => code_protocol::config_types::ReasoningEffort::High, - code_core::config_types::ReasoningEffort::XHigh => { - code_protocol::config_types::ReasoningEffort::XHigh - } - code_core::config_types::ReasoningEffort::None => { - code_protocol::config_types::ReasoningEffort::Minimal - } - } -} - -fn map_reasoning_summary_to_wire( - summary: code_core::config_types::ReasoningSummary, -) -> code_protocol::config_types::ReasoningSummary { - match summary { - code_core::config_types::ReasoningSummary::Auto => code_protocol::config_types::ReasoningSummary::Auto, - code_core::config_types::ReasoningSummary::Concise => { - code_protocol::config_types::ReasoningSummary::Concise - } - code_core::config_types::ReasoningSummary::Detailed => { - code_protocol::config_types::ReasoningSummary::Detailed - } - code_core::config_types::ReasoningSummary::None => code_protocol::config_types::ReasoningSummary::None, - } -} - -fn map_verbosity_to_wire( - verbosity: code_core::config_types::TextVerbosity, -) -> code_protocol::config_types::Verbosity { - match verbosity { - code_core::config_types::TextVerbosity::Low => code_protocol::config_types::Verbosity::Low, - code_core::config_types::TextVerbosity::Medium => { - code_protocol::config_types::Verbosity::Medium - } - code_core::config_types::TextVerbosity::High => code_protocol::config_types::Verbosity::High, - } -} - -fn parse_plan_type(plan: Option) -> PlanType { - let Some(plan) = plan else { - return PlanType::Unknown; - }; - - match plan.trim().to_ascii_lowercase().as_str() { - "free" => PlanType::Free, - "go" => PlanType::Go, - "plus" => PlanType::Plus, - "pro" => PlanType::Pro, - "team" => PlanType::Team, - "business" => PlanType::Business, - "enterprise" => PlanType::Enterprise, - "edu" => PlanType::Edu, - _ => PlanType::Unknown, - } -} - -fn select_rate_limit_snapshot( - account_id: Option, - snapshots: Vec, -) -> Option { - if snapshots.is_empty() { - return None; - } - - if let Some(account_id) = account_id - && let Some(snapshot) = snapshots - .iter() - .find(|snapshot| snapshot.account_id == account_id) - { - return Some(snapshot.clone()); - } - - snapshots.into_iter().next() -} - -fn rate_limit_snapshot_from_event( - snapshot: &code_core::protocol::RateLimitSnapshotEvent, - plan_type: Option, -) -> CoreRateLimitSnapshot { - let primary = CoreRateLimitWindow { - used_percent: snapshot.primary_used_percent, - window_minutes: Some(snapshot.primary_window_minutes), - resets_in_seconds: snapshot.primary_reset_after_seconds, - resets_at: None, - }; - let secondary = CoreRateLimitWindow { - used_percent: snapshot.secondary_used_percent, - window_minutes: Some(snapshot.secondary_window_minutes), - resets_in_seconds: snapshot.secondary_reset_after_seconds, - resets_at: None, - }; - - CoreRateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: Some(primary), - secondary: Some(secondary), - credits: None, - plan_type, - rate_limit_reached_type: snapshot - .rate_limit_reached_type - .map(rate_limit_reached_type_to_protocol), - } -} - -fn rate_limit_reached_type_to_protocol( - reached: code_core::protocol::RateLimitReachedType, -) -> CoreRateLimitReachedType { - match reached { - code_core::protocol::RateLimitReachedType::RateLimitReached => { - CoreRateLimitReachedType::RateLimitReached - } - code_core::protocol::RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted - } - code_core::protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted => { - CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted - } - code_core::protocol::RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached - } - code_core::protocol::RateLimitReachedType::WorkspaceMemberUsageLimitReached => { - CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached - } - } -} - -fn conversation_id_from_rollout_path(path: &std::path::Path) -> Option { - let stem = path.file_stem()?.to_str()?; - let (_, id) = stem.rsplit_once('-')?; - ConversationId::from_string(id).ok() -} - -fn snippet_from_rollout_tail(tail: &[serde_json::Value]) -> Option { - for value in tail.iter().rev() { - let item = match serde_json::from_value::(value.clone()) { - Ok(item) => item, - Err(_) => continue, - }; - if let code_protocol::protocol::RolloutItem::ResponseItem( - code_protocol::models::ResponseItem::Message { role, content, .. }, - ) = item - && role.eq_ignore_ascii_case("user") - { - if let Some(snippet) = snippet_from_content(&content) - && !snippet.starts_with("== System Status ==") - { - return Some(snippet); - } - } - } - None -} - -fn snippet_from_content(content: &[code_protocol::models::ContentItem]) -> Option { - content.iter().find_map(|item| match item { - code_protocol::models::ContentItem::InputText { text } - | code_protocol::models::ContentItem::OutputText { text } => { - if text.trim().is_empty() { - None - } else { - Some(text.chars().take(100).collect()) - } - } - _ => None, - }) -} - -// Unused legacy mappers removed to avoid warnings. diff --git a/code-rs/app-server/src/command_exec.rs b/code-rs/app-server/src/command_exec.rs new file mode 100644 index 00000000000..443117e5920 --- /dev/null +++ b/code-rs/app-server/src/command_exec.rs @@ -0,0 +1,1056 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::CommandExecOutputDeltaNotification; +use codex_app_server_protocol::CommandExecOutputStream; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecResizeResponse; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::CommandExecTerminalSize; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecTerminateResponse; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::CommandExecWriteResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ServerNotification; +use codex_core::config::StartedNetworkProxy; +use codex_core::exec::ExecExpiration; +use codex_core::exec::ExecExpirationOutcome; +use codex_core::exec::IO_DRAIN_TIMEOUT_MS; +use codex_core::sandboxing::ExecRequest; +use codex_protocol::exec_output::bytes_to_string_smart; +use codex_sandboxing::SandboxType; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use codex_utils_pty::ProcessHandle; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; + +use crate::error_code::internal_error; +use crate::error_code::invalid_params; +use crate::error_code::invalid_request; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; + +const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; +const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024; + +#[derive(Clone)] +pub(crate) struct CommandExecManager { + sessions: Arc>>, + next_generated_process_id: Arc, +} + +impl Default for CommandExecManager { + fn default() -> Self { + Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + next_generated_process_id: Arc::new(AtomicI64::new(1)), + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ConnectionProcessId { + connection_id: ConnectionId, + process_id: InternalProcessId, +} + +#[derive(Clone)] +enum CommandExecSession { + Active { + control_tx: mpsc::Sender, + }, + UnsupportedWindowsSandbox, +} + +enum CommandControl { + Write { delta: Vec, close_stdin: bool }, + Resize { size: TerminalSize }, + Terminate, +} + +struct CommandControlRequest { + control: CommandControl, + response_tx: Option>>, +} + +pub(crate) struct StartCommandExecParams { + pub(crate) outgoing: Arc, + pub(crate) request_id: ConnectionRequestId, + pub(crate) process_id: Option, + pub(crate) exec_request: ExecRequest, + pub(crate) started_network_proxy: Option, + pub(crate) tty: bool, + pub(crate) stream_stdin: bool, + pub(crate) stream_stdout_stderr: bool, + pub(crate) output_bytes_cap: Option, + pub(crate) size: Option, +} + +struct RunCommandParams { + outgoing: Arc, + request_id: ConnectionRequestId, + process_id: Option, + spawned: SpawnedProcess, + control_rx: mpsc::Receiver, + stream_stdin: bool, + stream_stdout_stderr: bool, + expiration: ExecExpiration, + output_bytes_cap: Option, +} + +struct SpawnProcessOutputParams { + connection_id: ConnectionId, + process_id: Option, + output_rx: mpsc::Receiver>, + stdio_timeout_rx: watch::Receiver, + outgoing: Arc, + stream: CommandExecOutputStream, + stream_output: bool, + output_bytes_cap: Option, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum InternalProcessId { + Generated(i64), + Client(String), +} + +trait InternalProcessIdExt { + fn error_repr(&self) -> String; +} + +impl InternalProcessIdExt for InternalProcessId { + fn error_repr(&self) -> String { + match self { + Self::Generated(id) => id.to_string(), + Self::Client(id) => serde_json::to_string(id).unwrap_or_else(|_| format!("{id:?}")), + } + } +} + +impl CommandExecManager { + pub(crate) async fn start( + &self, + params: StartCommandExecParams, + ) -> Result<(), JSONRPCErrorError> { + let StartCommandExecParams { + outgoing, + request_id, + process_id, + exec_request, + started_network_proxy, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + } = params; + if process_id.is_none() && (tty || stream_stdin || stream_stdout_stderr) { + return Err(invalid_request( + "command/exec tty or streaming requires a client-supplied processId", + )); + } + let process_id = process_id.map_or_else( + || { + InternalProcessId::Generated( + self.next_generated_process_id + .fetch_add(1, Ordering::Relaxed), + ) + }, + InternalProcessId::Client, + ); + let process_key = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + }; + + if matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken) { + if tty || stream_stdin || stream_stdout_stderr { + return Err(invalid_request( + "streaming command/exec is not supported with windows sandbox", + )); + } + if output_bytes_cap != Some(DEFAULT_OUTPUT_BYTES_CAP) { + return Err(invalid_request( + "custom outputBytesCap is not supported with windows sandbox", + )); + } + if let InternalProcessId::Client(_) = &process_id { + let mut sessions = self.sessions.lock().await; + if sessions.contains_key(&process_key) { + return Err(invalid_request(format!( + "duplicate active command/exec process id: {}", + process_key.process_id.error_repr(), + ))); + } + sessions.insert( + process_key.clone(), + CommandExecSession::UnsupportedWindowsSandbox, + ); + } + let sessions = Arc::clone(&self.sessions); + tokio::spawn(async move { + let _started_network_proxy = started_network_proxy; + match codex_core::sandboxing::execute_env(exec_request, /*stdout_stream*/ None) + .await + { + Ok(output) => { + outgoing + .send_response( + request_id, + CommandExecResponse { + exit_code: output.exit_code, + stdout: output.stdout.text, + stderr: output.stderr.text, + }, + ) + .await; + } + Err(err) => { + outgoing + .send_error(request_id, internal_error(format!("exec failed: {err}"))) + .await; + } + } + sessions.lock().await.remove(&process_key); + }); + return Ok(()); + } + + let ExecRequest { + command, + cwd, + env, + expiration, + sandbox: _sandbox, + arg0, + .. + } = exec_request; + + let stream_stdin = tty || stream_stdin; + let stream_stdout_stderr = tty || stream_stdout_stderr; + let (control_tx, control_rx) = mpsc::channel(32); + let notification_process_id = match &process_id { + InternalProcessId::Generated(_) => None, + InternalProcessId::Client(process_id) => Some(process_id.clone()), + }; + + let sessions = Arc::clone(&self.sessions); + let (program, args) = command + .split_first() + .ok_or_else(|| invalid_request("command must not be empty"))?; + { + let mut sessions = self.sessions.lock().await; + if sessions.contains_key(&process_key) { + return Err(invalid_request(format!( + "duplicate active command/exec process id: {}", + process_key.process_id.error_repr(), + ))); + } + sessions.insert( + process_key.clone(), + CommandExecSession::Active { control_tx }, + ); + } + let spawned = if tty { + codex_utils_pty::spawn_pty_process( + program, + args, + cwd.as_path(), + &env, + &arg0, + size.unwrap_or_default(), + ) + .await + } else if stream_stdin { + codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0) + .await + }; + let spawned = match spawned { + Ok(spawned) => spawned, + Err(err) => { + self.sessions.lock().await.remove(&process_key); + return Err(internal_error(format!("failed to spawn command: {err}"))); + } + }; + tokio::spawn(async move { + let _started_network_proxy = started_network_proxy; + run_command(RunCommandParams { + outgoing, + request_id: request_id.clone(), + process_id: notification_process_id, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + }) + .await; + sessions.lock().await.remove(&process_key); + }); + Ok(()) + } + + pub(crate) async fn write( + &self, + request_id: ConnectionRequestId, + params: CommandExecWriteParams, + ) -> Result { + if params.delta_base64.is_none() && !params.close_stdin { + return Err(invalid_params( + "command/exec/write requires deltaBase64 or closeStdin", + )); + } + + let delta = match params.delta_base64 { + Some(delta_base64) => STANDARD + .decode(delta_base64) + .map_err(|err| invalid_params(format!("invalid deltaBase64: {err}")))?, + None => Vec::new(), + }; + + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control( + target_process_id, + CommandControl::Write { + delta, + close_stdin: params.close_stdin, + }, + ) + .await?; + + Ok(CommandExecWriteResponse {}) + } + + pub(crate) async fn terminate( + &self, + request_id: ConnectionRequestId, + params: CommandExecTerminateParams, + ) -> Result { + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control(target_process_id, CommandControl::Terminate) + .await?; + Ok(CommandExecTerminateResponse {}) + } + + pub(crate) async fn resize( + &self, + request_id: ConnectionRequestId, + params: CommandExecResizeParams, + ) -> Result { + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control( + target_process_id, + CommandControl::Resize { + size: terminal_size_from_protocol(params.size)?, + }, + ) + .await?; + Ok(CommandExecResizeResponse {}) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let controls = { + let mut sessions = self.sessions.lock().await; + let process_ids = sessions + .keys() + .filter(|process_id| process_id.connection_id == connection_id) + .cloned() + .collect::>(); + let mut controls = Vec::with_capacity(process_ids.len()); + for process_id in process_ids { + if let Some(control) = sessions.remove(&process_id) { + controls.push(control); + } + } + controls + }; + + for control in controls { + if let CommandExecSession::Active { control_tx } = control { + let _ = control_tx + .send(CommandControlRequest { + control: CommandControl::Terminate, + response_tx: None, + }) + .await; + } + } + } + + async fn send_control( + &self, + process_id: ConnectionProcessId, + control: CommandControl, + ) -> Result<(), JSONRPCErrorError> { + let session = { + self.sessions + .lock() + .await + .get(&process_id) + .cloned() + .ok_or_else(|| { + invalid_request(format!( + "no active command/exec for process id {}", + process_id.process_id.error_repr(), + )) + })? + }; + let CommandExecSession::Active { control_tx } = session else { + return Err(invalid_request( + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes", + )); + }; + let (response_tx, response_rx) = oneshot::channel(); + let request = CommandControlRequest { + control, + response_tx: Some(response_tx), + }; + control_tx + .send(request) + .await + .map_err(|_| command_no_longer_running_error(&process_id.process_id))?; + response_rx + .await + .map_err(|_| command_no_longer_running_error(&process_id.process_id))? + } +} + +async fn run_command(params: RunCommandParams) { + let RunCommandParams { + outgoing, + request_id, + process_id, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + } = params; + let mut control_rx = control_rx; + let mut control_open = true; + let expiration = expiration.wait_with_outcome(); + tokio::pin!(expiration); + let SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + tokio::pin!(exit_rx); + let mut expiration_outcome = None; + let (stdio_timeout_tx, stdio_timeout_rx) = watch::channel(false); + + let stdout_handle = spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + output_rx: stdout_rx, + stdio_timeout_rx: stdio_timeout_rx.clone(), + outgoing: Arc::clone(&outgoing), + stream: CommandExecOutputStream::Stdout, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + let stderr_handle = spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + output_rx: stderr_rx, + stdio_timeout_rx, + outgoing: Arc::clone(&outgoing), + stream: CommandExecOutputStream::Stderr, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + + let exit_code = loop { + tokio::select! { + control = control_rx.recv(), if control_open => { + match control { + Some(CommandControlRequest { control, response_tx }) => { + let result = match control { + CommandControl::Write { delta, close_stdin } => { + handle_process_write( + &session, + stream_stdin, + delta, + close_stdin, + ).await + } + CommandControl::Resize { size } => { + handle_process_resize(&session, size) + } + CommandControl::Terminate => { + session.request_terminate(); + Ok(()) + } + }; + if let Some(response_tx) = response_tx { + let _ = response_tx.send(result); + } + }, + None => { + control_open = false; + session.request_terminate(); + } + } + } + outcome = &mut expiration, if expiration_outcome.is_none() => { + expiration_outcome = Some(outcome); + session.request_terminate(); + } + exit = &mut exit_rx => { + if matches!(expiration_outcome, Some(ExecExpirationOutcome::TimedOut)) { + break EXEC_TIMEOUT_EXIT_CODE; + } else { + break exit.unwrap_or(-1); + } + } + } + }; + + let timeout_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(IO_DRAIN_TIMEOUT_MS)).await; + let _ = stdio_timeout_tx.send(true); + }); + + let stdout = stdout_handle.await.unwrap_or_default(); + let stderr = stderr_handle.await.unwrap_or_default(); + timeout_handle.abort(); + + outgoing + .send_response( + request_id, + CommandExecResponse { + exit_code, + stdout, + stderr, + }, + ) + .await; +} + +fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHandle { + let SpawnProcessOutputParams { + connection_id, + process_id, + mut output_rx, + mut stdio_timeout_rx, + outgoing, + stream, + stream_output, + output_bytes_cap, + } = params; + tokio::spawn(async move { + let mut buffer: Vec = Vec::new(); + let mut observed_num_bytes = 0usize; + loop { + let mut chunk = tokio::select! { + chunk = output_rx.recv() => match chunk { + Some(chunk) => chunk, + None => break, + }, + _ = stdio_timeout_rx.wait_for(|&v| v) => break, + }; + // Individual chunks are at most 8KiB, so overshooting a bit is acceptable. + while chunk.len() < OUTPUT_CHUNK_SIZE_HINT + && let Ok(next_chunk) = output_rx.try_recv() + { + chunk.extend_from_slice(&next_chunk); + } + let capped_chunk = match output_bytes_cap { + Some(output_bytes_cap) => { + let capped_chunk_len = output_bytes_cap + .saturating_sub(observed_num_bytes) + .min(chunk.len()); + observed_num_bytes += capped_chunk_len; + &chunk[0..capped_chunk_len] + } + None => chunk.as_slice(), + }; + let cap_reached = Some(observed_num_bytes) == output_bytes_cap; + if let (true, Some(process_id)) = (stream_output, process_id.as_ref()) { + outgoing + .send_server_notification_to_connection_and_wait( + connection_id, + ServerNotification::CommandExecOutputDelta( + CommandExecOutputDeltaNotification { + process_id: process_id.clone(), + stream, + delta_base64: STANDARD.encode(capped_chunk), + cap_reached, + }, + ), + ) + .await; + } else if !stream_output { + buffer.extend_from_slice(capped_chunk); + } + if cap_reached { + break; + } + } + bytes_to_string_smart(&buffer) + }) +} + +async fn handle_process_write( + session: &ProcessHandle, + stream_stdin: bool, + delta: Vec, + close_stdin: bool, +) -> Result<(), JSONRPCErrorError> { + if !stream_stdin { + return Err(invalid_request( + "stdin streaming is not enabled for this command/exec", + )); + } + if !delta.is_empty() { + session + .writer_sender() + .send(delta) + .await + .map_err(|_| invalid_request("stdin is already closed"))?; + } + if close_stdin { + session.close_stdin(); + } + Ok(()) +} + +fn handle_process_resize( + session: &ProcessHandle, + size: TerminalSize, +) -> Result<(), JSONRPCErrorError> { + session + .resize(size) + .map_err(|err| invalid_request(format!("failed to resize PTY: {err}"))) +} + +pub(crate) fn terminal_size_from_protocol( + size: CommandExecTerminalSize, +) -> Result { + if size.rows == 0 || size.cols == 0 { + return Err(invalid_params( + "command/exec size rows and cols must be greater than 0", + )); + } + Ok(TerminalSize { + rows: size.rows, + cols: size.cols, + }) +} + +fn command_no_longer_running_error(process_id: &InternalProcessId) -> JSONRPCErrorError { + invalid_request(format!( + "command/exec {} is no longer running", + process_id.error_repr(), + )) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::error_code::INVALID_REQUEST_ERROR_CODE; + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::models::PermissionProfile; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + #[cfg(not(target_os = "windows"))] + use tokio::time::Duration; + #[cfg(not(target_os = "windows"))] + use tokio::time::timeout; + #[cfg(not(target_os = "windows"))] + use tokio_util::sync::CancellationToken; + + use super::*; + #[cfg(not(target_os = "windows"))] + use crate::outgoing_message::OutgoingEnvelope; + #[cfg(not(target_os = "windows"))] + use crate::outgoing_message::OutgoingMessage; + + fn windows_sandbox_exec_request() -> ExecRequest { + let cwd = AbsolutePathBuf::current_dir().expect("current dir"); + ExecRequest::new( + vec!["cmd".to_string()], + cwd, + HashMap::new(), + /*network*/ None, + ExecExpiration::DefaultTimeout, + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::WindowsRestrictedToken, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + PermissionProfile::read_only(), + /*arg0*/ None, + ) + } + + #[tokio::test] + async fn windows_sandbox_streaming_exec_is_rejected() { + let (tx, _rx) = mpsc::channel(1); + let manager = CommandExecManager::default(); + let err = manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )), + request_id: ConnectionRequestId { + connection_id: ConnectionId(1), + request_id: codex_app_server_protocol::RequestId::Integer(42), + }, + process_id: Some("proc-42".to_string()), + exec_request: windows_sandbox_exec_request(), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + size: None, + }) + .await + .expect_err("streaming windows sandbox exec should be rejected"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "streaming command/exec is not supported with windows sandbox" + ); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn windows_sandbox_non_streaming_exec_uses_execution_path() { + let (tx, mut rx) = mpsc::channel(1); + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(7), + request_id: codex_app_server_protocol::RequestId::Integer(99), + }; + + manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )), + request_id: request_id.clone(), + process_id: Some("proc-99".to_string()), + exec_request: windows_sandbox_exec_request(), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP), + size: None, + }) + .await + .expect("non-streaming windows sandbox exec should start"); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for outgoing message") + .expect("channel closed before outgoing message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } = envelope + else { + panic!("expected connection-scoped outgoing message"); + }; + assert_eq!(connection_id, request_id.connection_id); + let OutgoingMessage::Error(error) = message else { + panic!("expected execution failure to be reported as an error"); + }; + assert_eq!(error.id, request_id.request_id); + assert!(error.error.message.starts_with("exec failed:")); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn cancellation_expiration_keeps_process_alive_until_terminated() { + let (tx, mut rx) = mpsc::channel(4); + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(8), + request_id: codex_app_server_protocol::RequestId::Integer(100), + }; + let cwd = AbsolutePathBuf::current_dir().expect("current dir"); + + manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )), + request_id: request_id.clone(), + process_id: Some("proc-100".to_string()), + exec_request: ExecRequest::new( + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + cwd.clone(), + HashMap::new(), + /*network*/ None, + ExecExpiration::Cancellation(CancellationToken::new()), + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::None, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + PermissionProfile::read_only(), + /*arg0*/ None, + ), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP), + size: None, + }) + .await + .expect("cancellation-based exec should start"); + + assert!( + timeout(Duration::from_millis(250), rx.recv()) + .await + .is_err(), + "command/exec should remain active until explicit termination", + ); + + manager + .terminate( + request_id.clone(), + CommandExecTerminateParams { + process_id: "proc-100".to_string(), + }, + ) + .await + .expect("terminate should succeed"); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for outgoing message") + .expect("channel closed before outgoing message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } = envelope + else { + panic!("expected connection-scoped outgoing message"); + }; + assert_eq!(connection_id, request_id.connection_id); + let OutgoingMessage::Response(response) = message else { + panic!("expected execution response after termination"); + }; + assert_eq!(response.id, request_id.request_id); + let response: CommandExecResponse = + serde_json::from_value(response.result).expect("deserialize command/exec response"); + assert_ne!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + // The deferred response now drains any already-emitted stderr before + // replying, so shell startup noise is allowed here. + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn timeout_or_cancellation_reports_cancellation_without_timeout_exit_code() { + let (tx, mut rx) = mpsc::channel(4); + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(9), + request_id: codex_app_server_protocol::RequestId::Integer(101), + }; + let cancellation = CancellationToken::new(); + let cancel = cancellation.clone(); + + manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )), + request_id: request_id.clone(), + process_id: Some("proc-101".to_string()), + exec_request: ExecRequest::new( + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + AbsolutePathBuf::current_dir().expect("current dir"), + HashMap::new(), + /*network*/ None, + ExecExpiration::TimeoutOrCancellation { + timeout: Duration::from_secs(30), + cancellation, + }, + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::None, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + PermissionProfile::read_only(), + /*arg0*/ None, + ), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP), + size: None, + }) + .await + .expect("timeout-or-cancellation exec should start"); + + cancel.cancel(); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for outgoing message") + .expect("channel closed before outgoing message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } = envelope + else { + panic!("expected connection-scoped outgoing message"); + }; + assert_eq!(connection_id, request_id.connection_id); + let OutgoingMessage::Response(response) = message else { + panic!("expected execution response after cancellation"); + }; + assert_eq!(response.id, request_id.request_id); + let response: CommandExecResponse = + serde_json::from_value(response.result).expect("deserialize command/exec response"); + assert_ne!(response.exit_code, EXEC_TIMEOUT_EXIT_CODE); + } + + #[tokio::test] + async fn windows_sandbox_process_ids_reject_write_requests() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(11), + request_id: codex_app_server_protocol::RequestId::Integer(1), + }; + let process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client("proc-11".to_string()), + }; + manager + .sessions + .lock() + .await + .insert(process_id, CommandExecSession::UnsupportedWindowsSandbox); + + let err = manager + .write( + request_id, + CommandExecWriteParams { + process_id: "proc-11".to_string(), + delta_base64: Some(STANDARD.encode("hello")), + close_stdin: false, + }, + ) + .await + .expect_err("windows sandbox process ids should reject command/exec/write"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes" + ); + } + + #[tokio::test] + async fn windows_sandbox_process_ids_reject_terminate_requests() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(12), + request_id: codex_app_server_protocol::RequestId::Integer(2), + }; + let process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client("proc-12".to_string()), + }; + manager + .sessions + .lock() + .await + .insert(process_id, CommandExecSession::UnsupportedWindowsSandbox); + + let err = manager + .terminate( + request_id, + CommandExecTerminateParams { + process_id: "proc-12".to_string(), + }, + ) + .await + .expect_err("windows sandbox process ids should reject command/exec/terminate"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes" + ); + } + + #[tokio::test] + async fn dropped_control_request_is_reported_as_not_running() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(13), + request_id: codex_app_server_protocol::RequestId::Integer(3), + }; + let process_id = InternalProcessId::Client("proc-13".to_string()); + let (control_tx, mut control_rx) = mpsc::channel(1); + manager.sessions.lock().await.insert( + ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + }, + CommandExecSession::Active { control_tx }, + ); + + tokio::spawn(async move { + let _request = control_rx + .recv() + .await + .expect("expected queued control request"); + }); + + let err = manager + .terminate( + request_id, + CommandExecTerminateParams { + process_id: "proc-13".to_string(), + }, + ) + .await + .expect_err("dropped control request should be treated as not running"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(err.message, "command/exec \"proc-13\" is no longer running"); + } +} diff --git a/code-rs/app-server/src/config/external_agent_config.rs b/code-rs/app-server/src/config/external_agent_config.rs new file mode 100644 index 00000000000..9de2c184b98 --- /dev/null +++ b/code-rs/app-server/src/config/external_agent_config.rs @@ -0,0 +1,1623 @@ +use codex_config::types::PluginConfig; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginsManager; +use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; +use codex_core_plugins::marketplace::find_marketplace_manifest_path; +use codex_core_plugins::marketplace_add::MarketplaceAddRequest; +use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_add::is_local_marketplace_source; +use codex_external_agent_migration::build_mcp_config_from_external; +use codex_external_agent_migration::count_missing_commands; +use codex_external_agent_migration::count_missing_subagents; +use codex_external_agent_migration::hook_migration_event_names; +use codex_external_agent_migration::import_commands; +use codex_external_agent_migration::import_hooks; +use codex_external_agent_migration::import_subagents; +use codex_external_agent_migration::missing_command_names; +use codex_external_agent_migration::missing_subagent_names; +use codex_external_agent_sessions::ExternalAgentSessionMigration; +use codex_external_agent_sessions::detect_recent_sessions; +use codex_plugin::PluginId; +use codex_protocol::protocol::Product; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use toml::Value as TomlValue; + +const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect"; +const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import"; +const EXTERNAL_AGENT_DIR: &str = ".claude"; +const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md"; +const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official"; +const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExternalAgentConfigDetectOptions { + pub include_home: bool, + pub cwds: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExternalAgentConfigMigrationItemType { + Config, + Skills, + AgentsMd, + Plugins, + McpServerConfig, + Subagents, + Hooks, + Commands, + Sessions, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PluginsMigration { + pub marketplace_name: String, + pub plugin_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NamedMigration { + pub name: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MigrationDetails { + pub plugins: Vec, + pub sessions: Vec, + pub mcp_servers: Vec, + pub hooks: Vec, + pub subagents: Vec, + pub commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PendingPluginImport { + pub cwd: Option, + pub details: MigrationDetails, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct PluginImportOutcome { + pub succeeded_marketplaces: Vec, + pub succeeded_plugin_ids: Vec, + pub failed_marketplaces: Vec, + pub failed_plugin_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExternalAgentConfigMigrationItem { + pub item_type: ExternalAgentConfigMigrationItemType, + pub description: String, + pub cwd: Option, + pub details: Option, +} + +#[derive(Clone)] +pub(crate) struct ExternalAgentConfigService { + codex_home: PathBuf, + external_agent_home: PathBuf, +} + +impl ExternalAgentConfigService { + pub(crate) fn new(codex_home: PathBuf) -> Self { + let external_agent_home = default_external_agent_home(); + Self { + codex_home, + external_agent_home, + } + } + + #[cfg(test)] + fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self { + Self { + codex_home, + external_agent_home, + } + } + + pub(crate) async fn detect( + &self, + params: ExternalAgentConfigDetectOptions, + ) -> io::Result> { + let mut items = Vec::new(); + if params.include_home { + self.detect_migrations(/*repo_root*/ None, &mut items) + .await?; + } + + for cwd in params.cwds.as_deref().unwrap_or(&[]) { + let Some(repo_root) = find_repo_root(Some(cwd))? else { + continue; + }; + self.detect_migrations(Some(&repo_root), &mut items).await?; + } + + Ok(items) + } + + pub(crate) fn external_agent_session_source_path( + &self, + path: &Path, + ) -> io::Result> { + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + return Ok(None); + } + let path = match fs::canonicalize(path) { + Ok(path) => path, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + let projects_root = match fs::canonicalize(self.external_agent_home.join("projects")) { + Ok(projects_root) => projects_root, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + Ok(path.starts_with(projects_root).then_some(path)) + } + + pub(crate) async fn import( + &self, + migration_items: Vec, + ) -> io::Result> { + let mut pending_plugin_imports = Vec::new(); + for migration_item in migration_items { + match migration_item.item_type { + ExternalAgentConfigMigrationItemType::Config => { + self.import_config(migration_item.cwd.as_deref())?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::Config, + /*skills_count*/ None, + ); + } + ExternalAgentConfigMigrationItemType::Skills => { + let skills_count = self.import_skills(migration_item.cwd.as_deref())?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::Skills, + Some(skills_count), + ); + } + ExternalAgentConfigMigrationItemType::AgentsMd => { + self.import_agents_md(migration_item.cwd.as_deref())?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::AgentsMd, + /*skills_count*/ None, + ); + } + ExternalAgentConfigMigrationItemType::Plugins => { + let cwd = migration_item.cwd; + let details = migration_item.details.ok_or_else(|| { + invalid_data_error("plugins migration item is missing details".to_string()) + })?; + let (local_details, remote_details) = + self.partition_plugin_migration_details(cwd.as_deref(), details)?; + + if let Some(local_details) = local_details { + self.import_plugins(cwd.as_deref(), Some(local_details)) + .await?; + } + if let Some(remote_details) = remote_details { + pending_plugin_imports.push(PendingPluginImport { + cwd, + details: remote_details, + }); + } + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::Plugins, + /*skills_count*/ None, + ); + } + ExternalAgentConfigMigrationItemType::McpServerConfig => { + self.import_mcp_server_config(migration_item.cwd.as_deref())?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::McpServerConfig, + /*skills_count*/ None, + ); + } + ExternalAgentConfigMigrationItemType::Subagents => { + let subagents_count = self.import_subagents(migration_item.cwd.as_deref())?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::Subagents, + Some(subagents_count), + ); + } + ExternalAgentConfigMigrationItemType::Hooks => { + self.import_hooks(migration_item.cwd.as_deref())?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::Hooks, + /*skills_count*/ None, + ); + } + ExternalAgentConfigMigrationItemType::Commands => { + let commands_count = self.import_commands(migration_item.cwd.as_deref())?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::Commands, + Some(commands_count), + ); + } + ExternalAgentConfigMigrationItemType::Sessions => {} + } + } + + Ok(pending_plugin_imports) + } + + async fn detect_migrations( + &self, + repo_root: Option<&Path>, + items: &mut Vec, + ) -> io::Result<()> { + let cwd = repo_root.map(Path::to_path_buf); + let source_settings = repo_root.map_or_else( + || self.external_agent_home.join("settings.json"), + |repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + ); + let settings = effective_external_settings(&source_settings)?; + let target_config = repo_root.map_or_else( + || self.codex_home.join("config.toml"), + |repo_root| repo_root.join(".codex").join("config.toml"), + ); + if let Some(settings) = settings.as_ref() { + let migrated = build_config_from_external(settings)?; + if !is_empty_toml_table(&migrated) { + let mut should_include = true; + if target_config.exists() { + let existing_raw = fs::read_to_string(&target_config)?; + let mut existing = if existing_raw.trim().is_empty() { + TomlValue::Table(Default::default()) + } else { + toml::from_str::(&existing_raw).map_err(|err| { + invalid_data_error(format!("invalid existing config.toml: {err}")) + })? + }; + should_include = merge_missing_toml_values(&mut existing, &migrated)?; + } + + if should_include { + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: format!( + "Migrate {} into {}", + source_settings.display(), + target_config.display() + ), + cwd: cwd.clone(), + details: None, + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Config, + /*skills_count*/ None, + ); + } + } + } + + let source_root = self.source_root(repo_root); + let mcp_settings = self.mcp_settings(repo_root, settings.clone())?; + let migrated_mcp = build_mcp_config_from_external( + source_root.as_path(), + Some(self.external_agent_home.as_path()), + mcp_settings.as_ref(), + )?; + let mcp_server_names = migrated_mcp_server_names(&migrated_mcp); + if !is_empty_toml_table(&migrated_mcp) { + let mut should_include = true; + if target_config.exists() { + let existing_raw = fs::read_to_string(&target_config)?; + let mut existing = if existing_raw.trim().is_empty() { + TomlValue::Table(Default::default()) + } else { + toml::from_str::(&existing_raw).map_err(|err| { + invalid_data_error(format!("invalid existing config.toml: {err}")) + })? + }; + should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?; + } + + if should_include { + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: format!( + "Migrate MCP servers from {} into {}", + source_root.display(), + target_config.display() + ), + cwd: cwd.clone(), + details: Some(MigrationDetails { + mcp_servers: named_migrations(mcp_server_names.clone()), + ..Default::default() + }), + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::McpServerConfig, + /*skills_count*/ None, + ); + } + } + + let source_external_agent_dir = repo_root.map_or_else( + || self.external_agent_home.clone(), + |repo_root| repo_root.join(EXTERNAL_AGENT_DIR), + ); + let target_hooks = repo_root.map_or_else( + || self.codex_home.join("hooks.json"), + |repo_root| repo_root.join(".codex").join("hooks.json"), + ); + let hook_event_names = + hook_migration_event_names(source_external_agent_dir.as_path(), &target_hooks)?; + if !hook_event_names.is_empty() && is_missing_or_empty_text_file(&target_hooks)? { + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Hooks, + description: format!( + "Migrate hooks from {} to {}", + source_external_agent_dir.display(), + target_hooks.display() + ), + cwd: cwd.clone(), + details: Some(MigrationDetails { + hooks: named_migrations(hook_event_names), + ..Default::default() + }), + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Hooks, + /*skills_count*/ None, + ); + } + + let source_skills = repo_root.map_or_else( + || self.external_agent_home.join("skills"), + |repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"), + ); + let target_skills = repo_root.map_or_else( + || self.home_target_skills_dir(), + |repo_root| repo_root.join(".agents").join("skills"), + ); + let skills_count = count_missing_subdirectories(&source_skills, &target_skills)?; + if skills_count > 0 { + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: format!( + "Migrate skills from {} to {}", + source_skills.display(), + target_skills.display() + ), + cwd: cwd.clone(), + details: None, + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Skills, + Some(skills_count), + ); + } + + let source_commands = source_external_agent_dir.join("commands"); + let target_command_skills = repo_root.map_or_else( + || self.home_target_skills_dir(), + |repo_root| repo_root.join(".agents").join("skills"), + ); + let commands_count = count_missing_commands(&source_commands, &target_command_skills)?; + if commands_count > 0 { + let command_names = missing_command_names(&source_commands, &target_command_skills)?; + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Commands, + description: format!( + "Migrate commands from {} to {}", + source_commands.display(), + target_command_skills.display() + ), + cwd: cwd.clone(), + details: Some(MigrationDetails { + commands: named_migrations(command_names), + ..Default::default() + }), + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Commands, + Some(commands_count), + ); + } + + let source_subagents = source_external_agent_dir.join("agents"); + let target_subagents = repo_root.map_or_else( + || self.codex_home.join("agents"), + |repo_root| repo_root.join(".codex").join("agents"), + ); + let subagents_count = count_missing_subagents(&source_subagents, &target_subagents)?; + if subagents_count > 0 { + let subagent_names = missing_subagent_names(&source_subagents, &target_subagents)?; + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Subagents, + description: format!( + "Migrate subagents from {} to {}", + source_subagents.display(), + target_subagents.display() + ), + cwd: cwd.clone(), + details: Some(MigrationDetails { + subagents: named_migrations(subagent_names), + ..Default::default() + }), + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Subagents, + Some(subagents_count), + ); + } + + let source_agents_md = if let Some(repo_root) = repo_root { + find_repo_agents_md_source(repo_root)? + } else { + let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD); + is_non_empty_text_file(&path)?.then_some(path) + }; + let target_agents_md = repo_root.map_or_else( + || self.codex_home.join("AGENTS.md"), + |repo_root| repo_root.join("AGENTS.md"), + ); + if let Some(source_agents_md) = source_agents_md + && is_missing_or_empty_text_file(&target_agents_md)? + { + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Migrate {} to {}", + source_agents_md.display(), + target_agents_md.display() + ), + cwd: cwd.clone(), + details: None, + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::AgentsMd, + /*skills_count*/ None, + ); + } + + if let Some(settings) = settings.as_ref() { + match ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .fallback_cwd(Some(self.codex_home.clone())) + .build() + .await + { + Ok(config) => { + let configured_plugin_ids = config + .config_layer_stack + .get_user_layer() + .and_then(|user_layer| user_layer.config.get("plugins")) + .and_then(|plugins| { + match plugins.clone().try_into::>() { + Ok(plugins) => Some(plugins), + Err(err) => { + tracing::warn!("invalid plugins config: {err}"); + None + } + } + }) + .map(|plugins| plugins.into_keys().collect::>()) + .unwrap_or_default(); + let configured_marketplace_plugins = configured_marketplace_plugins( + &config, + &PluginsManager::new(self.codex_home.clone()), + )?; + if let Some(item) = self.detect_plugin_migration( + source_settings.as_path(), + repo_root.unwrap_or(self.external_agent_home.as_path()), + cwd.clone(), + settings, + &configured_plugin_ids, + &configured_marketplace_plugins, + ) { + items.push(item); + } + } + Err(err) => { + tracing::warn!( + error = %err, + settings_path = %source_settings.display(), + "skipping external agent plugin migration detection because config load failed" + ); + } + } + } + + if repo_root.is_none() { + let sessions = detect_recent_sessions(&self.external_agent_home, &self.codex_home)?; + if !sessions.is_empty() { + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Sessions, + description: format!( + "Migrate recent sessions from {}", + self.external_agent_home.join("projects").display() + ), + cwd: None, + details: Some(MigrationDetails { + sessions, + ..Default::default() + }), + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Sessions, + /*skills_count*/ None, + ); + } + } + + Ok(()) + } + + fn home_target_skills_dir(&self) -> PathBuf { + self.codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")) + } + + fn mcp_settings( + &self, + repo_root: Option<&Path>, + source_settings: Option, + ) -> io::Result> { + if repo_root.is_some() && source_settings.is_none() { + let home_settings = self.external_agent_home.join("settings.json"); + match effective_external_settings(&home_settings) { + Ok(settings) => Ok(settings), + Err(err) => { + tracing::warn!( + path = %home_settings.display(), + error = %err, + "ignoring invalid external agent home settings during repo MCP migration" + ); + Ok(None) + } + } + } else { + Ok(source_settings) + } + } + + fn source_root(&self, repo_root: Option<&Path>) -> PathBuf { + repo_root.map_or_else( + || { + self.external_agent_home + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) + }, + Path::to_path_buf, + ) + } + + fn detect_plugin_migration( + &self, + source_settings: &Path, + source_root: &Path, + cwd: Option, + settings: &JsonValue, + configured_plugin_ids: &HashSet, + configured_marketplace_plugins: &BTreeMap>, + ) -> Option { + let plugin_details = extract_plugin_migration_details( + settings, + source_root, + configured_plugin_ids, + configured_marketplace_plugins, + )?; + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Plugins, + /*skills_count*/ None, + ); + + Some(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!("Migrate enabled plugins from {}", source_settings.display()), + cwd, + details: Some(plugin_details), + }) + } + + fn partition_plugin_migration_details( + &self, + cwd: Option<&Path>, + details: MigrationDetails, + ) -> io::Result<(Option, Option)> { + let source_settings = cwd.map_or_else( + || self.external_agent_home.join("settings.json"), + |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"), + ); + let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); + let import_sources = effective_external_settings(&source_settings)? + .map(|settings| collect_marketplace_import_sources(&settings, source_root)) + .unwrap_or_default(); + + let mut local_plugins = Vec::new(); + let mut remote_plugins = Vec::new(); + for plugin_group in details.plugins { + let is_local = import_sources + .get(&plugin_group.marketplace_name) + .and_then(|import_source| { + is_local_marketplace_source( + &import_source.source, + import_source.ref_name.clone(), + ) + .ok() + }) + .unwrap_or(false); + + if is_local { + local_plugins.push(plugin_group); + } else { + remote_plugins.push(plugin_group); + } + } + + let local_details = (!local_plugins.is_empty()).then_some(MigrationDetails { + plugins: local_plugins, + ..Default::default() + }); + let remote_details = (!remote_plugins.is_empty()).then_some(MigrationDetails { + plugins: remote_plugins, + ..Default::default() + }); + + Ok((local_details, remote_details)) + } + + pub(crate) async fn import_plugins( + &self, + cwd: Option<&Path>, + details: Option, + ) -> io::Result { + let Some(MigrationDetails { plugins, .. }) = details else { + return Err(invalid_data_error( + "plugins migration item is missing details".to_string(), + )); + }; + let mut outcome = PluginImportOutcome::default(); + let plugins_manager = PluginsManager::new(self.codex_home.clone()); + for plugin_group in plugins { + let marketplace_name = plugin_group.marketplace_name.clone(); + let plugin_names = plugin_group.plugin_names; + let plugin_ids = plugin_names + .iter() + .map(|plugin_name| format!("{plugin_name}@{marketplace_name}")) + .collect::>(); + let source_settings = cwd.map_or_else( + || self.external_agent_home.join("settings.json"), + |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"), + ); + let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); + let import_source = + effective_external_settings(&source_settings)?.and_then(|settings| { + collect_marketplace_import_sources(&settings, source_root) + .remove(&marketplace_name) + }); + let Some(import_source) = import_source else { + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + }; + let request = MarketplaceAddRequest { + source: import_source.source, + ref_name: import_source.ref_name, + sparse_paths: Vec::new(), + }; + let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await; + let marketplace_path = match add_marketplace_outcome { + Ok(add_marketplace_outcome) => { + let Some(marketplace_path) = find_marketplace_manifest_path( + add_marketplace_outcome.installed_root.as_path(), + ) else { + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + }; + outcome + .succeeded_marketplaces + .push(marketplace_name.clone()); + marketplace_path + } + Err(_) => { + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + } + }; + for plugin_name in plugin_names { + match plugins_manager + .install_plugin(PluginInstallRequest { + plugin_name: plugin_name.clone(), + marketplace_path: marketplace_path.clone(), + }) + .await + { + Ok(_) => outcome + .succeeded_plugin_ids + .push(format!("{plugin_name}@{marketplace_name}")), + Err(_) => outcome + .failed_plugin_ids + .push(format!("{plugin_name}@{marketplace_name}")), + } + } + } + + Ok(outcome) + } + + fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> { + let repo_root = find_repo_root(cwd)?; + let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { + ( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + repo_root.join(".codex").join("config.toml"), + ) + } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { + return Ok(()); + } else { + ( + self.external_agent_home.join("settings.json"), + self.codex_home.join("config.toml"), + ) + }; + let Some(settings) = effective_external_settings(&source_settings)? else { + return Ok(()); + }; + let migrated = build_config_from_external(&settings)?; + if is_empty_toml_table(&migrated) { + return Ok(()); + } + + let Some(target_parent) = target_config.parent() else { + return Err(invalid_data_error("config target path has no parent")); + }; + fs::create_dir_all(target_parent)?; + if !target_config.exists() { + write_toml_file(&target_config, &migrated)?; + return Ok(()); + } + + let existing_raw = fs::read_to_string(&target_config)?; + let mut existing = if existing_raw.trim().is_empty() { + TomlValue::Table(Default::default()) + } else { + toml::from_str::(&existing_raw) + .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))? + }; + + let changed = merge_missing_toml_values(&mut existing, &migrated)?; + if !changed { + return Ok(()); + } + + write_toml_file(&target_config, &existing)?; + Ok(()) + } + + fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<()> { + let repo_root = find_repo_root(cwd)?; + let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { + ( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + repo_root.join(".codex").join("config.toml"), + ) + } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { + return Ok(()); + } else { + ( + self.external_agent_home.join("settings.json"), + self.codex_home.join("config.toml"), + ) + }; + let settings = self.mcp_settings( + repo_root.as_deref(), + effective_external_settings(&source_settings)?, + )?; + let migrated = build_mcp_config_from_external( + self.source_root(repo_root.as_deref()).as_path(), + Some(self.external_agent_home.as_path()), + settings.as_ref(), + )?; + if is_empty_toml_table(&migrated) { + return Ok(()); + } + + let Some(target_parent) = target_config.parent() else { + return Err(invalid_data_error("config target path has no parent")); + }; + fs::create_dir_all(target_parent)?; + if !target_config.exists() { + write_toml_file(&target_config, &migrated)?; + return Ok(()); + } + + let existing_raw = fs::read_to_string(&target_config)?; + let mut existing = if existing_raw.trim().is_empty() { + TomlValue::Table(Default::default()) + } else { + toml::from_str::(&existing_raw) + .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))? + }; + if merge_missing_toml_values(&mut existing, &migrated)? { + write_toml_file(&target_config, &existing)?; + } + Ok(()) + } + + fn import_subagents(&self, cwd: Option<&Path>) -> io::Result { + let (source_agents, target_agents) = if let Some(repo_root) = find_repo_root(cwd)? { + ( + repo_root.join(EXTERNAL_AGENT_DIR).join("agents"), + repo_root.join(".codex").join("agents"), + ) + } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { + return Ok(0); + } else { + ( + self.external_agent_home.join("agents"), + self.codex_home.join("agents"), + ) + }; + + import_subagents(&source_agents, &target_agents) + } + + fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<()> { + let (source_external_agent_dir, target_hooks) = + if let Some(repo_root) = find_repo_root(cwd)? { + ( + repo_root.join(EXTERNAL_AGENT_DIR), + repo_root.join(".codex").join("hooks.json"), + ) + } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { + return Ok(()); + } else { + ( + self.external_agent_home.clone(), + self.codex_home.join("hooks.json"), + ) + }; + + import_hooks(&source_external_agent_dir, &target_hooks)?; + Ok(()) + } + + fn import_commands(&self, cwd: Option<&Path>) -> io::Result { + let (source_commands, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? { + ( + repo_root.join(EXTERNAL_AGENT_DIR).join("commands"), + repo_root.join(".agents").join("skills"), + ) + } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { + return Ok(0); + } else { + ( + self.external_agent_home.join("commands"), + self.home_target_skills_dir(), + ) + }; + + import_commands(&source_commands, &target_skills) + } + + fn import_skills(&self, cwd: Option<&Path>) -> io::Result { + let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? { + ( + repo_root.join(EXTERNAL_AGENT_DIR).join("skills"), + repo_root.join(".agents").join("skills"), + ) + } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { + return Ok(0); + } else { + ( + self.external_agent_home.join("skills"), + self.home_target_skills_dir(), + ) + }; + if !source_skills.is_dir() { + return Ok(0); + } + + fs::create_dir_all(&target_skills)?; + let mut copied_count = 0usize; + + for entry in fs::read_dir(&source_skills)? { + let entry = entry?; + let file_type = entry.file_type()?; + if !file_type.is_dir() { + continue; + } + + let target = target_skills.join(entry.file_name()); + if target.exists() { + continue; + } + + copy_dir_recursive(&entry.path(), &target)?; + copied_count += 1; + } + + Ok(copied_count) + } + + fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<()> { + let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? { + let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else { + return Ok(()); + }; + (source_agents_md, repo_root.join("AGENTS.md")) + } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { + return Ok(()); + } else { + ( + self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD), + self.codex_home.join("AGENTS.md"), + ) + }; + if !is_non_empty_text_file(&source_agents_md)? + || !is_missing_or_empty_text_file(&target_agents_md)? + { + return Ok(()); + } + + let Some(target_parent) = target_agents_md.parent() else { + return Err(invalid_data_error("AGENTS.md target path has no parent")); + }; + fs::create_dir_all(target_parent)?; + + rewrite_and_copy_text_file(&source_agents_md, &target_agents_md) + } +} + +fn default_external_agent_home() -> PathBuf { + if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { + return PathBuf::from(home).join(EXTERNAL_AGENT_DIR); + } + + PathBuf::from(EXTERNAL_AGENT_DIR) +} + +fn read_external_settings(path: &Path) -> io::Result> { + if !path.is_file() { + return Ok(None); + } + + let raw_settings = fs::read_to_string(path)?; + let settings = + serde_json::from_str(&raw_settings).map_err(|err| invalid_data_error(err.to_string()))?; + Ok(Some(settings)) +} + +fn effective_external_settings(project_settings: &Path) -> io::Result> { + let mut effective = read_external_settings(project_settings)?; + let Some(settings_dir) = project_settings.parent() else { + return Ok(effective); + }; + let local_settings = settings_dir.join("settings.local.json"); + let local_settings = match read_external_settings(&local_settings) { + Ok(Some(local_settings)) => local_settings, + Ok(None) => return Ok(effective), + Err(err) if err.kind() == io::ErrorKind::InvalidData => return Ok(effective), + Err(err) => return Err(err), + }; + if let Some(effective) = effective.as_mut() { + merge_json_settings(effective, &local_settings); + } else { + effective = Some(local_settings); + } + Ok(effective) +} + +fn merge_json_settings(existing: &mut JsonValue, incoming: &JsonValue) { + match (existing, incoming) { + (JsonValue::Object(existing), JsonValue::Object(incoming)) => { + for (key, incoming_value) in incoming { + match existing.get_mut(key) { + Some(existing_value) => merge_json_settings(existing_value, incoming_value), + None => { + existing.insert(key.clone(), incoming_value.clone()); + } + } + } + } + (existing, incoming) => { + *existing = incoming.clone(); + } + } +} +fn extract_plugin_migration_details( + settings: &JsonValue, + source_root: &Path, + configured_plugin_ids: &HashSet, + configured_marketplace_plugins: &BTreeMap>, +) -> Option { + let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root) + .into_iter() + .filter_map(|(marketplace_name, source)| { + is_local_marketplace_source(&source.source, source.ref_name) + .ok() + .map(|_| marketplace_name) + }) + .collect::>(); + let mut plugins = BTreeMap::new(); + for plugin_id in collect_enabled_plugins(settings) + .into_iter() + .filter(|plugin_id| !configured_plugin_ids.contains(plugin_id)) + { + let Ok(plugin_id) = PluginId::parse(&plugin_id) else { + continue; + }; + if let Some(installable_plugins) = + configured_marketplace_plugins.get(&plugin_id.marketplace_name) + { + if !installable_plugins.contains(&plugin_id.plugin_name) { + continue; + } + } else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) { + continue; + } + let plugin_group = plugins + .entry(plugin_id.marketplace_name.clone()) + .or_insert_with(|| PluginsMigration { + marketplace_name: plugin_id.marketplace_name.clone(), + plugin_names: Vec::new(), + }); + plugin_group.plugin_names.push(plugin_id.plugin_name); + } + + let plugins = plugins + .into_values() + .filter_map(|mut plugin_group| { + if plugin_group.plugin_names.is_empty() { + return None; + } + plugin_group.plugin_names.sort(); + Some(plugin_group) + }) + .collect::>(); + if plugins.is_empty() { + return None; + } + + Some(MigrationDetails { + plugins, + ..Default::default() + }) +} + +fn collect_enabled_plugins(settings: &JsonValue) -> Vec { + let Some(enabled_plugins) = settings + .as_object() + .and_then(|settings| settings.get("enabledPlugins")) + .and_then(JsonValue::as_object) + else { + return Vec::new(); + }; + + enabled_plugins + .iter() + .filter_map(|(plugin_key, enabled)| { + if !enabled.as_bool().unwrap_or(false) { + return None; + } + PluginId::parse(plugin_key) + .ok() + .map(|plugin_id| plugin_id.as_key()) + }) + .collect() +} + +fn has_enabled_plugin_for_marketplace(settings: &JsonValue, marketplace_name: &str) -> bool { + collect_enabled_plugins(settings) + .into_iter() + .any(|plugin_id| { + PluginId::parse(&plugin_id) + .map(|plugin_id| plugin_id.marketplace_name == marketplace_name) + .unwrap_or(false) + }) +} + +fn configured_marketplace_plugins( + config: &Config, + plugins_manager: &PluginsManager, +) -> io::Result>> { + let plugins_input = config.plugins_config_input(); + let marketplaces = plugins_manager + .list_marketplaces_for_config(&plugins_input, &[]) + .map_err(|err| { + invalid_data_error(format!("failed to list configured marketplaces: {err}")) + })?; + let mut marketplace_plugins = BTreeMap::new(); + for marketplace in marketplaces.marketplaces { + let plugins = marketplace + .plugins + .into_iter() + .filter(|plugin| { + plugin.policy.installation != MarketplacePluginInstallPolicy::NotAvailable + }) + .filter(|plugin| { + plugin + .policy + .products + .as_deref() + .is_none_or(|products| Product::Codex.matches_product_restriction(products)) + }) + .map(|plugin| plugin.name) + .collect::>(); + marketplace_plugins.insert(marketplace.name, plugins); + } + Ok(marketplace_plugins) +} + +fn collect_marketplace_import_sources( + settings: &JsonValue, + source_root: &Path, +) -> BTreeMap { + let mut import_sources: BTreeMap = settings + .as_object() + .and_then(|settings| settings.get("extraKnownMarketplaces")) + .and_then(JsonValue::as_object) + .map(|extra_known_marketplaces| { + extra_known_marketplaces + .iter() + .filter_map(|(name, value)| { + let source_fields = if let Some(source) = value.get("source") + && source.is_object() + { + source.as_object()? + } else { + value.as_object()? + }; + let source = source_fields + .get("repo") + .or_else(|| source_fields.get("url")) + .or_else(|| source_fields.get("path")) + .or_else(|| value.get("source"))? + .as_str()? + .trim() + .to_string(); + if source.is_empty() { + return None; + } + let source = resolve_external_marketplace_source(&source, source_root); + + let ref_name = source_fields + .get("ref") + .or_else(|| value.get("ref")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + + Some((name.clone(), MarketplaceImportSource { source, ref_name })) + }) + .collect() + }) + .unwrap_or_default(); + + if has_enabled_plugin_for_marketplace(settings, EXTERNAL_OFFICIAL_MARKETPLACE_NAME) + && !import_sources.contains_key(EXTERNAL_OFFICIAL_MARKETPLACE_NAME) + { + import_sources.insert( + EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(), + MarketplaceImportSource { + source: EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE.to_string(), + ref_name: None, + }, + ); + } + + import_sources +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MarketplaceImportSource { + source: String, + ref_name: Option, +} + +fn resolve_external_marketplace_source(source: &str, source_root: &Path) -> String { + if !looks_like_relative_local_path(source) { + return source.to_string(); + } + + source_root.join(source).display().to_string() +} + +fn looks_like_relative_local_path(source: &str) -> bool { + source.starts_with("./") || source.starts_with("../") || source == "." || source == ".." +} + +fn find_repo_root(cwd: Option<&Path>) -> io::Result> { + let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else { + return Ok(None); + }; + + let mut current = if cwd.is_absolute() { + cwd.to_path_buf() + } else { + std::env::current_dir()?.join(cwd) + }; + + if !current.exists() { + return Ok(None); + } + + if current.is_file() { + let Some(parent) = current.parent() else { + return Ok(None); + }; + current = parent.to_path_buf(); + } + + let fallback = current.clone(); + loop { + let git_path = current.join(".git"); + if git_path.is_dir() || git_path.is_file() { + return Ok(Some(current)); + } + if !current.pop() { + break; + } + } + + Ok(Some(fallback)) +} + +fn collect_subdirectory_names(path: &Path) -> io::Result> { + let mut names = HashSet::new(); + if !path.is_dir() { + return Ok(names); + } + + for entry in fs::read_dir(path)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + names.insert(entry.file_name()); + } + } + + Ok(names) +} + +fn count_missing_subdirectories(source: &Path, target: &Path) -> io::Result { + let source_names = collect_subdirectory_names(source)?; + let target_names = collect_subdirectory_names(target)?; + Ok(source_names + .iter() + .filter(|name| !target_names.contains(*name)) + .count()) +} + +fn is_missing_or_empty_text_file(path: &Path) -> io::Result { + if !path.exists() { + return Ok(true); + } + if !path.is_file() { + return Ok(false); + } + + Ok(fs::read_to_string(path)?.trim().is_empty()) +} + +fn is_non_empty_text_file(path: &Path) -> io::Result { + if !path.is_file() { + return Ok(false); + } + + Ok(!fs::read_to_string(path)?.trim().is_empty()) +} + +fn find_repo_agents_md_source(repo_root: &Path) -> io::Result> { + for candidate in [ + repo_root.join(EXTERNAL_AGENT_CONFIG_MD), + repo_root + .join(EXTERNAL_AGENT_DIR) + .join(EXTERNAL_AGENT_CONFIG_MD), + ] { + if is_non_empty_text_file(&candidate)? { + return Ok(Some(candidate)); + } + } + + Ok(None) +} + +fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> { + fs::create_dir_all(target)?; + + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = entry.file_type()?; + + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + continue; + } + + if file_type.is_file() { + if is_skill_md(&source_path) { + rewrite_and_copy_text_file(&source_path, &target_path)?; + } else { + fs::copy(source_path, target_path)?; + } + } + } + + Ok(()) +} + +fn is_skill_md(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) +} + +fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> { + let source_contents = fs::read_to_string(source)?; + let rewritten = rewrite_external_agent_terms(&source_contents); + fs::write(target, rewritten) +} + +fn rewrite_external_agent_terms(content: &str) -> String { + let mut rewritten = replace_case_insensitive_with_boundaries( + content, + &EXTERNAL_AGENT_CONFIG_MD.to_ascii_lowercase(), + "AGENTS.md", + ); + for from in [ + "claude code", + "claude-code", + "claude_code", + "claudecode", + "claude", + ] { + rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex"); + } + rewritten +} + +fn replace_case_insensitive_with_boundaries( + input: &str, + needle: &str, + replacement: &str, +) -> String { + let needle_lower = needle.to_ascii_lowercase(); + if needle_lower.is_empty() { + return input.to_string(); + } + + let haystack_lower = input.to_ascii_lowercase(); + let bytes = input.as_bytes(); + let mut output = String::with_capacity(input.len()); + let mut last_emitted = 0usize; + let mut search_start = 0usize; + + while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) { + let start = search_start + relative_pos; + let end = start + needle_lower.len(); + let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]); + let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]); + + if boundary_before && boundary_after { + output.push_str(&input[last_emitted..start]); + output.push_str(replacement); + last_emitted = end; + } + + search_start = start + 1; + } + + if last_emitted == 0 { + return input.to_string(); + } + + output.push_str(&input[last_emitted..]); + output +} + +fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +fn build_config_from_external(settings: &JsonValue) -> io::Result { + let Some(settings_obj) = settings.as_object() else { + return Err(invalid_data_error( + "external agent settings root must be an object", + )); + }; + + let mut root = toml::map::Map::new(); + + if let Some(env) = settings_obj.get("env").and_then(JsonValue::as_object) + && !env.is_empty() + { + let mut shell_policy = toml::map::Map::new(); + shell_policy.insert("inherit".to_string(), TomlValue::String("core".to_string())); + shell_policy.insert( + "set".to_string(), + TomlValue::Table(json_object_to_env_toml_table(env)), + ); + root.insert( + "shell_environment_policy".to_string(), + TomlValue::Table(shell_policy), + ); + } + + if let Some(sandbox_enabled) = settings_obj + .get("sandbox") + .and_then(JsonValue::as_object) + .and_then(|sandbox| sandbox.get("enabled")) + .and_then(JsonValue::as_bool) + && sandbox_enabled + { + root.insert( + "sandbox_mode".to_string(), + TomlValue::String("workspace-write".to_string()), + ); + } + + Ok(TomlValue::Table(root)) +} + +fn json_object_to_env_toml_table( + object: &serde_json::Map, +) -> toml::map::Map { + let mut table = toml::map::Map::new(); + for (key, value) in object { + if let Some(value) = json_env_value_to_string(value) { + table.insert(key.clone(), TomlValue::String(value)); + } + } + table +} + +fn json_env_value_to_string(value: &JsonValue) -> Option { + match value { + JsonValue::String(value) => Some(value.clone()), + JsonValue::Null => None, + JsonValue::Bool(value) => Some(value.to_string()), + JsonValue::Number(value) => Some(value.to_string()), + JsonValue::Array(_) | JsonValue::Object(_) => None, + } +} + +fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> io::Result { + match (existing, incoming) { + (TomlValue::Table(existing_table), TomlValue::Table(incoming_table)) => { + let mut changed = false; + for (key, incoming_value) in incoming_table { + match existing_table.get_mut(key) { + Some(existing_value) => { + if matches!( + (&*existing_value, incoming_value), + (TomlValue::Table(_), TomlValue::Table(_)) + ) && merge_missing_toml_values(existing_value, incoming_value)? + { + changed = true; + } + } + None => { + existing_table.insert(key.clone(), incoming_value.clone()); + changed = true; + } + } + } + Ok(changed) + } + _ => Err(invalid_data_error( + "expected TOML table while merging migrated config values", + )), + } +} + +fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> { + let serialized = toml::to_string_pretty(value) + .map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?; + fs::write(path, format!("{}\n", serialized.trim_end())) +} + +fn migrated_mcp_server_names(value: &TomlValue) -> Vec { + value + .get("mcp_servers") + .and_then(TomlValue::as_table) + .map(|servers| servers.keys().cloned().collect()) + .unwrap_or_default() +} + +fn named_migrations(names: Vec) -> Vec { + names + .into_iter() + .map(|name| NamedMigration { name }) + .collect() +} + +fn is_empty_toml_table(value: &TomlValue) -> bool { + match value { + TomlValue::Table(table) => table.is_empty(), + TomlValue::String(_) + | TomlValue::Integer(_) + | TomlValue::Float(_) + | TomlValue::Boolean(_) + | TomlValue::Datetime(_) + | TomlValue::Array(_) => false, + } +} + +fn invalid_data_error(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn migration_metric_tags( + item_type: ExternalAgentConfigMigrationItemType, + skills_count: Option, +) -> Vec<(&'static str, String)> { + let migration_type = match item_type { + ExternalAgentConfigMigrationItemType::Config => "config", + ExternalAgentConfigMigrationItemType::Skills => "skills", + ExternalAgentConfigMigrationItemType::AgentsMd => "agents_md", + ExternalAgentConfigMigrationItemType::Plugins => "plugins", + ExternalAgentConfigMigrationItemType::McpServerConfig => "mcp_server_config", + ExternalAgentConfigMigrationItemType::Subagents => "subagents", + ExternalAgentConfigMigrationItemType::Hooks => "hooks", + ExternalAgentConfigMigrationItemType::Commands => "commands", + ExternalAgentConfigMigrationItemType::Sessions => "sessions", + }; + let mut tags = vec![("migration_type", migration_type.to_string())]; + if matches!( + item_type, + ExternalAgentConfigMigrationItemType::Skills + | ExternalAgentConfigMigrationItemType::Subagents + | ExternalAgentConfigMigrationItemType::Commands + ) { + tags.push(("skills_count", skills_count.unwrap_or(0).to_string())); + } + tags +} + +fn emit_migration_metric( + metric_name: &str, + item_type: ExternalAgentConfigMigrationItemType, + skills_count: Option, +) { + let Some(metrics) = codex_otel::global() else { + return; + }; + let tags = migration_metric_tags(item_type, skills_count); + let tag_refs = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); +} + +#[cfg(test)] +#[path = "external_agent_config_tests.rs"] +mod tests; diff --git a/code-rs/app-server/src/config/external_agent_config_tests.rs b/code-rs/app-server/src/config/external_agent_config_tests.rs new file mode 100644 index 00000000000..8435f9baf53 --- /dev/null +++ b/code-rs/app-server/src/config/external_agent_config_tests.rs @@ -0,0 +1,2452 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io; +use tempfile::TempDir; + +const EXTERNAL_AGENT_PROJECT_CONFIG_FILE: &str = ".claude.json"; +const EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR: &str = ".claude-plugin"; +const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude"; +const SOURCE_EXTERNAL_AGENT_DISPLAY_NAME: &str = "Claude"; +const SOURCE_EXTERNAL_AGENT_PRODUCT_NAME: &str = "Claude Code"; +const SOURCE_EXTERNAL_AGENT_UPPER_NAME: &str = "CLAUDE"; +const SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME: &str = "CLAUDE-CODE"; + +fn fixture_paths() -> (TempDir, PathBuf, PathBuf) { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + (root, external_agent_home, codex_home) +} + +fn service_for_paths( + external_agent_home: PathBuf, + codex_home: PathBuf, +) -> ExternalAgentConfigService { + ExternalAgentConfigService::new_for_test(codex_home, external_agent_home) +} + +fn github_plugin_details() -> MigrationDetails { + MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["formatter".to_string()], + }], + ..Default::default() + } +} + +#[tokio::test] +async fn detect_home_lists_config_skills_and_agents_md() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills"); + fs::write( + external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD), + format!("{SOURCE_EXTERNAL_AGENT_NAME} rules"), + ) + .expect("write external agent md"); + fs::write( + external_agent_home.join("settings.json"), + format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","env":{{"FOO":"bar"}}}}"#), + ) + .expect("write settings"); + + let items = service_for_paths(external_agent_home.clone(), codex_home.clone()) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + let expected = vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: format!( + "Migrate {} into {}", + external_agent_home.join("settings.json").display(), + codex_home.join("config.toml").display() + ), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: format!( + "Migrate skills from {} to {}", + external_agent_home.join("skills").display(), + agents_skills.display() + ), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Migrate {} to {}", + external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD).display(), + codex_home.join("AGENTS.md").display() + ), + cwd: None, + details: None, + }, + ]; + + assert_eq!(items, expected); +} + +#[tokio::test] +async fn detect_home_lists_recent_sessions() { + let (root, external_agent_home, codex_home) = fixture_paths(); + let project_root = root.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_path = external_agent_home + .join("projects") + .join("repo") + .join("session.jsonl"); + fs::create_dir_all(&project_root).expect("create project root"); + fs::create_dir_all(session_path.parent().expect("session parent")).expect("create sessions"); + fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + ) + .expect("write session"); + + let items = service_for_paths(external_agent_home.clone(), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Sessions, + description: format!( + "Migrate recent sessions from {}", + external_agent_home.join("projects").display() + ), + cwd: None, + details: Some(MigrationDetails { + plugins: Vec::new(), + sessions: vec![ExternalAgentSessionMigration { + path: session_path, + cwd: project_root, + title: Some("first request".to_string()), + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn detect_repo_lists_agents_md_for_each_cwd() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + let nested = repo_root.join("nested").join("child"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(&nested).expect("create nested"); + fs::write( + repo_root.join(EXTERNAL_AGENT_CONFIG_MD), + format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), + ) + .expect("write source"); + + let items = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![nested, repo_root.clone()]), + }) + .await + .expect("detect"); + + let expected = vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Migrate {} to {}", + repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root.clone()), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Migrate {} to {}", + repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root), + details: None, + }, + ]; + + assert_eq!(items, expected); +} + +#[tokio::test] +async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + let codex_home = root.path().join(".codex"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("skills") + .join("skill-a"), + ) + .expect("create repo skills"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write(codex_home.join("config.toml"), "this is not valid = [toml") + .expect("write invalid codex config"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{"env":{"FOO":"bar"}}"#, + ) + .expect("write settings"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("skills") + .join("skill-a") + .join("SKILL.md"), + format!( + "Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities." + ), + ) + .expect("write skill"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join(EXTERNAL_AGENT_CONFIG_MD), + format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), + ) + .expect("write agents"); + + let items = service_for_paths(root.path().join(EXTERNAL_AGENT_DIR), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: format!( + "Migrate {} into {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("settings.json") + .display(), + repo_root.join(".codex").join("config.toml").display() + ), + cwd: Some(repo_root.clone()), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: format!( + "Migrate skills from {} to {}", + repo_root.join(EXTERNAL_AGENT_DIR).join("skills").display(), + repo_root.join(".agents").join("skills").display() + ), + cwd: Some(repo_root.clone()), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Migrate {} to {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join(EXTERNAL_AGENT_CONFIG_MD) + .display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root), + details: None, + }, + ] + ); +} + +#[tokio::test] +async fn detect_repo_lists_mcp_hooks_commands_and_subagents() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("commands") + .join("pr"), + ) + .expect("create commands"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents"); + fs::write( + repo_root.join(".mcp.json"), + r#"{"mcpServers":{"docs":{"command":"docs-server"}}}"#, + ) + .expect("write mcp"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"http","url":"https://example.invalid/hook"}]}]}}"#, + ) + .expect("write hooks"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("commands") + .join("pr") + .join("review.md"), + "---\ndescription: Review PR\n---\nReview the pull request carefully.\n", + ) + .expect("write command"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("agents") + .join("researcher.md"), + "---\nname: researcher\ndescription: Research role\n---\nResearch carefully.\n", + ) + .expect("write subagent"); + + let items = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: format!( + "Migrate MCP servers from {} into {}", + repo_root.display(), + repo_root.join(".codex").join("config.toml").display() + ), + cwd: Some(repo_root.clone()), + details: Some(MigrationDetails { + mcp_servers: vec![NamedMigration { + name: "docs".to_string(), + }], + ..Default::default() + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Hooks, + description: format!( + "Migrate hooks from {} to {}", + repo_root.join(EXTERNAL_AGENT_DIR).display(), + repo_root.join(".codex").join("hooks.json").display() + ), + cwd: Some(repo_root.clone()), + details: Some(MigrationDetails { + hooks: vec![NamedMigration { + name: "PreToolUse".to_string(), + }], + ..Default::default() + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Commands, + description: format!( + "Migrate commands from {} to {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("commands") + .display(), + repo_root.join(".agents").join("skills").display() + ), + cwd: Some(repo_root.clone()), + details: Some(MigrationDetails { + commands: vec![NamedMigration { + name: "source-command-pr-review".to_string(), + }], + ..Default::default() + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Subagents, + description: format!( + "Migrate subagents from {} to {}", + repo_root.join(EXTERNAL_AGENT_DIR).join("agents").display(), + repo_root.join(".codex").join("agents").display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + subagents: vec![NamedMigration { + name: "researcher".to_string(), + }], + ..Default::default() + }), + }, + ] + ); +} + +#[tokio::test] +async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"SubagentStart":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, + ) + .expect("write hooks"); + + let items = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root]), + }) + .await + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[tokio::test] +async fn import_repo_migrates_mcp_hooks_commands_and_subagents() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("commands") + .join("pr"), + ) + .expect("create commands"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "docs": { + "command": "docs-server", + "args": ["--stdio"], + "headers": {"X-Ignored": "unsupported for stdio"}, + "env": {"DOCS_TOKEN": "${DOCS_TOKEN}", "STATIC": "yes"} + }, + "api": { + "url": "https://example.com/mcp", + "args": ["ignored-for-http"], + "env": {"IGNORED": "unsupported for http"}, + "headers": { + "Authorization": "Bearer ${API_TOKEN}", + "X-Team": "${TEAM}" + } + } + } + }"#, + ) + .expect("write mcp"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"prompt","prompt":"skip"}]}],"Stop":[{"matcher":"ignored","hooks":[{"command":"echo done"}]}]}}"#, + ) + .expect("write hooks"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("commands") + .join("pr") + .join("review.md"), + "---\ndescription: Review PR\n---\nReview the pull request carefully.\n", + ) + .expect("write command"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("agents") + .join("researcher.md"), + format!("---\nname: researcher\ndescription: Research role\npermissionMode: acceptEdits\nskills: [deep-research]\ntools: Bash, Read\ndisallowedTools: WebFetch\neffort: high\n---\nResearch with {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} carefully.\n"), + ) + .expect("write subagent"); + + service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .import(vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Hooks, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Commands, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Subagents, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }, + ]) + .await + .expect("import"); + + let config: TomlValue = toml::from_str( + &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + ) + .expect("parse config"); + let expected_config: TomlValue = toml::from_str( + r#" +[mcp_servers.api] +url = "https://example.com/mcp" +bearer_token_env_var = "API_TOKEN" + +[mcp_servers.api.env_http_headers] +X-Team = "TEAM" + +[mcp_servers.docs] +command = "docs-server" +args = ["--stdio"] +env_vars = ["DOCS_TOKEN"] + +[mcp_servers.docs.env] +STATIC = "yes" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected_config); + let mcp_servers = config + .get("mcp_servers") + .cloned() + .ok_or_else(|| io::Error::other("missing mcp_servers")) + .expect("mcp servers"); + let _supported_mcp_config: std::collections::HashMap< + String, + codex_config::types::McpServerConfig, + > = mcp_servers + .try_into() + .expect("migrated MCP config should be supported"); + + let hooks: JsonValue = serde_json::from_str( + &fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"), + ) + .expect("parse hooks"); + let _supported_hooks: codex_config::HooksFile = + serde_json::from_value(hooks.clone()).expect("migrated hooks should be supported"); + assert_eq!( + hooks, + serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "echo external-agent", + "timeout": 3 + }] + }], + "Stop": [{ + "hooks": [{ + "type": "command", + "command": "echo done" + }] + }] + } + }) + ); + assert!( + !repo_root + .join(".codex") + .join("hooks.migration-notes.md") + .exists() + ); + + assert_eq!( + fs::read_to_string( + repo_root + .join(".agents") + .join("skills") + .join("source-command-pr-review") + .join("SKILL.md") + ) + .expect("read command skill"), + "---\nname: \"source-command-pr-review\"\ndescription: \"Review PR\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview the pull request carefully.\n" + ); + + let agent: TomlValue = toml::from_str( + &fs::read_to_string( + repo_root + .join(".codex") + .join("agents") + .join("researcher.toml"), + ) + .expect("read agent"), + ) + .expect("parse agent"); + let expected_agent: TomlValue = toml::from_str( + r#" +name = "researcher" +description = "Research role" +model_reasoning_effort = "high" +sandbox_mode = "workspace-write" +developer_instructions = """ +Research with Codex carefully.""" +"#, + ) + .expect("parse expected agent"); + assert_eq!(agent, expected_agent); +} + +#[tokio::test] +async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills"); + fs::write( + external_agent_home.join("settings.json"), + format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","permissions":{{"ask":["git push"]}},"env":{{"FOO":"bar","CI":false,"MAX_RETRIES":3,"MY_TEAM":"codex","IGNORED":null,"LIST":["a","b"],"MAP":{{"x":1}}}},"sandbox":{{"enabled":true,"network":{{"allowLocalBinding":true}}}}}}"#), + ) + .expect("write settings"); + fs::write( + external_agent_home + .join("skills") + .join("skill-a") + .join("SKILL.md"), + format!( + "Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities." + ), + ) + .expect("write skill"); + fs::write( + external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD), + format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), + ) + .expect("write agents"); + + service_for_paths(external_agent_home, codex_home.clone()) + .import(vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: String::new(), + cwd: None, + details: None, + }, + ]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), + "Codex guidance" + ); + + assert_eq!( + fs::read_to_string(codex_home.join("config.toml")).expect("read config"), + "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n" + ); + assert_eq!( + fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) + .expect("read copied skill"), + "Use Codex and Codex utilities." + ); +} + +#[tokio::test] +async fn import_home_config_uses_local_settings_over_project_settings() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{"env":{"FOO":"project","PROJECT_ONLY":"yes"},"sandbox":{"enabled":false}}"#, + ) + .expect("write project settings"); + fs::write( + external_agent_home.join("settings.local.json"), + r#"{"env":{"FOO":"local","LOCAL_ONLY":true},"sandbox":{"enabled":true}}"#, + ) + .expect("write local settings"); + + service_for_paths(external_agent_home, codex_home.clone()) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(codex_home.join("config.toml")).expect("read config"), + "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n" + ); +} + +#[tokio::test] +async fn import_home_config_ignores_invalid_local_settings() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{"env":{"FOO":"project"},"sandbox":{"enabled":false}}"#, + ) + .expect("write project settings"); + fs::write( + external_agent_home.join("settings.local.json"), + "{invalid json", + ) + .expect("write local settings"); + + service_for_paths(external_agent_home, codex_home.clone()) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(codex_home.join("config.toml")).expect("read config"), + "[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"project\"\n" + ); +} + +#[tokio::test] +async fn import_home_skips_empty_config_migration() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","sandbox":{{"enabled":false}}}}"#), + ) + .expect("write settings"); + + service_for_paths(external_agent_home, codex_home.clone()) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + details: None, + }]) + .await + .expect("import"); + + assert!(!codex_home.join("config.toml").exists()); +} + +#[tokio::test] +async fn import_local_plugins_returns_completed_status() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let marketplace_root = external_agent_home.join("my-marketplace"); + let plugin_root = marketplace_root.join("plugins").join("cloudflare"); + fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) + .expect("create marketplace manifest dir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + external_agent_home.join("settings.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "enabledPlugins": { + "cloudflare@my-plugins": true + }, + "extraKnownMarketplaces": { + "my-plugins": { + "source": "local", + "path": marketplace_root + } + } + })) + .expect("serialize settings"), + ) + .expect("write settings"); + fs::write( + marketplace_root + .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) + .join("marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [ + { + "name": "cloudflare", + "source": "./plugins/cloudflare" + } + ] + }"#, + ) + .expect("write marketplace manifest"); + fs::write( + plugin_root.join(".codex-plugin").join("plugin.json"), + r#"{"name":"cloudflare","version":"0.1.0"}"#, + ) + .expect("write plugin manifest"); + + let outcome = service_for_paths(external_agent_home, codex_home.clone()) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: String::new(), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "my-plugins".to_string(), + plugin_names: vec!["cloudflare".to_string()], + }], + ..Default::default() + }), + }]) + .await + .expect("import"); + + assert_eq!(outcome, Vec::::new()); + let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config"); + assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#)); + assert!(config.contains("enabled = true")); +} + +#[tokio::test] +async fn import_git_plugins_returns_pending_async_status() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "owner/debug-marketplace" + } + } + }"#, + ) + .expect("write settings"); + + let outcome = service_for_paths(external_agent_home, codex_home.clone()) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: String::new(), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["formatter".to_string()], + }], + ..Default::default() + }), + }]) + .await + .expect("import"); + + assert_eq!( + outcome, + vec![PendingPluginImport { + cwd: None, + details: MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["formatter".to_string()], + }], + ..Default::default() + }, + }] + ); + assert!(!codex_home.join("config.toml").exists()); +} + +#[tokio::test] +async fn detect_home_skips_config_when_target_already_has_supported_fields() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#, + ) + .expect("write settings"); + fs::write( + codex_home.join("config.toml"), + r#" + sandbox_mode = "workspace-write" + + [shell_environment_policy] + inherit = "core" + + [shell_environment_policy.set] + FOO = "bar" + "#, + ) + .expect("write config"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[tokio::test] +async fn detect_home_skips_skills_when_all_skill_directories_exist() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create source"); + fs::create_dir_all(agents_skills.join("skill-a")).expect("create target"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[tokio::test] +async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo-a"); + let repo_with_existing_target = root.path().join("repo-b"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git"); + fs::write( + repo_root.join(EXTERNAL_AGENT_CONFIG_MD), + format!( + "{SOURCE_EXTERNAL_AGENT_PRODUCT_NAME}\n{SOURCE_EXTERNAL_AGENT_NAME}\n{SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME}\nSee {EXTERNAL_AGENT_CONFIG_MD}\n" + ), + ) + .expect("write source"); + fs::write( + repo_with_existing_target.join(EXTERNAL_AGENT_CONFIG_MD), + "new source", + ) + .expect("write source"); + fs::write( + repo_with_existing_target.join("AGENTS.md"), + "keep existing target", + ) + .expect("write target"); + + service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .import(vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_with_existing_target.clone()), + details: None, + }, + ]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), + "Codex\nCodex\nCodex\nSee AGENTS.md\n" + ); + assert_eq!( + fs::read_to_string(repo_with_existing_target.join("AGENTS.md")) + .expect("read existing target"), + "keep existing target" + ); +} + +#[tokio::test] +async fn import_repo_agents_md_overwrites_empty_targets() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::write( + repo_root.join(EXTERNAL_AGENT_CONFIG_MD), + format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), + ) + .expect("write source"); + fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target"); + + service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), + "Codex guidance" + ); +} + +#[tokio::test] +async fn detect_repo_prefers_non_empty_external_agent_agents_source() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); + fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), " \n\t").expect("write empty root source"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join(EXTERNAL_AGENT_CONFIG_MD), + format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), + ) + .expect("write external agent source"); + + let items = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Migrate {} to {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join(EXTERNAL_AGENT_CONFIG_MD) + .display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root), + details: None, + }] + ); +} + +#[tokio::test] +async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); + fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{"hooks":{"Stop":[{"hooks":[{"command":"echo done"}]}]}}"#, + ) + .expect("write hooks"); + fs::write( + repo_root.join(".codex").join("config.toml"), + "[features]\ncodex_hooks = false\n", + ) + .expect("write config"); + + service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Hooks, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + "[features]\ncodex_hooks = false\n" + ); + let hooks: JsonValue = serde_json::from_str( + &fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"), + ) + .expect("parse hooks"); + assert_eq!( + hooks, + serde_json::json!({ + "hooks": { + "Stop": [{ + "hooks": [{ + "type": "command", + "command": "echo done" + }] + }] + } + }) + ); +} + +#[tokio::test] +async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{"disabledMcpjsonServers":["blocked"]}"#, + ) + .expect("write home settings"); + fs::write( + root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE), + serde_json::json!({ + "projects": { + repo_root.display().to_string(): { + "mcpServers": { + "allowed": {"command": "allowed-server"}, + "blocked": {"command": "blocked-server"} + } + } + } + }) + .to_string(), + ) + .expect("write external agent project config"); + + service_for_paths(external_agent_home, root.path().join(".codex")) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + let config: TomlValue = toml::from_str( + &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + ) + .expect("parse config"); + let expected: TomlValue = toml::from_str( + r#" +[mcp_servers.allowed] +command = "allowed-server" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected); +} + +#[tokio::test] +async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "project-disabled": {"command": "project-disabled-server"}, + "local-disabled": {"command": "local-disabled-server"}, + "local-enabled": {"command": "local-enabled-server"} + } + }"#, + ) + .expect("write mcp"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledMcpjsonServers": ["project-disabled", "local-disabled"], + "disabledMcpjsonServers": ["project-disabled"] + }"#, + ) + .expect("write project settings"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("settings.local.json"), + r#"{ + "enabledMcpjsonServers": ["local-enabled", "local-disabled"], + "disabledMcpjsonServers": ["local-disabled"] + }"#, + ) + .expect("write local settings"); + + service_for_paths(external_agent_home, root.path().join(".codex")) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + let config: TomlValue = toml::from_str( + &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + ) + .expect("parse config"); + let expected: TomlValue = toml::from_str( + r#" +[mcp_servers.local-enabled] +command = "local-enabled-server" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected); +} + +#[tokio::test] +async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missing() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write(external_agent_home.join("settings.json"), "{ invalid json") + .expect("write invalid home settings"); + fs::write( + root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE), + serde_json::json!({ + "projects": { + repo_root.display().to_string(): { + "mcpServers": { + "docs": {"command": "docs-server"} + } + } + } + }) + .to_string(), + ) + .expect("write external agent project config"); + + service_for_paths(external_agent_home, root.path().join(".codex")) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + let config: TomlValue = toml::from_str( + &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + ) + .expect("parse config"); + let expected: TomlValue = toml::from_str( + r#" +[mcp_servers.docs] +command = "docs-server" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected); +} + +#[tokio::test] +async fn import_repo_uses_non_empty_external_agent_agents_source() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); + fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), "").expect("write empty root source"); + fs::write( + repo_root + .join(EXTERNAL_AGENT_DIR) + .join(EXTERNAL_AGENT_CONFIG_MD), + format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"), + ) + .expect("write external agent source"); + + service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), + "Codex guidance" + ); +} + +#[test] +fn migration_metric_tags_for_skills_include_skills_count() { + assert_eq!( + migration_metric_tags(ExternalAgentConfigMigrationItemType::Skills, Some(3)), + vec![ + ("migration_type", "skills".to_string()), + ("skills_count", "3".to_string()), + ] + ); +} + +#[tokio::test] +async fn detect_home_lists_enabled_plugins_from_settings() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true, + "deployer@acme-tools": true, + "analyzer@security-plugins": false + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "acme-corp/external-agent-plugins" + } + } + }"#, + ) + .expect("write settings"); + + let items = service_for_paths(external_agent_home.clone(), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + external_agent_home.join("settings.json").display() + ), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["deployer".to_string(), "formatter".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn detect_home_plugins_uses_local_settings_over_project_settings() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true, + "legacy@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "acme-corp/external-agent-plugins" + } + } + }"#, + ) + .expect("write project settings"); + fs::write( + external_agent_home.join("settings.local.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": false, + "deployer@acme-tools": true + } + }"#, + ) + .expect("write local settings"); + + let items = service_for_paths(external_agent_home.clone(), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + external_agent_home.join("settings.json").display() + ), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["deployer".to_string(), "legacy".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn detect_repo_skips_plugins_that_are_already_configured_in_codex() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true, + "deployer@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "acme-corp/external-agent-plugins" + } + } + }"#, + ) + .expect("write repo settings"); + fs::write( + codex_home.join("config.toml"), + r#" +[plugins."formatter@acme-tools"] +enabled = true +"#, + ) + .expect("write codex config"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("settings.json") + .display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["deployer".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn detect_repo_skips_plugins_that_are_disabled_in_codex() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "acme-corp/external-agent-plugins" + } + } + }"#, + ) + .expect("write repo settings"); + fs::write( + codex_home.join("config.toml"), + r#" +[plugins."formatter@acme-tools"] +enabled = false +"#, + ) + .expect("write codex config"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root]), + }) + .await + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[tokio::test] +async fn detect_repo_skips_plugins_without_explicit_enabled_in_codex() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "acme-corp/external-agent-plugins" + } + } + }"#, + ) + .expect("write repo settings"); + fs::write( + codex_home.join("config.toml"), + r#" +[plugins."formatter@acme-tools"] +"#, + ) + .expect("write codex config"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root]), + }) + .await + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[tokio::test] +async fn import_plugins_requires_details() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + + let err = service_for_paths(external_agent_home, codex_home) + .import_plugins(/*cwd*/ None, /*details*/ None) + .await + .expect_err("expected missing details error"); + + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "plugins migration item is missing details"); +} + +#[tokio::test] +async fn detect_repo_does_not_skip_plugins_only_configured_in_project_codex() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); + fs::create_dir_all(repo_root.join(".codex")).expect("create repo codex dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "acme-corp/external-agent-plugins" + } + } + }"#, + ) + .expect("write repo settings"); + fs::write( + repo_root.join(".codex").join("config.toml"), + r#" +[plugins."formatter@acme-tools"] +enabled = true +"#, + ) + .expect("write project codex config"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("settings.json") + .display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["formatter".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn detect_home_skips_plugins_without_marketplace_source() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + } + }"#, + ) + .expect("write settings"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[tokio::test] +async fn detect_home_skips_plugins_with_invalid_marketplace_source() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "github" + } + } + }"#, + ) + .expect("write settings"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!(items, Vec::::new()); +} + +#[tokio::test] +async fn detect_repo_filters_plugins_against_installed_marketplace() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + let marketplace_root = codex_home.join(".tmp").join("marketplaces").join("debug"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); + fs::create_dir_all(marketplace_root.join(".agents").join("plugins")) + .expect("create marketplace manifest dir"); + fs::create_dir_all( + marketplace_root + .join("plugins") + .join("sample") + .join(".codex-plugin"), + ) + .expect("create sample plugin"); + fs::create_dir_all( + marketplace_root + .join("plugins") + .join("available") + .join(".codex-plugin"), + ) + .expect("create available plugin"); + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledPlugins": { + "sample@debug": true, + "available@debug": true, + "missing@debug": true + }, + "extraKnownMarketplaces": { + "debug": { + "source": "owner/debug-marketplace" + } + } + }"#, + ) + .expect("write repo settings"); + fs::write( + codex_home.join("config.toml"), + r#" +[marketplaces.debug] +source_type = "git" +source = "owner/debug-marketplace" +"#, + ) + .expect("write codex config"); + fs::write( + marketplace_root + .join(".agents") + .join("plugins") + .join("marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + }, + "policy": { + "installation": "NOT_AVAILABLE" + } + }, + { + "name": "available", + "source": { + "source": "local", + "path": "./plugins/available" + } + } + ] +}"#, + ) + .expect("write marketplace manifest"); + fs::write( + marketplace_root + .join("plugins") + .join("sample") + .join(".codex-plugin") + .join("plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write sample plugin manifest"); + fs::write( + marketplace_root + .join("plugins") + .join("available") + .join(".codex-plugin") + .join("plugin.json"), + r#"{"name":"available"}"#, + ) + .expect("write available plugin manifest"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("settings.json") + .display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "debug".to_string(), + plugin_names: vec!["available".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn import_plugins_requires_source_marketplace_details() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "github", + "repo": "acme-corp/external-agent-plugins" + } + } + }"#, + ) + .expect("write settings"); + + let outcome = service_for_paths(external_agent_home, codex_home) + .import_plugins( + /*cwd*/ None, + Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "other-tools".to_string(), + plugin_names: github_plugin_details().plugins[0].plugin_names.clone(), + }], + ..Default::default() + }), + ) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: Vec::new(), + succeeded_plugin_ids: Vec::new(), + failed_marketplaces: vec!["other-tools".to_string()], + failed_plugin_ids: vec!["formatter@other-tools".to_string()], + } + ); +} + +#[tokio::test] +async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "local", + "path": "./external_plugins/acme-tools" + } + } + }"#, + ) + .expect("write settings"); + + let outcome = service_for_paths(external_agent_home, codex_home) + .import_plugins(/*cwd*/ None, Some(github_plugin_details())) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: Vec::new(), + succeeded_plugin_ids: Vec::new(), + failed_marketplaces: vec!["acme-tools".to_string()], + failed_plugin_ids: vec!["formatter@acme-tools".to_string()], + } + ); +} + +#[tokio::test] +async fn import_plugins_supports_external_agent_plugin_marketplace_layout() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let marketplace_root = external_agent_home.join("my-marketplace"); + let plugin_root = marketplace_root.join("plugins").join("cloudflare"); + fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) + .expect("create marketplace manifest dir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + external_agent_home.join("settings.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "enabledPlugins": { + "cloudflare@my-plugins": true + }, + "extraKnownMarketplaces": { + "my-plugins": { + "source": "local", + "path": marketplace_root + } + } + })) + .expect("serialize settings"), + ) + .expect("write settings"); + fs::write( + marketplace_root + .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) + .join("marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [ + { + "name": "cloudflare", + "source": "./plugins/cloudflare" + } + ] + }"#, + ) + .expect("write marketplace manifest"); + fs::write( + plugin_root.join(".codex-plugin").join("plugin.json"), + r#"{"name":"cloudflare","version":"0.1.0"}"#, + ) + .expect("write plugin manifest"); + + let outcome = service_for_paths(external_agent_home, codex_home.clone()) + .import_plugins( + /*cwd*/ None, + Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "my-plugins".to_string(), + plugin_names: vec!["cloudflare".to_string()], + }], + ..Default::default() + }), + ) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: vec!["my-plugins".to_string()], + succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()], + failed_marketplaces: Vec::new(), + failed_plugin_ids: Vec::new(), + } + ); + let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config"); + assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#)); + assert!(config.contains("enabled = true")); +} + +#[tokio::test] +async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let marketplace_root = external_agent_home.join("my-marketplace"); + let plugin_root = marketplace_root.join("plugins").join("cloudflare"); + fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) + .expect("create marketplace manifest dir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "cloudflare@my-plugins": true + }, + "extraKnownMarketplaces": { + "my-plugins": { + "source": "directory", + "path": "./my-marketplace" + } + } + }"#, + ) + .expect("write settings"); + fs::write( + marketplace_root + .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) + .join("marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [ + { + "name": "cloudflare", + "source": "./plugins/cloudflare" + } + ] + }"#, + ) + .expect("write marketplace manifest"); + fs::write( + plugin_root.join(".codex-plugin").join("plugin.json"), + r#"{"name":"cloudflare","version":"0.1.0"}"#, + ) + .expect("write plugin manifest"); + + let items = service_for_paths(external_agent_home.clone(), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + external_agent_home.join("settings.json").display() + ), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "my-plugins".to_string(), + plugin_names: vec!["cloudflare".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn detect_home_infers_external_official_marketplace_when_missing_from_settings() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + external_agent_home.join("settings.json"), + format!( + r#"{{ + "enabledPlugins": {{ + "sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true + }} + }}"# + ), + ) + .expect("write settings"); + + let items = service_for_paths(external_agent_home.clone(), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + external_agent_home.join("settings.json").display() + ), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(), + plugin_names: vec!["sample".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn import_plugins_supports_relative_external_agent_plugin_marketplace_path() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let marketplace_root = external_agent_home.join("my-marketplace"); + let plugin_root = marketplace_root.join("plugins").join("cloudflare"); + fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) + .expect("create marketplace manifest dir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "cloudflare@my-plugins": true + }, + "extraKnownMarketplaces": { + "my-plugins": { + "source": "directory", + "path": "./my-marketplace" + } + } + }"#, + ) + .expect("write settings"); + fs::write( + marketplace_root + .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) + .join("marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [ + { + "name": "cloudflare", + "source": "./plugins/cloudflare" + } + ] + }"#, + ) + .expect("write marketplace manifest"); + fs::write( + plugin_root.join(".codex-plugin").join("plugin.json"), + r#"{"name":"cloudflare","version":"0.1.0"}"#, + ) + .expect("write plugin manifest"); + + let outcome = service_for_paths(external_agent_home, codex_home.clone()) + .import_plugins( + /*cwd*/ None, + Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "my-plugins".to_string(), + plugin_names: vec!["cloudflare".to_string()], + }], + ..Default::default() + }), + ) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: vec!["my-plugins".to_string()], + succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()], + failed_marketplaces: Vec::new(), + failed_plugin_ids: Vec::new(), + } + ); + let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config"); + assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#)); + assert!(config.contains("enabled = true")); +} + +#[tokio::test] +async fn import_plugins_infers_external_official_marketplace_when_missing_from_settings() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + fs::create_dir_all(&external_agent_home).expect("create external agent home"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + external_agent_home.join("settings.json"), + format!( + r#"{{ + "enabledPlugins": {{ + "sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true + }} + }}"# + ), + ) + .expect("write settings"); + + let outcome = service_for_paths(external_agent_home, codex_home) + .import_plugins( + /*cwd*/ None, + Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(), + plugin_names: vec!["sample".to_string()], + }], + ..Default::default() + }), + ) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: vec![EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string()], + succeeded_plugin_ids: Vec::new(), + failed_marketplaces: Vec::new(), + failed_plugin_ids: vec![format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}")], + } + ); +} + +#[tokio::test] +async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace_path() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + let marketplace_root = repo_root.join("my-marketplace"); + let plugin_root = marketplace_root.join("plugins").join("cloudflare"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); + fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) + .expect("create marketplace manifest dir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledPlugins": { + "cloudflare@my-plugins": true + }, + "extraKnownMarketplaces": { + "my-plugins": { + "source": "directory", + "path": "./my-marketplace" + } + } + }"#, + ) + .expect("write settings"); + fs::write( + marketplace_root + .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) + .join("marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [ + { + "name": "cloudflare", + "source": "./plugins/cloudflare" + } + ] + }"#, + ) + .expect("write marketplace manifest"); + fs::write( + plugin_root.join(".codex-plugin").join("plugin.json"), + r#"{"name":"cloudflare","version":"0.1.0"}"#, + ) + .expect("write plugin manifest"); + + let items = service_for_paths(external_agent_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + repo_root + .join(EXTERNAL_AGENT_DIR) + .join("settings.json") + .display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "my-plugins".to_string(), + plugin_names: vec!["cloudflare".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn import_plugins_supports_project_relative_external_agent_plugin_marketplace_path() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + let marketplace_root = repo_root.join("my-marketplace"); + let plugin_root = marketplace_root.join("plugins").join("cloudflare"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir"); + fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) + .expect("create marketplace manifest dir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), + r#"{ + "enabledPlugins": { + "cloudflare@my-plugins": true + }, + "extraKnownMarketplaces": { + "my-plugins": { + "source": "directory", + "path": "./my-marketplace" + } + } + }"#, + ) + .expect("write settings"); + fs::write( + marketplace_root + .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) + .join("marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [ + { + "name": "cloudflare", + "source": "./plugins/cloudflare" + } + ] + }"#, + ) + .expect("write marketplace manifest"); + fs::write( + plugin_root.join(".codex-plugin").join("plugin.json"), + r#"{"name":"cloudflare","version":"0.1.0"}"#, + ) + .expect("write plugin manifest"); + + let outcome = service_for_paths(external_agent_home, codex_home.clone()) + .import_plugins( + Some(repo_root.as_path()), + Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "my-plugins".to_string(), + plugin_names: vec!["cloudflare".to_string()], + }], + ..Default::default() + }), + ) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: vec!["my-plugins".to_string()], + succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()], + failed_marketplaces: Vec::new(), + failed_plugin_ids: Vec::new(), + } + ); + let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config"); + assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#)); + assert!(config.contains("enabled = true")); +} + +#[test] +fn import_skills_returns_only_new_skill_directory_count() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let agents_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(external_agent_home.join("skills").join("skill-a")) + .expect("create source a"); + fs::create_dir_all(external_agent_home.join("skills").join("skill-b")) + .expect("create source b"); + fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target"); + + let copied_count = service_for_paths(external_agent_home, codex_home) + .import_skills(/*cwd*/ None) + .expect("import skills"); + + assert_eq!(copied_count, 1); +} diff --git a/code-rs/app-server/src/config/mod.rs b/code-rs/app-server/src/config/mod.rs new file mode 100644 index 00000000000..95d64d152ae --- /dev/null +++ b/code-rs/app-server/src/config/mod.rs @@ -0,0 +1 @@ +pub(crate) mod external_agent_config; diff --git a/code-rs/app-server/src/config_manager.rs b/code-rs/app-server/src/config_manager.rs new file mode 100644 index 00000000000..030829fa4b4 --- /dev/null +++ b/code-rs/app-server/src/config_manager.rs @@ -0,0 +1,339 @@ +use codex_arg0::Arg0DispatchPaths; +use codex_cloud_requirements::cloud_requirements_loader; +use codex_config::CloudRequirementsLoader; +use codex_config::ConfigLayerStack; +use codex_config::LoaderOverrides; +use codex_config::ThreadConfigLoader; +use codex_config::loader::load_config_layers_state; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_exec_server::LOCAL_FS; +use codex_features::feature_for_key; +use codex_login::AuthManager; +use codex_login::default_client::set_default_client_residency_requirement; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_json_to_toml::json_to_toml; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::RwLock; +use toml::Value as TomlValue; +use tracing::warn; + +/// Shared app-server entry point for loading effective Codex configuration. +#[derive(Clone)] +pub(crate) struct ConfigManager { + codex_home: PathBuf, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, + loader_overrides: LoaderOverrides, + cloud_requirements: Arc>, + arg0_paths: Arg0DispatchPaths, + thread_config_loader: Arc>>, +} + +impl ConfigManager { + pub(crate) fn new( + codex_home: PathBuf, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + arg0_paths: Arg0DispatchPaths, + thread_config_loader: Arc, + ) -> Self { + Self { + codex_home, + cli_overrides: Arc::new(RwLock::new(cli_overrides)), + runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), + loader_overrides, + cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), + arg0_paths, + thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), + } + } + + pub(crate) fn codex_home(&self) -> &Path { + self.codex_home.as_path() + } + + pub(crate) fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader { + self.cloud_requirements + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + pub(crate) fn extend_runtime_feature_enablement(&self, enablement: I) -> Result<(), ()> + where + I: IntoIterator, + { + let mut runtime_feature_enablement = + self.runtime_feature_enablement.write().map_err(|_| ())?; + runtime_feature_enablement.extend(enablement); + Ok(()) + } + + pub(crate) fn replace_cloud_requirements_loader( + &self, + auth_manager: Arc, + chatgpt_base_url: String, + ) { + let loader = + cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); + if let Ok(mut guard) = self.cloud_requirements.write() { + *guard = loader; + } else { + warn!("failed to update cloud requirements loader"); + } + } + + pub(crate) fn replace_thread_config_loader( + &self, + thread_config_loader: Arc, + ) { + if let Ok(mut guard) = self.thread_config_loader.write() { + *guard = thread_config_loader; + } else { + warn!("failed to update thread config loader"); + } + } + + fn current_thread_config_loader(&self) -> Arc { + self.thread_config_loader + .read() + .map(|guard| Arc::clone(&*guard)) + .unwrap_or_else(|_| Arc::new(codex_config::NoopThreadConfigLoader)) + } + + pub(crate) async fn sync_default_client_residency_requirement(&self) { + match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => { + set_default_client_residency_requirement(config.enforce_residency.value()); + } + Err(err) => warn!( + error = %err, + "failed to sync default client residency requirement after auth refresh" + ), + } + } + + pub(crate) async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> std::io::Result { + self.load_with_cli_overrides( + &self.current_cli_overrides(), + /*request_overrides*/ None, + ConfigOverrides::default(), + fallback_cwd, + ) + .await + } + + pub(crate) async fn load_latest_config_for_thread( + &self, + thread_config: &Config, + ) -> std::io::Result { + let refreshed_config = self + .load_latest_config(Some(thread_config.cwd.to_path_buf())) + .await?; + let mut config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) + } + + pub(crate) async fn load_default_config(&self) -> std::io::Result { + let mut config = Config::load_default_with_cli_overrides_for_codex_home( + self.codex_home.clone(), + self.current_cli_overrides(), + ) + .await?; + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) + } + + pub(crate) async fn load_with_overrides( + &self, + request_overrides: Option>, + typesafe_overrides: ConfigOverrides, + ) -> std::io::Result { + self.load_with_cli_overrides( + &self.current_cli_overrides(), + request_overrides, + typesafe_overrides, + /*fallback_cwd*/ None, + ) + .await + } + + pub(crate) async fn load_for_cwd( + &self, + request_overrides: Option>, + typesafe_overrides: ConfigOverrides, + cwd: Option, + ) -> std::io::Result { + self.load_with_cli_overrides( + &self.current_cli_overrides(), + request_overrides, + typesafe_overrides, + cwd, + ) + .await + } + + pub(crate) async fn load_with_cli_overrides( + &self, + cli_overrides: &[(String, TomlValue)], + request_overrides: Option>, + typesafe_overrides: ConfigOverrides, + fallback_cwd: Option, + ) -> std::io::Result { + let merged_cli_overrides = cli_overrides + .iter() + .cloned() + .chain( + request_overrides + .unwrap_or_default() + .into_iter() + .map(|(key, value)| (key, json_to_toml(value))), + ) + .collect::>(); + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(merged_cli_overrides) + .loader_overrides(self.loader_overrides.clone()) + .harness_overrides(typesafe_overrides) + .fallback_cwd(fallback_cwd) + .cloud_requirements(self.current_cloud_requirements()) + .thread_config_loader(self.current_thread_config_loader()) + .build() + .await?; + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) + } + + pub(crate) async fn load_config_layers_for_cwd( + &self, + cwd: AbsolutePathBuf, + ) -> std::io::Result { + self.load_config_layers(Some(cwd)).await + } + + pub(crate) async fn load_config_layers( + &self, + cwd: Option, + ) -> std::io::Result { + let thread_config_loader = self.current_thread_config_loader(); + load_config_layers_state( + LOCAL_FS.as_ref(), + &self.codex_home, + cwd, + &self.current_cli_overrides(), + self.loader_overrides.clone(), + self.current_cloud_requirements(), + thread_config_loader.as_ref(), + ) + .await + } + + fn apply_runtime_feature_enablement(&self, config: &mut Config) { + apply_runtime_feature_enablement(config, &self.current_runtime_feature_enablement()); + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn apply_arg0_paths(&self, config: &mut Config) { + config.codex_self_exe = self.arg0_paths.codex_self_exe.clone(); + config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); + config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); + } + + #[cfg(test)] + pub(crate) fn new_for_tests( + codex_home: PathBuf, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + ) -> Self { + Self::new( + codex_home, + cli_overrides, + loader_overrides, + cloud_requirements, + Arg0DispatchPaths::default(), + Arc::new(codex_config::NoopThreadConfigLoader), + ) + } + + #[cfg(test)] + pub(crate) fn without_managed_config_for_tests(codex_home: PathBuf) -> Self { + Self::new_for_tests( + codex_home, + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + ) + } +} + +pub(crate) fn protected_feature_keys(config_layer_stack: &ConfigLayerStack) -> BTreeSet { + let mut protected_features = config_layer_stack + .effective_config() + .get("features") + .and_then(toml::Value::as_table) + .map(|features| features.keys().cloned().collect::>()) + .unwrap_or_default(); + + if let Some(feature_requirements) = config_layer_stack + .requirements_toml() + .feature_requirements + .as_ref() + { + protected_features.extend(feature_requirements.entries.keys().cloned()); + } + + protected_features +} + +pub(crate) fn apply_runtime_feature_enablement( + config: &mut Config, + runtime_feature_enablement: &BTreeMap, +) { + let protected_features = protected_feature_keys(&config.config_layer_stack); + for (name, enabled) in runtime_feature_enablement { + if protected_features.contains(name) { + continue; + } + let Some(feature) = feature_for_key(name) else { + continue; + }; + if let Err(err) = config.features.set_enabled(feature, *enabled) { + warn!( + feature = name, + error = %err, + "failed to apply runtime feature enablement" + ); + } + } +} diff --git a/code-rs/app-server/src/config_manager_service.rs b/code-rs/app-server/src/config_manager_service.rs new file mode 100644 index 00000000000..aef4393a093 --- /dev/null +++ b/code-rs/app-server/src/config_manager_service.rs @@ -0,0 +1,651 @@ +use crate::config_manager::ConfigManager; +use codex_app_server_protocol::Config as ApiConfig; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigLayerMetadata; +use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteErrorCode; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::OverriddenMetadata; +use codex_app_server_protocol::WriteStatus; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::ConfigRequirementsToml; +use codex_config::config_toml::ConfigToml; +use codex_config::merge_toml_values; +use codex_core::config::deserialize_config_toml_with_base; +use codex_core::config::edit::ConfigEdit; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::validate_feature_requirements_for_config_toml; +use codex_core::path_utils; +use codex_core::path_utils::SymlinkWritePaths; +use codex_core::path_utils::resolve_symlink_write_paths; +use codex_core::path_utils::write_atomically; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde_json::Value as JsonValue; +use std::borrow::Cow; +use std::path::Path; +use std::path::PathBuf; +use thiserror::Error; +use tokio::task; +use toml::Value as TomlValue; +use toml_edit::Item as TomlItem; + +#[derive(Debug, Error)] +pub(crate) enum ConfigManagerError { + #[error("{message}")] + Write { + code: ConfigWriteErrorCode, + message: String, + }, + + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: std::io::Error, + }, + + #[error("{context}: {source}")] + Json { + context: &'static str, + #[source] + source: serde_json::Error, + }, + + #[error("{context}: {source}")] + Toml { + context: &'static str, + #[source] + source: toml::de::Error, + }, + + #[error("{context}: {source}")] + Anyhow { + context: &'static str, + #[source] + source: anyhow::Error, + }, +} + +impl ConfigManagerError { + fn write(code: ConfigWriteErrorCode, message: impl Into) -> Self { + Self::Write { + code, + message: message.into(), + } + } + + fn io(context: &'static str, source: std::io::Error) -> Self { + Self::Io { context, source } + } + + fn json(context: &'static str, source: serde_json::Error) -> Self { + Self::Json { context, source } + } + + fn toml(context: &'static str, source: toml::de::Error) -> Self { + Self::Toml { context, source } + } + + fn anyhow(context: &'static str, source: anyhow::Error) -> Self { + Self::Anyhow { context, source } + } + + pub(crate) fn write_error_code(&self) -> Option { + match self { + Self::Write { code, .. } => Some(code.clone()), + _ => None, + } + } +} + +impl ConfigManager { + pub(crate) async fn read( + &self, + params: ConfigReadParams, + ) -> Result { + let layers = match params.cwd.as_deref() { + Some(cwd) => { + let cwd = AbsolutePathBuf::try_from(PathBuf::from(cwd)).map_err(|err| { + ConfigManagerError::io("failed to resolve config cwd to an absolute path", err) + })?; + self.load_config_layers(Some(cwd)).await.map_err(|err| { + ConfigManagerError::io("failed to read configuration layers", err) + })? + } + None => self.load_thread_agnostic_config().await.map_err(|err| { + ConfigManagerError::io("failed to read configuration layers", err) + })?, + }; + + let effective = layers.effective_config(); + + let effective_config_toml: ConfigToml = effective + .try_into() + .map_err(|err| ConfigManagerError::toml("invalid configuration", err))?; + + let json_value = serde_json::to_value(&effective_config_toml) + .map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?; + let config: ApiConfig = serde_json::from_value(json_value) + .map_err(|err| ConfigManagerError::json("failed to deserialize configuration", err))?; + + Ok(ConfigReadResponse { + config, + origins: layers.origins(), + layers: params.include_layers.then(|| { + layers + .get_layers( + ConfigLayerStackOrdering::HighestPrecedenceFirst, + /*include_disabled*/ true, + ) + .iter() + .map(|layer| layer.as_layer()) + .collect() + }), + }) + } + + pub(crate) async fn read_requirements( + &self, + ) -> Result, ConfigManagerError> { + let layers = self + .load_thread_agnostic_config() + .await + .map_err(|err| ConfigManagerError::io("failed to read configuration layers", err))?; + + let requirements = layers.requirements_toml().clone(); + if requirements.is_empty() { + Ok(None) + } else { + Ok(Some(requirements)) + } + } + + pub(crate) async fn write_value( + &self, + params: ConfigValueWriteParams, + ) -> Result { + let edits = vec![(params.key_path, params.value, params.merge_strategy)]; + self.apply_edits(params.file_path, params.expected_version, edits) + .await + } + + pub(crate) async fn batch_write( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + let edits = params + .edits + .into_iter() + .map(|edit| (edit.key_path, edit.value, edit.merge_strategy)) + .collect(); + + self.apply_edits(params.file_path, params.expected_version, edits) + .await + } + + async fn apply_edits( + &self, + file_path: Option, + expected_version: Option, + edits: Vec<(String, JsonValue, MergeStrategy)>, + ) -> Result { + let allowed_path = + AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home()); + let provided_path = match file_path { + Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path)) + .map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?, + None => allowed_path.clone(), + }; + + if !paths_match(&allowed_path, &provided_path) { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigLayerReadonly, + "Only writes to the user config are allowed", + )); + } + + let layers = self + .load_thread_agnostic_config() + .await + .map_err(|err| ConfigManagerError::io("failed to load configuration", err))?; + let user_layer = match layers.get_user_layer() { + Some(layer) => Cow::Borrowed(layer), + None => Cow::Owned(create_empty_user_layer(&allowed_path).await?), + }; + + if let Some(expected) = expected_version.as_deref() + && expected != user_layer.version + { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigVersionConflict, + "Configuration was modified since last read. Fetch latest version and retry.", + )); + } + + let mut user_config = user_layer.config.clone(); + let mut parsed_segments = Vec::new(); + let mut config_edits = Vec::new(); + + for (key_path, value, strategy) in edits.into_iter() { + let segments = parse_key_path(&key_path).map_err(|message| { + ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) + })?; + let original_value = value_at_path(&user_config, &segments).cloned(); + let parsed_value = parse_value(value).map_err(|message| { + ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) + })?; + + apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err( + |err| match err { + MergeError::Validation(message) => ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + message, + ), + }, + )?; + + let updated_value = value_at_path(&user_config, &segments).cloned(); + if original_value != updated_value { + let edit = match updated_value { + Some(value) => ConfigEdit::SetPath { + segments: segments.clone(), + value: toml_value_to_item(&value).map_err(|err| { + ConfigManagerError::anyhow("failed to build config edits", err) + })?, + }, + None => ConfigEdit::ClearPath { + segments: segments.clone(), + }, + }; + config_edits.push(edit); + } + + parsed_segments.push(segments); + } + + validate_config(&user_config).map_err(|err| { + ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + let user_config_toml = + deserialize_config_toml_with_base(user_config.clone(), self.codex_home()).map_err( + |err| { + ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + }, + )?; + validate_feature_requirements_for_config_toml( + &user_config_toml, + layers.requirements().feature_requirements.as_ref(), + ) + .map_err(|err| { + ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + let updated_layers = layers.with_user_config(&provided_path, user_config.clone()); + let effective = updated_layers.effective_config(); + validate_config(&effective).map_err(|err| { + ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + + if !config_edits.is_empty() { + ConfigEditsBuilder::new(self.codex_home()) + .with_edits(config_edits) + .apply() + .await + .map_err(|err| ConfigManagerError::anyhow("failed to persist config.toml", err))?; + } + + let overridden = first_overridden_edit(&updated_layers, &effective, &parsed_segments); + let status = overridden + .as_ref() + .map(|_| WriteStatus::OkOverridden) + .unwrap_or(WriteStatus::Ok); + + Ok(ConfigWriteResponse { + status, + version: updated_layers + .get_user_layer() + .ok_or_else(|| { + ConfigManagerError::write( + ConfigWriteErrorCode::UserLayerNotFound, + "user layer not found in updated layers", + ) + })? + .version + .clone(), + file_path: provided_path, + overridden_metadata: overridden, + }) + } + + /// Loads a "thread-agnostic" config, which means the config layers do not + /// include any in-repo .codex/ folders because there is no cwd/project root + /// associated with this query. + async fn load_thread_agnostic_config(&self) -> std::io::Result { + self.load_config_layers(/*cwd*/ None).await + } +} + +async fn create_empty_user_layer( + config_toml: &AbsolutePathBuf, +) -> Result { + let SymlinkWritePaths { + read_path, + write_path, + } = resolve_symlink_write_paths(config_toml.as_path()) + .map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?; + let toml_value = match read_path { + Some(path) => match tokio::fs::read_to_string(&path).await { + Ok(contents) => toml::from_str(&contents).map_err(|e| { + ConfigManagerError::toml("failed to parse existing user config.toml", e) + })?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + write_empty_user_config(write_path.clone()).await?; + TomlValue::Table(toml::map::Map::new()) + } + Err(err) => { + return Err(ConfigManagerError::io( + "failed to read user config.toml", + err, + )); + } + }, + None => { + write_empty_user_config(write_path).await?; + TomlValue::Table(toml::map::Map::new()) + } + }; + Ok(ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_toml.clone(), + }, + toml_value, + )) +} + +async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigManagerError> { + task::spawn_blocking(move || write_atomically(&write_path, "")) + .await + .map_err(|err| ConfigManagerError::anyhow("config persistence task panicked", err.into()))? + .map_err(|err| ConfigManagerError::io("failed to create empty user config.toml", err)) +} + +fn parse_value(value: JsonValue) -> Result, String> { + if value.is_null() { + return Ok(None); + } + + serde_json::from_value::(value) + .map(Some) + .map_err(|err| format!("invalid value: {err}")) +} + +fn parse_key_path(path: &str) -> Result, String> { + if path.trim().is_empty() { + return Err("keyPath must not be empty".to_string()); + } + Ok(path + .split('.') + .map(std::string::ToString::to_string) + .collect()) +} + +#[derive(Debug)] +enum MergeError { + Validation(String), +} + +fn apply_merge( + root: &mut TomlValue, + segments: &[String], + value: Option<&TomlValue>, + strategy: MergeStrategy, +) -> Result { + let Some(value) = value else { + return clear_path(root, segments); + }; + + let Some((last, parents)) = segments.split_last() else { + return Err(MergeError::Validation( + "keyPath must not be empty".to_string(), + )); + }; + + let mut current = root; + + for segment in parents { + match current { + TomlValue::Table(table) => { + current = table + .entry(segment.clone()) + .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); + } + _ => { + *current = TomlValue::Table(toml::map::Map::new()); + if let TomlValue::Table(table) = current { + current = table + .entry(segment.clone()) + .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); + } + } + } + } + + let table = current.as_table_mut().ok_or_else(|| { + MergeError::Validation("cannot set value on non-table parent".to_string()) + })?; + + if matches!(strategy, MergeStrategy::Upsert) + && let Some(existing) = table.get_mut(last) + && matches!(existing, TomlValue::Table(_)) + && matches!(value, TomlValue::Table(_)) + { + merge_toml_values(existing, value); + return Ok(true); + } + + let changed = table + .get(last) + .map(|existing| Some(existing) != Some(value)) + .unwrap_or(true); + table.insert(last.clone(), value.clone()); + Ok(changed) +} + +fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result { + let Some((last, parents)) = segments.split_last() else { + return Err(MergeError::Validation( + "keyPath must not be empty".to_string(), + )); + }; + + let mut current = root; + for segment in parents { + match current { + TomlValue::Table(table) => { + let Some(next) = table.get_mut(segment) else { + return Ok(false); + }; + current = next; + } + _ => return Ok(false), + } + } + + let Some(parent) = current.as_table_mut() else { + return Ok(false); + }; + + Ok(parent.remove(last).is_some()) +} + +fn toml_value_to_item(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::Table(table) => { + let mut table_item = toml_edit::Table::new(); + table_item.set_implicit(false); + for (key, val) in table { + table_item.insert(key, toml_value_to_item(val)?); + } + Ok(TomlItem::Table(table_item)) + } + other => Ok(TomlItem::Value(toml_value_to_value(other)?)), + } +} + +fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())), + TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(toml_value_to_value(item)?); + } + Ok(toml_edit::Value::Array(array)) + } + TomlValue::Table(table) => { + let mut inline = toml_edit::InlineTable::new(); + for (key, val) in table { + inline.insert(key, toml_value_to_value(val)?); + } + Ok(toml_edit::Value::InlineTable(inline)) + } + } +} + +fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { + let _: ConfigToml = value.clone().try_into()?; + Ok(()) +} + +fn paths_match(expected: impl AsRef, provided: impl AsRef) -> bool { + path_utils::paths_match_after_normalization(expected, provided) +} + +fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { + let mut current = root; + for segment in segments { + match current { + TomlValue::Table(table) => { + current = table.get(segment)?; + } + TomlValue::Array(items) => { + let idx = segment.parse::().ok()?; + let idx = usize::try_from(idx).ok()?; + current = items.get(idx)?; + } + _ => return None, + } + } + Some(current) +} + +fn override_message(layer: &ConfigLayerSource) -> String { + match layer { + ConfigLayerSource::Mdm { domain, key: _ } => { + format!("Overridden by managed policy (MDM): {domain}") + } + ConfigLayerSource::System { file } => { + format!("Overridden by managed config (system): {}", file.display()) + } + ConfigLayerSource::Project { dot_codex_folder } => format!( + "Overridden by project config: {}/{CONFIG_TOML_FILE}", + dot_codex_folder.display(), + ), + ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(), + ConfigLayerSource::User { file } => { + format!("Overridden by user config: {}", file.display()) + } + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => { + format!( + "Overridden by legacy managed_config.toml: {}", + file.display() + ) + } + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { + "Overridden by legacy managed configuration from MDM".to_string() + } + } +} + +fn compute_override_metadata( + layers: &ConfigLayerStack, + effective: &TomlValue, + segments: &[String], +) -> Option { + let user_value = match layers.get_user_layer() { + Some(user_layer) => value_at_path(&user_layer.config, segments), + None => return None, + }; + let effective_value = value_at_path(effective, segments); + + if user_value.is_some() && user_value == effective_value { + return None; + } + + if user_value.is_none() && effective_value.is_none() { + return None; + } + + let overriding_layer = find_effective_layer(layers, segments)?; + let message = override_message(&overriding_layer.name); + + Some(OverriddenMetadata { + message, + overriding_layer, + effective_value: effective_value + .and_then(|value| serde_json::to_value(value).ok()) + .unwrap_or(JsonValue::Null), + }) +} + +fn first_overridden_edit( + layers: &ConfigLayerStack, + effective: &TomlValue, + edits: &[Vec], +) -> Option { + for segments in edits { + if let Some(meta) = compute_override_metadata(layers, effective, segments) { + return Some(meta); + } + } + None +} + +fn find_effective_layer( + layers: &ConfigLayerStack, + segments: &[String], +) -> Option { + for layer in layers.layers_high_to_low() { + if let Some(meta) = value_at_path(&layer.config, segments).map(|_| layer.metadata()) { + return Some(meta); + } + } + + None +} + +#[cfg(test)] +#[path = "config_manager_service_tests.rs"] +mod tests; diff --git a/code-rs/app-server/src/config_manager_service_tests.rs b/code-rs/app-server/src/config_manager_service_tests.rs new file mode 100644 index 00000000000..e9b0b3c769f --- /dev/null +++ b/code-rs/app-server/src/config_manager_service_tests.rs @@ -0,0 +1,793 @@ +use super::*; +use anyhow::Result; +use codex_app_server_protocol::AppConfig; +use codex_app_server_protocol::AppToolApproval; +use codex_app_server_protocol::AppsConfig; +use codex_app_server_protocol::AskForApproval; +use codex_config::CloudRequirementsLoader; +use codex_config::FeatureRequirementsToml; +use codex_config::LoaderOverrides; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::tempdir; + +#[test] +fn toml_value_to_item_handles_nested_config_tables() { + let config = r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.http_headers] +X-Doc = "42" +"#; + + let value: TomlValue = toml::from_str(config).expect("parse config example"); + let item = toml_value_to_item(&value).expect("convert to toml_edit item"); + + let root = item.as_table().expect("root table"); + assert!(!root.is_implicit(), "root table should be explicit"); + + let mcp_servers = root + .get("mcp_servers") + .and_then(TomlItem::as_table) + .expect("mcp_servers table"); + assert!( + !mcp_servers.is_implicit(), + "mcp_servers table should be explicit" + ); + + let docs = mcp_servers + .get("docs") + .and_then(TomlItem::as_table) + .expect("docs table"); + assert_eq!( + docs.get("command") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("docs-server") + ); + + let http_headers = docs + .get("http_headers") + .and_then(TomlItem::as_table) + .expect("http_headers table"); + assert_eq!( + http_headers + .get("X-Doc") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("42") + ); +} + +#[tokio::test] +async fn write_value_preserves_comments_and_order() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let original = r#"# Codex user configuration +model = "gpt-5.2" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +"#; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "features.personality".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let updated = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"# Codex user configuration +model = "gpt-5.2" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +personality = true +"#; + assert_eq!(updated, expected); + Ok(()) +} + +#[tokio::test] +async fn clear_missing_nested_config_is_noop() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let response = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "features.personality".to_string(), + value: serde_json::Value::Null, + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("clear missing config succeeds"); + + assert_eq!(response.status, WriteStatus::Ok); + assert_eq!(response.overridden_metadata, None); + assert_eq!(std::fs::read_to_string(&path)?, ""); + Ok(()) +} + +#[tokio::test] +async fn write_value_supports_nested_app_paths() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "apps".to_string(), + value: serde_json::json!({ + "app1": { + "enabled": false, + }, + }), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write apps succeeds"); + + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "apps.app1.default_tools_approval_mode".to_string(), + value: serde_json::json!("prompt"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write apps.app1.default_tools_approval_mode succeeds"); + + let read = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read succeeds"); + + assert_eq!( + read.config.apps, + Some(AppsConfig { + default: None, + apps: std::collections::HashMap::from([( + "app1".to_string(), + AppConfig { + enabled: false, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, + }, + )]), + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn write_value_supports_custom_mcp_server_default_tool_approval_mode() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "[mcp_servers.docs]\ncommand = \"docs-server\"\n", + )?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "mcp_servers.docs.default_tools_approval_mode".to_string(), + value: serde_json::json!("approve"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write mcp server default_tools_approval_mode succeeds"); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?; + assert!(contents.contains("default_tools_approval_mode = \"approve\"")); + + let read = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read succeeds"); + + assert_eq!( + read.config + .additional + .get("mcp_servers") + .and_then(|servers| servers.get("docs")) + .and_then(|docs| docs.get("default_tools_approval_mode")), + Some(&serde_json::json!("approve")) + ); + + Ok(()) +} + +#[tokio::test] +async fn read_includes_origins_and_layers() { + let tmp = tempdir().expect("tempdir"); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), + CloudRequirementsLoader::default(), + ); + + let response = service + .read(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await + .expect("response"); + + assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); + + assert_eq!( + response + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + }, + ); + let layers = response.layers.expect("layers present"); + // Local macOS machines can surface an MDM-managed config layer at the + // top of the stack; ignore it so this test stays focused on file/user/system ordering. + let layers = if matches!( + layers.first().map(|layer| &layer.name), + Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) + ) { + &layers[1..] + } else { + layers.as_slice() + }; + assert_eq!(layers.len(), 3, "expected three layers"); + assert_eq!( + layers.first().unwrap().name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + } + ); + assert_eq!( + layers.get(1).unwrap().name, + ConfigLayerSource::User { + file: user_file.clone() + } + ); + assert!(matches!( + layers.get(2).unwrap().name, + ConfigLayerSource::System { .. } + )); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> { + use base64::Engine; + + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?; + + let mut loader_overrides = + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")); + loader_overrides.managed_preferences_base64 = Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + loader_overrides, + CloudRequirementsLoader::default(), + ); + + let response = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model".to_string(), + value: serde_json::json!("updated"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + assert_eq!(response.status, WriteStatus::Ok); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"), + "model = \"updated\"\n" + ); + + Ok(()) +} + +#[tokio::test] +async fn write_value_reports_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "approval_policy = \"on-request\"", + ) + .unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), + CloudRequirementsLoader::default(), + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("never"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + let read_after = service + .read(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await + .expect("read"); + assert_eq!( + read_after.config.approval_policy, + Some(AskForApproval::Never) + ); + assert_eq!( + read_after + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + } + ); + assert_eq!(result.status, WriteStatus::Ok); + assert!(result.overridden_metadata.is_none()); +} + +#[tokio::test] +async fn version_conflict_rejected() { + let tmp = tempdir().expect("tempdir"); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model".to_string(), + value: serde_json::json!("gpt-5.2"), + merge_strategy: MergeStrategy::Replace, + expected_version: Some("sha256:bogus".to_string()), + }) + .await + .expect_err("should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigVersionConflict) + ); +} + +#[tokio::test] +async fn write_value_defaults_to_user_config_path() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: serde_json::json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert!( + contents.contains("model = \"gpt-new\""), + "config.toml should be updated even when file_path is omitted" + ); +} + +#[tokio::test] +async fn invalid_user_value_rejected_even_if_overridden_by_managed() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), + CloudRequirementsLoader::default(), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("bogus"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("should fail validation"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents.trim(), "model = \"user\""); +} + +#[tokio::test] +async fn reserved_builtin_provider_override_rejected() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap(); + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model_providers.openai.name".to_string(), + value: serde_json::json!("OpenAI Override"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("should reject reserved provider override"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!(error.to_string().contains("reserved built-in provider IDs")); + assert!(error.to_string().contains("`openai`")); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "model = \"user\"\n"); +} + +#[tokio::test] +async fn write_value_rejects_feature_requirement_conflict() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some(FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }), + ..Default::default() + })) + }), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "features.personality".to_string(), + value: serde_json::json!(false), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("conflicting feature write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("invalid value for `features`: `features.personality=false`"), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), + "" + ); +} + +#[tokio::test] +async fn write_value_rejects_profile_feature_requirement_conflict() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some(FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }), + ..Default::default() + })) + }), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "profiles.enterprise.features.personality".to_string(), + value: serde_json::json!(false), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("conflicting profile feature write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error.to_string().contains( + "invalid value for `features`: `profiles.enterprise.features.personality=false`" + ), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), + "" + ); +} + +#[tokio::test] +async fn read_reports_managed_overrides_user_and_session_flags() { + let tmp = tempdir().expect("tempdir"); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "model = \"system\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let cli_overrides = vec![( + "model".to_string(), + TomlValue::String("session".to_string()), + )]; + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + cli_overrides, + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), + CloudRequirementsLoader::default(), + ); + + let response = service + .read(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await + .expect("response"); + + assert_eq!(response.config.model.as_deref(), Some("system")); + assert_eq!( + response.origins.get("model").expect("origin").name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone() + }, + ); + let layers = response.layers.expect("layers"); + // Local macOS machines can surface an MDM-managed config layer at the + // top of the stack; ignore it so this test stays focused on file/session/user ordering. + let layers = if matches!( + layers.first().map(|layer| &layer.name), + Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) + ) { + &layers[1..] + } else { + layers.as_slice() + }; + assert_eq!( + layers.first().unwrap().name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } + ); + assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags); + assert_eq!( + layers.get(2).unwrap().name, + ConfigLayerSource::User { file: user_file } + ); +} + +#[tokio::test] +async fn write_value_reports_managed_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), + CloudRequirementsLoader::default(), + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("on-request"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + assert_eq!(result.status, WriteStatus::OkOverridden); + let overridden = result.overridden_metadata.expect("overridden metadata"); + assert_eq!( + overridden.overriding_layer.name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } + ); + assert_eq!(overridden.effective_value, serde_json::json!("never")); +} + +#[tokio::test] +async fn upsert_merges_tables_replace_overwrites() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + let base = r#"[mcp_servers.linear] +bearer_token_env_var = "TOKEN" +name = "linear" +url = "https://linear.example" + +[mcp_servers.linear.env_http_headers] +existing = "keep" + +[mcp_servers.linear.http_headers] +alpha = "a" +"#; + + let overlay = serde_json::json!({ + "bearer_token_env_var": "NEW_TOKEN", + "http_headers": { + "alpha": "updated", + "beta": "b" + }, + "name": "linear", + "url": "https://linear.example" + }); + + std::fs::write(&path, base)?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "mcp_servers.linear".to_string(), + value: overlay.clone(), + merge_strategy: MergeStrategy::Upsert, + expected_version: None, + }) + .await + .expect("upsert succeeds"); + + let upserted: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?; + let expected_upsert: TomlValue = toml::from_str( + r#"[mcp_servers.linear] +bearer_token_env_var = "NEW_TOKEN" +name = "linear" +url = "https://linear.example" + +[mcp_servers.linear.env_http_headers] +existing = "keep" + +[mcp_servers.linear.http_headers] +alpha = "updated" +beta = "b" +"#, + )?; + assert_eq!(upserted, expected_upsert); + + std::fs::write(&path, base)?; + + service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "mcp_servers.linear".to_string(), + value: overlay, + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("replace succeeds"); + + let replaced: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?; + let expected_replace: TomlValue = toml::from_str( + r#"[mcp_servers.linear] +bearer_token_env_var = "NEW_TOKEN" +name = "linear" +url = "https://linear.example" + +[mcp_servers.linear.http_headers] +alpha = "updated" +beta = "b" +"#, + )?; + assert_eq!(replaced, expected_replace); + + Ok(()) +} diff --git a/code-rs/app-server/src/connection_rpc_gate.rs b/code-rs/app-server/src/connection_rpc_gate.rs new file mode 100644 index 00000000000..12fed79b363 --- /dev/null +++ b/code-rs/app-server/src/connection_rpc_gate.rs @@ -0,0 +1,209 @@ +use std::future::Future; + +use tokio::sync::Mutex; +use tokio_util::task::TaskTracker; + +/// Per-connection gate for initialized RPC handler execution. +/// +/// Closing the gate prevents queued handlers from starting while allowing +/// handlers that already acquired a token to finish. +#[derive(Debug)] +pub(crate) struct ConnectionRpcGate { + accepting: Mutex, + tasks: TaskTracker, +} + +impl ConnectionRpcGate { + pub(crate) fn new() -> Self { + let accepting = true; + Self { + accepting: Mutex::new(accepting), + tasks: TaskTracker::new(), + } + } + + pub(crate) async fn run(&self, future: F) + where + F: Future, + { + let token = { + let accepting = self.accepting.lock().await; + if !*accepting { + return; + } + self.tasks.token() + }; + + future.await; + drop(token); + } + + pub(crate) async fn shutdown(&self) { + { + let mut accepting = self.accepting.lock().await; + *accepting = false; + self.tasks.close(); + } + self.tasks.wait().await; + } + + #[cfg(test)] + async fn is_accepting(&self) -> bool { + *self.accepting.lock().await + } + + #[cfg(test)] + fn inflight_count(&self) -> usize { + self.tasks.len() + } +} + +impl Default for ConnectionRpcGate { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use tokio::sync::oneshot; + use tokio::time::Duration; + use tokio::time::timeout; + + #[tokio::test] + async fn run_executes_while_open() { + let gate = ConnectionRpcGate::new(); + let ran = Arc::new(AtomicBool::new(/*v*/ false)); + let ran_clone = Arc::clone(&ran); + + gate.run(async move { + ran_clone.store(/*val*/ true, Ordering::Release); + }) + .await; + + assert!(ran.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn run_drops_future_without_polling_after_shutdown() { + let gate = ConnectionRpcGate::new(); + gate.shutdown().await; + let polled = Arc::new(AtomicBool::new(/*v*/ false)); + let polled_clone = Arc::clone(&polled); + + gate.run(async move { + polled_clone.store(/*val*/ true, Ordering::Release); + }) + .await; + + assert!(!polled.load(Ordering::Acquire)); + assert!(!gate.is_accepting().await); + } + + #[tokio::test] + async fn shutdown_waits_for_started_run_to_finish() { + let gate = Arc::new(ConnectionRpcGate::new()); + let (started_tx, started_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let gate_for_run = Arc::clone(&gate); + let run_task = tokio::spawn(async move { + gate_for_run + .run(async move { + started_tx.send(()).expect("receiver should be open"); + let _ = finish_rx.await; + }) + .await; + }); + + started_rx.await.expect("run should start"); + assert_eq!(gate.inflight_count(), 1); + + let gate_for_shutdown = Arc::clone(&gate); + let shutdown_task = tokio::spawn(async move { + gate_for_shutdown.shutdown().await; + }); + + timeout(Duration::from_millis(/*millis*/ 50), shutdown_task) + .await + .expect_err("shutdown should wait for the running future"); + + finish_tx + .send(()) + .expect("running future should be waiting"); + run_task.await.expect("run task should complete"); + gate.shutdown().await; + assert_eq!(gate.inflight_count(), 0); + } + + #[tokio::test] + async fn shutdown_drops_late_runs_while_waiting_for_inflight_work() { + let gate = Arc::new(ConnectionRpcGate::new()); + let (started_tx, started_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let gate_for_run = Arc::clone(&gate); + let run_task = tokio::spawn(async move { + gate_for_run + .run(async move { + started_tx.send(()).expect("receiver should be open"); + let _ = finish_rx.await; + }) + .await; + }); + + started_rx.await.expect("run should start"); + let gate_for_shutdown = Arc::clone(&gate); + let shutdown_task = tokio::spawn(async move { + gate_for_shutdown.shutdown().await; + }); + + timeout(Duration::from_millis(/*millis*/ 50), shutdown_task) + .await + .expect_err("shutdown should wait for the running future"); + + let late_polled = Arc::new(AtomicBool::new(/*v*/ false)); + let late_polled_clone = Arc::clone(&late_polled); + gate.run(async move { + late_polled_clone.store(/*val*/ true, Ordering::Release); + }) + .await; + + assert!(!late_polled.load(Ordering::Acquire)); + + finish_tx + .send(()) + .expect("running future should still be waiting"); + run_task.await.expect("run task should complete"); + gate.shutdown().await; + assert_eq!(gate.inflight_count(), 0); + } + + #[tokio::test] + async fn run_is_counted_before_handler_body_continues() { + let gate = Arc::new(ConnectionRpcGate::new()); + let (entered_tx, entered_rx) = oneshot::channel(); + let (continue_tx, continue_rx) = oneshot::channel(); + let gate_for_run = Arc::clone(&gate); + let run_task = tokio::spawn(async move { + gate_for_run + .run(async move { + entered_tx.send(()).expect("receiver should be open"); + let _ = continue_rx.await; + }) + .await; + }); + + entered_rx.await.expect("handler body should be entered"); + assert_eq!(gate.inflight_count(), 1); + + continue_tx + .send(()) + .expect("handler body should still be waiting"); + run_task.await.expect("run task should complete"); + assert_eq!(gate.inflight_count(), 0); + } +} diff --git a/code-rs/app-server/src/dynamic_tools.rs b/code-rs/app-server/src/dynamic_tools.rs new file mode 100644 index 00000000000..c5e7550d9f9 --- /dev/null +++ b/code-rs/app-server/src/dynamic_tools.rs @@ -0,0 +1,75 @@ +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_core::CodexThread; +use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; +use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::protocol::Op; +use std::sync::Arc; +use tokio::sync::oneshot; +use tracing::error; + +use crate::outgoing_message::ClientRequestResult; +use crate::server_request_error::is_turn_transition_server_request_error; + +pub(crate) async fn on_call_response( + call_id: String, + receiver: oneshot::Receiver, + conversation: Arc, +) { + let response = receiver.await; + let (response, _error) = match response { + Ok(Ok(value)) => decode_response(value), + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + fallback_response("dynamic tool request failed") + } + Err(err) => { + error!("request failed: {err:?}"); + fallback_response("dynamic tool request failed") + } + }; + + let DynamicToolCallResponse { + content_items, + success, + } = response.clone(); + let core_response = CoreDynamicToolResponse { + content_items: content_items + .into_iter() + .map(CoreDynamicToolCallOutputContentItem::from) + .collect(), + success, + }; + if let Err(err) = conversation + .submit(Op::DynamicToolResponse { + id: call_id.clone(), + response: core_response, + }) + .await + { + error!("failed to submit DynamicToolResponse: {err}"); + } +} + +fn decode_response(value: serde_json::Value) -> (DynamicToolCallResponse, Option) { + match serde_json::from_value::(value) { + Ok(response) => (response, None), + Err(err) => { + error!("failed to deserialize DynamicToolCallResponse: {err}"); + fallback_response("dynamic tool response was invalid") + } + } +} + +fn fallback_response(message: &str) -> (DynamicToolCallResponse, Option) { + ( + DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: message.to_string(), + }], + success: false, + }, + Some(message.to_string()), + ) +} diff --git a/code-rs/app-server/src/error_code.rs b/code-rs/app-server/src/error_code.rs index ca93b2f2d33..48e401f7bcf 100644 --- a/code-rs/app-server/src/error_code.rs +++ b/code-rs/app-server/src/error_code.rs @@ -1,3 +1,32 @@ +use codex_app_server_protocol::JSONRPCErrorError; + pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +pub(crate) const METHOD_NOT_FOUND_ERROR_CODE: i64 = -32601; +pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602; pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603; pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001; +pub const INPUT_TOO_LARGE_ERROR_CODE: &str = "input_too_large"; + +pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { + error(INVALID_REQUEST_ERROR_CODE, message) +} + +pub(crate) fn method_not_found(message: impl Into) -> JSONRPCErrorError { + error(METHOD_NOT_FOUND_ERROR_CODE, message) +} + +pub(crate) fn invalid_params(message: impl Into) -> JSONRPCErrorError { + error(INVALID_PARAMS_ERROR_CODE, message) +} + +pub(crate) fn internal_error(message: impl Into) -> JSONRPCErrorError { + error(INTERNAL_ERROR_CODE, message) +} + +fn error(code: i64, message: impl Into) -> JSONRPCErrorError { + JSONRPCErrorError { + code, + message: message.into(), + data: None, + } +} diff --git a/code-rs/app-server/src/external_agent_config_api.rs b/code-rs/app-server/src/external_agent_config_api.rs deleted file mode 100644 index 46ea86072df..00000000000 --- a/code-rs/app-server/src/external_agent_config_api.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::error_code::INTERNAL_ERROR_CODE; -use code_app_server_protocol::ExternalAgentConfigDetectParams; -use code_app_server_protocol::ExternalAgentConfigDetectResponse; -use code_app_server_protocol::ExternalAgentConfigImportParams; -use code_app_server_protocol::ExternalAgentConfigImportResponse; -use code_app_server_protocol::ExternalAgentConfigMigrationItem; -use code_app_server_protocol::ExternalAgentConfigMigrationItemType; -use code_core::external_agent_config::ExternalAgentConfigDetectOptions; -use code_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem; -use code_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; -use code_core::external_agent_config::ExternalAgentConfigService; -use mcp_types::JSONRPCErrorError; -use std::io; -use std::path::PathBuf; - -#[derive(Clone)] -pub(crate) struct ExternalAgentConfigApi { - migration_service: ExternalAgentConfigService, -} - -impl ExternalAgentConfigApi { - pub(crate) fn new(codex_home: PathBuf) -> Self { - Self { - migration_service: ExternalAgentConfigService::new(codex_home), - } - } - - pub(crate) async fn detect( - &self, - params: ExternalAgentConfigDetectParams, - ) -> Result { - let items = self - .migration_service - .detect(ExternalAgentConfigDetectOptions { - include_home: params.include_home, - cwds: params.cwds, - }) - .map_err(map_io_error)?; - - Ok(ExternalAgentConfigDetectResponse { - items: items - .into_iter() - .map(|migration_item| ExternalAgentConfigMigrationItem { - item_type: match migration_item.item_type { - CoreMigrationItemType::Config => { - ExternalAgentConfigMigrationItemType::Config - } - CoreMigrationItemType::Skills => { - ExternalAgentConfigMigrationItemType::Skills - } - CoreMigrationItemType::AgentsMd => { - ExternalAgentConfigMigrationItemType::AgentsMd - } - CoreMigrationItemType::McpServerConfig => { - ExternalAgentConfigMigrationItemType::McpServerConfig - } - }, - description: migration_item.description, - cwd: migration_item.cwd, - }) - .collect(), - }) - } - - pub(crate) async fn import( - &self, - params: ExternalAgentConfigImportParams, - ) -> Result { - self.migration_service - .import( - params - .migration_items - .into_iter() - .map(|migration_item| CoreMigrationItem { - item_type: match migration_item.item_type { - ExternalAgentConfigMigrationItemType::Config => { - CoreMigrationItemType::Config - } - ExternalAgentConfigMigrationItemType::Skills => { - CoreMigrationItemType::Skills - } - ExternalAgentConfigMigrationItemType::AgentsMd => { - CoreMigrationItemType::AgentsMd - } - ExternalAgentConfigMigrationItemType::McpServerConfig => { - CoreMigrationItemType::McpServerConfig - } - }, - description: migration_item.description, - cwd: migration_item.cwd, - }) - .collect(), - ) - .map_err(map_io_error)?; - - Ok(ExternalAgentConfigImportResponse {}) - } -} - -fn map_io_error(err: io::Error) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err.to_string(), - data: None, - } -} diff --git a/code-rs/app-server/src/filters.rs b/code-rs/app-server/src/filters.rs new file mode 100644 index 00000000000..20608d93cd2 --- /dev/null +++ b/code-rs/app-server/src/filters.rs @@ -0,0 +1,158 @@ +use codex_app_server_protocol::ThreadSourceKind; +use codex_core::INTERACTIVE_SESSION_SOURCES; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; + +pub(crate) fn compute_source_filters( + source_kinds: Option>, +) -> (Vec, Option>) { + let Some(source_kinds) = source_kinds else { + return (INTERACTIVE_SESSION_SOURCES.to_vec(), None); + }; + + if source_kinds.is_empty() { + return (INTERACTIVE_SESSION_SOURCES.to_vec(), None); + } + + let requires_post_filter = source_kinds.iter().any(|kind| { + matches!( + kind, + ThreadSourceKind::Exec + | ThreadSourceKind::AppServer + | ThreadSourceKind::SubAgent + | ThreadSourceKind::SubAgentReview + | ThreadSourceKind::SubAgentCompact + | ThreadSourceKind::SubAgentThreadSpawn + | ThreadSourceKind::SubAgentOther + | ThreadSourceKind::Unknown + ) + }); + + if requires_post_filter { + (Vec::new(), Some(source_kinds)) + } else { + let interactive_sources = source_kinds + .iter() + .filter_map(|kind| match kind { + ThreadSourceKind::Cli => Some(CoreSessionSource::Cli), + ThreadSourceKind::VsCode => Some(CoreSessionSource::VSCode), + ThreadSourceKind::Exec + | ThreadSourceKind::AppServer + | ThreadSourceKind::SubAgent + | ThreadSourceKind::SubAgentReview + | ThreadSourceKind::SubAgentCompact + | ThreadSourceKind::SubAgentThreadSpawn + | ThreadSourceKind::SubAgentOther + | ThreadSourceKind::Unknown => None, + }) + .collect::>(); + (interactive_sources, Some(source_kinds)) + } +} + +pub(crate) fn source_kind_matches(source: &CoreSessionSource, filter: &[ThreadSourceKind]) -> bool { + filter.iter().any(|kind| match kind { + ThreadSourceKind::Cli => matches!(source, CoreSessionSource::Cli), + ThreadSourceKind::VsCode => matches!(source, CoreSessionSource::VSCode), + ThreadSourceKind::Exec => matches!(source, CoreSessionSource::Exec), + ThreadSourceKind::AppServer => matches!(source, CoreSessionSource::Mcp), + ThreadSourceKind::SubAgent => matches!(source, CoreSessionSource::SubAgent(_)), + ThreadSourceKind::SubAgentReview => { + matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::Review) + ) + } + ThreadSourceKind::SubAgentCompact => { + matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::Compact) + ) + } + ThreadSourceKind::SubAgentThreadSpawn => matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { .. }) + ), + ThreadSourceKind::SubAgentOther => matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::Other(_)) + ), + ThreadSourceKind::Unknown => matches!(source, CoreSessionSource::Unknown), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ThreadId; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[test] + fn compute_source_filters_defaults_to_interactive_sources() { + let (allowed_sources, filter) = compute_source_filters(/*source_kinds*/ None); + + assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec()); + assert_eq!(filter, None); + } + + #[test] + fn compute_source_filters_empty_means_interactive_sources() { + let (allowed_sources, filter) = compute_source_filters(Some(Vec::new())); + + assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec()); + assert_eq!(filter, None); + } + + #[test] + fn compute_source_filters_interactive_only_skips_post_filtering() { + let source_kinds = vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]; + let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone())); + + assert_eq!( + allowed_sources, + vec![CoreSessionSource::Cli, CoreSessionSource::VSCode] + ); + assert_eq!(filter, Some(source_kinds)); + } + + #[test] + fn compute_source_filters_subagent_variant_requires_post_filtering() { + let source_kinds = vec![ThreadSourceKind::SubAgentReview]; + let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone())); + + assert_eq!(allowed_sources, Vec::new()); + assert_eq!(filter, Some(source_kinds)); + } + + #[test] + fn source_kind_matches_distinguishes_subagent_variants() { + let parent_thread_id = + ThreadId::from_string(&Uuid::new_v4().to_string()).expect("valid thread id"); + let review = CoreSessionSource::SubAgent(CoreSubAgentSource::Review); + let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + + assert!(source_kind_matches( + &review, + &[ThreadSourceKind::SubAgentReview] + )); + assert!(!source_kind_matches( + &review, + &[ThreadSourceKind::SubAgentThreadSpawn] + )); + assert!(source_kind_matches( + &spawn, + &[ThreadSourceKind::SubAgentThreadSpawn] + )); + assert!(!source_kind_matches( + &spawn, + &[ThreadSourceKind::SubAgentReview] + )); + } +} diff --git a/code-rs/app-server/src/fs_watch.rs b/code-rs/app-server/src/fs_watch.rs new file mode 100644 index 00000000000..47248451a2c --- /dev/null +++ b/code-rs/app-server/src/fs_watch.rs @@ -0,0 +1,417 @@ +use crate::error_code::invalid_request; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::FsChangedNotification; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsUnwatchResponse; +use codex_app_server_protocol::FsWatchParams; +use codex_app_server_protocol::FsWatchResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ServerNotification; +use codex_core::file_watcher::FileWatcher; +use codex_core::file_watcher::FileWatcherEvent; +use codex_core::file_watcher::FileWatcherSubscriber; +use codex_core::file_watcher::Receiver; +use codex_core::file_watcher::WatchPath; +use codex_core::file_watcher::WatchRegistration; +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::hash_map::Entry; +use std::hash::Hash; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex as AsyncMutex; +#[cfg(test)] +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Instant; +use tracing::warn; + +const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200); + +struct DebouncedReceiver { + rx: Receiver, + interval: Duration, + changed_paths: HashSet, + next_allowance: Option, +} + +impl DebouncedReceiver { + fn new(rx: Receiver, interval: Duration) -> Self { + Self { + rx, + interval, + changed_paths: HashSet::new(), + next_allowance: None, + } + } + + async fn recv(&mut self) -> Option { + while self.changed_paths.is_empty() { + self.changed_paths.extend(self.rx.recv().await?.paths); + } + let next_allowance = *self + .next_allowance + .get_or_insert_with(|| Instant::now() + self.interval); + + loop { + tokio::select! { + event = self.rx.recv() => self.changed_paths.extend(event?.paths), + _ = tokio::time::sleep_until(next_allowance) => break, + } + } + + Some(FileWatcherEvent { + paths: self.changed_paths.drain().collect(), + }) + } +} + +#[derive(Clone)] +pub(crate) struct FsWatchManager { + outgoing: Arc, + file_watcher: Arc, + state: Arc>, +} + +#[derive(Default)] +struct FsWatchState { + entries: HashMap, +} + +struct WatchEntry { + terminate_tx: oneshot::Sender>, + _subscriber: FileWatcherSubscriber, + _registration: WatchRegistration, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WatchKey { + connection_id: ConnectionId, + watch_id: String, +} + +impl FsWatchManager { + pub(crate) fn new(outgoing: Arc) -> Self { + let file_watcher = match FileWatcher::new() { + Ok(file_watcher) => Arc::new(file_watcher), + Err(err) => { + warn!("filesystem watch manager falling back to noop core watcher: {err}"); + Arc::new(FileWatcher::noop()) + } + }; + Self::new_with_file_watcher(outgoing, file_watcher) + } + + fn new_with_file_watcher( + outgoing: Arc, + file_watcher: Arc, + ) -> Self { + Self { + outgoing, + file_watcher, + state: Arc::new(AsyncMutex::new(FsWatchState::default())), + } + } + + pub(crate) async fn watch( + &self, + connection_id: ConnectionId, + params: FsWatchParams, + ) -> Result { + let watch_id = params.watch_id; + let watch_key = WatchKey { + connection_id, + watch_id: watch_id.clone(), + }; + let outgoing = self.outgoing.clone(); + let (subscriber, rx) = self.file_watcher.add_subscriber(); + let watch_root = params.path.clone(); + let registration = subscriber.register_paths(vec![WatchPath { + path: params.path.to_path_buf(), + recursive: false, + }]); + let (terminate_tx, terminate_rx) = oneshot::channel(); + + match self.state.lock().await.entries.entry(watch_key) { + Entry::Occupied(_) => { + return Err(invalid_request(format!( + "watchId already exists: {watch_id}" + ))); + } + Entry::Vacant(entry) => { + entry.insert(WatchEntry { + terminate_tx, + _subscriber: subscriber, + _registration: registration, + }); + } + } + + let task_watch_id = watch_id.clone(); + tokio::spawn(async move { + let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE); + tokio::pin!(terminate_rx); + loop { + let event = tokio::select! { + biased; + _ = &mut terminate_rx => break, + event = rx.recv() => match event { + Some(event) => event, + None => break, + }, + }; + let mut changed_paths = event + .paths + .into_iter() + .map(|path| watch_root.join(path)) + .collect::>(); + changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path())); + if !changed_paths.is_empty() { + outgoing + .send_server_notification_to_connection_and_wait( + connection_id, + ServerNotification::FsChanged(FsChangedNotification { + watch_id: task_watch_id.clone(), + changed_paths, + }), + ) + .await; + } + } + }); + + Ok(FsWatchResponse { path: params.path }) + } + + pub(crate) async fn unwatch( + &self, + connection_id: ConnectionId, + params: FsUnwatchParams, + ) -> Result { + let watch_key = WatchKey { + connection_id, + watch_id: params.watch_id, + }; + let entry = self.state.lock().await.entries.remove(&watch_key); + if let Some(entry) = entry { + // Wait for the oneshot to be destroyed by the task to ensure that no notifications + // are send after the unwatch response. + let (done_tx, done_rx) = oneshot::channel(); + let _ = entry.terminate_tx.send(done_tx); + let _ = done_rx.await; + } + Ok(FsUnwatchResponse {}) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let mut state = self.state.lock().await; + state + .entries + .extract_if(|key, _| key.connection_id == connection_id) + .count(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { + assert!( + path.is_absolute(), + "path must be absolute: {}", + path.display() + ); + AbsolutePathBuf::try_from(path).expect("path should be absolute") + } + + fn manager_with_noop_watcher() -> FsWatchManager { + const OUTGOING_BUFFER: usize = 1; + let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); + FsWatchManager::new_with_file_watcher( + Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )), + Arc::new(FileWatcher::noop()), + ) + } + + #[tokio::test] + async fn watch_uses_client_id_and_tracks_the_owner_scoped_entry() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + + let manager = manager_with_noop_watcher(); + let path = absolute_path(head_path); + let watch_id = "watch-head".to_string(); + let response = manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: watch_id.clone(), + path: path.clone(), + }, + ) + .await + .expect("watch should succeed"); + + assert_eq!(response.path, path); + + let state = manager.state.lock().await; + assert_eq!( + state.entries.keys().cloned().collect::>(), + HashSet::from([WatchKey { + connection_id: ConnectionId(1), + watch_id, + }]) + ); + } + + #[tokio::test] + async fn unwatch_is_scoped_to_the_connection_that_created_the_watch() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + + let manager = manager_with_noop_watcher(); + manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: "watch-head".to_string(), + path: absolute_path(head_path), + }, + ) + .await + .expect("watch should succeed"); + let watch_key = WatchKey { + connection_id: ConnectionId(1), + watch_id: "watch-head".to_string(), + }; + + manager + .unwatch( + ConnectionId(2), + FsUnwatchParams { + watch_id: "watch-head".to_string(), + }, + ) + .await + .expect("foreign unwatch should be a no-op"); + assert!(manager.state.lock().await.entries.contains_key(&watch_key)); + + manager + .unwatch( + ConnectionId(1), + FsUnwatchParams { + watch_id: "watch-head".to_string(), + }, + ) + .await + .expect("owner unwatch should succeed"); + assert!(!manager.state.lock().await.entries.contains_key(&watch_key)); + } + + #[tokio::test] + async fn watch_rejects_duplicate_id_for_the_same_connection() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + let fetch_head_path = temp_dir.path().join("FETCH_HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD"); + + let manager = manager_with_noop_watcher(); + manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: "watch-head".to_string(), + path: absolute_path(head_path), + }, + ) + .await + .expect("first watch should succeed"); + + let error = manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: "watch-head".to_string(), + path: absolute_path(fetch_head_path), + }, + ) + .await + .expect_err("duplicate watch should fail"); + + assert_eq!(error.message, "watchId already exists: watch-head"); + assert_eq!(manager.state.lock().await.entries.len(), 1); + } + + #[tokio::test] + async fn connection_closed_removes_only_that_connections_watches() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + let fetch_head_path = temp_dir.path().join("FETCH_HEAD"); + let packed_refs_path = temp_dir.path().join("packed-refs"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD"); + std::fs::write(&packed_refs_path, "refs\n").expect("write packed-refs"); + + let manager = manager_with_noop_watcher(); + let response = manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: "watch-head".to_string(), + path: absolute_path(head_path.clone()), + }, + ) + .await + .expect("first watch should succeed"); + manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: "watch-fetch-head".to_string(), + path: absolute_path(fetch_head_path), + }, + ) + .await + .expect("second watch should succeed"); + manager + .watch( + ConnectionId(2), + FsWatchParams { + watch_id: "watch-packed-refs".to_string(), + path: absolute_path(packed_refs_path), + }, + ) + .await + .expect("third watch should succeed"); + + manager.connection_closed(ConnectionId(1)).await; + + assert_eq!( + manager + .state + .lock() + .await + .entries + .keys() + .cloned() + .collect::>(), + HashSet::from([WatchKey { + connection_id: ConnectionId(2), + watch_id: "watch-packed-refs".to_string(), + }]) + ); + assert_eq!(response.path, absolute_path(head_path)); + } +} diff --git a/code-rs/app-server/src/fuzzy_file_search.rs b/code-rs/app-server/src/fuzzy_file_search.rs index b8774a056a4..f8cd61e3ad0 100644 --- a/code-rs/app-server/src/fuzzy_file_search.rs +++ b/code-rs/app-server/src/fuzzy_file_search.rs @@ -1,96 +1,256 @@ use std::num::NonZero; -use std::num::NonZeroUsize; -use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; -use code_file_search as file_search; -use code_protocol::mcp_protocol::FuzzyFileSearchResult; -use tokio::task::JoinSet; +use codex_app_server_protocol::FuzzyFileSearchMatchType; +use codex_app_server_protocol::FuzzyFileSearchResult; +use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification; +use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification; +use codex_app_server_protocol::ServerNotification; +use codex_file_search as file_search; use tracing::warn; -#[allow(dead_code)] -const LIMIT_PER_ROOT: usize = 50; -#[allow(dead_code)] +use crate::outgoing_message::OutgoingMessageSender; + +const MATCH_LIMIT: usize = 50; 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, cancellation_flag: Arc, ) -> Vec { + if roots.is_empty() { + return Vec::new(); + } + #[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 limit = NonZero::new(MATCH_LIMIT).expect("MATCH_LIMIT 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 = 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(), + #[expect(clippy::expect_used)] + let threads = NonZero::new(threads.max(1)).expect("threads should be non-zero"); + let search_dirs: Vec = roots.iter().map(PathBuf::from).collect(); + + let mut files = match tokio::task::spawn_blocking(move || { + file_search::run( + query.as_str(), + search_dirs, + file_search::FileSearchOptions { + limit, threads, - cancel_flag, - COMPUTE_INDICES, - ) { - Ok(res) => Ok((root, res)), - Err(err) => Err((root, err)), - } + compute_indices: true, + ..Default::default() + }, + Some(cancellation_flag), + ) + }) + .await + { + Ok(Ok(res)) => res + .matches + .into_iter() + .map(|m| { + let file_name = m.path.file_name().unwrap_or_default(); + FuzzyFileSearchResult { + root: m.root.to_string_lossy().to_string(), + path: m.path.to_string_lossy().to_string(), + match_type: match m.match_type { + file_search::MatchType::File => FuzzyFileSearchMatchType::File, + file_search::MatchType::Directory => FuzzyFileSearchMatchType::Directory, + }, + file_name: file_name.to_string_lossy().to_string(), + score: m.score, + indices: m.indices, + } + }) + .collect::>(), + Ok(Err(err)) => { + warn!("fuzzy-file-search failed: {err}"); + Vec::new() + } + Err(err) => { + warn!("fuzzy-file-search join failed: {err}"); + Vec::new() + } + }; + + files.sort_by(file_search::cmp_by_score_desc_then_path_asc::< + FuzzyFileSearchResult, + _, + _, + >(|f| f.score, |f| f.path.as_str())); + + files +} + +pub(crate) struct FuzzyFileSearchSession { + session: file_search::FileSearchSession, + shared: Arc, +} + +impl FuzzyFileSearchSession { + pub(crate) fn update_query(&self, query: String) { + if self.shared.canceled.load(Ordering::Relaxed) { + return; + } + { + #[expect(clippy::unwrap_used)] + let mut latest_query = self.shared.latest_query.lock().unwrap(); + *latest_query = query.clone(); + } + self.session.update_query(&query); + } +} + +impl Drop for FuzzyFileSearchSession { + fn drop(&mut self) { + self.shared.canceled.store(true, Ordering::Relaxed); + } +} + +pub(crate) fn start_fuzzy_file_search_session( + session_id: String, + roots: Vec, + outgoing: Arc, +) -> anyhow::Result { + #[expect(clippy::expect_used)] + let limit = NonZero::new(MATCH_LIMIT).expect("MATCH_LIMIT 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); + #[expect(clippy::expect_used)] + let threads = NonZero::new(threads.max(1)).expect("threads should be non-zero"); + let search_dirs: Vec = roots.iter().map(PathBuf::from).collect(); + let canceled = Arc::new(AtomicBool::new(false)); + + let shared = Arc::new(SessionShared { + session_id, + latest_query: Mutex::new(String::new()), + outgoing, + runtime: tokio::runtime::Handle::current(), + canceled: canceled.clone(), + }); + + let reporter = Arc::new(SessionReporterImpl { + shared: shared.clone(), + }); + let session = file_search::create_session( + search_dirs, + file_search::FileSearchOptions { + limit, + threads, + compute_indices: true, + ..Default::default() + }, + reporter, + Some(canceled), + )?; + + Ok(FuzzyFileSearchSession { session, shared }) +} + +struct SessionShared { + session_id: String, + latest_query: Mutex, + outgoing: Arc, + runtime: tokio::runtime::Handle, + canceled: Arc, +} + +struct SessionReporterImpl { + shared: Arc, +} + +impl SessionReporterImpl { + fn send_snapshot(&self, snapshot: &file_search::FileSearchSnapshot) { + if self.shared.canceled.load(Ordering::Relaxed) { + return; + } + + let query = { + #[expect(clippy::unwrap_used)] + self.shared.latest_query.lock().unwrap().clone() + }; + if snapshot.query != query { + return; + } + + let files = if query.is_empty() { + Vec::new() + } else { + collect_files(snapshot) + }; + + let notification = ServerNotification::FuzzyFileSearchSessionUpdated( + FuzzyFileSearchSessionUpdatedNotification { + session_id: self.shared.session_id.clone(), + query, + files, + }, + ); + let outgoing = self.shared.outgoing.clone(); + self.shared.runtime.spawn(async move { + outgoing.send_server_notification(notification).await; }); } - 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}"); - } + fn send_complete(&self) { + if self.shared.canceled.load(Ordering::Relaxed) { + return; } + let session_id = self.shared.session_id.clone(); + let outgoing = self.shared.outgoing.clone(); + self.shared.runtime.spawn(async move { + let notification = ServerNotification::FuzzyFileSearchSessionCompleted( + FuzzyFileSearchSessionCompletedNotification { session_id }, + ); + outgoing.send_server_notification(notification).await; + }); + } +} + +impl file_search::SessionReporter for SessionReporterImpl { + fn on_update(&self, snapshot: &file_search::FileSearchSnapshot) { + self.send_snapshot(snapshot); + } + + fn on_complete(&self) { + self.send_complete(); } +} + +fn collect_files(snapshot: &file_search::FileSearchSnapshot) -> Vec { + let mut files = snapshot + .matches + .iter() + .map(|m| { + let file_name = m.path.file_name().unwrap_or_default(); + FuzzyFileSearchResult { + root: m.root.to_string_lossy().to_string(), + path: m.path.to_string_lossy().to_string(), + match_type: match m.match_type { + file_search::MatchType::File => FuzzyFileSearchMatchType::File, + file_search::MatchType::Directory => FuzzyFileSearchMatchType::Directory, + }, + file_name: file_name.to_string_lossy().to_string(), + score: m.score, + indices: m.indices.clone(), + } + }) + .collect::>(); files.sort_by(file_search::cmp_by_score_desc_then_path_asc::< FuzzyFileSearchResult, _, _, >(|f| f.score, |f| f.path.as_str())); - files } diff --git a/code-rs/app-server/src/in_process.rs b/code-rs/app-server/src/in_process.rs new file mode 100644 index 00000000000..d812888e62a --- /dev/null +++ b/code-rs/app-server/src/in_process.rs @@ -0,0 +1,890 @@ +//! In-process app-server runtime host for local embedders. +//! +//! This module runs the existing [`MessageProcessor`] and outbound routing logic +//! on Tokio tasks, but replaces socket/stdio transports with bounded in-memory +//! channels. The intent is to preserve app-server semantics while avoiding a +//! process boundary for CLI surfaces that run in the same process. +//! +//! # Lifecycle +//! +//! 1. Construct runtime state with [`InProcessStartArgs`]. +//! 2. Call [`start`], which performs the `initialize` / `initialized` handshake +//! internally and returns a ready-to-use [`InProcessClientHandle`]. +//! 3. Send requests via [`InProcessClientHandle::request`], notifications via +//! [`InProcessClientHandle::notify`], and consume events via +//! [`InProcessClientHandle::next_event`]. +//! 4. Terminate with [`InProcessClientHandle::shutdown`]. +//! +//! # Transport model +//! +//! The runtime is transport-local but not protocol-free. Incoming requests are +//! typed [`ClientRequest`] values, yet responses still come back through the +//! same JSON-RPC result envelope that `MessageProcessor` uses for stdio and +//! websocket transports. This keeps in-process behavior aligned with +//! app-server rather than creating a second execution contract. +//! +//! # Backpressure +//! +//! Command submission uses `try_send` and can return `WouldBlock`, while event +//! fanout may drop notifications under saturation. Server requests are never +//! silently abandoned: if they cannot be queued they are failed back into +//! `MessageProcessor` with overload or internal errors so approval flows do +//! not hang indefinitely. +//! +//! # Relationship to `codex-app-server-client` +//! +//! This module provides the low-level runtime handle ([`InProcessClientHandle`]). +//! Higher-level callers (TUI, exec) should go through `codex-app-server-client`, +//! which wraps this module behind a worker task with async request/response +//! helpers, surface-specific startup policy, and bounded shutdown. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::hash_map::Entry; +use std::io::Error as IoError; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::sync::Arc; +use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use crate::analytics_utils::analytics_events_client_from_config; +use crate::config_manager::ConfigManager; +use crate::error_code::OVERLOADED_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::message_processor::ConnectionSessionState; +use crate::message_processor::MessageProcessor; +use crate::message_processor::MessageProcessorArgs; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingEnvelope; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::QueuedOutgoingMessage; +use crate::transport::CHANNEL_CAPACITY; +use crate::transport::OutboundConnectionState; +use crate::transport::route_outgoing_envelope; +use codex_analytics::AppServerRpcTransport; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::ThreadConfigLoader; +use codex_core::config::Config; +use codex_core::resolve_installation_id; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_login::AuthManager; +use codex_protocol::protocol::SessionSource; +pub use codex_rollout::StateDbHandle; +pub use codex_state::log_db::LogDbLayer; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::timeout; +use toml::Value as TomlValue; +use tracing::warn; + +const IN_PROCESS_CONNECTION_ID: ConnectionId = ConnectionId(0); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +/// Default bounded channel capacity for in-process runtime queues. +pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY; + +type PendingClientRequestResponse = std::result::Result; + +fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { + matches!(notification, ServerNotification::TurnCompleted(_)) +} + +/// Input needed to start an in-process app-server runtime. +/// +/// These fields mirror the pieces of ambient process state that stdio and +/// websocket transports normally assemble before `MessageProcessor` starts. +#[derive(Clone)] +pub struct InProcessStartArgs { + /// Resolved argv0 dispatch paths used by command execution internals. + pub arg0_paths: Arg0DispatchPaths, + /// Shared base config used to initialize core components. + pub config: Arc, + /// CLI config overrides that are already parsed into TOML values. + pub cli_overrides: Vec<(String, TomlValue)>, + /// Loader override knobs used by config API paths. + pub loader_overrides: LoaderOverrides, + /// Preloaded cloud requirements provider. + pub cloud_requirements: CloudRequirementsLoader, + /// Loader used to fetch typed thread config sources before a thread starts. + pub thread_config_loader: Arc, + /// Feedback sink used by app-server/core telemetry and logs. + pub feedback: CodexFeedback, + /// SQLite tracing layer used to flush recently emitted logs before feedback upload. + pub log_db: Option, + /// Process-wide SQLite state handle shared with embedded app-server consumers. + pub state_db: Option, + /// Environment manager used by core execution and filesystem operations. + pub environment_manager: Arc, + /// Startup warnings emitted after initialize succeeds. + pub config_warnings: Vec, + /// Session source stamped into thread/session metadata. + pub session_source: SessionSource, + /// Whether auth loading should honor the `CODEX_API_KEY` environment variable. + pub enable_codex_api_key_env: bool, + /// Initialize params used for initial handshake. + pub initialize: InitializeParams, + /// Capacity used for all runtime queues (clamped to at least 1). + pub channel_capacity: usize, +} + +/// Event emitted from the app-server to the in-process client. +/// +/// [`Lagged`](Self::Lagged) is a transport health marker, not an application +/// event — it signals that the consumer fell behind and some events were dropped. +#[derive(Debug, Clone)] +pub enum InProcessServerEvent { + /// Server request that requires client response/rejection. + ServerRequest(ServerRequest), + /// App-server notification directed to the embedded client. + ServerNotification(ServerNotification), + /// Indicates one or more events were dropped due to backpressure. + Lagged { skipped: usize }, +} + +/// Internal message sent from [`InProcessClientHandle`] methods to the runtime task. +/// +/// Requests carry a oneshot sender for the response; notifications and server-request +/// replies are fire-and-forget from the caller's perspective (transport errors are +/// caught by `try_send` on the outer channel). +enum InProcessClientMessage { + Request { + request: Box, + response_tx: oneshot::Sender, + }, + Notification { + notification: ClientNotification, + }, + ServerRequestResponse { + request_id: RequestId, + result: Result, + }, + ServerRequestError { + request_id: RequestId, + error: JSONRPCErrorError, + }, + Shutdown { + done_tx: oneshot::Sender<()>, + }, +} + +enum ProcessorCommand { + Request(Box), + Notification(ClientNotification), +} + +#[derive(Clone)] +pub struct InProcessClientSender { + client_tx: mpsc::Sender, +} + +impl InProcessClientSender { + pub async fn request(&self, request: ClientRequest) -> IoResult { + let (response_tx, response_rx) = oneshot::channel(); + self.try_send_client_message(InProcessClientMessage::Request { + request: Box::new(request), + response_tx, + })?; + response_rx.await.map_err(|err| { + IoError::new( + ErrorKind::BrokenPipe, + format!("in-process request response channel closed: {err}"), + ) + }) + } + + pub fn notify(&self, notification: ClientNotification) -> IoResult<()> { + self.try_send_client_message(InProcessClientMessage::Notification { notification }) + } + + pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> { + self.try_send_client_message(InProcessClientMessage::ServerRequestResponse { + request_id, + result, + }) + } + + pub fn fail_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + self.try_send_client_message(InProcessClientMessage::ServerRequestError { + request_id, + error, + }) + } + + fn try_send_client_message(&self, message: InProcessClientMessage) -> IoResult<()> { + match self.client_tx.try_send(message) { + Ok(()) => Ok(()), + Err(mpsc::error::TrySendError::Full(_)) => Err(IoError::new( + ErrorKind::WouldBlock, + "in-process app-server client queue is full", + )), + Err(mpsc::error::TrySendError::Closed(_)) => Err(IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server runtime is closed", + )), + } + } +} + +/// Handle used by an in-process client to call app-server and consume events. +/// +/// This is the low-level runtime handle. Higher-level callers should usually go +/// through `codex-app-server-client`, which adds worker-task buffering, +/// request/response helpers, and surface-specific startup policy. +pub struct InProcessClientHandle { + client: InProcessClientSender, + event_rx: mpsc::Receiver, + runtime_handle: tokio::task::JoinHandle<()>, + #[cfg(test)] + _test_codex_home: Option, +} + +impl InProcessClientHandle { + /// Sends a typed client request into the in-process runtime. + /// + /// The returned value is a transport-level `IoResult` containing either a + /// JSON-RPC success payload or JSON-RPC error payload. Callers must keep + /// request IDs unique among concurrent requests; reusing an in-flight ID + /// produces an `INVALID_REQUEST` response and can make request routing + /// ambiguous in the caller. + pub async fn request(&self, request: ClientRequest) -> IoResult { + self.client.request(request).await + } + + /// Sends a typed client notification into the in-process runtime. + /// + /// Notifications do not have an application-level response. Transport + /// errors indicate queue saturation or closed runtime. + pub fn notify(&self, notification: ClientNotification) -> IoResult<()> { + self.client.notify(notification) + } + + /// Resolves a pending [`ServerRequest`](InProcessServerEvent::ServerRequest). + /// + /// This should be used only with request IDs received from the current + /// runtime event stream; sending arbitrary IDs has no effect on app-server + /// state and can mask a stuck approval flow in the caller. + pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> { + self.client.respond_to_server_request(request_id, result) + } + + /// Rejects a pending [`ServerRequest`](InProcessServerEvent::ServerRequest). + /// + /// Use this when the embedder cannot satisfy a server request; leaving + /// requests unanswered can stall turn progress. + pub fn fail_server_request( + &self, + request_id: RequestId, + error: JSONRPCErrorError, + ) -> IoResult<()> { + self.client.fail_server_request(request_id, error) + } + + /// Receives the next server event from the in-process runtime. + /// + /// Returns `None` when the runtime task exits and no more events are + /// available. + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } + + /// Requests runtime shutdown and waits for worker termination. + /// + /// Shutdown is bounded by internal timeouts and may abort background tasks + /// if graceful drain does not complete in time. + pub async fn shutdown(self) -> IoResult<()> { + let mut runtime_handle = self.runtime_handle; + let (done_tx, done_rx) = oneshot::channel(); + + if self + .client + .client_tx + .send(InProcessClientMessage::Shutdown { done_tx }) + .await + .is_ok() + { + let _ = timeout(SHUTDOWN_TIMEOUT, done_rx).await; + } + + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut runtime_handle).await { + runtime_handle.abort(); + let _ = runtime_handle.await; + } + Ok(()) + } + + pub fn sender(&self) -> InProcessClientSender { + self.client.clone() + } +} + +/// Starts an in-process app-server runtime and performs initialize handshake. +/// +/// This function sends `initialize` followed by `initialized` before returning +/// the handle, so callers receive a ready-to-use runtime. If initialize fails, +/// the runtime is shut down and an `InvalidData` error is returned. +pub async fn start(args: InProcessStartArgs) -> IoResult { + let initialize = args.initialize.clone(); + let client = start_uninitialized(args).await?; + + let initialize_response = client + .request(ClientRequest::Initialize { + request_id: RequestId::Integer(0), + params: initialize, + }) + .await?; + if let Err(error) = initialize_response { + let _ = client.shutdown().await; + return Err(IoError::new( + ErrorKind::InvalidData, + format!("in-process initialize failed: {}", error.message), + )); + } + client.notify(ClientNotification::Initialized)?; + + Ok(client) +} + +async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { + let channel_capacity = args.channel_capacity.max(1); + let installation_id = resolve_installation_id(&args.config.codex_home).await?; + let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); + let (event_tx, event_rx) = mpsc::channel::(channel_capacity); + + let runtime_handle = tokio::spawn(async move { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env) + .await; + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); + + let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); + let outbound_initialized = Arc::new(AtomicBool::new(false)); + let outbound_experimental_api_enabled = Arc::new(AtomicBool::new(false)); + let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new())); + + let mut outbound_connections = HashMap::::new(); + outbound_connections.insert( + IN_PROCESS_CONNECTION_ID, + OutboundConnectionState::new( + writer_tx, + Arc::clone(&outbound_initialized), + Arc::clone(&outbound_experimental_api_enabled), + Arc::clone(&outbound_opted_out_notification_methods), + /*disconnect_sender*/ None, + ), + ); + let mut outbound_handle = tokio::spawn(async move { + while let Some(envelope) = outgoing_rx.recv().await { + route_outgoing_envelope(&mut outbound_connections, envelope).await; + } + }); + + let processor_outgoing = Arc::clone(&outgoing_message_sender); + let config_manager = ConfigManager::new( + args.config.codex_home.to_path_buf(), + args.cli_overrides, + args.loader_overrides, + args.cloud_requirements, + args.arg0_paths.clone(), + args.thread_config_loader, + ); + let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); + let mut processor_handle = tokio::spawn(async move { + let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { + outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, + arg0_paths: args.arg0_paths, + config: args.config, + config_manager, + environment_manager: args.environment_manager, + feedback: args.feedback, + log_db: args.log_db, + state_db: args.state_db, + config_warnings: args.config_warnings, + session_source: args.session_source, + auth_manager, + installation_id, + rpc_transport: AppServerRpcTransport::InProcess, + remote_control_handle: None, + plugin_startup_tasks: crate::PluginStartupTasks::Start, + })); + let mut thread_created_rx = processor.thread_created_receiver(); + let session = Arc::new(ConnectionSessionState::new()); + let mut listen_for_threads = true; + + loop { + tokio::select! { + command = processor_rx.recv() => { + match command { + Some(ProcessorCommand::Request(request)) => { + let was_initialized = session.initialized(); + processor + .process_client_request( + IN_PROCESS_CONNECTION_ID, + *request, + Arc::clone(&session), + &outbound_initialized, + ) + .await; + let opted_out_notification_methods_snapshot = + session.opted_out_notification_methods(); + let experimental_api_enabled = + session.experimental_api_enabled(); + let is_initialized = session.initialized(); + if let Ok(mut opted_out_notification_methods) = + outbound_opted_out_notification_methods.write() + { + *opted_out_notification_methods = + opted_out_notification_methods_snapshot; + } else { + warn!("failed to update outbound opted-out notifications"); + } + outbound_experimental_api_enabled.store( + experimental_api_enabled, + Ordering::Release, + ); + if !was_initialized && is_initialized { + processor.send_initialize_notifications().await; + } + } + Some(ProcessorCommand::Notification(notification)) => { + processor.process_client_notification(notification).await; + } + None => { + break; + } + } + } + created = thread_created_rx.recv(), if listen_for_threads => { + match created { + Ok(thread_id) => { + let connection_ids = if session.initialized() { + vec![IN_PROCESS_CONNECTION_ID] + } else { + Vec::::new() + }; + processor + .try_attach_thread_listener(thread_id, connection_ids) + .await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + warn!("thread_created receiver lagged; skipping resync"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + listen_for_threads = false; + } + } + } + } + } + + processor.clear_runtime_references(); + processor.cancel_active_login().await; + processor + .connection_closed(IN_PROCESS_CONNECTION_ID, &session) + .await; + processor.clear_all_thread_listeners().await; + processor.drain_background_tasks().await; + processor.shutdown_threads().await; + }); + let mut pending_request_responses = + HashMap::>::new(); + let mut shutdown_ack = None; + + loop { + tokio::select! { + message = client_rx.recv() => { + match message { + Some(InProcessClientMessage::Request { request, response_tx }) => { + let request = *request; + let request_id = request.id().clone(); + match pending_request_responses.entry(request_id.clone()) { + Entry::Vacant(entry) => { + entry.insert(response_tx); + } + Entry::Occupied(_) => { + let _ = response_tx.send(Err(invalid_request(format!( + "duplicate request id: {request_id:?}" + )))); + continue; + } + } + + match processor_tx.try_send(ProcessorCommand::Request(Box::new(request))) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + if let Some(response_tx) = + pending_request_responses.remove(&request_id) + { + let _ = response_tx.send(Err(JSONRPCErrorError { + code: OVERLOADED_ERROR_CODE, + message: "in-process app-server request queue is full" + .to_string(), + data: None, + })); + } + } + Err(mpsc::error::TrySendError::Closed(_)) => { + if let Some(response_tx) = + pending_request_responses.remove(&request_id) + { + let _ = response_tx.send(Err(internal_error( + "in-process app-server request processor is closed", + ))); + } + break; + } + } + } + Some(InProcessClientMessage::Notification { notification }) => { + match processor_tx.try_send(ProcessorCommand::Notification(notification)) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + warn!("dropping in-process client notification (queue full)"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + break; + } + } + } + Some(InProcessClientMessage::ServerRequestResponse { request_id, result }) => { + outgoing_message_sender + .notify_client_response(request_id, result) + .await; + } + Some(InProcessClientMessage::ServerRequestError { request_id, error }) => { + outgoing_message_sender + .notify_client_error(request_id, error) + .await; + } + Some(InProcessClientMessage::Shutdown { done_tx }) => { + shutdown_ack = Some(done_tx); + break; + } + None => { + break; + } + } + } + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { + break; + }; + let outgoing_message = queued_message.message; + match outgoing_message { + OutgoingMessage::Response(response) => { + if let Some(response_tx) = pending_request_responses.remove(&response.id) { + let _ = response_tx.send(Ok(response.result)); + } else { + warn!( + request_id = ?response.id, + "dropping unmatched in-process response" + ); + } + } + OutgoingMessage::Error(error) => { + if let Some(response_tx) = pending_request_responses.remove(&error.id) { + let _ = response_tx.send(Err(error.error)); + } else { + warn!( + request_id = ?error.id, + "dropping unmatched in-process error response" + ); + } + } + OutgoingMessage::Request(request) => { + // Send directly to avoid cloning; on failure the + // original value is returned inside the error. + if let Err(send_error) = event_tx + .try_send(InProcessServerEvent::ServerRequest(request)) + { + let (error, inner) = match send_error { + mpsc::error::TrySendError::Full(inner) => ( + JSONRPCErrorError { + code: OVERLOADED_ERROR_CODE, + message: + "in-process server request queue is full".to_string(), + data: None, + }, + inner, + ), + mpsc::error::TrySendError::Closed(inner) => ( + internal_error( + "in-process server request consumer is closed", + ), + inner, + ), + }; + let request_id = match inner { + InProcessServerEvent::ServerRequest(req) => req.id().clone(), + _ => unreachable!("we just sent a ServerRequest variant"), + }; + outgoing_message_sender + .notify_client_error(request_id, error) + .await; + } + } + OutgoingMessage::AppServerNotification(notification) => { + if server_notification_requires_delivery(¬ification) { + if event_tx + .send(InProcessServerEvent::ServerNotification(notification)) + .await + .is_err() + { + break; + } + } else if let Err(send_error) = + event_tx.try_send(InProcessServerEvent::ServerNotification(notification)) + { + match send_error { + mpsc::error::TrySendError::Full(_) => { + warn!("dropping in-process server notification (queue full)"); + } + mpsc::error::TrySendError::Closed(_) => { + break; + } + } + } + } + } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + } + } + + drop(writer_rx); + drop(processor_tx); + outgoing_message_sender + .cancel_all_requests(Some(internal_error( + "in-process app-server runtime is shutting down", + ))) + .await; + // Drop the runtime's last sender before awaiting the router task so + // `outgoing_rx.recv()` can observe channel closure and exit cleanly. + drop(outgoing_message_sender); + for (_, response_tx) in pending_request_responses { + let _ = response_tx.send(Err(internal_error( + "in-process app-server runtime is shutting down", + ))); + } + + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut processor_handle).await { + processor_handle.abort(); + let _ = processor_handle.await; + } + if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut outbound_handle).await { + outbound_handle.abort(); + let _ = outbound_handle.await; + } + + if let Some(done_tx) = shutdown_ack { + let _ = done_tx.send(()); + } + }); + + Ok(InProcessClientHandle { + client: InProcessClientSender { client_tx }, + event_rx, + runtime_handle, + #[cfg(test)] + _test_codex_home: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ClientInfo; + use codex_app_server_protocol::ConfigRequirementsReadResponse; + use codex_app_server_protocol::SessionSource as ApiSessionSource; + use codex_app_server_protocol::ThreadStartParams; + use codex_app_server_protocol::ThreadStartResponse; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnItemsView; + use codex_app_server_protocol::TurnStatus; + use codex_core::config::ConfigBuilder; + use pretty_assertions::assert_eq; + use std::path::Path; + use tempfile::TempDir; + + async fn build_test_config(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), + } + } + + async fn start_test_client_with_capacity( + session_source: SessionSource, + channel_capacity: usize, + ) -> InProcessClientHandle { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config(codex_home.path()).await); + let state_db = codex_rollout::state_db::try_init(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); + let args = InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config, + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: Some(state_db), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-in-process-test".to_string(), + title: None, + version: "0.0.0".to_string(), + }, + capabilities: None, + }, + channel_capacity, + }; + let mut client = start(args).await.expect("in-process runtime should start"); + client._test_codex_home = Some(codex_home); + client + } + + async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle { + start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await + } + + #[tokio::test] + async fn in_process_start_initializes_and_handles_typed_v2_request() { + let client = start_test_client(SessionSource::Cli).await; + let response = client + .request(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(1), + params: None, + }) + .await + .expect("request transport should work") + .expect("request should succeed"); + assert!(response.is_object()); + + let _parsed: ConfigRequirementsReadResponse = + serde_json::from_value(response).expect("response should match v2 schema"); + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + } + + #[tokio::test] + async fn in_process_start_uses_requested_session_source_for_thread_start() { + for (requested_source, expected_source) in [ + (SessionSource::Cli, ApiSessionSource::Cli), + (SessionSource::Exec, ApiSessionSource::Exec), + ] { + let client = start_test_client(requested_source).await; + let response = client + .request(ClientRequest::ThreadStart { + request_id: RequestId::Integer(2), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }) + .await + .expect("request transport should work") + .expect("thread/start should succeed"); + let parsed: ThreadStartResponse = + serde_json::from_value(response).expect("thread/start response should parse"); + assert_eq!(parsed.thread.source, expected_source); + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + } + } + + #[tokio::test] + async fn in_process_start_clamps_zero_channel_capacity() { + let client = + start_test_client_with_capacity(SessionSource::Cli, /*channel_capacity*/ 0).await; + let response = loop { + match client + .request(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(4), + params: None, + }) + .await + { + Ok(response) => break response.expect("request should succeed"), + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + tokio::task::yield_now().await; + } + Err(err) => panic!("request transport should work: {err}"), + } + }; + let _parsed: ConfigRequirementsReadResponse = + serde_json::from_value(response).expect("response should match v2 schema"); + client + .shutdown() + .await + .expect("in-process runtime should shutdown cleanly"); + } + + #[test] + fn guaranteed_delivery_helpers_cover_terminal_server_notifications() { + assert!(server_notification_requires_delivery( + &ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + items_view: TurnItemsView::NotLoaded, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, + }, + }) + )); + } +} diff --git a/code-rs/app-server/src/lib.rs b/code-rs/app-server/src/lib.rs index 95d8b68141b..08aab99f654 100644 --- a/code-rs/app-server/src/lib.rs +++ b/code-rs/app-server/src/lib.rs @@ -1,116 +1,439 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] +use codex_arg0::Arg0DispatchPaths; +use codex_config::ConfigLayerStackOrdering; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_config::RemoteThreadConfigLoader; +use codex_config::ThreadConfigLoader; +use codex_core::config::Config; +use codex_core::resolve_installation_id; +use codex_exec_server::EnvironmentManagerArgs; +use codex_features::Feature; +use codex_login::AuthManager; +use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; use std::collections::HashSet; -use std::collections::VecDeque; use std::io::ErrorKind; use std::io::Result as IoResult; -use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; -use code_common::CliConfigOverrides; -use code_core::config::Config; -use code_core::config::ConfigOverrides; -use mcp_types::JSONRPCMessage; -use mcp_types::RequestId; -use tokio::sync::mpsc; -use tokio::sync::Notify; -use tokio::task::JoinHandle; -use tokio::time::Duration; -use tokio::time::sleep; -use tracing::info; -use tracing::warn; -use tracing_subscriber::EnvFilter; -use serde_json::json; - +use crate::analytics_utils::analytics_events_client_from_config; +use crate::config_manager::ConfigManager; use crate::message_processor::MessageProcessor; +use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; -use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; use crate::transport::TransportEvent; +use crate::transport::auth::policy_from_settings; use crate::transport::route_outgoing_envelope; +use crate::transport::start_control_socket_acceptor; +use crate::transport::start_remote_control; use crate::transport::start_stdio_connection; use crate::transport::start_websocket_acceptor; +use codex_analytics::AppServerRpcTransport; +use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TextPosition as AppTextPosition; +use codex_app_server_protocol::TextRange as AppTextRange; +use codex_config::ConfigLoadError; +use codex_config::TextRange as CoreTextRange; +use codex_core::ExecPolicyError; +use codex_core::check_execpolicy_for_warnings; +use codex_core::config::find_codex_home; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::ExecServerRuntimePaths; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use codex_rollout::state_db as rollout_state_db; +use codex_state::log_db; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::Level; +use tracing::error; +use tracing::info; +use tracing::warn; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::Layer; +use tracing_subscriber::filter::Targets; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::Registry; +use tracing_subscriber::util::SubscriberInitExt; -pub mod code_message_processor; +mod analytics_utils; +mod app_server_tracing; +mod bespoke_event_handling; +mod command_exec; +mod config; +mod config_manager; +mod config_manager_service; +mod connection_rpc_gate; +mod dynamic_tools; mod error_code; -mod external_agent_config_api; +mod filters; +mod fs_watch; mod fuzzy_file_search; +pub mod in_process; +mod mcp_refresh; mod message_processor; -pub mod outgoing_message; +mod models; +mod outgoing_message; +mod request_processors; +mod request_serialization; +mod server_request_error; +mod thread_state; +mod thread_status; mod transport; +pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; +pub use crate::error_code::INVALID_PARAMS_ERROR_CODE; pub use crate::transport::AppServerTransport; +pub use crate::transport::app_server_control_socket_path; +pub use crate::transport::auth::AppServerWebsocketAuthArgs; +pub use crate::transport::auth::AppServerWebsocketAuthSettings; +pub use crate::transport::auth::WebsocketAuthCliMode; + +const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT"; -const INTERNAL_REQUEST_ID_PREFIX: &str = "__code_internal_request__"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LogFormat { + Default, + Json, +} + +type StderrLogLayer = Box + Send + Sync + 'static>; -/// Control-plane messages from the processor side to the outbound router task. +fn configured_thread_config_loader(config: &Config) -> Arc { + match config.experimental_thread_config_endpoint.as_deref() { + Some(endpoint) => Arc::new(RemoteThreadConfigLoader::new(endpoint)), + None => Arc::new(NoopThreadConfigLoader), + } +} + +/// Control-plane messages from the processor/transport side to the outbound router task. +/// +/// `run_main_with_transport` now uses two loops/tasks: +/// - processor loop: handles incoming JSON-RPC and request dispatch +/// - outbound loop: performs potentially slow writes to per-connection writers +/// +/// `OutboundControlEvent` keeps those loops coordinated without sharing mutable +/// connection state directly. In particular, the outbound loop needs to know +/// when a connection opens/closes so it can route messages correctly. enum OutboundControlEvent { + /// Register a new writer for an opened connection. Opened { connection_id: ConnectionId, - writer: mpsc::Sender, + writer: mpsc::Sender, + disconnect_sender: Option, initialized: Arc, + experimental_api_enabled: Arc, opted_out_notification_methods: Arc>>, - disconnect_notify: Option>, - }, - Closed { - connection_id: ConnectionId, }, + /// Remove state for a closed/disconnected connection. + Closed { connection_id: ConnectionId }, + /// Disconnect all connection-oriented clients during graceful restart. + DisconnectAll, +} + +#[derive(Default)] +struct ShutdownState { + requested: bool, + forced: bool, + last_logged_running_turn_count: Option, +} + +enum ShutdownAction { + Noop, + Finish, +} + +async fn shutdown_signal() -> IoResult<()> { + #[cfg(unix)] + { + use tokio::signal::unix::SignalKind; + use tokio::signal::unix::signal; + + let mut term = signal(SignalKind::terminate())?; + tokio::select! { + ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result, + _ = term.recv() => Ok(()), + } + } + + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await + } +} + +impl ShutdownState { + fn requested(&self) -> bool { + self.requested + } + + fn forced(&self) -> bool { + self.forced + } + + fn on_signal(&mut self, connection_count: usize, running_turn_count: usize) { + if self.requested { + self.forced = true; + return; + } + + self.requested = true; + self.last_logged_running_turn_count = None; + info!( + "received shutdown signal; entering graceful restart drain (connections={}, runningAssistantTurns={}, requests still accepted until no assistant turns are running)", + connection_count, running_turn_count, + ); + } + + fn update(&mut self, running_turn_count: usize, connection_count: usize) -> ShutdownAction { + if !self.requested { + return ShutdownAction::Noop; + } + + if self.forced || running_turn_count == 0 { + if self.forced { + info!( + "received second shutdown signal; forcing restart with {running_turn_count} running assistant turn(s) and {connection_count} connection(s)" + ); + } else { + info!( + "shutdown signal restart: no assistant turns running; stopping acceptor and disconnecting {connection_count} connection(s)" + ); + } + return ShutdownAction::Finish; + } + + if self.last_logged_running_turn_count != Some(running_turn_count) { + info!( + "shutdown signal restart: waiting for {running_turn_count} running assistant turn(s) to finish" + ); + self.last_logged_running_turn_count = Some(running_turn_count); + } + + ShutdownAction::Noop + } +} + +fn config_warning_from_error( + summary: impl Into, + err: &std::io::Error, +) -> ConfigWarningNotification { + let (path, range) = match config_error_location(err) { + Some((path, range)) => (Some(path), Some(range)), + None => (None, None), + }; + ConfigWarningNotification { + summary: summary.into(), + details: Some(err.to_string()), + path, + range, + } +} + +fn config_error_location(err: &std::io::Error) -> Option<(String, AppTextRange)> { + err.get_ref() + .and_then(|err| err.downcast_ref::()) + .map(|err| { + let config_error = err.config_error(); + ( + config_error.path.to_string_lossy().to_string(), + app_text_range(&config_error.range), + ) + }) +} + +fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option, Option) { + match err { + ExecPolicyError::ParsePolicy { path, source } => { + if let Some(location) = source.location() { + let range = AppTextRange { + start: AppTextPosition { + line: location.range.start.line, + column: location.range.start.column, + }, + end: AppTextPosition { + line: location.range.end.line, + column: location.range.end.column, + }, + }; + return (Some(location.path), Some(range)); + } + (Some(path.clone()), None) + } + _ => (None, None), + } } -#[derive(Clone, Debug)] -struct RequestRoute { - connection_id: ConnectionId, - original_request_id: RequestId, +fn app_text_range(range: &CoreTextRange) -> AppTextRange { + AppTextRange { + start: AppTextPosition { + line: range.start.line, + column: range.start.column, + }, + end: AppTextPosition { + line: range.end.line, + column: range.end.column, + }, + } +} + +fn project_config_warning(config: &Config) -> Option { + let mut disabled_folders = Vec::new(); + + for layer in config.config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) { + let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else { + continue; + }; + let Some(disabled_reason) = &layer.disabled_reason else { + continue; + }; + disabled_folders.push(( + dot_codex_folder.as_path().display().to_string(), + disabled_reason.clone(), + )); + } + + if disabled_folders.is_empty() { + return None; + } + + let mut message = concat!( + "Project-local config, hooks, and exec policies are disabled in the following folders ", + "until the project is trusted, but skills still load.\n", + ) + .to_string(); + for (index, (folder, reason)) in disabled_folders.iter().enumerate() { + let display_index = index + 1; + message.push_str(&format!(" {display_index}. {folder}\n")); + message.push_str(&format!(" {reason}\n")); + } + + Some(ConfigWarningNotification { + summary: message, + details: None, + path: None, + range: None, + }) +} + +impl LogFormat { + fn from_env_value(value: Option<&str>) -> Self { + match value.map(str::trim).map(str::to_ascii_lowercase) { + Some(value) if value == "json" => Self::Json, + _ => Self::Default, + } + } +} + +fn log_format_from_env() -> LogFormat { + let value = std::env::var(LOG_FORMAT_ENV_VAR).ok(); + LogFormat::from_env_value(value.as_deref()) } pub async fn run_main( - code_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, + loader_overrides: LoaderOverrides, + default_analytics_enabled: bool, ) -> IoResult<()> { run_main_with_transport( - code_linux_sandbox_exe, + arg0_paths, cli_config_overrides, + loader_overrides, + default_analytics_enabled, AppServerTransport::Stdio, + SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), ) .await } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginStartupTasks { + Start, + Skip, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppServerRuntimeOptions { + pub plugin_startup_tasks: PluginStartupTasks, +} + +impl Default for AppServerRuntimeOptions { + fn default() -> Self { + Self { + plugin_startup_tasks: PluginStartupTasks::Start, + } + } +} + pub async fn run_main_with_transport( - code_linux_sandbox_exe: Option, + arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, + loader_overrides: LoaderOverrides, + default_analytics_enabled: bool, transport: AppServerTransport, + session_source: SessionSource, + auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { - let _ = tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_env_filter(EnvFilter::from_default_env()) - .try_init(); + run_main_with_transport_options( + arg0_paths, + cli_config_overrides, + loader_overrides, + default_analytics_enabled, + transport, + session_source, + auth, + AppServerRuntimeOptions::default(), + ) + .await +} +#[allow(clippy::too_many_arguments)] +pub async fn run_main_with_transport_options( + arg0_paths: Arg0DispatchPaths, + cli_config_overrides: CliConfigOverrides, + loader_overrides: LoaderOverrides, + default_analytics_enabled: bool, + transport: AppServerTransport, + session_source: SessionSource, + auth: AppServerWebsocketAuthSettings, + runtime_options: AppServerRuntimeOptions, +) -> IoResult<()> { + let environment_manager = Arc::new( + EnvironmentManager::new(EnvironmentManagerArgs::new( + ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?, + )) + .await, + ); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outbound_control_tx, mut outbound_control_rx) = mpsc::channel::(CHANNEL_CAPACITY); - let mut stdio_handles = Vec::>::new(); - let mut websocket_accept_handle = None; - match transport { - AppServerTransport::Stdio => { - start_stdio_connection(transport_event_tx.clone(), &mut stdio_handles).await?; - } - AppServerTransport::WebSocket { bind_address } => { - websocket_accept_handle = - Some(start_websocket_acceptor(bind_address, transport_event_tx.clone()).await?); - } - } - let shutdown_when_no_connections = matches!(transport, AppServerTransport::Stdio); - // 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| { @@ -119,95 +442,310 @@ pub async fn run_main_with_transport( format!("error parsing -c overrides: {e}"), ) })?; - let mut config_overrides = ConfigOverrides::default(); - config_overrides.code_linux_sandbox_exe = code_linux_sandbox_exe.clone(); - let mut config_warnings = Vec::::new(); - - let config = match Config::load_with_cli_overrides(cli_kv_overrides.clone(), config_overrides.clone()) { - Ok(config) => config, + let codex_home = find_codex_home()?; + let config_manager = ConfigManager::new( + codex_home.to_path_buf(), + cli_kv_overrides.clone(), + loader_overrides, + Default::default(), + arg0_paths.clone(), + Arc::new(NoopThreadConfigLoader), + ); + match config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + { + Ok(config) => { + let discovered_thread_config_loader = configured_thread_config_loader(&config); + config_manager + .replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader)); + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + config_manager.replace_cloud_requirements_loader(auth_manager, config.chatgpt_base_url); + } + Err(err) => { + warn!(error = %err, "Failed to preload config for cloud requirements"); + // TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed. + } + }; + let mut config_warnings = Vec::new(); + let (mut config, should_run_personality_migration) = match config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + { + Ok(config) => (config, true), Err(err) => { - config_warnings.push(json!({ - "summary": "Invalid configuration; using defaults.", - "details": err.to_string(), - "path": serde_json::Value::Null, - "range": serde_json::Value::Null, - })); - Config::load_default_with_cli_overrides(cli_kv_overrides.clone(), config_overrides) - .map_err(|fallback_err| { - std::io::Error::new( - ErrorKind::InvalidData, - format!( - "error loading default config after config error: {fallback_err}" - ), + let message = config_warning_from_error("Invalid configuration; using defaults.", &err); + config_warnings.push(message); + ( + config_manager.load_default_config().await.map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading default config after config error: {e}"), + ) + })?, + false, + ) + } + }; + + let state_db_result = rollout_state_db::try_init(&config).await; + let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string); + let state_db = state_db_result.ok(); + + if should_run_personality_migration { + let effective_toml = config.config_layer_stack.effective_config(); + match effective_toml.try_into() { + Ok(config_toml) => { + match codex_core::personality_migration::maybe_migrate_personality( + &config.codex_home, + &config_toml, + state_db.clone(), ) - })? + .await + { + Ok(codex_core::personality_migration::PersonalityMigrationStatus::Applied) => { + config = config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + .map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!( + "error reloading config after personality migration: {err}" + ), + ) + })?; + } + Ok( + codex_core::personality_migration::PersonalityMigrationStatus::SkippedMarker + | codex_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality + | codex_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions, + ) => {} + Err(err) => { + warn!(error = %err, "Failed to run personality migration"); + } + } + } + Err(err) => { + warn!(error = %err, "Failed to deserialize config for personality migration"); + } } + } + + if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await { + let (path, range) = exec_policy_warning_location(&err); + let message = ConfigWarningNotification { + summary: "Error parsing rules; custom rules not applied.".to_string(), + details: Some(err.to_string()), + path, + range, + }; + config_warnings.push(message); + } + + if let Some(warning) = project_config_warning(&config) { + config_warnings.push(warning); + } + for warning in &config.startup_warnings { + config_warnings.push(ConfigWarningNotification { + summary: warning.clone(), + details: None, + path: None, + range: None, + }); + } + if let Some(warning) = + codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + { + config_warnings.push(ConfigWarningNotification { + summary: warning, + details: None, + path: None, + range: None, + }); + } + + let feedback = CodexFeedback::new(); + + let otel = codex_core::otel_init::build_provider( + &config, + env!("CARGO_PKG_VERSION"), + Some("codex-app-server"), + default_analytics_enabled, + ) + .map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading otel config: {e}"), + ) + })?; + + // Install a simple subscriber so `tracing` output is visible. Users can + // control the log level with `RUST_LOG` and switch to JSON logs with + // `LOG_FORMAT=json`. + let stderr_fmt: StderrLogLayer = match log_format_from_env() { + LogFormat::Json => tracing_subscriber::fmt::layer() + .json() + .with_writer(std::io::stderr) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_filter(EnvFilter::from_default_env()) + .boxed(), + LogFormat::Default => tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_filter(EnvFilter::from_default_env()) + .boxed(), }; - let request_routes = Arc::new(tokio::sync::Mutex::new(HashMap::::new())); - let request_routes_for_outbound = Arc::clone(&request_routes); - let transport_event_tx_for_outbound = transport_event_tx.clone(); + let feedback_layer = feedback.logger_layer(); + let feedback_metadata_layer = feedback.metadata_layer(); + let log_db = state_db.clone().map(log_db::start); + let log_db_layer = log_db + .clone() + .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); + let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); + let _ = tracing_subscriber::registry() + .with(stderr_fmt) + .with(feedback_layer) + .with(feedback_metadata_layer) + .with(log_db_layer) + .with(otel_logger_layer) + .with(otel_tracing_layer) + .try_init(); + for warning in &config_warnings { + match &warning.details { + Some(details) => error!("{} {}", warning.summary, details), + None => error!("{}", warning.summary), + } + } + let installation_id = resolve_installation_id(&config.codex_home).await?; + if let Some(err) = &state_db_init_error { + error!("failed to initialize sqlite state db: {err}"); + } + + let transport_shutdown_token = CancellationToken::new(); + let mut transport_accept_handles = Vec::>::new(); + + let single_client_mode = matches!(&transport, AppServerTransport::Stdio); + let shutdown_when_no_connections = single_client_mode; + let graceful_signal_restart_enabled = !single_client_mode; + let mut app_server_client_name_rx = None; + + match &transport { + AppServerTransport::Stdio => { + let (stdio_client_name_tx, stdio_client_name_rx) = oneshot::channel::(); + app_server_client_name_rx = Some(stdio_client_name_rx); + start_stdio_connection( + transport_event_tx.clone(), + &mut transport_accept_handles, + stdio_client_name_tx, + ) + .await?; + } + AppServerTransport::UnixSocket { socket_path } => { + let accept_handle = start_control_socket_acceptor( + socket_path.clone(), + transport_event_tx.clone(), + transport_shutdown_token.clone(), + ) + .await?; + transport_accept_handles.push(accept_handle); + } + AppServerTransport::WebSocket { bind_address } => { + let accept_handle = start_websocket_acceptor( + *bind_address, + transport_event_tx.clone(), + transport_shutdown_token.clone(), + policy_from_settings(&auth)?, + ) + .await?; + transport_accept_handles.push(accept_handle); + } + AppServerTransport::Off => {} + } + + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + + let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl); + let remote_control_enabled = remote_control_config_enabled && state_db.is_some(); + if remote_control_config_enabled && state_db.is_none() { + error!("remote control disabled because sqlite state db is unavailable"); + } + if transport_accept_handles.is_empty() && !remote_control_enabled { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + if remote_control_config_enabled && state_db.is_none() { + "no transport configured; remote control disabled because sqlite state db is unavailable" + } else { + "no transport configured; use --listen or enable remote control" + }, + )); + } + + let (remote_control_accept_handle, remote_control_handle) = start_remote_control( + config.chatgpt_base_url.clone(), + state_db.clone(), + auth_manager.clone(), + transport_event_tx.clone(), + transport_shutdown_token.clone(), + app_server_client_name_rx, + remote_control_enabled, + ) + .await?; + transport_accept_handles.push(remote_control_accept_handle); + let outbound_handle = tokio::spawn(async move { let mut outbound_connections = HashMap::::new(); - let mut pending_closed_connections = VecDeque::::new(); loop { tokio::select! { - biased; - event = outbound_control_rx.recv() => { - let Some(event) = event else { - break; - }; - match event { - OutboundControlEvent::Opened { - connection_id, - writer, - initialized, - opted_out_notification_methods, - disconnect_notify, - } => { - outbound_connections.insert( + biased; + event = outbound_control_rx.recv() => { + let Some(event) = event else { + break; + }; + match event { + OutboundControlEvent::Opened { connection_id, - OutboundConnectionState::new( - writer, - initialized, - opted_out_notification_methods, - disconnect_notify, - ), - ); - } - OutboundControlEvent::Closed { connection_id } => { - outbound_connections.remove(&connection_id); + writer, + disconnect_sender, + initialized, + experimental_api_enabled, + opted_out_notification_methods, + } => { + outbound_connections.insert( + connection_id, + OutboundConnectionState::new( + writer, + initialized, + experimental_api_enabled, + opted_out_notification_methods, + disconnect_sender, + ), + ); + } + OutboundControlEvent::Closed { connection_id } => { + outbound_connections.remove(&connection_id); + } + OutboundControlEvent::DisconnectAll => { + info!( + "disconnecting {} outbound websocket connection(s) for graceful restart", + outbound_connections.len() + ); + for connection_state in outbound_connections.values() { + connection_state.request_disconnect(); + } + outbound_connections.clear(); + } } } - } - envelope = outgoing_rx.recv() => { + envelope = outgoing_rx.recv() => { let Some(envelope) = envelope else { break; }; - let Some(envelope) = - rewrite_response_routing(envelope, &request_routes_for_outbound).await - else { - continue; - }; - let disconnected_connections = - route_outgoing_envelope(&mut outbound_connections, envelope).await; - pending_closed_connections.extend(disconnected_connections); - } - } - - while let Some(connection_id) = pending_closed_connections.front().copied() { - match transport_event_tx_for_outbound - .try_send(TransportEvent::ConnectionClosed { connection_id }) - { - Ok(()) => { - pending_closed_connections.pop_front(); - } - Err(mpsc::error::TrySendError::Full(_)) => { - break; - } - Err(mpsc::error::TrySendError::Closed(_)) => { - return; - } + route_outgoing_envelope(&mut outbound_connections, envelope).await; } } } @@ -215,146 +753,276 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = - Arc::new(OutgoingMessageSender::new_with_routed_sender(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); + let initialize_notification_sender = outgoing_message_sender.clone(); let outbound_control_tx = outbound_control_tx; - let request_routes = Arc::clone(&request_routes); - let mut processor = MessageProcessor::new( - Arc::clone(&outgoing_message_sender), - code_linux_sandbox_exe, - Arc::new(config), + let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { + outgoing: outgoing_message_sender, + analytics_events_client, + arg0_paths, + config: Arc::new(config), + config_manager, + environment_manager, + feedback: feedback.clone(), + log_db, + state_db: state_db.clone(), config_warnings, - cli_kv_overrides, - ); + session_source, + auth_manager, + installation_id, + rpc_transport: analytics_rpc_transport(&transport), + remote_control_handle: Some(remote_control_handle.clone()), + plugin_startup_tasks: runtime_options.plugin_startup_tasks, + })); + let mut thread_created_rx = processor.thread_created_receiver(); + let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); let mut connections = HashMap::::new(); - let mut next_internal_request_ordinal = 0_u64; + let mut remote_control_status_rx = remote_control_handle.status_receiver(); + let mut remote_control_status = remote_control_status_rx.borrow().clone(); + let transport_shutdown_token = transport_shutdown_token.clone(); async move { + let mut listen_for_threads = true; + let mut shutdown_state = ShutdownState::default(); loop { - let Some(event) = transport_event_rx.recv().await else { - break; + let running_turn_count = { + let running_turn_count = running_turn_count_rx.borrow(); + *running_turn_count }; - match event { - TransportEvent::ConnectionOpened { - connection_id, - writer, - disconnect_notify, - } => { - let outbound_initialized = Arc::new(AtomicBool::new(false)); - let outbound_opted_out_notification_methods = - Arc::new(RwLock::new(HashSet::new())); - if outbound_control_tx - .send(OutboundControlEvent::Opened { - connection_id, - writer, - initialized: Arc::clone(&outbound_initialized), - opted_out_notification_methods: Arc::clone( - &outbound_opted_out_notification_methods, - ), - disconnect_notify, - }) - .await - .is_err() - { - break; - } - connections.insert( - connection_id, - ConnectionState::new( - outbound_initialized, - outbound_opted_out_notification_methods, - ), - ); - } - TransportEvent::ConnectionClosed { connection_id } => { - if shutdown_when_no_connections { - // Stdio clients can close stdin after sending requests while still - // expecting pending responses on stdout. - outgoing_message_sender - .clear_callbacks_for_connection(connection_id) - .await; - processor.on_connection_closed(connection_id).await; - wait_for_request_routes_for_connection( - &request_routes, - connection_id, - ) - .await; - } + if matches!( + shutdown_state.update(running_turn_count, connections.len()), + ShutdownAction::Finish + ) { + transport_shutdown_token.cancel(); + let _ = outbound_control_tx + .send(OutboundControlEvent::DisconnectAll) + .await; + break; + } - if outbound_control_tx - .send(OutboundControlEvent::Closed { connection_id }) - .await - .is_err() - { - break; - } - connections.remove(&connection_id); - remove_request_routes_for_connection(&request_routes, connection_id).await; - if !shutdown_when_no_connections { - outgoing_message_sender - .clear_callbacks_for_connection(connection_id) - .await; - processor.on_connection_closed(connection_id).await; + tokio::select! { + shutdown_signal_result = shutdown_signal(), if graceful_signal_restart_enabled && !shutdown_state.forced() => { + if let Err(err) = shutdown_signal_result { + warn!("failed to listen for shutdown signal during graceful restart drain: {err}"); } - - if shutdown_when_no_connections && connections.is_empty() { - break; + let running_turn_count = *running_turn_count_rx.borrow(); + shutdown_state.on_signal(connections.len(), running_turn_count); + } + changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => { + if changed.is_err() { + warn!("running-turn watcher closed during graceful restart drain"); } } - TransportEvent::IncomingMessage { - connection_id, - message, - } => match message { - JSONRPCMessage::Request(mut request) => { - let Some(connection_state) = connections.get_mut(&connection_id) else { - warn!("dropping request from unknown connection: {:?}", connection_id); - continue; - }; - - let original_request_id = request.id.clone(); - let internal_request_id = RequestId::String(format!( - "{INTERNAL_REQUEST_ID_PREFIX}{}:{next_internal_request_ordinal}", - connection_id.0 - )); - next_internal_request_ordinal += 1; - request.id = internal_request_id.clone(); - { - let mut request_routes = request_routes.lock().await; - request_routes.insert( - internal_request_id, - RequestRoute { + event = transport_event_rx.recv() => { + let Some(event) = event else { + break; + }; + match event { + TransportEvent::ConnectionOpened { + connection_id, + origin, + writer, + disconnect_sender, + } => { + let outbound_initialized = Arc::new(AtomicBool::new(false)); + let outbound_experimental_api_enabled = + Arc::new(AtomicBool::new(false)); + let outbound_opted_out_notification_methods = + Arc::new(RwLock::new(HashSet::new())); + if outbound_control_tx + .send(OutboundControlEvent::Opened { connection_id, - original_request_id, - }, + writer, + disconnect_sender, + initialized: Arc::clone(&outbound_initialized), + experimental_api_enabled: Arc::clone( + &outbound_experimental_api_enabled, + ), + opted_out_notification_methods: Arc::clone( + &outbound_opted_out_notification_methods, + ), + }) + .await + .is_err() + { + break; + } + connections.insert( + connection_id, + ConnectionState::new( + origin, + outbound_initialized, + outbound_experimental_api_enabled, + outbound_opted_out_notification_methods, + ), ); } - - let was_initialized = connection_state.session.initialized; - processor - .process_request( - connection_id, - request, - &mut connection_state.session, - &connection_state.outbound_initialized, - &connection_state.outbound_opted_out_notification_methods, - ) - .await; - if !was_initialized && connection_state.session.initialized { - processor.send_initialize_notifications(connection_id).await; + TransportEvent::ConnectionClosed { connection_id } => { + let Some(connection_state) = connections.remove(&connection_id) else { + continue; + }; + if outbound_control_tx + .send(OutboundControlEvent::Closed { connection_id }) + .await + .is_err() + { + break; + } + processor.connection_closed(connection_id, &connection_state.session).await; + if shutdown_when_no_connections && connections.is_empty() { + break; + } + } + TransportEvent::IncomingMessage { connection_id, message } => { + match message { + JSONRPCMessage::Request(request) => { + let Some(connection_state) = connections.get_mut(&connection_id) else { + warn!("dropping request from unknown connection: {connection_id:?}"); + continue; + }; + let was_initialized = + connection_state.session.initialized(); + processor + .process_request( + connection_id, + request, + &transport, + Arc::clone(&connection_state.session), + ) + .await; + let opted_out_notification_methods_snapshot = connection_state + .session + .opted_out_notification_methods(); + let experimental_api_enabled = + connection_state.session.experimental_api_enabled(); + let is_initialized = connection_state.session.initialized(); + if let Ok(mut opted_out_notification_methods) = connection_state + .outbound_opted_out_notification_methods + .write() + { + *opted_out_notification_methods = + opted_out_notification_methods_snapshot; + } else { + warn!( + "failed to update outbound opted-out notifications" + ); + } + connection_state + .outbound_experimental_api_enabled + .store( + experimental_api_enabled, + std::sync::atomic::Ordering::Release, + ); + if !was_initialized && is_initialized { + processor + .send_initialize_notifications_to_connection( + connection_id, + ) + .await; + initialize_notification_sender + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::RemoteControlStatusChanged( + remote_control_status.clone(), + ), + ) + .await; + processor.connection_initialized(connection_id).await; + connection_state + .outbound_initialized + .store(true, std::sync::atomic::Ordering::Release); + } + } + JSONRPCMessage::Response(response) => { + if !connections.contains_key(&connection_id) { + warn!("dropping response from unknown connection: {connection_id:?}"); + continue; + } + processor.process_response(response).await; + } + JSONRPCMessage::Notification(notification) => { + if !connections.contains_key(&connection_id) { + warn!("dropping notification from unknown connection: {connection_id:?}"); + continue; + } + processor.process_notification(notification).await; + } + JSONRPCMessage::Error(err) => { + if !connections.contains_key(&connection_id) { + warn!("dropping error from unknown connection: {connection_id:?}"); + continue; + } + processor.process_error(err).await; + } + } } } - JSONRPCMessage::Response(response) => { - processor.process_response(connection_id, response).await; + } + changed = remote_control_status_rx.changed() => { + if changed.is_err() { + continue; } - JSONRPCMessage::Notification(notification) => { - processor.process_notification(notification).await; + let status = remote_control_status_rx.borrow().clone(); + if remote_control_status == status { + continue; } - JSONRPCMessage::Error(err) => { - processor.process_error(connection_id, err).await; + remote_control_status = status.clone(); + initialize_notification_sender + .send_server_notification(ServerNotification::RemoteControlStatusChanged( + RemoteControlStatusChangedNotification { + status: status.status, + environment_id: status.environment_id, + }, + )) + .await; + } + created = thread_created_rx.recv(), if listen_for_threads => { + match created { + Ok(thread_id) => { + let mut initialized_connection_ids = Vec::new(); + for (connection_id, connection_state) in &connections { + if connection_state.session.initialized() { + initialized_connection_ids.push(*connection_id); + } + } + processor + .try_attach_thread_listener( + thread_id, + initialized_connection_ids, + ) + .await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // TODO(jif) handle lag. + // Assumes thread creation volume is low enough that lag never happens. + // If it does, we log and continue without resyncing to avoid attaching + // listeners for threads that should remain unsubscribed. + warn!("thread_created receiver lagged; skipping resync"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + listen_for_threads = false; + } } - }, + } } } + if !shutdown_state.forced() { + futures::future::join_all( + connections + .values() + .map(|connection_state| connection_state.session.rpc_gate.shutdown()), + ) + .await; + processor.drain_background_tasks().await; + processor.shutdown_threads().await; + } info!("processor task exited (channel closed)"); } }); @@ -364,108 +1032,47 @@ pub async fn run_main_with_transport( let _ = processor_handle.await; let _ = outbound_handle.await; - if let Some(handle) = websocket_accept_handle { - handle.abort(); + transport_shutdown_token.cancel(); + for handle in transport_accept_handles { + let _ = handle.await; } - for handle in stdio_handles { - let _ = handle.await; + if let Some(otel) = otel { + otel.shutdown(); } Ok(()) } -async fn rewrite_response_routing( - envelope: OutgoingEnvelope, - request_routes: &Arc>>, -) -> Option { - match envelope { - OutgoingEnvelope::Broadcast { - message: OutgoingMessage::Response(mut response), - } => { - let route = { - let mut request_routes = request_routes.lock().await; - request_routes.remove(&response.id) - }; - if let Some(route) = route { - response.id = route.original_request_id; - return Some(OutgoingEnvelope::ToConnection { - connection_id: route.connection_id, - message: OutgoingMessage::Response(response), - }); - } - - if is_internal_request_id(&response.id) { - warn!( - "dropping response for disconnected request route: {:?}", - response.id - ); - return None; - } - - Some(OutgoingEnvelope::Broadcast { - message: OutgoingMessage::Response(response), - }) - } - OutgoingEnvelope::Broadcast { - message: OutgoingMessage::Error(mut outgoing_error), - } => { - let route = { - let mut request_routes = request_routes.lock().await; - request_routes.remove(&outgoing_error.id) - }; - if let Some(route) = route { - outgoing_error.id = route.original_request_id; - return Some(OutgoingEnvelope::ToConnection { - connection_id: route.connection_id, - message: OutgoingMessage::Error(outgoing_error), - }); - } - - if is_internal_request_id(&outgoing_error.id) { - warn!( - "dropping error for disconnected request route: {:?}", - outgoing_error.id - ); - return None; - } - - Some(OutgoingEnvelope::Broadcast { - message: OutgoingMessage::Error(outgoing_error), - }) - } - _ => Some(envelope), +fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport { + match transport { + AppServerTransport::Stdio => AppServerRpcTransport::Stdio, + AppServerTransport::UnixSocket { .. } + | AppServerTransport::WebSocket { .. } + | AppServerTransport::Off => AppServerRpcTransport::Websocket, } } -fn is_internal_request_id(request_id: &RequestId) -> bool { - matches!(request_id, RequestId::String(value) if value.starts_with(INTERNAL_REQUEST_ID_PREFIX)) -} +#[cfg(test)] +mod tests { + use super::LogFormat; + use pretty_assertions::assert_eq; -async fn remove_request_routes_for_connection( - request_routes: &Arc>>, - connection_id: ConnectionId, -) { - let mut request_routes = request_routes.lock().await; - request_routes.retain(|_, route| route.connection_id != connection_id); -} - -async fn wait_for_request_routes_for_connection( - request_routes: &Arc>>, - connection_id: ConnectionId, -) { - loop { - let has_pending_requests = { - let request_routes = request_routes.lock().await; - request_routes - .values() - .any(|route| route.connection_id == connection_id) - }; - - if !has_pending_requests { - return; - } + #[test] + fn log_format_from_env_value_matches_json_values_case_insensitively() { + assert_eq!(LogFormat::from_env_value(Some("json")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some("JSON")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some(" Json ")), LogFormat::Json); + } - sleep(Duration::from_millis(10)).await; + #[test] + fn log_format_from_env_value_defaults_for_non_json_values() { + assert_eq!( + LogFormat::from_env_value(/*value*/ None), + LogFormat::Default + ); + assert_eq!(LogFormat::from_env_value(Some("")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); } } diff --git a/code-rs/app-server/src/main.rs b/code-rs/app-server/src/main.rs index 6d27d3c161b..1cb4bd9a8e0 100644 --- a/code-rs/app-server/src/main.rs +++ b/code-rs/app-server/src/main.rs @@ -1,32 +1,107 @@ use clap::Parser; -use code_app_server::AppServerTransport; -use code_app_server::run_main_with_transport; -use code_arg0::arg0_dispatch_or_else; -use code_common::CliConfigOverrides; +use codex_app_server::AppServerRuntimeOptions; +use codex_app_server::AppServerTransport; +use codex_app_server::AppServerWebsocketAuthArgs; +use codex_app_server::PluginStartupTasks; +use codex_app_server::run_main_with_transport_options; +use codex_arg0::Arg0DispatchPaths; +use codex_arg0::arg0_dispatch_or_else; +use codex_config::LoaderOverrides; +use codex_protocol::protocol::SessionSource; +use codex_utils_cli::CliConfigOverrides; +use std::path::PathBuf; + +// Debug-only test hook: lets integration tests point the server at a temporary +// managed config file without writing to /etc. +const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { - /// Accepted for Codex Desktop compatibility. Every Code handles analytics - /// policy through its normal config path, so this flag is intentionally a - /// no-op for the app-server process. - #[arg(long = "analytics-default-enabled", default_value_t = false)] - _analytics_default_enabled: bool, - /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`. + /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", default_value = AppServerTransport::DEFAULT_LISTEN_URL )] listen: AppServerTransport, + + /// Session source used to derive product restrictions and metadata. + #[arg( + long = "session-source", + value_name = "SOURCE", + default_value = "vscode", + value_parser = SessionSource::from_startup_arg + )] + session_source: SessionSource, + + #[command(flatten)] + auth: AppServerWebsocketAuthArgs, + + /// Hidden debug-only test hook used by integration tests that spawn the + /// production app-server binary. + #[cfg(debug_assertions)] + #[arg(long = "disable-plugin-startup-tasks-for-tests", hide = true)] + disable_plugin_startup_tasks_for_tests: bool, } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|code_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - run_main_with_transport(code_linux_sandbox_exe, CliConfigOverrides::default(), args.listen) - .await?; + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + managed_config_path_from_debug_env() + .map(LoaderOverrides::with_managed_config_path_for_tests) + .unwrap_or_default() + }; + let transport = args.listen; + let session_source = args.session_source; + let auth = args.auth.try_into_settings()?; + let mut runtime_options = AppServerRuntimeOptions::default(); + #[cfg(debug_assertions)] + if args.disable_plugin_startup_tasks_for_tests { + runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; + } + + run_main_with_transport_options( + arg0_paths, + CliConfigOverrides::default(), + loader_overrides, + /*default_analytics_enabled*/ false, + transport, + session_source, + auth, + runtime_options, + ) + .await?; Ok(()) }) } + +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + +fn managed_config_path_from_debug_env() -> Option { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(MANAGED_CONFIG_PATH_ENV_VAR) { + return if value.is_empty() { + None + } else { + Some(PathBuf::from(value)) + }; + } + } + + None +} diff --git a/code-rs/app-server/src/mcp_refresh.rs b/code-rs/app-server/src/mcp_refresh.rs new file mode 100644 index 00000000000..8e1ccd3c0aa --- /dev/null +++ b/code-rs/app-server/src/mcp_refresh.rs @@ -0,0 +1,239 @@ +use crate::config_manager::ConfigManager; +use codex_core::CodexThread; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::McpServerRefreshConfig; +use codex_protocol::protocol::Op; +use std::io; +use std::sync::Arc; +use tracing::warn; + +pub(crate) async fn queue_strict_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) -> io::Result<()> { + config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await?; + let mut refreshes = Vec::new(); + for thread_id in thread_manager.list_thread_ids().await { + let thread = thread_manager + .get_thread(thread_id) + .await + .map_err(|err| io::Error::other(format!("failed to load thread {thread_id}: {err}")))?; + let config = + build_refresh_config(thread_manager, config_manager, thread.config().await).await?; + refreshes.push((thread_id, thread, config)); + } + for (thread_id, thread, config) in refreshes { + queue_refresh(thread_id, thread, config).await?; + } + Ok(()) +} + +pub(crate) async fn queue_best_effort_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) { + for thread_id in thread_manager.list_thread_ids().await { + let thread = match thread_manager.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + warn!("failed to load thread {thread_id} for MCP refresh: {err}"); + continue; + } + }; + let config = + match build_refresh_config(thread_manager, config_manager, thread.config().await).await + { + Ok(config) => config, + Err(err) => { + warn!("failed to build MCP refresh config for thread {thread_id}: {err}"); + continue; + } + }; + if let Err(err) = queue_refresh(thread_id, thread, config).await { + warn!("{err}"); + } + } +} + +async fn build_refresh_config( + thread_manager: &ThreadManager, + config_manager: &ConfigManager, + thread_config: Arc, +) -> io::Result { + let config = config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await?; + let mcp_servers = thread_manager + .mcp_manager() + .configured_servers(&config) + .await; + Ok(McpServerRefreshConfig { + mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + ) + .map_err(io::Error::other)?, + }) +} + +async fn queue_refresh( + thread_id: ThreadId, + thread: Arc, + config: McpServerRefreshConfig, +) -> io::Result<()> { + thread + .submit(Op::RefreshMcpServers { config }) + .await + .map(|_| ()) + .map_err(|err| { + io::Error::other(format!( + "failed to queue MCP refresh for thread {thread_id}: {err}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use codex_arg0::Arg0DispatchPaths; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::ThreadConfigContext; + use codex_config::ThreadConfigLoadError; + use codex_config::ThreadConfigLoadErrorCode; + use codex_config::ThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_core::config::ConfigOverrides; + use codex_core::init_state_db; + use codex_core::thread_store_from_config; + use codex_exec_server::EnvironmentManager; + use codex_login::AuthManager; + use codex_login::CodexAuth; + use codex_protocol::protocol::SessionSource; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + + #[tokio::test] + async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?; + + let err = queue_strict_refresh(&thread_manager, &config_manager) + .await + .expect_err("strict refresh should fail"); + + assert_eq!(err.to_string(), "failed to load refresh config"); + Ok(()) + } + + #[tokio::test] + async fn best_effort_refresh_attempts_every_loaded_thread() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, loader) = refresh_test_state().await?; + + queue_best_effort_refresh(&thread_manager, &config_manager).await; + + assert_eq!(loader.good_loads.load(Ordering::Relaxed), 1); + assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 1); + Ok(()) + } + + async fn refresh_test_state() -> anyhow::Result<( + TempDir, + Arc, + ConfigManager, + Arc, + )> { + let temp_dir = TempDir::new()?; + let good_cwd = temp_dir.path().join("good"); + let bad_cwd = temp_dir.path().join("bad"); + std::fs::create_dir_all(&good_cwd)?; + std::fs::create_dir_all(&bad_cwd)?; + + let initial_config_manager = + ConfigManager::without_managed_config_for_tests(temp_dir.path().to_path_buf()); + let good_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(good_cwd.clone()), + ) + .await?; + let bad_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(bad_cwd.clone()), + ) + .await?; + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")); + let state_db = init_state_db(&good_config) + .await + .expect("refresh tests require state db"); + let thread_store = thread_store_from_config(&good_config, Some(state_db.clone())); + let thread_manager = Arc::new(ThreadManager::new( + &good_config, + auth_manager, + SessionSource::Exec, + Arc::new(EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + thread_store, + Some(state_db.clone()), + "11111111-1111-4111-8111-111111111111".to_string(), + )); + thread_manager.start_thread(good_config).await?; + thread_manager.start_thread(bad_config).await?; + + let loader = Arc::new(CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf::try_from(good_cwd)?, + bad_cwd: AbsolutePathBuf::try_from(bad_cwd)?, + good_loads: AtomicUsize::new(0), + bad_loads: AtomicUsize::new(0), + }); + let config_manager = ConfigManager::new( + temp_dir.path().to_path_buf(), + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + loader.clone(), + ); + + Ok((temp_dir, thread_manager, config_manager, loader)) + } + + struct CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf, + bad_cwd: AbsolutePathBuf, + good_loads: AtomicUsize, + bad_loads: AtomicUsize, + } + + #[async_trait] + impl ThreadConfigLoader for CountingThreadConfigLoader { + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + if context.cwd.as_ref() == Some(&self.good_cwd) { + self.good_loads.fetch_add(1, Ordering::Relaxed); + } + if context.cwd.as_ref() == Some(&self.bad_cwd) { + self.bad_loads.fetch_add(1, Ordering::Relaxed); + return Err(ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Internal, + /*status_code*/ None, + "failed to load refresh config", + )); + } + Ok(Vec::new()) + } + } +} diff --git a/code-rs/app-server/src/message_processor.rs b/code-rs/app-server/src/message_processor.rs index 008c7c8257b..7006c403431 100644 --- a/code-rs/app-server/src/message_processor.rs +++ b/code-rs/app-server/src/message_processor.rs @@ -1,1179 +1,1278 @@ -use std::collections::HashMap; use std::collections::HashSet; -use std::path::Path; -use std::path::PathBuf; +use std::future::Future; use std::sync::Arc; -use std::sync::RwLock; +use std::sync::OnceLock; use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use crate::code_message_processor::CodexMessageProcessor; -use crate::external_agent_config_api::ExternalAgentConfigApi; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::config_manager::ConfigManager; +use crate::connection_rpc_gate::ConnectionRpcGate; +use crate::error_code::invalid_request; +use crate::fs_watch::FsWatchManager; use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; -use code_app_server_protocol::AuthMode; -use code_app_server_protocol::ConfigRequirements; -use code_app_server_protocol::CancelLoginAccountParams; -use code_app_server_protocol::Config as V2Config; -use code_app_server_protocol::ConfigBatchWriteParams; -use code_app_server_protocol::ConfigEdit; -use code_app_server_protocol::ConfigReadParams; -use code_app_server_protocol::ConfigReadResponse; -use code_app_server_protocol::ConfigRequirementsReadResponse; -use code_app_server_protocol::ConfigValueWriteParams; -use code_app_server_protocol::ConfigWriteErrorCode; -use code_app_server_protocol::ConfigWriteResponse; -use code_app_server_protocol::ExternalAgentConfigDetectParams; -use code_app_server_protocol::ExternalAgentConfigImportParams; -use code_app_server_protocol::ExperimentalFeatureListResponse; -use code_app_server_protocol::GetAccountParams; -use code_app_server_protocol::ListMcpServerStatusResponse; -use code_app_server_protocol::LoginAccountParams; -use code_app_server_protocol::MergeStrategy; -use code_app_server_protocol::ModelListResponse; -use code_app_server_protocol::ThreadListResponse; -use code_app_server_protocol::ToolsV2; -use code_app_server_protocol::AskForApproval as V2AskForApproval; -use code_app_server_protocol::WriteStatus; -use code_protocol::config_types::Verbosity; -use code_protocol::config_types::WebSearchMode; -use code_protocol::config_types::WebSearchToolConfig; -use code_core::AuthManager; -use code_core::ConversationManager; -use code_core::config::Config; -use code_core::default_client::get_code_user_agent_with_suffix; -use code_protocol::mcp_protocol::ClientRequest; -use code_protocol::mcp_protocol::ClientRequest::Initialize; -use code_protocol::mcp_protocol::GetUserAgentResponse; -use code_protocol::mcp_protocol::InitializeResponse; -use code_protocol::protocol::SessionSource; -use mcp_types::JSONRPCError; -use mcp_types::JSONRPCErrorError; -use mcp_types::JSONRPCNotification; -use mcp_types::JSONRPCRequest; -use mcp_types::JSONRPCResponse; -use code_utils_absolute_path::AbsolutePathBuf; -use code_utils_json_to_toml::json_to_toml; -use serde_json::json; -use sha1::Digest; -use sha1::Sha1; -use toml::Value as TomlValue; - -pub(crate) struct MessageProcessor { +use crate::outgoing_message::RequestContext; +use crate::request_processors::AccountRequestProcessor; +use crate::request_processors::AppsRequestProcessor; +use crate::request_processors::CatalogRequestProcessor; +use crate::request_processors::CommandExecRequestProcessor; +use crate::request_processors::ConfigRequestProcessor; +use crate::request_processors::ExternalAgentConfigRequestProcessor; +use crate::request_processors::FeedbackRequestProcessor; +use crate::request_processors::FsRequestProcessor; +use crate::request_processors::GitRequestProcessor; +use crate::request_processors::InitializeRequestProcessor; +use crate::request_processors::MarketplaceRequestProcessor; +use crate::request_processors::McpRequestProcessor; +use crate::request_processors::PluginRequestProcessor; +use crate::request_processors::ProcessExecRequestProcessor; +use crate::request_processors::SearchRequestProcessor; +use crate::request_processors::ThreadGoalRequestProcessor; +use crate::request_processors::ThreadRequestProcessor; +use crate::request_processors::TurnRequestProcessor; +use crate::request_processors::WindowsSandboxRequestProcessor; +use crate::request_serialization::QueuedInitializedRequest; +use crate::request_serialization::RequestSerializationQueueKey; +use crate::request_serialization::RequestSerializationQueues; +use crate::thread_state::ThreadStateManager; +use crate::transport::AppServerTransport; +use crate::transport::RemoteControlHandle; +use async_trait::async_trait; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AppServerRpcTransport; +use codex_app_server_protocol::AuthMode as LoginAuthMode; +use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; +use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::ExperimentalApi; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::experimental_required_message; +use codex_arg0::Arg0DispatchPaths; +use codex_chatgpt::workspace_settings; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::thread_store_from_config; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_login::AuthManager; +use codex_login::auth::ExternalAuth; +use codex_login::auth::ExternalAuthRefreshContext; +use codex_login::auth::ExternalAuthRefreshReason; +use codex_login::auth::ExternalAuthTokens; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::W3cTraceContext; +use codex_rollout::StateDbHandle; +use codex_state::log_db::LogDbLayer; +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::sync::broadcast; +use tokio::sync::watch; +use tokio::time::Duration; +use tokio::time::timeout; +use tracing::Instrument; + +const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); +#[derive(Clone)] +struct ExternalAuthRefreshBridge { outgoing: Arc, - code_message_processor: CodexMessageProcessor, - external_agent_config_api: ExternalAgentConfigApi, - base_config: Arc, - config_warnings: Arc>, - cli_overrides: Vec<(String, TomlValue)>, } -#[derive(Clone, Debug, Default)] -pub(crate) struct ConnectionSessionState { - pub(crate) initialized: bool, - pub(crate) user_agent_suffix: Option, - pub(crate) opted_out_notification_methods: HashSet, +impl ExternalAuthRefreshBridge { + fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason { + match reason { + ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized, + } + } } -impl MessageProcessor { - /// Create a new `MessageProcessor`, retaining a handle to the outgoing - /// `Sender` so handlers can enqueue messages to be written to the - /// transport. - pub(crate) fn new( - outgoing: Arc, - code_linux_sandbox_exe: Option, - config: Arc, - config_warnings: Vec, - cli_overrides: Vec<(String, TomlValue)>, - ) -> Self { - 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(), - ); - let external_agent_config_api = - ExternalAgentConfigApi::new(config.code_home.clone()); - - Self { - outgoing, - code_message_processor, - external_agent_config_api, - base_config: config_for_processor, - config_warnings: Arc::new(config_warnings), - cli_overrides, - } +#[async_trait] +impl ExternalAuth for ExternalAuthRefreshBridge { + fn auth_mode(&self) -> LoginAuthMode { + LoginAuthMode::Chatgpt } - pub(crate) async fn process_request( - &mut self, - connection_id: ConnectionId, - request: JSONRPCRequest, - session: &mut ConnectionSessionState, - outbound_initialized: &AtomicBool, - outbound_opted_out_notification_methods: &RwLock>, - ) { - let request_id = request.id.clone(); + async fn refresh( + &self, + context: ExternalAuthRefreshContext, + ) -> std::io::Result { + let params = ChatgptAuthTokensRefreshParams { + reason: Self::map_reason(context.reason), + previous_account_id: context.previous_account_id, + }; - if self - .try_process_v2_config_request(request_id.clone(), &request, session.initialized) - .await - { - return; - } + let (request_id, rx) = self + .outgoing + .send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params)) + .await; - if let Ok(request_json) = serde_json::to_value(request) - && let Ok(code_request) = serde_json::from_value::(request_json) - { - match code_request { - // Handle Initialize internally so CodexMessageProcessor does not have to concern - // itself with per-connection initialization state. - Initialize { request_id, params } => { - if session.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; - } + let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await { + Ok(result) => { + // Two failure scenarios: + // 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed. + // 2) client answered with JSON-RPC error payload => propagate code/message. + let result = result.map_err(|err| { + std::io::Error::other(format!("auth refresh request canceled: {err}")) + })?; + result.map_err(|err| { + std::io::Error::other(format!( + "auth refresh request failed: code={} message={}", + err.code, err.message + )) + })? + } + Err(_) => { + let _canceled = self.outgoing.cancel_request(&request_id).await; + return Err(std::io::Error::other(format!( + "auth refresh request timed out after {}s", + EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs() + ))); + } + }; - let client_info = params.client_info; - let opted_out_notification_methods = params - .capabilities - .and_then(|capabilities| capabilities.opt_out_notification_methods) - .unwrap_or_default(); - session.opted_out_notification_methods = - opted_out_notification_methods.into_iter().collect(); - session.user_agent_suffix = Some(format!("{}; {}", client_info.name, client_info.version)); + let response: ChatgptAuthTokensRefreshResponse = + serde_json::from_value(result).map_err(std::io::Error::other)?; - if let Ok(mut methods) = outbound_opted_out_notification_methods.write() { - *methods = session.opted_out_notification_methods.clone(); - } + Ok(ExternalAuthTokens::chatgpt( + response.access_token, + response.chatgpt_account_id, + response.chatgpt_plan_type, + )) + } +} - let user_agent = get_code_user_agent_with_suffix( - Some(&self.base_config.responses_originator_header), - session.user_agent_suffix.as_deref(), - ); - let response = InitializeResponse { user_agent }; - self.outgoing.send_response(request_id, response).await; +pub(crate) struct MessageProcessor { + outgoing: Arc, + account_processor: AccountRequestProcessor, + apps_processor: AppsRequestProcessor, + catalog_processor: CatalogRequestProcessor, + command_exec_processor: CommandExecRequestProcessor, + process_exec_processor: ProcessExecRequestProcessor, + config_processor: ConfigRequestProcessor, + external_agent_config_processor: ExternalAgentConfigRequestProcessor, + feedback_processor: FeedbackRequestProcessor, + fs_processor: FsRequestProcessor, + git_processor: GitRequestProcessor, + initialize_processor: InitializeRequestProcessor, + marketplace_processor: MarketplaceRequestProcessor, + mcp_processor: McpRequestProcessor, + plugin_processor: PluginRequestProcessor, + search_processor: SearchRequestProcessor, + thread_goal_processor: ThreadGoalRequestProcessor, + thread_processor: ThreadRequestProcessor, + turn_processor: TurnRequestProcessor, + windows_sandbox_processor: WindowsSandboxRequestProcessor, + request_serialization_queues: RequestSerializationQueues, +} - session.initialized = true; - outbound_initialized.store(true, Ordering::Release); - return; - } - ClientRequest::GetUserAgent { request_id, .. } => { - if !session.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; - } +#[derive(Debug)] +pub(crate) struct ConnectionSessionState { + pub(crate) rpc_gate: Arc, + initialized: OnceLock, +} - let response = GetUserAgentResponse { - user_agent: get_code_user_agent_with_suffix( - Some(&self.base_config.responses_originator_header), - session.user_agent_suffix.as_deref(), - ), - }; - self.outgoing.send_response(request_id, response).await; - return; - } - _ => { - if !session.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; - } - } - } +#[derive(Debug)] +pub(crate) struct InitializedConnectionSessionState { + pub(crate) experimental_api_enabled: bool, + pub(crate) opted_out_notification_methods: HashSet, + pub(crate) app_server_client_name: String, + pub(crate) client_version: String, +} - self.code_message_processor - .process_request_for_connection(connection_id, 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; +impl Default for ConnectionSessionState { + fn default() -> Self { + Self::new() + } +} + +impl ConnectionSessionState { + pub(crate) fn new() -> Self { + Self { + rpc_gate: Arc::new(ConnectionRpcGate::new()), + initialized: OnceLock::new(), } } - 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); + pub(crate) fn initialized(&self) -> bool { + self.initialized.get().is_some() } - pub(crate) async fn send_initialize_notifications(&self, connection_id: ConnectionId) { - for params in self.config_warnings.iter().cloned() { - self.outgoing - .send_notification_to_connection( - connection_id, - crate::outgoing_message::OutgoingNotification { - method: "configWarning".to_string(), - params: Some(params), - }, - ) - .await; - } + pub(crate) fn experimental_api_enabled(&self) -> bool { + self.initialized + .get() + .is_some_and(|session| session.experimental_api_enabled) } - pub(crate) async fn on_connection_closed(&mut self, connection_id: ConnectionId) { - self.code_message_processor - .on_connection_closed(connection_id) - .await; + pub(crate) fn opted_out_notification_methods(&self) -> HashSet { + self.initialized + .get() + .map(|session| session.opted_out_notification_methods.clone()) + .unwrap_or_default() } - /// Handle a standalone JSON-RPC response originating from the peer. - pub(crate) async fn process_response( - &mut self, - connection_id: ConnectionId, - response: JSONRPCResponse, - ) { - tracing::info!("<- response: {:?}", response); - let JSONRPCResponse { id, result, .. } = response; - self.outgoing - .notify_client_response_for_connection(Some(connection_id), id, result) - .await + pub(crate) fn app_server_client_name(&self) -> Option<&str> { + self.initialized + .get() + .map(|session| session.app_server_client_name.as_str()) } - /// Handle an error object received from the peer. - pub(crate) async fn process_error(&mut self, connection_id: ConnectionId, err: JSONRPCError) { - tracing::error!("<- error: {:?}", err); - self.outgoing - .notify_client_error_for_connection(Some(connection_id), err.id, err.error) - .await; + pub(crate) fn client_version(&self) -> Option<&str> { + self.initialized + .get() + .map(|session| session.client_version.as_str()) } - async fn try_process_v2_config_request( - &self, - request_id: mcp_types::RequestId, - request: &JSONRPCRequest, - session_initialized: bool, - ) -> bool { - let is_v2_request = matches!( - request.method.as_str(), - "config/read" - | "configRequirements/read" - | "config/value/write" - | "config/batchWrite" - | "externalAgentConfig/detect" - | "externalAgentConfig/import" - | "thread/list" - | "model/list" - | "skills/list" - | "plugin/list" - | "hooks/list" - | "mcpServerStatus/list" - | "remoteControl/status/read" - | "remoteControl/enable" - | "collaborationMode/list" - | "experimentalFeature/list" - | "experimentalFeature/enablement/set" - | "account/read" - | "account/login/start" - | "account/login/cancel" - | "account/logout" - | "account/rateLimits/read" - ); - if is_v2_request && !session_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 true; - } + pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { + self.initialized.set(session).map_err(|_| ()) + } +} - match request.method.as_str() { - "config/read" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: ConfigReadParams = match serde_json::from_value(params_value) { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid config/read params: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; +pub(crate) struct MessageProcessorArgs { + pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, + pub(crate) arg0_paths: Arg0DispatchPaths, + pub(crate) config: Arc, + pub(crate) config_manager: ConfigManager, + pub(crate) environment_manager: Arc, + pub(crate) feedback: CodexFeedback, + pub(crate) log_db: Option, + pub(crate) state_db: Option, + pub(crate) config_warnings: Vec, + pub(crate) session_source: SessionSource, + pub(crate) auth_manager: Arc, + pub(crate) installation_id: String, + pub(crate) rpc_transport: AppServerRpcTransport, + pub(crate) remote_control_handle: Option, + pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, +} - let config = match self.load_effective_config(params.cwd.as_deref()) { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return true; - } - }; +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(args: MessageProcessorArgs) -> Self { + let MessageProcessorArgs { + outgoing, + analytics_events_client, + arg0_paths, + config, + config_manager, + environment_manager, + feedback, + log_db, + state_db, + config_warnings, + session_source, + auth_manager, + installation_id, + rpc_transport, + remote_control_handle, + plugin_startup_tasks, + } = args; + auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { + outgoing: outgoing.clone(), + })); + // The thread store is intentionally process-scoped. Config reloads can + // affect per-thread behavior, but they must not move newly started, + // resumed, or forked threads to a different persistence backend/root. + let thread_store = thread_store_from_config(config.as_ref(), state_db.clone()); + let thread_manager = Arc::new(ThreadManager::new( + config.as_ref(), + auth_manager.clone(), + session_source, + environment_manager, + Some(analytics_events_client.clone()), + Arc::clone(&thread_store), + state_db.clone(), + installation_id, + )); + thread_manager + .plugins_manager() + .set_analytics_events_client(analytics_events_client.clone()); + + let pending_thread_unloads = Arc::new(Mutex::new(HashSet::new())); + let thread_state_manager = ThreadStateManager::new(); + let thread_watch_manager = + crate::thread_status::ThreadWatchManager::new_with_outgoing(outgoing.clone()); + let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1)); + let workspace_settings_cache = + Arc::new(workspace_settings::WorkspaceSettingsCache::default()); + let account_processor = AccountRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + Arc::clone(&config), + config_manager.clone(), + ); + let apps_processor = AppsRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + config_manager.clone(), + Arc::clone(&workspace_settings_cache), + ); + let catalog_processor = CatalogRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + Arc::clone(&config), + config_manager.clone(), + Arc::clone(&workspace_settings_cache), + ); + let command_exec_processor = CommandExecRequestProcessor::new( + arg0_paths.clone(), + Arc::clone(&config), + outgoing.clone(), + ); + let process_exec_processor = ProcessExecRequestProcessor::new(outgoing.clone()); + let feedback_processor = FeedbackRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + Arc::clone(&config), + feedback, + log_db, + state_db.clone(), + ); + let git_processor = GitRequestProcessor::new(); + let initialize_processor = InitializeRequestProcessor::new( + outgoing.clone(), + analytics_events_client.clone(), + Arc::clone(&config), + config_warnings, + rpc_transport, + ); + let marketplace_processor = MarketplaceRequestProcessor::new( + Arc::clone(&config), + config_manager.clone(), + Arc::clone(&thread_manager), + ); + let mcp_processor = McpRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + config_manager.clone(), + ); + let plugin_processor = PluginRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + analytics_events_client.clone(), + config_manager.clone(), + workspace_settings_cache, + ); + let search_processor = SearchRequestProcessor::new(outgoing.clone()); + let thread_goal_processor = ThreadGoalRequestProcessor::new( + Arc::clone(&thread_manager), + outgoing.clone(), + Arc::clone(&config), + thread_state_manager.clone(), + state_db.clone(), + ); + let thread_processor = ThreadRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + arg0_paths.clone(), + Arc::clone(&config), + config_manager.clone(), + Arc::clone(&thread_store), + Arc::clone(&pending_thread_unloads), + thread_state_manager.clone(), + thread_watch_manager.clone(), + Arc::clone(&thread_list_state_permit), + thread_goal_processor.clone(), + state_db, + ); + let turn_processor = TurnRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + analytics_events_client.clone(), + arg0_paths.clone(), + Arc::clone(&config), + config_manager.clone(), + pending_thread_unloads, + thread_state_manager, + thread_watch_manager, + thread_list_state_permit, + ); + if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { + // Keep plugin startup warmups aligned at app-server startup. + let on_effective_plugins_changed = + plugin_processor.effective_plugins_changed_callback(); + thread_manager + .plugins_manager() + .maybe_start_plugin_startup_tasks_for_config( + &config.plugins_config_input(), + auth_manager.clone(), + Some(on_effective_plugins_changed), + ); + } + let fs_watch_manager = FsWatchManager::new(outgoing.clone()); + let config_processor = ConfigRequestProcessor::new( + outgoing.clone(), + config_manager.clone(), + auth_manager, + thread_manager.clone(), + analytics_events_client, + remote_control_handle, + ); + let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( + outgoing.clone(), + Arc::clone(&thread_manager), + config_manager.clone(), + config_processor.clone(), + arg0_paths, + config.codex_home.to_path_buf(), + ); + let fs_processor = FsRequestProcessor::new( + thread_manager + .environment_manager() + .local_environment() + .get_filesystem(), + fs_watch_manager, + ); + let windows_sandbox_processor = WindowsSandboxRequestProcessor::new( + outgoing.clone(), + Arc::clone(&config), + config_manager, + ); - let response = ConfigReadResponse { - config: self.v2_config_snapshot_from(&config), - origins: HashMap::new(), - layers: if params.include_layers { - Some(Vec::new()) - } else { - None - }, - }; - self.outgoing.send_response(request_id, response).await; - true - } - "configRequirements/read" => { - let requirements = match code_core::config::load_allowed_approval_policies( - &self.base_config.code_home, - ) { - Ok(Some(allowed_approval_policies)) => Some(ConfigRequirements { - allowed_approval_policies: Some( - allowed_approval_policies - .into_iter() - .map(map_approval_policy_to_v2) - .collect(), - ), - allowed_sandbox_modes: None, - allowed_web_search_modes: None, - enforce_residency: None, - network: None, - }), - Ok(None) => None, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("Unable to read config requirements: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; + Self { + outgoing, + account_processor, + apps_processor, + catalog_processor, + command_exec_processor, + process_exec_processor, + config_processor, + external_agent_config_processor, + feedback_processor, + fs_processor, + git_processor, + initialize_processor, + marketplace_processor, + mcp_processor, + plugin_processor, + search_processor, + thread_goal_processor, + thread_processor, + turn_processor, + windows_sandbox_processor, + request_serialization_queues: RequestSerializationQueues::default(), + } + } - let response = ConfigRequirementsReadResponse { requirements }; - self.outgoing.send_response(request_id, response).await; - true - } - "config/value/write" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: ConfigValueWriteParams = match serde_json::from_value(params_value) { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid config/value/write params: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; + pub(crate) fn clear_runtime_references(&self) { + self.account_processor.clear_external_auth(); + } - match self.apply_config_value_write(params) { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - true - } - "config/batchWrite" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: ConfigBatchWriteParams = match serde_json::from_value(params_value) { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid config/batchWrite params: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; + pub(crate) async fn process_request( + self: &Arc, + connection_id: ConnectionId, + request: JSONRPCRequest, + transport: &AppServerTransport, + session: Arc, + ) { + let request_method = request.method.as_str(); + tracing::trace!( + ?connection_id, + request_id = ?request.id, + "app-server request: {request_method}" + ); + let request_id = ConnectionRequestId { + connection_id, + request_id: request.id.clone(), + }; + let request_span = + crate::app_server_tracing::request_span(&request, transport, connection_id, &session); + let request_trace = request.trace.as_ref().map(|trace| W3cTraceContext { + traceparent: trace.traceparent.clone(), + tracestate: trace.tracestate.clone(), + }); + let request_context = RequestContext::new(request_id.clone(), request_span, request_trace); + Self::run_request_with_context( + Arc::clone(&self.outgoing), + request_context.clone(), + async { + let codex_request = serde_json::to_value(&request) + .map_err(|err| invalid_request(format!("Invalid request: {err}"))) + .and_then(|request_json| { + serde_json::from_value::(request_json) + .map_err(|err| invalid_request(format!("Invalid request: {err}"))) + }); + let result = match codex_request { + Ok(codex_request) => { + // Websocket callers finalize outbound readiness in lib.rs after mirroring + // session state into outbound state and sending initialize notifications to + // this specific connection. Passing `None` avoids marking the connection + // ready too early from inside the shared request handler. + self.handle_client_request( + request_id.clone(), + codex_request, + Arc::clone(&session), + /*outbound_initialized*/ None, + request_context.clone(), + ) + .await } + Err(error) => Err(error), }; - - match self.apply_config_batch_write(params) { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - true - } - "externalAgentConfig/detect" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: ExternalAgentConfigDetectParams = - match serde_json::from_value(params_value) { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "Invalid externalAgentConfig/detect params: {err}" - ), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; - - match self.external_agent_config_api.detect(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, + if let Err(error) = result { + self.outgoing.send_error(request_id.clone(), error).await; } - true - } - "externalAgentConfig/import" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: ExternalAgentConfigImportParams = - match serde_json::from_value(params_value) { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "Invalid externalAgentConfig/import params: {err}" - ), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; + }, + ) + .await; + } - match self.external_agent_config_api.import(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - true - } - "thread/list" => { - let response = ThreadListResponse { - data: Vec::new(), - next_cursor: None, - }; - self.outgoing.send_response(request_id, response).await; - true - } - "model/list" => { - let response = ModelListResponse { - data: Vec::new(), - next_cursor: None, - }; - self.outgoing.send_response(request_id, response).await; - true - } - "skills/list" => { - self.outgoing - .send_response(request_id, json!({ "data": [], "nextCursor": null })) - .await; - true - } - "plugin/list" | "hooks/list" => { - self.outgoing - .send_response(request_id, json!({ "data": [], "nextCursor": null })) - .await; - true - } - "mcpServerStatus/list" => { - let response = ListMcpServerStatusResponse { - data: Vec::new(), - next_cursor: None, - }; - self.outgoing.send_response(request_id, response).await; - true - } - "remoteControl/status/read" => { - self.outgoing - .send_response(request_id, json!({ "enabled": false })) - .await; - true - } - "remoteControl/enable" => { - self.outgoing - .send_response( - request_id, - json!({ - "enabled": false, - "unsupported": true, - }), - ) - .await; - true - } - "collaborationMode/list" => { - self.outgoing - .send_response(request_id, json!({ "data": [], "nextCursor": null })) - .await; - true - } - "experimentalFeature/list" => { - let response = ExperimentalFeatureListResponse { - data: Vec::new(), - next_cursor: None, - }; - self.outgoing.send_response(request_id, response).await; - true - } - "experimentalFeature/enablement/set" => { - self.outgoing - .send_response( - request_id, - json!({ - "enabled": false, - "unsupported": true, - }), + /// Handles a typed request path used by in-process embedders. + /// + /// This bypasses JSON request deserialization but keeps identical request + /// semantics by delegating to `handle_client_request`. + pub(crate) async fn process_client_request( + self: &Arc, + connection_id: ConnectionId, + request: ClientRequest, + session: Arc, + outbound_initialized: &AtomicBool, + ) { + let request_id = ConnectionRequestId { + connection_id, + request_id: request.id().clone(), + }; + let request_span = + crate::app_server_tracing::typed_request_span(&request, connection_id, &session); + let request_context = + RequestContext::new(request_id.clone(), request_span, /*parent_trace*/ None); + tracing::trace!( + ?connection_id, + request_id = ?request_id.request_id, + "app-server typed request" + ); + Self::run_request_with_context( + Arc::clone(&self.outgoing), + request_context.clone(), + async { + // In-process clients do not have the websocket transport loop that performs + // post-initialize bookkeeping, so they still finalize outbound readiness in + // the shared request handler. + let result = self + .handle_client_request( + request_id.clone(), + request, + Arc::clone(&session), + Some(outbound_initialized), + request_context.clone(), ) .await; - true - } - "account/read" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: GetAccountParams = match serde_json::from_value(params_value) { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid account/read params: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; - - match self - .code_message_processor - .get_account_response_v2(params.refresh_token) - .await - { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, + if let Err(error) = result { + self.outgoing.send_error(request_id.clone(), error).await; } - true - } - "account/login/start" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: LoginAccountParams = match serde_json::from_value(params_value) { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid account/login/start params: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; - - match self.code_message_processor.login_account_v2(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - true - } - "account/login/cancel" => { - let params_value = request.params.clone().unwrap_or_else(|| json!({})); - let params: CancelLoginAccountParams = match serde_json::from_value(params_value) - { - Ok(params) => params, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid account/login/cancel params: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return true; - } - }; - - match self.code_message_processor.cancel_login_account_v2(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - true - } - "account/logout" => { - match self.code_message_processor.logout_account_v2().await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - true - } - "account/rateLimits/read" => { - match self.code_message_processor.get_account_rate_limits_v2() { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - true - } - _ => false, - } + }, + ) + .await; } - fn load_effective_config(&self, cwd: Option<&str>) -> Result { - let mut overrides = code_core::config::ConfigOverrides::default(); - overrides.code_linux_sandbox_exe = self.base_config.code_linux_sandbox_exe.clone(); - overrides.cwd = cwd.map(PathBuf::from); - - Config::load_with_cli_overrides(self.cli_overrides.clone(), overrides).map_err(|err| { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("Unable to load effective config: {err}"), - data: None, - } - }) + 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); } - fn v2_config_snapshot_from(&self, config: &Config) -> V2Config { - V2Config { - model: Some(config.model.clone()), - review_model: Some(config.review_model.clone()), - model_context_window: config - .model_context_window - .and_then(|value| i64::try_from(value).ok()), - model_auto_compact_token_limit: config.model_auto_compact_token_limit, - model_provider: Some(config.model_provider_id.clone()), - approval_policy: Some(match config.approval_policy { - code_core::protocol::AskForApproval::UnlessTrusted => { - V2AskForApproval::UnlessTrusted - } - code_core::protocol::AskForApproval::OnFailure => V2AskForApproval::OnFailure, - code_core::protocol::AskForApproval::OnRequest => V2AskForApproval::OnRequest, - code_core::protocol::AskForApproval::Reject(config) => V2AskForApproval::Reject { - sandbox_approval: config.sandbox_approval, - rules: config.rules, - skill_approval: config.skill_approval, - request_permissions: config.request_permissions, - mcp_elicitations: config.mcp_elicitations, - }, - code_core::protocol::AskForApproval::Never => V2AskForApproval::Never, - }), - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: Some(if config.tools_web_search_request { - WebSearchMode::Cached - } else { - WebSearchMode::Disabled - }), - tools: Some(ToolsV2 { - web_search: config - .tools_web_search_request - .then(|| WebSearchToolConfig { - allowed_domains: config.tools_web_search_allowed_domains.clone(), - ..Default::default() - }), - view_image: Some(config.include_view_image_tool), - }), - profile: config.active_profile.clone(), - profiles: HashMap::new(), - instructions: config.base_instructions.clone(), - developer_instructions: None, - compact_prompt: config.compact_prompt_override.clone(), - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: Some(match config.model_text_verbosity { - code_core::config_types::TextVerbosity::Low => Verbosity::Low, - code_core::config_types::TextVerbosity::Medium => Verbosity::Medium, - code_core::config_types::TextVerbosity::High => Verbosity::High, - }), - analytics: None, - apps: None, - additional: HashMap::new(), - } + /// Handles typed notifications from in-process clients. + pub(crate) async fn process_client_notification(&self, notification: ClientNotification) { + // Currently, we do not expect to receive any typed notifications from + // in-process clients, so we just log them. + tracing::info!("<- typed notification: {:?}", notification); } - fn apply_config_value_write( - &self, - params: ConfigValueWriteParams, - ) -> Result { - let ConfigValueWriteParams { - key_path, - value, - merge_strategy, - file_path, - expected_version, - } = params; - self.apply_config_edits( - vec![ConfigEdit { - key_path, - value, - merge_strategy, - }], - file_path, - expected_version, - ) + async fn run_request_with_context( + outgoing: Arc, + request_context: RequestContext, + request_fut: F, + ) where + F: Future, + { + outgoing + .register_request_context(request_context.clone()) + .await; + request_fut.instrument(request_context.span()).await; } - fn apply_config_batch_write( - &self, - params: ConfigBatchWriteParams, - ) -> Result { - self.apply_config_edits(params.edits, params.file_path, params.expected_version) + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { + self.thread_processor.thread_created_receiver() } - fn apply_config_edits( + pub(crate) async fn send_initialize_notifications_to_connection( &self, - edits: Vec, - file_path: Option, - expected_version: Option, - ) -> Result { - let allowed_file_path = self.base_config.code_home.join("config.toml"); - let file_path = self.resolve_config_file_path(file_path, &allowed_file_path)?; - let current_contents = match std::fs::read_to_string(&file_path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - format!("Unable to read config file: {err}"), - )); - } - }; - let current_version = config_version(¤t_contents); - if let Some(expected_version) = expected_version - && expected_version != current_version - { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigVersionConflict, - "Config version conflict", - )); - } - - let mut root = if current_contents.trim().is_empty() { - TomlValue::Table(Default::default()) - } else { - current_contents.parse::().map_err(|err| { - config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - format!("Invalid TOML in config file: {err}"), - ) - })? - }; - - for edit in edits { - apply_toml_edit( - &mut root, - edit.key_path.as_str(), - json_to_toml(edit.value), - edit.merge_strategy, - )?; - } - - let serialized = toml::to_string_pretty(&root).map_err(|err| { - config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - format!("Unable to serialize config TOML: {err}"), - ) - })?; - - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).map_err(|err| { - config_write_error( - ConfigWriteErrorCode::UserLayerNotFound, - format!("Unable to create config directory: {err}"), - ) - })?; - } - - std::fs::write(&file_path, serialized.as_bytes()).map_err(|err| { - config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - format!("Unable to write config file: {err}"), - ) - })?; - - let absolute_file_path = to_absolute_path_buf(&file_path).map_err(|err| { - config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - format!("Unable to resolve config file path: {err}"), - ) - })?; - - Ok(ConfigWriteResponse { - status: WriteStatus::Ok, - version: config_version(serialized.as_str()), - file_path: absolute_file_path, - overridden_metadata: None, - }) + connection_id: ConnectionId, + ) { + self.initialize_processor + .send_initialize_notifications_to_connection(connection_id) + .await; } - fn resolve_config_file_path( - &self, - file_path: Option, - allowed_file_path: &Path, - ) -> Result { - let path = match file_path { - Some(path) => { - let path = PathBuf::from(path); - if !path.is_absolute() { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - "filePath must be an absolute path", - )); - } - if !paths_match(allowed_file_path, &path) { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigLayerReadonly, - "Only writes to the user config are allowed", - )); - } - path - } - None => allowed_file_path.to_path_buf(), - }; - - Ok(path) + pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { + self.thread_processor + .connection_initialized(connection_id) + .await; } -} - -fn paths_match(expected: &Path, provided: &Path) -> bool { - let expected = expected.canonicalize().unwrap_or_else(|_| expected.to_path_buf()); - let provided = provided.canonicalize().unwrap_or_else(|_| provided.to_path_buf()); - expected == provided -} -fn map_approval_policy_to_v2( - policy: code_core::protocol::AskForApproval, -) -> V2AskForApproval { - match policy { - code_core::protocol::AskForApproval::UnlessTrusted => V2AskForApproval::UnlessTrusted, - code_core::protocol::AskForApproval::OnFailure => V2AskForApproval::OnFailure, - code_core::protocol::AskForApproval::OnRequest => V2AskForApproval::OnRequest, - code_core::protocol::AskForApproval::Reject(config) => V2AskForApproval::Reject { - sandbox_approval: config.sandbox_approval, - rules: config.rules, - skill_approval: config.skill_approval, - request_permissions: config.request_permissions, - mcp_elicitations: config.mcp_elicitations, - }, - code_core::protocol::AskForApproval::Never => V2AskForApproval::Never, + pub(crate) async fn send_initialize_notifications(&self) { + self.initialize_processor + .send_initialize_notifications() + .await; } -} -fn apply_toml_edit( - root: &mut TomlValue, - key_path: &str, - value: TomlValue, - merge_strategy: MergeStrategy, -) -> Result<(), JSONRPCErrorError> { - match merge_strategy { - MergeStrategy::Replace => set_toml_path(root, key_path, value), - MergeStrategy::Upsert => upsert_toml_path(root, key_path, value), + pub(crate) async fn try_attach_thread_listener( + &self, + thread_id: ThreadId, + connection_ids: Vec, + ) { + self.thread_processor + .try_attach_thread_listener(thread_id, connection_ids) + .await; } -} -fn set_toml_path(root: &mut TomlValue, key_path: &str, value: TomlValue) -> Result<(), JSONRPCErrorError> { - let segments: Vec<&str> = key_path.split('.').filter(|segment| !segment.is_empty()).collect(); - if segments.is_empty() { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigPathNotFound, - "Config key path must not be empty", - )); + pub(crate) async fn drain_background_tasks(&self) { + self.thread_processor.drain_background_tasks().await; } - let mut current = root; - for segment in &segments[..segments.len() - 1] { - if !current.is_table() { - *current = TomlValue::Table(Default::default()); - } - let table = current - .as_table_mut() - .expect("table should exist after conversion"); - current = table - .entry((*segment).to_string()) - .or_insert_with(|| TomlValue::Table(Default::default())); + pub(crate) async fn cancel_active_login(&self) { + self.account_processor.cancel_active_login().await; } - if !current.is_table() { - *current = TomlValue::Table(Default::default()); + pub(crate) async fn clear_all_thread_listeners(&self) { + self.thread_processor.clear_all_thread_listeners().await; } - let table = current - .as_table_mut() - .expect("table should exist after conversion"); - table.insert( - segments - .last() - .expect("segments cannot be empty") - .to_string(), - value, - ); - Ok(()) -} + pub(crate) async fn shutdown_threads(&self) { + self.thread_processor.shutdown_threads().await; + } -fn upsert_toml_path( - root: &mut TomlValue, - key_path: &str, - value: TomlValue, -) -> Result<(), JSONRPCErrorError> { - let segments: Vec<&str> = key_path.split('.').filter(|segment| !segment.is_empty()).collect(); - if segments.is_empty() { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigPathNotFound, - "Config key path must not be empty", - )); + pub(crate) async fn connection_closed( + &self, + connection_id: ConnectionId, + session_state: &ConnectionSessionState, + ) { + session_state.rpc_gate.shutdown().await; + self.outgoing.connection_closed(connection_id).await; + self.fs_processor.connection_closed(connection_id).await; + self.command_exec_processor + .connection_closed(connection_id) + .await; + self.process_exec_processor + .connection_closed(connection_id) + .await; + self.thread_processor.connection_closed(connection_id).await; } - let mut current = root; - for segment in &segments[..segments.len() - 1] { - if !current.is_table() { - *current = TomlValue::Table(Default::default()); - } - let table = current - .as_table_mut() - .expect("table should exist after conversion"); - current = table - .entry((*segment).to_string()) - .or_insert_with(|| TomlValue::Table(Default::default())); + pub(crate) fn subscribe_running_assistant_turn_count(&self) -> watch::Receiver { + self.thread_processor + .subscribe_running_assistant_turn_count() } - if !current.is_table() { - *current = TomlValue::Table(Default::default()); + /// Handle a standalone JSON-RPC response originating from the peer. + pub(crate) async fn process_response(&self, response: JSONRPCResponse) { + tracing::info!("<- response: {:?}", response); + let JSONRPCResponse { id, result, .. } = response; + self.outgoing.notify_client_response(id, result).await } - let table = current - .as_table_mut() - .expect("table should exist after conversion"); - let key = segments - .last() - .expect("segments cannot be empty") - .to_string(); - if let Some(existing) = table.get_mut(&key) { - merge_toml_values(existing, value); - } else { - table.insert(key, value); + /// Handle an error object received from the peer. + pub(crate) async fn process_error(&self, err: JSONRPCError) { + tracing::error!("<- error: {:?}", err); + self.outgoing.notify_client_error(err.id, err.error).await; } - Ok(()) -} -fn merge_toml_values(target: &mut TomlValue, incoming: TomlValue) { - match (target, incoming) { - (TomlValue::Table(target_table), TomlValue::Table(incoming_table)) => { - for (key, incoming_value) in incoming_table { - if let Some(existing) = target_table.get_mut(&key) { - merge_toml_values(existing, incoming_value); - } else { - target_table.insert(key, incoming_value); - } + async fn handle_client_request( + self: &Arc, + connection_request_id: ConnectionRequestId, + codex_request: ClientRequest, + session: Arc, + // `Some(...)` means the caller wants initialize to immediately mark the + // connection outbound-ready. Websocket JSON-RPC calls pass `None` so + // lib.rs can deliver connection-scoped initialize notifications first. + outbound_initialized: Option<&AtomicBool>, + request_context: RequestContext, + ) -> Result<(), JSONRPCErrorError> { + let connection_id = connection_request_id.connection_id; + if let ClientRequest::Initialize { request_id, params } = codex_request { + let connection_initialized = self + .initialize_processor + .initialize( + connection_id, + request_id, + params, + &session, + outbound_initialized, + ) + .await?; + if connection_initialized { + self.thread_processor + .connection_initialized(connection_id) + .await; } + return Ok(()); } - (target_value, incoming_value) => { - *target_value = incoming_value; - } - } -} - -fn config_version(contents: &str) -> String { - let mut hasher = Sha1::new(); - hasher.update(contents.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -fn to_absolute_path_buf(path: &Path) -> std::io::Result { - let absolute_path = if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir()?.join(path) - }; - absolute_path - .try_into() - .map_err(std::io::Error::other) -} -fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.into(), - data: Some(json!({ - "config_write_error_code": code, - })), + self.dispatch_initialized_client_request( + connection_request_id, + codex_request, + session, + request_context, + ) + .await } -} -#[cfg(test)] -mod tests { - use super::*; - use crate::outgoing_message::OutgoingEnvelope; - use crate::outgoing_message::OutgoingMessage; - use mcp_types::JSONRPC_VERSION; - use mcp_types::RequestId; - use serde_json::json; - use tokio::sync::mpsc; - use uuid::Uuid; + async fn dispatch_initialized_client_request( + self: &Arc, + connection_request_id: ConnectionRequestId, + codex_request: ClientRequest, + session: Arc, + request_context: RequestContext, + ) -> Result<(), JSONRPCErrorError> { + if !session.initialized() { + return Err(invalid_request("Not initialized")); + } - #[tokio::test] - async fn initialize_applies_opt_out_notification_methods_per_connection() { - let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new_with_routed_sender(outgoing_tx)); - let config = Arc::new( - Config::load_with_cli_overrides(Vec::new(), code_core::config::ConfigOverrides::default()) - .expect("load default config"), + if let Some(reason) = codex_request.experimental_reason() + && !session.experimental_api_enabled() + { + return Err(invalid_request(experimental_required_message(reason))); + } + let connection_id = connection_request_id.connection_id; + self.initialize_processor.track_initialized_request( + connection_id, + connection_request_id.request_id.clone(), + &codex_request, ); - let mut processor = MessageProcessor::new(outgoing, None, config, Vec::new(), Vec::new()); - let mut session = ConnectionSessionState::default(); - let outbound_initialized = AtomicBool::new(false); - let outbound_opted_out_notification_methods = RwLock::new(HashSet::new()); - let request = JSONRPCRequest { - jsonrpc: JSONRPC_VERSION.to_string(), - id: RequestId::Integer(1), - method: "initialize".to_string(), - params: Some(json!({ - "clientInfo": { - "name": "client-a", - "version": "1.0.0" - }, - "capabilities": { - "experimentalApi": false, - "optOutNotificationMethods": ["configWarning", "codex/event/session_configured"] + let serialization_scope = codex_request.serialization_scope(); + let app_server_client_name = session.app_server_client_name().map(str::to_string); + let client_version = session.client_version().map(str::to_string); + let error_request_id = connection_request_id.clone(); + let rpc_gate = Arc::clone(&session.rpc_gate); + let processor = Arc::clone(self); + let span = request_context.span(); + let request = QueuedInitializedRequest::new( + rpc_gate, + async move { + let processor_for_request = Arc::clone(&processor); + let result = processor_for_request + .handle_initialized_client_request( + connection_request_id, + codex_request, + request_context, + app_server_client_name, + client_version, + ) + .await; + if let Err(error) = result { + processor.outgoing.send_error(error_request_id, error).await; } - })), - }; - - processor - .process_request( - ConnectionId(42), - request, - &mut session, - &outbound_initialized, - &outbound_opted_out_notification_methods, - ) - .await; - - assert!(session.initialized, "session should be initialized"); - assert!( - outbound_initialized.load(Ordering::Acquire), - "outbound initialized flag should be set" + } + .instrument(span), ); - let opted_out = outbound_opted_out_notification_methods - .read() - .expect("read lock"); - assert!(opted_out.contains("configWarning")); - assert!(opted_out.contains("codex/event/session_configured")); - - // Drain initialize response envelope to ensure processing completed. - let envelope = outgoing_rx.recv().await.expect("initialize response envelope"); - match envelope { - OutgoingEnvelope::Broadcast { .. } => {} - _ => panic!("expected initialize response to be emitted"), + if let Some(scope) = serialization_scope { + let (key, access) = RequestSerializationQueueKey::from_scope(connection_id, scope); + self.request_serialization_queues + .enqueue(key, access, request) + .await; + } else { + tokio::spawn(async move { + request.run().await; + }); } + Ok(()) } - #[tokio::test] - async fn v2_requests_require_initialize() { - let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new_with_routed_sender(outgoing_tx)); - let config = Arc::new( - Config::load_with_cli_overrides(Vec::new(), code_core::config::ConfigOverrides::default()) - .expect("load default config"), - ); - let mut processor = MessageProcessor::new(outgoing, None, config, Vec::new(), Vec::new()); - let mut session = ConnectionSessionState::default(); - let outbound_initialized = AtomicBool::new(false); - let outbound_opted_out_notification_methods = RwLock::new(HashSet::new()); - - let request = JSONRPCRequest { - jsonrpc: JSONRPC_VERSION.to_string(), - id: RequestId::Integer(7), - method: "config/read".to_string(), - params: Some(json!({ - "includeLayers": false, - })), + async fn handle_initialized_client_request( + self: Arc, + connection_request_id: ConnectionRequestId, + codex_request: ClientRequest, + request_context: RequestContext, + app_server_client_name: Option, + client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + let connection_id = connection_request_id.connection_id; + let request_id = ConnectionRequestId { + connection_id, + request_id: codex_request.id().clone(), }; - processor - .process_request( - ConnectionId(42), - request, - &mut session, - &outbound_initialized, - &outbound_opted_out_notification_methods, - ) - .await; + let result: Result, JSONRPCErrorError> = match codex_request { + ClientRequest::Initialize { .. } => { + panic!("Initialize should be handled before initialized request dispatch"); + } + ClientRequest::ConfigRead { params, .. } => self + .config_processor + .read(params) + .await + .map(|response| Some(response.into())), + ClientRequest::WindowsSandboxReadiness { .. } => self + .windows_sandbox_processor + .windows_sandbox_readiness() + .await + .map(|response| Some(response.into())), + ClientRequest::ExternalAgentConfigDetect { params, .. } => self + .external_agent_config_processor + .detect(params) + .await + .map(|response| Some(response.into())), + ClientRequest::ExternalAgentConfigImport { params, .. } => self + .external_agent_config_processor + .import(request_id.clone(), params) + .await + .map(|()| None), + ClientRequest::ConfigValueWrite { params, .. } => { + self.config_processor.value_write(params).await.map(Some) + } + ClientRequest::ConfigBatchWrite { params, .. } => { + self.config_processor.batch_write(params).await.map(Some) + } + ClientRequest::ExperimentalFeatureEnablementSet { params, .. } => { + self.config_processor + .experimental_feature_enablement_set(request_id.clone(), params) + .await + } + ClientRequest::ConfigRequirementsRead { params: _, .. } => self + .config_processor + .config_requirements_read() + .await + .map(|response| Some(response.into())), + ClientRequest::FsReadFile { params, .. } => self + .fs_processor + .read_file(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsWriteFile { params, .. } => self + .fs_processor + .write_file(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsCreateDirectory { params, .. } => self + .fs_processor + .create_directory(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsGetMetadata { params, .. } => self + .fs_processor + .get_metadata(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsReadDirectory { params, .. } => self + .fs_processor + .read_directory(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsRemove { params, .. } => self + .fs_processor + .remove(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsCopy { params, .. } => self + .fs_processor + .copy(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsWatch { params, .. } => self + .fs_processor + .watch(connection_id, params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsUnwatch { params, .. } => self + .fs_processor + .unwatch(connection_id, params) + .await + .map(|response| Some(response.into())), + ClientRequest::ModelProviderCapabilitiesRead { params: _, .. } => self + .config_processor + .model_provider_capabilities_read() + .await + .map(|response| Some(response.into())), + ClientRequest::ThreadStart { params, .. } => { + self.thread_processor + .thread_start( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + request_context, + ) + .await + } + ClientRequest::ThreadUnsubscribe { params, .. } => { + self.thread_processor + .thread_unsubscribe(&request_id, params) + .await + } + ClientRequest::ThreadResume { params, .. } => { + self.thread_processor + .thread_resume( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) + .await + } + ClientRequest::ThreadFork { params, .. } => { + self.thread_processor + .thread_fork( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) + .await + } + ClientRequest::ThreadArchive { params, .. } => { + self.thread_processor + .thread_archive(request_id.clone(), params) + .await + } + ClientRequest::ThreadIncrementElicitation { params, .. } => { + self.thread_processor + .thread_increment_elicitation(params) + .await + } + ClientRequest::ThreadDecrementElicitation { params, .. } => { + self.thread_processor + .thread_decrement_elicitation(params) + .await + } + ClientRequest::ThreadSetName { params, .. } => { + self.thread_processor + .thread_set_name(request_id.clone(), params) + .await + } + ClientRequest::ThreadGoalSet { params, .. } => { + self.thread_goal_processor + .thread_goal_set(request_id.clone(), params) + .await + } + ClientRequest::ThreadGoalGet { params, .. } => { + self.thread_goal_processor.thread_goal_get(params).await + } + ClientRequest::ThreadGoalClear { params, .. } => { + self.thread_goal_processor + .thread_goal_clear(request_id.clone(), params) + .await + } + ClientRequest::ThreadMetadataUpdate { params, .. } => { + self.thread_processor.thread_metadata_update(params).await + } + ClientRequest::ThreadMemoryModeSet { params, .. } => { + self.thread_processor.thread_memory_mode_set(params).await + } + ClientRequest::MemoryReset { .. } => self.thread_processor.memory_reset().await, + ClientRequest::ThreadUnarchive { params, .. } => { + self.thread_processor + .thread_unarchive(request_id.clone(), params) + .await + } + ClientRequest::ThreadCompactStart { params, .. } => { + self.thread_processor + .thread_compact_start(&request_id, params) + .await + } + ClientRequest::ThreadBackgroundTerminalsClean { params, .. } => { + self.thread_processor + .thread_background_terminals_clean(&request_id, params) + .await + } + ClientRequest::ThreadRollback { params, .. } => { + self.thread_processor + .thread_rollback(&request_id, params) + .await + } + ClientRequest::ThreadList { params, .. } => { + self.thread_processor.thread_list(params).await + } + ClientRequest::ThreadLoadedList { params, .. } => { + self.thread_processor.thread_loaded_list(params).await + } + ClientRequest::ThreadRead { params, .. } => { + self.thread_processor.thread_read(params).await + } + ClientRequest::ThreadTurnsList { params, .. } => { + self.thread_processor.thread_turns_list(params).await + } + ClientRequest::ThreadTurnsItemsList { params, .. } => { + self.thread_processor.thread_turns_items_list(params).await + } + ClientRequest::ThreadShellCommand { params, .. } => { + self.thread_processor + .thread_shell_command(&request_id, params) + .await + } + ClientRequest::ThreadApproveGuardianDeniedAction { params, .. } => { + self.thread_processor + .thread_approve_guardian_denied_action(&request_id, params) + .await + } + ClientRequest::GetConversationSummary { params, .. } => { + self.thread_processor.conversation_summary(params).await + } + ClientRequest::SkillsList { params, .. } => { + self.catalog_processor.skills_list(params).await + } + ClientRequest::HooksList { params, .. } => { + self.catalog_processor.hooks_list(params).await + } + ClientRequest::MarketplaceAdd { params, .. } => { + self.marketplace_processor.marketplace_add(params).await + } + ClientRequest::MarketplaceRemove { params, .. } => { + self.marketplace_processor.marketplace_remove(params).await + } + ClientRequest::MarketplaceUpgrade { params, .. } => { + self.marketplace_processor.marketplace_upgrade(params).await + } + ClientRequest::PluginList { params, .. } => { + self.plugin_processor.plugin_list(params).await + } + ClientRequest::PluginRead { params, .. } => { + self.plugin_processor.plugin_read(params).await + } + ClientRequest::PluginSkillRead { params, .. } => { + self.plugin_processor.plugin_skill_read(params).await + } + ClientRequest::PluginShareSave { params, .. } => { + self.plugin_processor.plugin_share_save(params).await + } + ClientRequest::PluginShareUpdateTargets { params, .. } => { + self.plugin_processor + .plugin_share_update_targets(params) + .await + } + ClientRequest::PluginShareList { params, .. } => { + self.plugin_processor.plugin_share_list(params).await + } + ClientRequest::PluginShareDelete { params, .. } => { + self.plugin_processor.plugin_share_delete(params).await + } + ClientRequest::AppsList { params, .. } => { + self.apps_processor.apps_list(&request_id, params).await + } + ClientRequest::SkillsConfigWrite { params, .. } => { + self.catalog_processor.skills_config_write(params).await + } + ClientRequest::PluginInstall { params, .. } => { + self.plugin_processor.plugin_install(params).await + } + ClientRequest::PluginUninstall { params, .. } => { + self.plugin_processor.plugin_uninstall(params).await + } + ClientRequest::ModelList { params, .. } => { + self.catalog_processor.model_list(params).await + } + ClientRequest::ExperimentalFeatureList { params, .. } => { + self.catalog_processor + .experimental_feature_list(params) + .await + } + ClientRequest::CollaborationModeList { params, .. } => { + self.catalog_processor.collaboration_mode_list(params).await + } + ClientRequest::MockExperimentalMethod { params, .. } => { + self.catalog_processor + .mock_experimental_method(params) + .await + } + ClientRequest::TurnStart { params, .. } => { + self.turn_processor + .turn_start( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) + .await + } + ClientRequest::ThreadInjectItems { params, .. } => { + self.turn_processor.thread_inject_items(params).await + } + ClientRequest::TurnSteer { params, .. } => { + self.turn_processor.turn_steer(&request_id, params).await + } + ClientRequest::TurnInterrupt { params, .. } => { + self.turn_processor + .turn_interrupt(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeStart { params, .. } => { + self.turn_processor + .thread_realtime_start(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeAppendAudio { params, .. } => { + self.turn_processor + .thread_realtime_append_audio(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeAppendText { params, .. } => { + self.turn_processor + .thread_realtime_append_text(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeStop { params, .. } => { + self.turn_processor + .thread_realtime_stop(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeListVoices { params: _, .. } => { + self.turn_processor.thread_realtime_list_voices().await + } + ClientRequest::ReviewStart { params, .. } => { + self.turn_processor.review_start(&request_id, params).await + } + ClientRequest::McpServerOauthLogin { params, .. } => { + self.mcp_processor.mcp_server_oauth_login(params).await + } + ClientRequest::McpServerRefresh { params, .. } => { + self.mcp_processor.mcp_server_refresh(params).await + } + ClientRequest::McpServerStatusList { params, .. } => { + self.mcp_processor + .mcp_server_status_list(&request_id, params) + .await + } + ClientRequest::McpResourceRead { params, .. } => { + self.mcp_processor + .mcp_resource_read(&request_id, params) + .await + } + ClientRequest::McpServerToolCall { params, .. } => { + self.mcp_processor + .mcp_server_tool_call(&request_id, params) + .await + } + ClientRequest::WindowsSandboxSetupStart { params, .. } => { + self.windows_sandbox_processor + .windows_sandbox_setup_start(&request_id, params) + .await + } + ClientRequest::LoginAccount { params, .. } => { + self.account_processor + .login_account(request_id.clone(), params) + .await + } + ClientRequest::LogoutAccount { .. } => { + self.account_processor + .logout_account(request_id.clone()) + .await + } + ClientRequest::CancelLoginAccount { params, .. } => { + self.account_processor.cancel_login_account(params).await + } + ClientRequest::GetAccount { params, .. } => { + self.account_processor.get_account(params).await + } + ClientRequest::GetAuthStatus { params, .. } => { + self.account_processor.get_auth_status(params).await + } + ClientRequest::GetAccountRateLimits { .. } => { + self.account_processor.get_account_rate_limits().await + } + ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { + self.account_processor + .send_add_credits_nudge_email(params) + .await + } + ClientRequest::GitDiffToRemote { params, .. } => { + self.git_processor.git_diff_to_remote(params).await + } + ClientRequest::FuzzyFileSearch { params, .. } => self + .search_processor + .fuzzy_file_search(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FuzzyFileSearchSessionStart { params, .. } => self + .search_processor + .fuzzy_file_search_session_start_response(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FuzzyFileSearchSessionUpdate { params, .. } => self + .search_processor + .fuzzy_file_search_session_update_response(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FuzzyFileSearchSessionStop { params, .. } => self + .search_processor + .fuzzy_file_search_session_stop(params) + .await + .map(|response| Some(response.into())), + ClientRequest::OneOffCommandExec { params, .. } => { + self.command_exec_processor + .one_off_command_exec(&request_id, params) + .await + } + ClientRequest::CommandExecWrite { params, .. } => { + self.command_exec_processor + .command_exec_write(request_id.clone(), params) + .await + } + ClientRequest::CommandExecResize { params, .. } => { + self.command_exec_processor + .command_exec_resize(request_id.clone(), params) + .await + } + ClientRequest::CommandExecTerminate { params, .. } => { + self.command_exec_processor + .command_exec_terminate(request_id.clone(), params) + .await + } + ClientRequest::ProcessSpawn { params, .. } => self + .process_exec_processor + .process_spawn(request_id.clone(), params) + .await + .map(|()| None), + ClientRequest::ProcessWriteStdin { params, .. } => { + self.process_exec_processor + .process_write_stdin(request_id.clone(), params) + .await + } + ClientRequest::ProcessKill { params, .. } => { + self.process_exec_processor + .process_kill(request_id.clone(), params) + .await + } + ClientRequest::ProcessResizePty { params, .. } => { + self.process_exec_processor + .process_resize_pty(request_id.clone(), params) + .await + } + ClientRequest::FeedbackUpload { params, .. } => { + self.feedback_processor.feedback_upload(params).await + } + }; - let envelope = outgoing_rx - .recv() - .await - .expect("expected not-initialized error"); - match envelope { - OutgoingEnvelope::Broadcast { - message: OutgoingMessage::Error(error), - } => { - assert_eq!(error.id, RequestId::Integer(7)); - assert_eq!(error.error.message, "Not initialized"); - } - _ => panic!("expected broadcast error response"), + match result { + Ok(Some(response)) => { + self.outgoing + .send_response_as(request_id.clone(), response) + .await; + } + Ok(None) => {} + Err(error) => { + self.outgoing.send_error(request_id.clone(), error).await; + } } - } - - #[test] - fn config_write_rejects_unreadable_existing_path() { - let (outgoing_tx, _outgoing_rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new_with_routed_sender(outgoing_tx)); - - let mut config = - Config::load_with_cli_overrides(Vec::new(), code_core::config::ConfigOverrides::default()) - .expect("load default config"); - let temp_code_home = std::env::temp_dir().join(format!( - "code-app-server-message-processor-{}", - Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_code_home).expect("create temp code home"); - let config_toml_path = temp_code_home.join("config.toml"); - std::fs::create_dir_all(&config_toml_path).expect("create unreadable config path"); - config.code_home = temp_code_home.clone(); - - let processor = MessageProcessor::new( - outgoing, - None, - Arc::new(config), - Vec::new(), - Vec::new(), - ); - let result = processor.apply_config_value_write(ConfigValueWriteParams { - key_path: "model".to_string(), - value: json!("o3"), - merge_strategy: MergeStrategy::Replace, - file_path: None, - expected_version: None, - }); - - let err = result.expect_err("write should fail when reading config path fails"); - assert!(err.message.contains("Unable to read config file")); - assert_eq!( - err.data, - Some(json!({ - "config_write_error_code": ConfigWriteErrorCode::ConfigValidationError, - })) - ); - - let _ = std::fs::remove_dir_all(temp_code_home); + Ok(()) } } + +#[cfg(test)] +#[path = "message_processor_tracing_tests.rs"] +mod message_processor_tracing_tests; diff --git a/code-rs/app-server/src/message_processor_tracing_tests.rs b/code-rs/app-server/src/message_processor_tracing_tests.rs new file mode 100644 index 00000000000..516e0423011 --- /dev/null +++ b/code-rs/app-server/src/message_processor_tracing_tests.rs @@ -0,0 +1,707 @@ +use super::ConnectionSessionState; +use super::MessageProcessor; +use super::MessageProcessorArgs; +use crate::analytics_utils::analytics_events_client_from_config; +use crate::config_manager::ConfigManager; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; +use crate::transport::AppServerTransport; +use anyhow::Result; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::write_mock_responses_config_toml; +use codex_analytics::AppServerRpcTransport; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_login::AuthManager; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::W3cTraceContext; +use opentelemetry::global; +use opentelemetry::trace::SpanId; +use opentelemetry::trace::SpanKind; +use opentelemetry::trace::TraceId; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::propagation::TraceContextPropagator; +use opentelemetry_sdk::trace::InMemorySpanExporter; +use opentelemetry_sdk::trace::SdkTracerProvider; +use opentelemetry_sdk::trace::SpanData; +use pretty_assertions::assert_eq; +use serial_test::serial; +use std::collections::BTreeMap; +use std::future::Future; +use std::path::Path; +use std::sync::Arc; +use std::sync::OnceLock; +use tempfile::TempDir; +use tokio::sync::mpsc; +use tracing_subscriber::layer::SubscriberExt; +use wiremock::MockServer; + +const TEST_CONNECTION_ID: ConnectionId = ConnectionId(7); + +struct TestTracing { + exporter: InMemorySpanExporter, + provider: SdkTracerProvider, +} + +struct RemoteTrace { + trace_id: TraceId, + parent_span_id: SpanId, + context: W3cTraceContext, +} + +impl RemoteTrace { + fn new(trace_id: &str, parent_span_id: &str) -> Self { + let trace_id = TraceId::from_hex(trace_id).expect("trace id"); + let parent_span_id = SpanId::from_hex(parent_span_id).expect("parent span id"); + let context = W3cTraceContext { + traceparent: Some(format!("00-{trace_id}-{parent_span_id}-01")), + tracestate: Some("vendor=value".to_string()), + }; + + Self { + trace_id, + parent_span_id, + context, + } + } +} + +fn init_test_tracing() -> &'static TestTracing { + static TEST_TRACING: OnceLock = OnceLock::new(); + TEST_TRACING.get_or_init(|| { + let exporter = InMemorySpanExporter::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let tracer = provider.tracer("codex-app-server-message-processor-tests"); + global::set_text_map_propagator(TraceContextPropagator::new()); + let subscriber = + tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)); + tracing::subscriber::set_global_default(subscriber) + .expect("global tracing subscriber should only be installed once"); + TestTracing { exporter, provider } + }) +} + +fn request_from_client_request(request: ClientRequest) -> JSONRPCRequest { + serde_json::from_value(serde_json::to_value(request).expect("serialize client request")) + .expect("client request should convert to JSON-RPC") +} + +struct TracingHarness { + _server: MockServer, + _codex_home: TempDir, + processor: Arc, + outgoing_rx: mpsc::Receiver, + session: Arc, + tracing: &'static TestTracing, +} + +impl TracingHarness { + async fn new() -> Result { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let config = Arc::new(build_test_config(codex_home.path(), &server.uri()).await?); + let (processor, outgoing_rx) = build_test_processor(config).await; + let tracing = init_test_tracing(); + tracing.exporter.reset(); + tracing::callsite::rebuild_interest_cache(); + let mut harness = Self { + _server: server, + _codex_home: codex_home, + processor, + outgoing_rx, + session: Arc::new(ConnectionSessionState::new()), + tracing, + }; + + let _: InitializeResponse = harness + .request( + ClientRequest::Initialize { + request_id: RequestId::Integer(1), + params: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + }, + /*trace*/ None, + ) + .await; + assert!(harness.session.initialized()); + + Ok(harness) + } + + fn reset_tracing(&self) { + self.tracing.exporter.reset(); + } + + async fn shutdown(self) { + self.processor.shutdown_threads().await; + self.processor.drain_background_tasks().await; + } + + async fn request(&mut self, request: ClientRequest, trace: Option) -> T + where + T: serde::de::DeserializeOwned, + { + let request_id = match request.id() { + RequestId::Integer(request_id) => *request_id, + request_id => panic!("expected integer request id in test harness, got {request_id:?}"), + }; + let mut request = request_from_client_request(request); + request.trace = trace; + + self.processor + .process_request( + TEST_CONNECTION_ID, + request, + &AppServerTransport::Stdio, + Arc::clone(&self.session), + ) + .await; + read_response(&mut self.outgoing_rx, request_id).await + } + + async fn start_thread( + &mut self, + request_id: i64, + trace: Option, + ) -> ThreadStartResponse { + let response = self + .request( + ClientRequest::ThreadStart { + request_id: RequestId::Integer(request_id), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }, + trace, + ) + .await; + read_thread_started_notification(&mut self.outgoing_rx).await; + response + } +} + +async fn build_test_config(codex_home: &Path, server_uri: &str) -> Result { + write_mock_responses_config_toml( + codex_home, + server_uri, + &BTreeMap::new(), + /*auto_compact_limit*/ 8_192, + Some(false), + "mock_provider", + "compact", + )?; + + Ok(ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await?) +} + +async fn build_test_processor( + config: Arc, +) -> ( + Arc, + mpsc::Receiver, +) { + let (outgoing_tx, outgoing_rx) = mpsc::channel(16); + let auth_manager = + AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false).await; + let config_manager = ConfigManager::new( + config.codex_home.to_path_buf(), + Vec::new(), + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + Arc::new(codex_config::NoopThreadConfigLoader), + ); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); + let outgoing = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); + let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { + outgoing, + analytics_events_client, + arg0_paths: Arg0DispatchPaths::default(), + config, + config_manager, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + config_warnings: Vec::new(), + session_source: SessionSource::VSCode, + auth_manager, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), + rpc_transport: AppServerRpcTransport::Stdio, + remote_control_handle: None, + plugin_startup_tasks: crate::PluginStartupTasks::Start, + })); + (processor, outgoing_rx) +} + +fn run_current_thread_test_with_stack(name: &str, future: F) -> Result<()> +where + F: Future> + Send + 'static, +{ + const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; + + let handle = std::thread::Builder::new() + .name(name.to_string()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(move || -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(Box::pin(future)) + })?; + + match handle.join() { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!("{name} thread panicked")), + } +} + +fn span_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a str> { + span.attributes + .iter() + .find(|kv| kv.key.as_str() == key) + .and_then(|kv| match &kv.value { + opentelemetry::Value::String(value) => Some(value.as_str()), + _ => None, + }) +} + +fn find_rpc_span_with_trace<'a>( + spans: &'a [SpanData], + kind: SpanKind, + method: &str, + trace_id: TraceId, +) -> &'a SpanData { + spans + .iter() + .find(|span| { + span.span_kind == kind + && span_attr(span, "rpc.system") == Some("jsonrpc") + && span_attr(span, "rpc.method") == Some(method) + && span.span_context.trace_id() == trace_id + }) + .unwrap_or_else(|| { + panic!( + "missing {kind:?} span for rpc.method={method} trace={trace_id}; exported spans:\n{}", + format_spans(spans) + ) + }) +} + +fn find_span_with_trace<'a, F>( + spans: &'a [SpanData], + trace_id: TraceId, + description: &str, + predicate: F, +) -> &'a SpanData +where + F: Fn(&SpanData) -> bool, +{ + spans + .iter() + .find(|span| span.span_context.trace_id() == trace_id && predicate(span)) + .unwrap_or_else(|| { + panic!( + "missing span matching {description} for trace={trace_id}; exported spans:\n{}", + format_spans(spans) + ) + }) +} + +fn format_spans(spans: &[SpanData]) -> String { + spans + .iter() + .map(|span| { + let rpc_method = span_attr(span, "rpc.method").unwrap_or("-"); + format!( + "name={} span_id={} kind={:?} parent={} trace={} rpc.method={}", + span.name, + span.span_context.span_id(), + span.span_kind, + span.parent_span_id, + span.span_context.trace_id(), + rpc_method + ) + }) + .collect::>() + .join("\n") +} + +fn span_depth_from_ancestor( + spans: &[SpanData], + child: &SpanData, + ancestor: &SpanData, +) -> Option { + let ancestor_span_id = ancestor.span_context.span_id(); + let mut parent_span_id = child.parent_span_id; + let mut depth = 1; + while parent_span_id != SpanId::INVALID { + if parent_span_id == ancestor_span_id { + return Some(depth); + } + let Some(parent_span) = spans + .iter() + .find(|span| span.span_context.span_id() == parent_span_id) + else { + break; + }; + parent_span_id = parent_span.parent_span_id; + depth += 1; + } + + None +} + +fn assert_span_descends_from(spans: &[SpanData], child: &SpanData, ancestor: &SpanData) { + if span_depth_from_ancestor(spans, child, ancestor).is_some() { + return; + } + + panic!( + "span {} does not descend from {}; exported spans:\n{}", + child.name, + ancestor.name, + format_spans(spans) + ); +} + +fn assert_has_internal_descendant_at_min_depth( + spans: &[SpanData], + ancestor: &SpanData, + min_depth: usize, +) { + if spans.iter().any(|span| { + span.span_kind == SpanKind::Internal + && span.span_context.trace_id() == ancestor.span_context.trace_id() + && span_depth_from_ancestor(spans, span, ancestor) + .is_some_and(|depth| depth >= min_depth) + }) { + return; + } + + panic!( + "missing internal descendant at depth >= {min_depth} below {}; exported spans:\n{}", + ancestor.name, + format_spans(spans) + ); +} + +async fn read_response( + outgoing_rx: &mut mpsc::Receiver, + request_id: i64, +) -> T { + loop { + let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), outgoing_rx.recv()) + .await + .expect("timed out waiting for response") + .expect("outgoing channel closed"); + let crate::outgoing_message::OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } = envelope + else { + continue; + }; + if connection_id != TEST_CONNECTION_ID { + continue; + } + let crate::outgoing_message::OutgoingMessage::Response(response) = message else { + continue; + }; + if response.id != RequestId::Integer(request_id) { + continue; + } + return serde_json::from_value(response.result) + .expect("response payload should deserialize"); + } +} + +async fn read_thread_started_notification( + outgoing_rx: &mut mpsc::Receiver, +) { + loop { + let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), outgoing_rx.recv()) + .await + .expect("timed out waiting for thread/started notification") + .expect("outgoing channel closed"); + match envelope { + crate::outgoing_message::OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } => { + if connection_id != TEST_CONNECTION_ID { + continue; + } + let crate::outgoing_message::OutgoingMessage::AppServerNotification(notification) = + message + else { + continue; + }; + if matches!( + notification, + codex_app_server_protocol::ServerNotification::ThreadStarted(_) + ) { + return; + } + } + crate::outgoing_message::OutgoingEnvelope::Broadcast { message } => { + let crate::outgoing_message::OutgoingMessage::AppServerNotification(notification) = + message + else { + continue; + }; + if matches!( + notification, + codex_app_server_protocol::ServerNotification::ThreadStarted(_) + ) { + return; + } + } + } + } +} + +async fn wait_for_exported_spans(tracing: &TestTracing, predicate: F) -> Vec +where + F: Fn(&[SpanData]) -> bool, +{ + let mut last_spans = Vec::new(); + for _ in 0..200 { + tokio::task::yield_now().await; + tracing + .provider + .force_flush() + .expect("force flush should succeed"); + let spans = tracing.exporter.get_finished_spans().expect("span export"); + last_spans = spans.clone(); + if predicate(&spans) { + return spans; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + panic!( + "timed out waiting for expected exported spans:\n{}", + format_spans(&last_spans) + ); +} + +async fn wait_for_new_exported_spans( + tracing: &TestTracing, + baseline_len: usize, + predicate: F, +) -> Vec +where + F: Fn(&[SpanData]) -> bool, +{ + let spans = wait_for_exported_spans(tracing, |spans| { + spans.len() > baseline_len && predicate(&spans[baseline_len..]) + }) + .await; + spans.into_iter().skip(baseline_len).collect() +} + +#[test] +#[serial(app_server_tracing)] +fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> { + run_current_thread_test_with_stack( + "thread_start_jsonrpc_span_exports_server_span_and_parents_children", + async { + let mut harness = TracingHarness::new().await?; + + let RemoteTrace { + trace_id: remote_trace_id, + parent_span_id: remote_parent_span_id, + context: remote_trace, + .. + } = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022"); + + let _: ThreadStartResponse = harness + .start_thread(/*request_id*/ 20_002, /*trace*/ None) + .await; + let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| { + spans.iter().any(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.method") == Some("thread/start") + }) + }) + .await; + let untraced_server_span = find_rpc_span_with_trace( + &untraced_spans, + SpanKind::Server, + "thread/start", + untraced_spans + .iter() + .rev() + .find(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.system") == Some("jsonrpc") + && span_attr(span, "rpc.method") == Some("thread/start") + }) + .unwrap_or_else(|| { + panic!( + "missing latest thread/start server span; exported spans:\n{}", + format_spans(&untraced_spans) + ) + }) + .span_context + .trace_id(), + ); + assert_has_internal_descendant_at_min_depth( + &untraced_spans, + untraced_server_span, + /*min_depth*/ 1, + ); + + let baseline_len = untraced_spans.len(); + let _: ThreadStartResponse = harness + .start_thread(/*request_id*/ 20_003, Some(remote_trace)) + .await; + let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| { + spans.iter().any(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.method") == Some("thread/start") + && span.span_context.trace_id() == remote_trace_id + }) && spans.iter().any(|span| { + span.name.as_ref() == "app_server.thread_start.notify_started" + && span.span_context.trace_id() == remote_trace_id + }) + }) + .await; + + let server_request_span = + find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id); + assert_eq!(server_request_span.name.as_ref(), "thread/start"); + assert_eq!(server_request_span.parent_span_id, remote_parent_span_id); + assert!(server_request_span.parent_span_is_remote); + assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id); + assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID); + assert_has_internal_descendant_at_min_depth( + &spans, + server_request_span, + /*min_depth*/ 1, + ); + assert_has_internal_descendant_at_min_depth( + &spans, + server_request_span, + /*min_depth*/ 2, + ); + harness.shutdown().await; + + Ok(()) + }, + ) +} + +#[tokio::test(flavor = "current_thread")] +#[serial(app_server_tracing)] +async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { + let mut harness = TracingHarness::new().await?; + let thread_start_response = harness.start_thread(/*request_id*/ 2, /*trace*/ None).await; + let thread_id = thread_start_response.thread.id.clone(); + + harness.reset_tracing(); + + let RemoteTrace { + trace_id: remote_trace_id, + parent_span_id: remote_parent_span_id, + context: remote_trace, + } = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088"); + let turn_start_response: TurnStartResponse = harness + .request( + ClientRequest::TurnStart { + request_id: RequestId::Integer(3), + params: TurnStartParams { + environments: None, + thread_id, + input: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + cwd: None, + approval_policy: None, + sandbox_policy: None, + permissions: None, + approvals_reviewer: None, + model: None, + service_tier: None, + effort: None, + summary: None, + personality: None, + output_schema: None, + collaboration_mode: None, + }, + }, + Some(remote_trace), + ) + .await; + let spans = wait_for_exported_spans(harness.tracing, |spans| { + spans.iter().any(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.method") == Some("turn/start") + && span.span_context.trace_id() == remote_trace_id + }) && spans.iter().any(|span| { + span_attr(span, "codex.op") == Some("user_input") + && span.span_context.trace_id() == remote_trace_id + }) + }) + .await; + + let server_request_span = + find_rpc_span_with_trace(&spans, SpanKind::Server, "turn/start", remote_trace_id); + let core_turn_span = + find_span_with_trace(&spans, remote_trace_id, "codex.op=user_input", |span| { + span_attr(span, "codex.op") == Some("user_input") + }); + + assert_eq!(server_request_span.parent_span_id, remote_parent_span_id); + assert!(server_request_span.parent_span_is_remote); + assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id); + assert_eq!( + span_attr(server_request_span, "turn.id"), + Some(turn_start_response.turn.id.as_str()) + ); + assert_span_descends_from(&spans, core_turn_span, server_request_span); + harness.shutdown().await; + + Ok(()) +} diff --git a/code-rs/app-server/src/models.rs b/code-rs/app-server/src/models.rs new file mode 100644 index 00000000000..4d75a205806 --- /dev/null +++ b/code-rs/app-server/src/models.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use codex_app_server_protocol::Model; +use codex_app_server_protocol::ModelServiceTier; +use codex_app_server_protocol::ModelUpgradeInfo; +use codex_app_server_protocol::ReasoningEffortOption; +use codex_core::ThreadManager; +use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffortPreset; + +pub async fn supported_models( + thread_manager: Arc, + include_hidden: bool, +) -> Vec { + thread_manager + .list_models(RefreshStrategy::OnlineIfUncached) + .await + .into_iter() + .filter(|preset| include_hidden || preset.show_in_picker) + .map(model_from_preset) + .collect() +} + +fn model_from_preset(preset: ModelPreset) -> Model { + Model { + id: preset.id.to_string(), + model: preset.model.to_string(), + upgrade: preset.upgrade.as_ref().map(|upgrade| upgrade.id.clone()), + upgrade_info: preset.upgrade.as_ref().map(|upgrade| ModelUpgradeInfo { + model: upgrade.id.clone(), + upgrade_copy: upgrade.upgrade_copy.clone(), + model_link: upgrade.model_link.clone(), + migration_markdown: upgrade.migration_markdown.clone(), + }), + availability_nux: preset.availability_nux.map(Into::into), + display_name: preset.display_name.to_string(), + description: preset.description.to_string(), + hidden: !preset.show_in_picker, + supported_reasoning_efforts: reasoning_efforts_from_preset( + preset.supported_reasoning_efforts, + ), + default_reasoning_effort: preset.default_reasoning_effort, + input_modalities: preset.input_modalities, + supports_personality: preset.supports_personality, + additional_speed_tiers: preset.additional_speed_tiers, + service_tiers: preset + .service_tiers + .into_iter() + .map(|service_tier| ModelServiceTier { + id: service_tier.id, + name: service_tier.name, + description: service_tier.description, + }) + .collect(), + is_default: preset.is_default, + } +} + +fn reasoning_efforts_from_preset( + efforts: Vec, +) -> Vec { + efforts + .iter() + .map(|preset| ReasoningEffortOption { + reasoning_effort: preset.effort, + description: preset.description.to_string(), + }) + .collect() +} diff --git a/code-rs/app-server/src/outgoing_message.rs b/code-rs/app-server/src/outgoing_message.rs index 7588ee40702..cbe196cd986 100644 --- a/code-rs/app-server/src/outgoing_message.rs +++ b/code-rs/app-server/src/outgoing_message.rs @@ -1,509 +1,1291 @@ use std::collections::HashMap; +use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; -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 as JsonRpcResult; -use serde::Serialize; +use codex_analytics::AnalyticsEventsClient; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestPayload; +use codex_otel::span_w3c_trace_context; +use codex_protocol::ThreadId; +use codex_protocol::protocol::W3cTraceContext; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; +use tracing::Instrument; +use tracing::Span; use tracing::warn; -use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::internal_error; +use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON; +pub(crate) use codex_app_server_transport::ConnectionId; +pub(crate) use codex_app_server_transport::OutgoingError; +pub(crate) use codex_app_server_transport::OutgoingMessage; +pub(crate) use codex_app_server_transport::OutgoingResponse; +pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; -/// Stable identifier for a transport connection. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct ConnectionId(pub u64); +#[cfg(test)] +use codex_protocol::account::PlanType; + +pub(crate) type ClientRequestResult = std::result::Result; + +/// Stable identifier for a client request scoped to a transport connection. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct ConnectionRequestId { + pub(crate) connection_id: ConnectionId, + pub(crate) request_id: RequestId, +} + +/// Trace data we keep for an incoming request until we send its final +/// response or error. +#[derive(Clone)] +pub(crate) struct RequestContext { + request_id: ConnectionRequestId, + span: Span, + parent_trace: Option, +} + +impl RequestContext { + pub(crate) fn new( + request_id: ConnectionRequestId, + span: Span, + parent_trace: Option, + ) -> Self { + Self { + request_id, + span, + parent_trace, + } + } + + pub(crate) fn request_trace(&self) -> Option { + span_w3c_trace_context(&self.span).or_else(|| self.parent_trace.clone()) + } + + pub(crate) fn span(&self) -> Span { + self.span.clone() + } + + fn record_turn_id(&self, turn_id: &str) { + self.span.record("turn.id", turn_id); + } +} -/// Envelope describing whether an outgoing message should be routed to a single -/// connection or broadcast to all initialized connections. -#[derive(Debug, Clone)] +#[derive(Debug)] pub(crate) enum OutgoingEnvelope { ToConnection { connection_id: ConnectionId, message: OutgoingMessage, + write_complete_tx: Option>, }, Broadcast { message: OutgoingMessage, }, } -#[derive(Debug)] -struct PendingRequestCallback { - connection_id: Option, - sender: oneshot::Sender, +/// Sends messages to the client and manages request callbacks. +pub(crate) struct OutgoingMessageSender { + next_server_request_id: AtomicI64, + sender: mpsc::Sender, + request_id_to_callback: Mutex>, + /// Incoming requests that are still waiting on a final response or error. + /// We keep them here because this is where responses, errors, and + /// disconnect cleanup all get handled. + request_contexts: Mutex>, + analytics_events_client: AnalyticsEventsClient, } -#[derive(Debug)] -enum OutgoingChannel { - Routed(mpsc::Sender), - Direct(mpsc::UnboundedSender), +#[derive(Clone)] +pub(crate) struct ThreadScopedOutgoingMessageSender { + outgoing: Arc, + connection_ids: Arc>, + thread_id: ThreadId, } -/// Sends messages to the client and manages request callbacks. -pub struct OutgoingMessageSender { - next_request_id: AtomicI64, - sender: OutgoingChannel, - request_id_to_callback: Mutex>, +struct PendingCallbackEntry { + callback: oneshot::Sender, + thread_id: Option, + request: ServerRequest, } -impl OutgoingMessageSender { - /// Legacy constructor used by `code-mcp-server`. - pub fn new(sender: mpsc::UnboundedSender) -> Self { +impl ThreadScopedOutgoingMessageSender { + pub(crate) fn new( + outgoing: Arc, + connection_ids: Vec, + thread_id: ThreadId, + ) -> Self { Self { - next_request_id: AtomicI64::new(0), - sender: OutgoingChannel::Direct(sender), - request_id_to_callback: Mutex::new(HashMap::new()), + outgoing, + connection_ids: Arc::new(connection_ids), + thread_id, } } - pub(crate) fn new_with_routed_sender(sender: mpsc::Sender) -> Self { + pub(crate) async fn send_request( + &self, + payload: ServerRequestPayload, + ) -> (RequestId, oneshot::Receiver) { + self.outgoing + .send_request_to_connections( + Some(self.connection_ids.as_slice()), + payload, + Some(self.thread_id), + ) + .await + } + + pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + self.outgoing + .analytics_events_client + .track_notification(notification.clone()); + if self.connection_ids.is_empty() { + return; + } + self.outgoing + .send_server_notification_to_connections(self.connection_ids.as_slice(), notification) + .await; + } + + pub(crate) async fn send_global_server_notification(&self, notification: ServerNotification) { + self.outgoing.send_server_notification(notification).await; + } + + pub(crate) async fn abort_pending_server_requests(&self) { + self.outgoing + .cancel_requests_for_thread( + self.thread_id, + Some({ + let mut error = internal_error( + "client request resolved because the turn state was changed", + ); + error.data = Some(serde_json::json!({ + "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON, + })); + error + }), + ) + .await + } + + pub(crate) async fn send_response(&self, request_id: ConnectionRequestId, response: T) + where + T: Into, + { + self.outgoing.send_response(request_id, response).await; + } + + pub(crate) async fn send_error( + &self, + request_id: ConnectionRequestId, + error: impl Into, + ) { + self.outgoing.send_error(request_id, error).await; + } +} + +impl OutgoingMessageSender { + pub(crate) fn new( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { Self { - next_request_id: AtomicI64::new(0), - sender: OutgoingChannel::Routed(sender), + next_server_request_id: AtomicI64::new(0), + sender, request_id_to_callback: Mutex::new(HashMap::new()), + request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } - pub async fn send_request( + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { + let mut request_contexts = self.request_contexts.lock().await; + if request_contexts + .insert(request_context.request_id.clone(), request_context) + .is_some() + { + warn!("replaced unresolved request context"); + } + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let mut request_contexts = self.request_contexts.lock().await; + request_contexts.retain(|request_id, _| request_id.connection_id != connection_id); + } + + pub(crate) async fn request_trace_context( &self, - method: &str, - params: Option, - ) -> oneshot::Receiver { - self.send_request_impl(None, method, params).await + request_id: &ConnectionRequestId, + ) -> Option { + let request_contexts = self.request_contexts.lock().await; + request_contexts + .get(request_id) + .and_then(RequestContext::request_trace) } - pub(crate) async fn send_request_to_connection( + pub(crate) async fn record_request_turn_id( &self, - connection_id: ConnectionId, - method: &str, - params: Option, - ) -> oneshot::Receiver { - self.send_request_impl(Some(connection_id), method, params) - .await + request_id: &ConnectionRequestId, + turn_id: &str, + ) { + let request_contexts = self.request_contexts.lock().await; + if let Some(request_context) = request_contexts.get(request_id) { + request_context.record_turn_id(turn_id); + } + } + + async fn take_request_context( + &self, + request_id: &ConnectionRequestId, + ) -> Option { + let mut request_contexts = self.request_contexts.lock().await; + request_contexts.remove(request_id) + } + + #[cfg(test)] + async fn request_context_count(&self) -> usize { + self.request_contexts.lock().await.len() + } + + pub(crate) async fn send_request( + &self, + request: ServerRequestPayload, + ) -> (RequestId, oneshot::Receiver) { + self.send_request_to_connections( + /*connection_ids*/ None, request, /*thread_id*/ None, + ) + .await + } + + fn next_request_id(&self) -> RequestId { + RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed)) } - async fn send_request_impl( + async fn send_request_to_connections( &self, - connection_id: Option, - method: &str, - params: Option, - ) -> oneshot::Receiver { - let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed)); + connection_ids: Option<&[ConnectionId]>, + request: ServerRequestPayload, + thread_id: Option, + ) -> (RequestId, oneshot::Receiver) { + let id = self.next_request_id(); let outgoing_message_id = id.clone(); - let (tx_callback, rx_callback) = oneshot::channel(); + let request = request.request_with_id(outgoing_message_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, - PendingRequestCallback { - connection_id, - sender: tx_callback, + PendingCallbackEntry { + callback: tx_approve, + thread_id, + request: request.clone(), }, ); } - let outgoing_message = OutgoingMessage::Request(OutgoingRequest { - id: outgoing_message_id.clone(), - method: method.to_string(), - params, - }); - let envelope = match connection_id { - Some(connection_id) => OutgoingEnvelope::ToConnection { - connection_id, - message: outgoing_message, - }, - None => OutgoingEnvelope::Broadcast { - message: outgoing_message, - }, + let outgoing_message = OutgoingMessage::Request(request.clone()); + let send_result = match connection_ids { + None => { + self.sender + .send(OutgoingEnvelope::Broadcast { + message: outgoing_message, + }) + .await + } + Some(connection_ids) => { + let mut send_error = None; + for connection_id in connection_ids { + if let Err(err) = self + .sender + .send(OutgoingEnvelope::ToConnection { + connection_id: *connection_id, + message: outgoing_message.clone(), + write_complete_tx: None, + }) + .await + { + send_error = Some(err); + break; + } else { + self.analytics_events_client + .track_server_request(connection_id.0, request.clone()); + } + } + match send_error { + Some(err) => Err(err), + None => Ok(()), + } + } }; - if let Err(err) = self.send_envelope(envelope).await { - warn!("failed to queue request {outgoing_message_id:?}: {err:?}"); + if let Err(err) = send_result { + warn!("failed to send request {outgoing_message_id:?} to client: {err:?}"); let mut request_id_to_callback = self.request_id_to_callback.lock().await; request_id_to_callback.remove(&outgoing_message_id); } - - rx_callback + (outgoing_message_id, rx_approve) } - pub async fn notify_client_response(&self, id: RequestId, result: JsonRpcResult) { - self.notify_client_response_for_connection(None, id, result) - .await; - } - - pub(crate) async fn notify_client_response_for_connection( + pub(crate) async fn replay_requests_to_connection_for_thread( &self, - connection_id: Option, - id: RequestId, - result: JsonRpcResult, + connection_id: ConnectionId, + thread_id: ThreadId, ) { - let entry = { - let mut request_id_to_callback = self.request_id_to_callback.lock().await; - let should_remove = request_id_to_callback - .get(&id) - .is_some_and(|pending| { - pending - .connection_id - .is_none_or(|owner_connection_id| { - connection_id.is_none_or(|connection_id| owner_connection_id == connection_id) - }) - }); - if should_remove { - request_id_to_callback.remove_entry(&id) - } else { - None + let requests = self.pending_requests_for_thread(thread_id).await; + for request in requests { + if let Err(err) = self + .sender + .send(OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::Request(request), + write_complete_tx: None, + }) + .await + { + warn!("failed to resend request to client: {err:?}"); } - }; + } + } + + pub(crate) async fn notify_client_response(&self, id: RequestId, result: Result) { + let entry = self.take_request_callback(&id).await; + + match entry { + Some((id, entry)) => { + let completed_at_ms = now_unix_timestamp_ms(); + if let Ok(response) = entry.request.response_from_result(result.clone()) { + self.analytics_events_client + .track_server_response(completed_at_ms, response); + } + if let Err(err) = entry.callback.send(Ok(result)) { + warn!("could not notify callback for {id:?} due to: {err:?}"); + } + } + None => { + warn!("could not find callback for {id:?}"); + } + } + } + + pub(crate) async fn notify_client_error(&self, id: RequestId, error: JSONRPCErrorError) { + let entry = self.take_request_callback(&id).await; match entry { - Some((id, pending)) => { - if let Err(err) = pending.sender.send(result) { + Some((id, entry)) => { + warn!("client responded with error for {id:?}: {error:?}"); + if let Err(err) = entry.callback.send(Err(error)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } } None => { - warn!( - "could not find callback for {id:?} on connection {:?}", - connection_id - ); + warn!("could not find callback for {id:?}"); + } + } + } + + pub(crate) async fn cancel_request(&self, id: &RequestId) -> bool { + self.take_request_callback(id).await.is_some() + } + + pub(crate) async fn cancel_all_requests(&self, error: Option) { + let entries = { + let mut request_id_to_callback = self.request_id_to_callback.lock().await; + request_id_to_callback + .drain() + .map(|(_, entry)| entry) + .collect::>() + }; + + if let Some(error) = error { + for entry in entries { + if let Err(err) = entry.callback.send(Err(error.clone())) { + let request_id = entry.request.id(); + warn!("could not notify callback for {request_id:?} due to: {err:?}"); + } } } } - pub async fn notify_client_error(&self, id: RequestId, error: JSONRPCErrorError) { - self.notify_client_error_for_connection(None, id, error).await; + async fn take_request_callback( + &self, + id: &RequestId, + ) -> Option<(RequestId, PendingCallbackEntry)> { + let mut request_id_to_callback = self.request_id_to_callback.lock().await; + request_id_to_callback.remove_entry(id) } - pub(crate) async fn notify_client_error_for_connection( + pub(crate) async fn pending_requests_for_thread( &self, - connection_id: Option, - id: RequestId, - error: JSONRPCErrorError, + thread_id: ThreadId, + ) -> Vec { + let request_id_to_callback = self.request_id_to_callback.lock().await; + let mut requests = request_id_to_callback + .iter() + .filter_map(|(_, entry)| { + (entry.thread_id == Some(thread_id)).then_some(entry.request.clone()) + }) + .collect::>(); + requests.sort_by(|left, right| left.id().cmp(right.id())); + requests + } + + pub(crate) async fn cancel_requests_for_thread( + &self, + thread_id: ThreadId, + error: Option, ) { - let entry = { + let entries = { let mut request_id_to_callback = self.request_id_to_callback.lock().await; - let should_remove = request_id_to_callback - .get(&id) - .is_some_and(|pending| { - pending - .connection_id - .is_none_or(|owner_connection_id| { - connection_id.is_none_or(|connection_id| owner_connection_id == connection_id) - }) - }); - if should_remove { - request_id_to_callback.remove_entry(&id) - } else { - None + let request_ids = request_id_to_callback + .iter() + .filter_map(|(request_id, entry)| { + (entry.thread_id == Some(thread_id)).then_some(request_id.clone()) + }) + .collect::>(); + + let mut entries = Vec::with_capacity(request_ids.len()); + for request_id in request_ids { + if let Some(entry) = request_id_to_callback.remove(&request_id) { + entries.push(entry); + } } + entries }; - match entry { - Some((request_id, _pending)) => { - warn!("client responded with error for {request_id:?}: {error:?}"); - } - None => { - warn!( - "could not find callback for {id:?} on connection {:?}", - connection_id - ); + if let Some(error) = error { + for entry in entries { + if let Err(err) = entry.callback.send(Err(error.clone())) { + let request_id = entry.request.id(); + warn!("could not notify callback for {request_id:?} due to: {err:?}",); + } } } } - pub(crate) async fn clear_callbacks_for_connection(&self, connection_id: ConnectionId) { - let mut request_id_to_callback = self.request_id_to_callback.lock().await; - request_id_to_callback.retain(|_, pending| { - pending - .connection_id - .is_none_or(|owner_connection_id| owner_connection_id != connection_id) - }); + pub(crate) async fn send_response(&self, request_id: ConnectionRequestId, response: T) + where + T: Into, + { + self.send_response_as(request_id, response.into()).await; } - pub async fn send_response(&self, id: RequestId, response: T) { - match serde_json::to_value(response) { - Ok(result) => { - let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result }); - if let Err(err) = self - .send_envelope(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) - .await - { - warn!("failed to queue response: {err:?}"); + pub(crate) async fn send_response_as( + &self, + request_id: ConnectionRequestId, + response: ClientResponsePayload, + ) { + let connection_id = request_id.connection_id; + let request_id_for_analytics = request_id.request_id.clone(); + let serialized_response = response + .into_jsonrpc_parts_and_payload(request_id.request_id.clone()) + .map(|(id, result, response)| { + if let Some(response) = response { + self.analytics_events_client.track_response( + connection_id.0, + request_id_for_analytics, + response, + ); } + (id, result) + }); + let request_context = self.take_request_context(&request_id).await; + + match serialized_response { + Ok((id, result)) => { + let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result }); + self.send_outgoing_message_to_connection( + request_context, + connection_id, + outgoing_message, + "response", + ) + .await; } Err(err) => { - self.send_error( - id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to serialize response: {err}"), - data: None, - }, + self.send_error_inner( + request_context, + request_id, + internal_error(format!("failed to serialize response: {err}")), ) .await; } } } - /// All notifications should be migrated to server notification enums and - /// this generic notification should be removed. - pub async fn send_notification(&self, notification: OutgoingNotification) { - let outgoing_message = OutgoingMessage::Notification(notification); - if let Err(err) = self - .send_envelope(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) - .await - { - warn!("failed to queue notification: {err:?}"); + pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + self.send_server_notification_to_connections(&[], notification) + .await; + } + + pub(crate) async fn send_server_notification_to_connections( + &self, + connection_ids: &[ConnectionId], + notification: ServerNotification, + ) { + tracing::trace!( + targeted_connections = connection_ids.len(), + "app-server event: {notification}" + ); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); + if connection_ids.is_empty() { + if let Err(err) = self + .sender + .send(OutgoingEnvelope::Broadcast { + message: outgoing_message, + }) + .await + { + warn!("failed to send server notification to client: {err:?}"); + } + return; + } + for connection_id in connection_ids { + if let Err(err) = self + .sender + .send(OutgoingEnvelope::ToConnection { + connection_id: *connection_id, + message: outgoing_message.clone(), + write_complete_tx: None, + }) + .await + { + warn!("failed to send server notification to client: {err:?}"); + } } } - pub(crate) async fn send_notification_to_connection( + pub(crate) async fn send_server_notification_to_connection_and_wait( &self, connection_id: ConnectionId, - notification: OutgoingNotification, + notification: ServerNotification, ) { - let outgoing_message = OutgoingMessage::Notification(notification); + tracing::trace!("app-server event: {notification}"); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); + let (write_complete_tx, write_complete_rx) = oneshot::channel(); if let Err(err) = self - .send_envelope(OutgoingEnvelope::ToConnection { + .sender + .send(OutgoingEnvelope::ToConnection { connection_id, message: outgoing_message, + write_complete_tx: Some(write_complete_tx), }) .await { - warn!("failed to queue notification to {connection_id:?}: {err:?}"); + warn!("failed to send server notification to client: {err:?}"); } + let _ = write_complete_rx.await; } - pub async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) { - let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error }); - if let Err(err) = self - .send_envelope(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) - .await - { - warn!("failed to queue error: {err:?}"); - } + pub(crate) async fn send_error( + &self, + request_id: ConnectionRequestId, + error: impl Into, + ) { + let request_context = self.take_request_context(&request_id).await; + self.send_error_inner(request_context, request_id, error.into()) + .await; } - async fn send_envelope( + pub(crate) async fn send_result( &self, - envelope: OutgoingEnvelope, - ) -> std::result::Result<(), mpsc::error::SendError> { - match &self.sender { - OutgoingChannel::Routed(sender) => sender.send(envelope).await, - OutgoingChannel::Direct(sender) => { - let message = match envelope { - OutgoingEnvelope::ToConnection { message, .. } => message, - OutgoingEnvelope::Broadcast { message } => message, - }; - sender - .send(message) - .map_err(|err| mpsc::error::SendError(OutgoingEnvelope::Broadcast { - message: err.0, - })) + request_id: ConnectionRequestId, + result: std::result::Result, + ) where + T: Into, + E: Into, + { + match result { + Ok(response) => { + self.send_response(request_id, response).await; } + Err(error) => self.send_error(request_id, error).await, } } -} - -/// Outgoing message from the server to the client. -#[derive(Debug, Clone)] -pub enum OutgoingMessage { - Request(OutgoingRequest), - Notification(OutgoingNotification), - Response(OutgoingResponse), - Error(OutgoingError), -} -impl From 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, - }), - } + async fn send_error_inner( + &self, + request_context: Option, + request_id: ConnectionRequestId, + error: JSONRPCErrorError, + ) { + let outgoing_message = OutgoingMessage::Error(OutgoingError { + id: request_id.request_id, + error, + }); + self.send_outgoing_message_to_connection( + request_context, + request_id.connection_id, + outgoing_message, + "error", + ) + .await; } -} - -#[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, -} -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct OutgoingNotification { - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, -} + async fn send_outgoing_message_to_connection( + &self, + request_context: Option, + connection_id: ConnectionId, + message: OutgoingMessage, + message_kind: &'static str, + ) { + let send_fut = self.sender.send(OutgoingEnvelope::ToConnection { + connection_id, + message, + write_complete_tx: None, + }); + let send_result = if let Some(request_context) = request_context { + send_fut.instrument(request_context.span()).await + } else { + send_fut.await + }; -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct OutgoingResponse { - pub id: RequestId, - pub result: JsonRpcResult, + if let Err(err) = send_result { + warn!("failed to send {message_kind} to client: {err:?}"); + } + } } -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct OutgoingError { - pub error: JSONRPCErrorError, - pub id: RequestId, +fn now_unix_timestamp_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or_default() } #[cfg(test)] mod tests { - use super::*; + use std::time::Duration; + + use codex_app_server_protocol::AccountLoginCompletedNotification; + use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; + use codex_app_server_protocol::AccountUpdatedNotification; + use codex_app_server_protocol::ApplyPatchApprovalParams; + use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::DynamicToolCallParams; + use codex_app_server_protocol::FileChangeRequestApprovalParams; + use codex_app_server_protocol::GuardianWarningNotification; + use codex_app_server_protocol::ModelRerouteReason; + use codex_app_server_protocol::ModelReroutedNotification; + use codex_app_server_protocol::ModelVerification; + use codex_app_server_protocol::ModelVerificationNotification; + use codex_app_server_protocol::RateLimitSnapshot; + use codex_app_server_protocol::RateLimitWindow; + use codex_app_server_protocol::ServerResponse; + use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_protocol::ThreadId; + use pretty_assertions::assert_eq; use serde_json::json; - use tokio::time::Duration; + use std::sync::Arc; use tokio::time::timeout; + use uuid::Uuid; - fn request_id_from_message(message: OutgoingMessage) -> RequestId { - match message { - OutgoingMessage::Request(request) => request.id, - _ => panic!("expected request message"), - } + use super::*; + + #[test] + fn verify_server_notification_serialization() { + let notification = + ServerNotification::AccountLoginCompleted(AccountLoginCompletedNotification { + login_id: Some(Uuid::nil().to_string()), + success: true, + error: None, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/login/completed", + "params": { + "loginId": Uuid::nil().to_string(), + "success": true, + "error": null, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the strum macros serialize the method field correctly"), + "ensure the strum macros serialize the method field correctly" + ); } - #[tokio::test] - async fn connection_scoped_callback_ignores_other_connection_responses() { - let (tx, mut rx_messages) = mpsc::unbounded_channel(); - let sender = OutgoingMessageSender::new(tx); + #[test] + fn verify_account_login_completed_notification_serialization() { + let notification = + ServerNotification::AccountLoginCompleted(AccountLoginCompletedNotification { + login_id: Some(Uuid::nil().to_string()), + success: true, + error: None, + }); - let callback = sender - .send_request_to_connection(ConnectionId(7), "test", None) - .await; - let request_id = request_id_from_message( - rx_messages - .recv() - .await - .expect("request should be emitted"), + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/login/completed", + "params": { + "loginId": Uuid::nil().to_string(), + "success": true, + "error": null, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_account_rate_limits_notification_serialization() { + let notification = + ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification { + rate_limits: RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 25, + window_duration_mins: Some(15), + resets_at: Some(123), + }), + secondary: None, + credits: None, + plan_type: Some(PlanType::Plus), + rate_limit_reached_type: None, + }, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/rateLimits/updated", + "params": { + "rateLimits": { + "limitId": "codex", + "limitName": null, + "primary": { + "usedPercent": 25, + "windowDurationMins": 15, + "resetsAt": 123 + }, + "secondary": null, + "credits": null, + "planType": "plus", + "rateLimitReachedType": null + } + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_account_updated_notification_serialization() { + let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { + auth_mode: Some(AuthMode::ApiKey), + plan_type: None, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/updated", + "params": { + "authMode": "apikey", + "planType": null + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_config_warning_notification_serialization() { + let notification = ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "Config error: using defaults".to_string(), + details: Some("error loading config: bad config".to_string()), + path: None, + range: None, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!( { + "method": "configWarning", + "params": { + "summary": "Config error: using defaults", + "details": "error loading config: bad config", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_guardian_warning_notification_serialization() { + let notification = ServerNotification::GuardianWarning(GuardianWarningNotification { + thread_id: "thread-1".to_string(), + message: "Automatic approval review denied the requested action.".to_string(), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "guardianWarning", + "params": { + "threadId": "thread-1", + "message": "Automatic approval review denied the requested action.", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_model_rerouted_notification_serialization() { + let notification = ServerNotification::ModelRerouted(ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5.3-codex".to_string(), + to_model: "gpt-5.2".to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "model/rerouted", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "fromModel": "gpt-5.3-codex", + "toModel": "gpt-5.2", + "reason": "highRiskCyberActivity", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_model_verification_notification_serialization() { + let notification = ServerNotification::ModelVerification(ModelVerificationNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + verifications: vec![ModelVerification::TrustedAccessForCyber], + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "model/verification", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "verifications": ["trustedAccessForCyber"], + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn server_request_response_from_result_decodes_typed_response() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + started_at_ms: 0, + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = request + .response_from_result(json!({ + "decision": "acceptForSession", + })) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession ); + } + #[tokio::test] + async fn send_response_routes_to_target_connection() { + let (tx, mut rx) = mpsc::channel::(4); + let outgoing = + OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(42), + request_id: RequestId::Integer(7), + }; - sender - .notify_client_response_for_connection( - Some(ConnectionId(8)), + outgoing + .send_response( request_id.clone(), - json!({ "ok": false }), + ClientResponsePayload::ThreadArchive( + codex_app_server_protocol::ThreadArchiveResponse {}, + ), ) .await; - assert!( - timeout(Duration::from_millis(25), callback) - .await - .is_err(), - "callback should not resolve from a different connection" - ); + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("should receive envelope before timeout") + .expect("channel should contain one message"); + + match envelope { + OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } => { + assert_eq!(connection_id, ConnectionId(42)); + let OutgoingMessage::Response(response) = message else { + panic!("expected response message"); + }; + assert_eq!(response.id, request_id.request_id); + assert_eq!(response.result, json!({})); + } + other => panic!("expected targeted response envelope, got: {other:?}"), + } + } - let callback = sender - .send_request_to_connection(ConnectionId(7), "test", None) + #[tokio::test] + async fn send_response_clears_registered_request_context() { + let (tx, _rx) = mpsc::channel::(4); + let outgoing = + OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(42), + request_id: RequestId::Integer(7), + }; + + outgoing + .register_request_context(RequestContext::new( + request_id.clone(), + tracing::info_span!("app_server.request", rpc.method = "thread/start"), + /*parent_trace*/ None, + )) .await; - let request_id = request_id_from_message( - rx_messages - .recv() - .await - .expect("request should be emitted"), - ); - sender - .notify_client_response_for_connection( - Some(ConnectionId(7)), + assert_eq!(outgoing.request_context_count().await, 1); + + outgoing + .send_response( request_id, - json!({ "ok": true }), + ClientResponsePayload::ThreadArchive( + codex_app_server_protocol::ThreadArchiveResponse {}, + ), ) .await; - let value = callback.await.expect("callback should resolve"); - assert_eq!(value, json!({ "ok": true })); + + assert_eq!(outgoing.request_context_count().await, 0); } #[tokio::test] - async fn clearing_connection_callbacks_only_drops_owned_callbacks() { - let (tx, mut rx_messages) = mpsc::unbounded_channel(); - let sender = OutgoingMessageSender::new(tx); + async fn send_error_routes_to_target_connection() { + let (tx, mut rx) = mpsc::channel::(4); + let outgoing = + OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(9), + request_id: RequestId::Integer(3), + }; + let error = internal_error("boom"); - let callback_conn1 = sender - .send_request_to_connection(ConnectionId(1), "conn1", None) - .await; - let request_conn1 = request_id_from_message( - rx_messages - .recv() + outgoing.send_error(request_id.clone(), error.clone()).await; + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("should receive envelope before timeout") + .expect("channel should contain one message"); + + match envelope { + OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } => { + assert_eq!(connection_id, ConnectionId(9)); + let OutgoingMessage::Error(outgoing_error) = message else { + panic!("expected error message"); + }; + assert_eq!(outgoing_error.id, RequestId::Integer(3)); + assert_eq!(outgoing_error.error, error); + } + other => panic!("expected targeted error envelope, got: {other:?}"), + } + } + + #[tokio::test] + async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { + let (tx, mut rx) = mpsc::channel::(4); + let outgoing = + OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); + let send_task = tokio::spawn(async move { + outgoing + .send_server_notification_to_connection_and_wait( + ConnectionId(42), + ServerNotification::ModelRerouted(ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5.3-codex".to_string(), + to_model: "gpt-5.2".to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }), + ) .await - .expect("first request should be emitted"), + }); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("should receive envelope before timeout") + .expect("channel should contain one message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + write_complete_tx, + } = envelope + else { + panic!("expected targeted server notification envelope"); + }; + assert_eq!(connection_id, ConnectionId(42)); + assert!(matches!(message, OutgoingMessage::AppServerNotification(_))); + write_complete_tx + .expect("write completion sender should be attached") + .send(()) + .expect("receiver should still be waiting"); + + timeout(Duration::from_secs(1), send_task) + .await + .expect("send task should finish after write completion is signaled") + .expect("send task should not panic"); + } + + #[tokio::test] + async fn connection_closed_clears_registered_request_contexts() { + let (tx, _rx) = mpsc::channel::(4); + let outgoing = + OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); + let closed_connection_request = ConnectionRequestId { + connection_id: ConnectionId(9), + request_id: RequestId::Integer(3), + }; + let open_connection_request = ConnectionRequestId { + connection_id: ConnectionId(10), + request_id: RequestId::Integer(4), + }; + + outgoing + .register_request_context(RequestContext::new( + closed_connection_request, + tracing::info_span!("app_server.request", rpc.method = "turn/interrupt"), + /*parent_trace*/ None, + )) + .await; + outgoing + .register_request_context(RequestContext::new( + open_connection_request, + tracing::info_span!("app_server.request", rpc.method = "turn/start"), + /*parent_trace*/ None, + )) + .await; + assert_eq!(outgoing.request_context_count().await, 2); + + outgoing.connection_closed(ConnectionId(9)).await; + + assert_eq!(outgoing.request_context_count().await, 1); + } + + #[tokio::test] + async fn notify_client_error_forwards_error_to_waiter() { + let (tx, _rx) = mpsc::channel::(4); + let outgoing = + OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); + + let (request_id, wait_for_result) = outgoing + .send_request(ServerRequestPayload::ApplyPatchApproval( + ApplyPatchApprovalParams { + conversation_id: ThreadId::new(), + call_id: "call-id".to_string(), + file_changes: HashMap::new(), + reason: None, + grant_root: None, + }, + )) + .await; + + let error = internal_error("refresh failed"); + + outgoing + .notify_client_error(request_id, error.clone()) + .await; + + let result = timeout(Duration::from_secs(1), wait_for_result) + .await + .expect("wait should not time out") + .expect("waiter should receive a callback"); + assert_eq!(result, Err(error)); + } + + #[tokio::test] + async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { + let (tx, _rx) = mpsc::channel::(8); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let thread_id = ThreadId::new(); + let thread_outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + vec![ConnectionId(1)], + thread_id, ); - let callback_conn2 = sender - .send_request_to_connection(ConnectionId(2), "conn2", None) + let (dynamic_tool_request_id, _dynamic_tool_waiter) = thread_outgoing + .send_request(ServerRequestPayload::DynamicToolCall( + DynamicToolCallParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + call_id: "call-0".to_string(), + namespace: None, + tool: "tool".to_string(), + arguments: json!({}), + }, + )) .await; - let request_conn2 = request_id_from_message( - rx_messages - .recv() - .await - .expect("second request should be emitted"), + let (first_request_id, _first_waiter) = thread_outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput( + ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![], + }, + )) + .await; + let (second_request_id, _second_waiter) = thread_outgoing + .send_request(ServerRequestPayload::FileChangeRequestApproval( + FileChangeRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-2".to_string(), + started_at_ms: 0, + reason: None, + grant_root: None, + }, + )) + .await; + let pending_requests = outgoing.pending_requests_for_thread(thread_id).await; + assert_eq!( + pending_requests + .iter() + .map(ServerRequest::id) + .collect::>(), + vec![ + &dynamic_tool_request_id, + &first_request_id, + &second_request_id + ] ); + } - sender.clear_callbacks_for_connection(ConnectionId(1)).await; + #[tokio::test] + async fn cancel_requests_for_thread_cancels_all_thread_requests() { + let (tx, _rx) = mpsc::channel::(8); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let thread_id = ThreadId::new(); + let thread_outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + vec![ConnectionId(1)], + thread_id, + ); - sender - .notify_client_response_for_connection( - Some(ConnectionId(1)), - request_conn1, - json!({ "ok": false }), - ) + let (_dynamic_tool_request_id, dynamic_tool_waiter) = thread_outgoing + .send_request(ServerRequestPayload::DynamicToolCall( + DynamicToolCallParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + call_id: "call-0".to_string(), + namespace: None, + tool: "tool".to_string(), + arguments: json!({}), + }, + )) .await; - let canceled = timeout(Duration::from_millis(25), callback_conn1) - .await - .expect("cleared callback should resolve") - .is_err(); - assert!(canceled, "cleared callback should be canceled"); - - sender - .notify_client_response_for_connection( - Some(ConnectionId(2)), - request_conn2, - json!({ "ok": true }), - ) + let (_request_id, user_input_waiter) = thread_outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput( + ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![], + }, + )) .await; - let value = callback_conn2.await.expect("remaining callback should resolve"); - assert_eq!(value, json!({ "ok": true })); + let error = internal_error("tracked request cancelled"); + + outgoing + .cancel_requests_for_thread(thread_id, Some(error.clone())) + .await; + + let dynamic_tool_result = timeout(Duration::from_secs(1), dynamic_tool_waiter) + .await + .expect("dynamic tool waiter should resolve") + .expect("dynamic tool waiter should receive a callback"); + let user_input_result = timeout(Duration::from_secs(1), user_input_waiter) + .await + .expect("user input waiter should resolve") + .expect("user input waiter should receive a callback"); + assert_eq!(dynamic_tool_result, Err(error.clone())); + assert_eq!(user_input_result, Err(error)); + assert!( + outgoing + .pending_requests_for_thread(thread_id) + .await + .is_empty() + ); } } diff --git a/code-rs/app-server/src/request_processors.rs b/code-rs/app-server/src/request_processors.rs new file mode 100644 index 00000000000..cfd2589df19 --- /dev/null +++ b/code-rs/app-server/src/request_processors.rs @@ -0,0 +1,509 @@ +use crate::bespoke_event_handling::apply_bespoke_event_handling; +use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed; +use crate::command_exec::CommandExecManager; +use crate::command_exec::StartCommandExecParams; +use crate::config_manager::ConfigManager; +use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; +use crate::error_code::invalid_params; +use crate::models::supported_models; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::RequestContext; +use crate::outgoing_message::ThreadScopedOutgoingMessageSender; +use crate::thread_status::ThreadWatchManager; +use crate::thread_status::resolve_thread_status; +use chrono::Duration as ChronoDuration; +use chrono::SecondsFormat; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginAccountStatus; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::ConversationGitInfo; +use codex_app_server_protocol::ConversationSummary; +use codex_app_server_protocol::DeprecationNoticeNotification; +use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; +use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureListParams; +use codex_app_server_protocol::ExperimentalFeatureListResponse; +use codex_app_server_protocol::ExperimentalFeatureStage as ApiExperimentalFeatureStage; +use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::FeedbackUploadResponse; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::GetConversationSummaryResponse; +use codex_app_server_protocol::GitDiffToRemoteParams; +use codex_app_server_protocol::GitDiffToRemoteResponse; +use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::MarketplaceAddResponse; +use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::MarketplaceRemoveParams; +use codex_app_server_protocol::MarketplaceRemoveResponse; +use codex_app_server_protocol::MarketplaceUpgradeErrorInfo; +use codex_app_server_protocol::MarketplaceUpgradeParams; +use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; +use codex_app_server_protocol::McpServerOauthLoginParams; +use codex_app_server_protocol::McpServerOauthLoginResponse; +use codex_app_server_protocol::McpServerRefreshResponse; +use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::McpServerToolCallResponse; +use codex_app_server_protocol::MemoryResetResponse; +use codex_app_server_protocol::MockExperimentalMethodParams; +use codex_app_server_protocol::MockExperimentalMethodResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionProfileModificationParams; +use codex_app_server_protocol::PermissionProfileSelectionParams; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListMarketplaceKind; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDeleteParams; +use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginShareListItem; +use codex_app_server_protocol::PluginShareListParams; +use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; +use codex_app_server_protocol::PluginShareSaveParams; +use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareTarget; +use codex_app_server_protocol::PluginShareUpdateDiscoverability; +use codex_app_server_protocol::PluginShareUpdateTargetsParams; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; +use codex_app_server_protocol::PluginSkillReadParams; +use codex_app_server_protocol::PluginSkillReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::SkillSummary; +use codex_app_server_protocol::SkillsConfigWriteParams; +use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_app_server_protocol::SkillsListParams; +use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams; +use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadArchivedNotification; +use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; +use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; +use codex_app_server_protocol::ThreadClosedNotification; +use codex_app_server_protocol::ThreadCompactStartParams; +use codex_app_server_protocol::ThreadCompactStartResponse; +use codex_app_server_protocol::ThreadDecrementElicitationParams; +use codex_app_server_protocol::ThreadDecrementElicitationResponse; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadGoal; +use codex_app_server_protocol::ThreadGoalClearParams; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalClearedNotification; +use codex_app_server_protocol::ThreadGoalGetParams; +use codex_app_server_protocol::ThreadGoalGetResponse; +use codex_app_server_protocol::ThreadGoalSetParams; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::ThreadIncrementElicitationParams; +use codex_app_server_protocol::ThreadIncrementElicitationResponse; +use codex_app_server_protocol::ThreadInjectItemsParams; +use codex_app_server_protocol::ThreadInjectItemsResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListCwdFilter; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadMemoryModeSetParams; +use codex_app_server_protocol::ThreadMemoryModeSetResponse; +use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateResponse; +use codex_app_server_protocol::ThreadNameUpdatedNotification; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; +use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; +use codex_app_server_protocol::ThreadRealtimeAppendTextParams; +use codex_app_server_protocol::ThreadRealtimeAppendTextResponse; +use codex_app_server_protocol::ThreadRealtimeListVoicesResponse; +use codex_app_server_protocol::ThreadRealtimeStartParams; +use codex_app_server_protocol::ThreadRealtimeStartResponse; +use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadRealtimeStopParams; +use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadShellCommandResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsItemsListParams; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; +use codex_app_server_protocol::ThreadUnarchivedNotification; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::ThreadUnsubscribeStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnError; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_app_server_protocol::WindowsSandboxReadiness; +use codex_app_server_protocol::WindowsSandboxReadinessResponse; +use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; +use codex_app_server_protocol::WindowsSandboxSetupMode; +use codex_app_server_protocol::WindowsSandboxSetupStartParams; +use codex_app_server_protocol::WindowsSandboxSetupStartResponse; +use codex_arg0::Arg0DispatchPaths; +use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; +use codex_backend_client::Client as BackendClient; +use codex_chatgpt::connectors; +use codex_chatgpt::workspace_settings; +use codex_config::CloudRequirementsLoadError; +use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::ConfigLayerStack; +use codex_config::loader::project_trust_key; +use codex_config::types::McpServerTransportConfig; +use codex_core::CodexThread; +use codex_core::CodexThreadTurnContextOverrides; +use codex_core::ExternalGoalPreviousStatus; +use codex_core::ExternalGoalSet; +use codex_core::ForkSnapshot; +use codex_core::NewThread; +#[cfg(test)] +use codex_core::SessionMeta; +use codex_core::StartThreadOptions; +use codex_core::SteerInputError; +use codex_core::ThreadConfigSnapshot; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::NetworkProxyAuditMetadata; +use codex_core::config::edit::ConfigEdit; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::exec::ExecCapturePolicy; +use codex_core::exec::ExecExpiration; +use codex_core::exec::ExecParams; +use codex_core::exec_env::create_env; +use codex_core::find_thread_path_by_id_str; +use codex_core::path_utils; +#[cfg(test)] +use codex_core::read_head_for_summary; +use codex_core::sandboxing::SandboxPermissions; +use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; +use codex_core::windows_sandbox::WindowsSandboxSetupRequest; +use codex_core::windows_sandbox::sandbox_setup_is_complete; +use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::PluginInstallError as CorePluginInstallError; +use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginLoadOutcome; +use codex_core_plugins::PluginReadRequest; +use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; +use codex_core_plugins::loader::load_plugin_apps; +use codex_core_plugins::loader::load_plugin_mcp_servers; +use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; +use codex_core_plugins::manifest::PluginManifestInterface; +use codex_core_plugins::marketplace::MarketplaceError; +use codex_core_plugins::marketplace::MarketplacePluginSource; +use codex_core_plugins::marketplace_add::MarketplaceAddError; +use codex_core_plugins::marketplace_add::MarketplaceAddRequest; +use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveError; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest; +use codex_core_plugins::marketplace_remove::remove_marketplace; +use codex_core_plugins::remote::RemoteMarketplace; +use codex_core_plugins::remote::RemoteMarketplaceSource; +use codex_core_plugins::remote::RemotePluginCatalogError; +use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail; +use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginShareContext; +use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; +use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::LOCAL_FS; +use codex_features::FEATURES; +use codex_features::Feature; +use codex_features::Stage; +use codex_feedback::CodexFeedback; +use codex_feedback::FeedbackAttachmentPath; +use codex_feedback::FeedbackUploadOptions; +use codex_git_utils::git_diff_to_remote; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_login::AuthManager; +use codex_login::CLIENT_ID; +use codex_login::CodexAuth; +use codex_login::ServerOptions as LoginServerOptions; +use codex_login::ShutdownHandle; +use codex_login::auth::login_with_chatgpt_auth_tokens; +use codex_login::complete_device_code_login; +use codex_login::login_with_api_key; +use codex_login::request_device_code; +use codex_login::run_login_server; +use codex_mcp::McpRuntimeEnvironment; +use codex_mcp::McpServerStatusSnapshot; +use codex_mcp::McpSnapshotDetail; +use codex_mcp::collect_mcp_server_status_snapshot_with_detail; +use codex_mcp::discover_supported_scopes; +use codex_mcp::effective_mcp_servers; +use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; +use codex_mcp::resolve_oauth_scopes; +use codex_memories_write::clear_memory_roots_contents; +use codex_model_provider::ProviderAccountError; +use codex_model_provider::create_model_provider; +use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +#[cfg(test)] +use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::ConversationStartParams; +use codex_protocol::protocol::ConversationStartTransport; +use codex_protocol::protocol::ConversationTextParams; +use codex_protocol::protocol::EventMsg; +#[cfg(test)] +use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RealtimeVoicesList; +use codex_protocol::protocol::ResumedHistory; +use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionConfiguredEvent; +#[cfg(test)] +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_protocol::protocol::W3cTraceContext; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_protocol::user_input::UserInput as CoreInputItem; +use codex_rmcp_client::perform_oauth_login_return_url; +use codex_rollout::EventPersistenceMode; +use codex_rollout::is_persisted_rollout_item; +use codex_rollout::state_db::StateDbHandle; +use codex_rollout::state_db::reconcile_rollout; +use codex_state::ThreadMetadata; +use codex_state::log_db::LogDbLayer; +use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; +use codex_thread_store::GitInfoPatch as StoreGitInfoPatch; +use codex_thread_store::ListThreadsParams as StoreListThreadsParams; +use codex_thread_store::LocalThreadStore; +use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams; +use codex_thread_store::ReadThreadParams as StoreReadThreadParams; +use codex_thread_store::SortDirection as StoreSortDirection; +use codex_thread_store::StoredThread; +use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch; +use codex_thread_store::ThreadSortKey as StoreThreadSortKey; +use codex_thread_store::ThreadStore; +use codex_thread_store::ThreadStoreError; +use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use std::collections::HashMap; +use std::collections::HashSet; +use std::io::Error as IoError; +use std::path::Path; +use std::path::PathBuf; +use std::result::Result; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; +use tokio::sync::broadcast; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; +use toml::Value as TomlValue; +use tracing::Instrument; +use tracing::error; +use tracing::info; +use tracing::warn; +use uuid::Uuid; + +#[cfg(test)] +use codex_app_server_protocol::ServerRequest; + +mod account_processor; +mod apps_processor; +mod catalog_processor; +mod command_exec_processor; +mod config_processor; +mod external_agent_config_processor; +mod feedback_processor; +mod fs_processor; +mod git_processor; +mod initialize_processor; +mod marketplace_processor; +mod mcp_processor; +mod plugins; +mod process_exec_processor; +mod search; +mod thread_processor; +mod token_usage_replay; +mod turn_processor; +mod windows_sandbox_processor; + +pub(crate) use account_processor::AccountRequestProcessor; +pub(crate) use apps_processor::AppsRequestProcessor; +pub(crate) use catalog_processor::CatalogRequestProcessor; +pub(crate) use command_exec_processor::CommandExecRequestProcessor; +pub(crate) use config_processor::ConfigRequestProcessor; +pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor; +pub(crate) use feedback_processor::FeedbackRequestProcessor; +pub(crate) use fs_processor::FsRequestProcessor; +pub(crate) use git_processor::GitRequestProcessor; +pub(crate) use initialize_processor::InitializeRequestProcessor; +pub(crate) use marketplace_processor::MarketplaceRequestProcessor; +pub(crate) use mcp_processor::McpRequestProcessor; +pub(crate) use plugins::PluginRequestProcessor; +pub(crate) use process_exec_processor::ProcessExecRequestProcessor; +pub(crate) use search::SearchRequestProcessor; +pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor; +pub(crate) use thread_processor::ThreadRequestProcessor; +pub(crate) use turn_processor::TurnRequestProcessor; +pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor; + +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::filters::compute_source_filters; +use crate::filters::source_kind_matches; +use crate::thread_state::ThreadListenerCommand; +use crate::thread_state::ThreadState; +use crate::thread_state::ThreadStateManager; +use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; +use token_usage_replay::send_thread_token_usage_update_to_connection; + +mod config_errors; +mod request_errors; +mod thread_goal_processor; +mod thread_lifecycle; +mod thread_summary; + +use self::config_errors::*; +use self::request_errors::*; +use self::thread_goal_processor::api_thread_goal_from_state; +use self::thread_lifecycle::*; +use self::thread_summary::*; + +pub(crate) use self::thread_lifecycle::populate_thread_turns_from_history; +pub(crate) use self::thread_processor::thread_from_stored_thread; +#[cfg(test)] +pub(crate) use self::thread_summary::read_summary_from_rollout; +#[cfg(test)] +pub(crate) use self::thread_summary::summary_to_thread; + +pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { + let mut builder = ThreadHistoryBuilder::new(); + for item in items { + if is_persisted_rollout_item(item, EventPersistenceMode::Limited) { + builder.handle_rollout_item(item); + } + } + builder.finish() +} diff --git a/code-rs/app-server/src/request_processors/account_processor.rs b/code-rs/app-server/src/request_processors/account_processor.rs new file mode 100644 index 00000000000..c73d6700e7a --- /dev/null +++ b/code-rs/app-server/src/request_processors/account_processor.rs @@ -0,0 +1,952 @@ +use super::*; + +// Duration before a browser ChatGPT login attempt is abandoned. +const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; + +enum ActiveLogin { + Browser { + shutdown_handle: ShutdownHandle, + login_id: Uuid, + }, + DeviceCode { + cancel: CancellationToken, + login_id: Uuid, + }, +} + +impl ActiveLogin { + fn login_id(&self) -> Uuid { + match self { + ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => { + *login_id + } + } + } + + fn cancel(&self) { + match self { + ActiveLogin::Browser { + shutdown_handle, .. + } => shutdown_handle.shutdown(), + ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(), + } + } +} + +#[derive(Clone, Copy, Debug)] +enum CancelLoginError { + NotFound, +} + +enum RefreshTokenRequestOutcome { + NotAttemptedOrSucceeded, + FailedTransiently, + FailedPermanently, +} + +impl Drop for ActiveLogin { + fn drop(&mut self) { + self.cancel(); + } +} + +#[derive(Clone)] +pub(crate) struct AccountRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, + active_login: Arc>>, +} + +impl AccountRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + config, + config_manager, + active_login: Arc::new(Mutex::new(None)), + } + } + + pub(crate) async fn login_account( + &self, + request_id: ConnectionRequestId, + params: LoginAccountParams, + ) -> Result, JSONRPCErrorError> { + self.login_v2(request_id, params).await.map(|()| None) + } + + pub(crate) async fn logout_account( + &self, + request_id: ConnectionRequestId, + ) -> Result, JSONRPCErrorError> { + self.logout_v2(request_id).await.map(|()| None) + } + + pub(crate) async fn cancel_login_account( + &self, + params: CancelLoginAccountParams, + ) -> Result, JSONRPCErrorError> { + self.cancel_login_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn get_account( + &self, + params: GetAccountParams, + ) -> Result, JSONRPCErrorError> { + self.get_account_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn get_auth_status( + &self, + params: GetAuthStatusParams, + ) -> Result, JSONRPCErrorError> { + self.get_auth_status_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn get_account_rate_limits( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_account_rate_limits_response() + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn send_add_credits_nudge_email( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result, JSONRPCErrorError> { + self.send_add_credits_nudge_email_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn cancel_active_login(&self) { + let mut guard = self.active_login.lock().await; + if let Some(active_login) = guard.take() { + drop(active_login); + } + } + + pub(crate) fn clear_external_auth(&self) { + self.auth_manager.clear_external_auth(); + } + + fn current_account_updated_notification(&self) -> AccountUpdatedNotification { + let auth = self.auth_manager.auth_cached(); + AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + } + } + + async fn maybe_refresh_remote_installed_plugins_cache_for_current_config( + config_manager: &ConfigManager, + thread_manager: &Arc, + auth: Option, + ) { + match config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + { + Ok(config) => { + let refresh_thread_manager = Arc::clone(thread_manager); + let refresh_config_manager = config_manager.clone(); + thread_manager + .plugins_manager() + .maybe_start_remote_installed_plugins_cache_refresh( + &config.plugins_config_input(), + auth, + Some(Arc::new(move || { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&refresh_thread_manager), + refresh_config_manager.clone(), + ); + })), + ); + } + Err(err) => { + warn!( + "failed to reload config after account changed, skipping remote installed plugins cache refresh: {err}" + ); + } + } + } + + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { + tokio::spawn(async move { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); + if thread_manager.list_thread_ids().await.is_empty() { + return; + } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; + }); + } + + async fn login_v2( + &self, + request_id: ConnectionRequestId, + params: LoginAccountParams, + ) -> Result<(), JSONRPCErrorError> { + match params { + LoginAccountParams::ApiKey { api_key } => { + self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) + .await; + } + LoginAccountParams::Chatgpt { + codex_streamlined_login, + } => { + self.login_chatgpt_v2(request_id, codex_streamlined_login) + .await; + } + LoginAccountParams::ChatgptDeviceCode => { + self.login_chatgpt_device_code_v2(request_id).await; + } + LoginAccountParams::ChatgptAuthTokens { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + } => { + self.login_chatgpt_auth_tokens( + request_id, + access_token, + chatgpt_account_id, + chatgpt_plan_type, + ) + .await; + } + } + Ok(()) + } + + fn external_auth_active_error(&self) -> JSONRPCErrorError { + invalid_request( + "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it.", + ) + } + + async fn login_api_key_common( + &self, + params: &LoginApiKeyParams, + ) -> std::result::Result<(), JSONRPCErrorError> { + if self.auth_manager.is_external_chatgpt_auth_active() { + return Err(self.external_auth_active_error()); + } + + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Chatgpt) + ) { + return Err(invalid_request( + "API key login is disabled. Use ChatGPT login instead.", + )); + } + + // Cancel any active login attempt. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + match login_with_api_key( + &self.config.codex_home, + ¶ms.api_key, + self.config.cli_auth_credentials_store_mode, + ) { + Ok(()) => { + self.auth_manager.reload().await; + Ok(()) + } + Err(err) => Err(internal_error(format!("failed to save api key: {err}"))), + } + } + + async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) { + let result = self + .login_api_key_common(¶ms) + .await + .map(|()| LoginAccountResponse::ApiKey {}); + let logged_in = result.is_ok(); + self.outgoing.send_result(request_id, result).await; + + if logged_in { + self.send_login_success_notifications(/*login_id*/ None) + .await; + } + } + + // Build options for a ChatGPT login attempt; performs validation. + async fn login_chatgpt_common( + &self, + codex_streamlined_login: bool, + ) -> std::result::Result { + let config = self.config.as_ref(); + + if self.auth_manager.is_external_chatgpt_auth_active() { + return Err(self.external_auth_active_error()); + } + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + return Err(invalid_request( + "ChatGPT login is disabled. Use API key login instead.", + )); + } + + let opts = LoginServerOptions { + open_browser: false, + codex_streamlined_login, + ..LoginServerOptions::new( + config.codex_home.to_path_buf(), + CLIENT_ID.to_string(), + config.forced_chatgpt_workspace_id.clone(), + config.cli_auth_credentials_store_mode, + ) + }; + #[cfg(debug_assertions)] + let opts = { + let mut opts = opts; + if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) + && !issuer.trim().is_empty() + { + opts.issuer = issuer; + } + opts + }; + + Ok(opts) + } + + fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { + let is_not_found = err.kind() == std::io::ErrorKind::NotFound; + if is_not_found { + invalid_request(err.to_string()) + } else { + internal_error(format!("failed to request device code: {err}")) + } + } + + async fn login_chatgpt_v2( + &self, + request_id: ConnectionRequestId, + codex_streamlined_login: bool, + ) { + let result = self.login_chatgpt_response(codex_streamlined_login).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn login_chatgpt_response( + &self, + codex_streamlined_login: bool, + ) -> Result { + let opts = self.login_chatgpt_common(codex_streamlined_login).await?; + let server = run_login_server(opts) + .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); + + // Replace active login if present. + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::Browser { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); + } + + let outgoing_clone = self.outgoing.clone(); + let config_manager = self.config_manager.clone(); + let thread_manager = Arc::clone(&self.thread_manager); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let active_login = self.active_login.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; + + Self::send_chatgpt_login_completion_notifications( + &outgoing_clone, + config_manager, + thread_manager, + chatgpt_base_url, + login_id, + success, + error_msg, + ) + .await; + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + + Ok(LoginAccountResponse::Chatgpt { + login_id: login_id.to_string(), + auth_url, + }) + } + + async fn login_chatgpt_device_code_v2(&self, request_id: ConnectionRequestId) { + let result = self.login_chatgpt_device_code_response().await; + self.outgoing.send_result(request_id, result).await; + } + + async fn login_chatgpt_device_code_response( + &self, + ) -> Result { + let opts = self + .login_chatgpt_common(/*codex_streamlined_login*/ false) + .await?; + let device_code = request_device_code(&opts) + .await + .map_err(Self::login_chatgpt_device_code_start_error)?; + let login_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::DeviceCode { + cancel: cancel.clone(), + login_id, + }); + } + + let verification_url = device_code.verification_url.clone(); + let user_code = device_code.user_code.clone(); + + let outgoing_clone = self.outgoing.clone(); + let config_manager = self.config_manager.clone(); + let thread_manager = Arc::clone(&self.thread_manager); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let active_login = self.active_login.clone(); + tokio::spawn(async move { + let (success, error_msg) = tokio::select! { + _ = cancel.cancelled() => { + (false, Some("Login was not completed".to_string())) + } + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + } + } + }; + + Self::send_chatgpt_login_completion_notifications( + &outgoing_clone, + config_manager, + thread_manager, + chatgpt_base_url, + login_id, + success, + error_msg, + ) + .await; + + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + + Ok(LoginAccountResponse::ChatgptDeviceCode { + login_id: login_id.to_string(), + verification_url, + user_code, + }) + } + + async fn cancel_login_chatgpt_common( + &self, + login_id: Uuid, + ) -> std::result::Result<(), CancelLoginError> { + let mut guard = self.active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + if let Some(active) = guard.take() { + drop(active); + } + Ok(()) + } else { + Err(CancelLoginError::NotFound) + } + } + + async fn cancel_login_response( + &self, + params: CancelLoginAccountParams, + ) -> Result { + let login_id = params.login_id; + let uuid = Uuid::parse_str(&login_id) + .map_err(|_| invalid_request(format!("invalid login id: {login_id}")))?; + let status = match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => CancelLoginAccountStatus::Canceled, + Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, + }; + Ok(CancelLoginAccountResponse { status }) + } + + async fn login_chatgpt_auth_tokens( + &self, + request_id: ConnectionRequestId, + access_token: String, + chatgpt_account_id: String, + chatgpt_plan_type: Option, + ) { + let result = self + .login_chatgpt_auth_tokens_response(access_token, chatgpt_account_id, chatgpt_plan_type) + .await; + let logged_in = result.is_ok(); + self.outgoing.send_result(request_id, result).await; + + if logged_in { + self.send_login_success_notifications(/*login_id*/ None) + .await; + } + } + + async fn login_chatgpt_auth_tokens_response( + &self, + access_token: String, + chatgpt_account_id: String, + chatgpt_plan_type: Option, + ) -> Result { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Api) + ) { + return Err(invalid_request( + "External ChatGPT auth is disabled. Use API key login instead.", + )); + } + + // Cancel any active login attempt to avoid persisting managed auth state. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() + && chatgpt_account_id != expected_workspace + { + return Err(invalid_request(format!( + "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + ))); + } + + login_with_chatgpt_auth_tokens( + &self.config.codex_home, + &access_token, + &chatgpt_account_id, + chatgpt_plan_type.as_deref(), + ) + .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; + self.auth_manager.reload().await; + self.config_manager.replace_cloud_requirements_loader( + self.auth_manager.clone(), + self.config.chatgpt_base_url.clone(), + ); + self.config_manager + .sync_default_client_residency_requirement() + .await; + + Ok(LoginAccountResponse::ChatgptAuthTokens {}) + } + + async fn send_login_success_notifications(&self, login_id: Option) { + Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + &self.config_manager, + &self.thread_manager, + self.auth_manager.auth_cached(), + ) + .await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: login_id.map(|id| id.to_string()), + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated( + self.current_account_updated_notification(), + )) + .await; + } + + async fn send_chatgpt_login_completion_notifications( + outgoing: &OutgoingMessageSender, + config_manager: ConfigManager, + thread_manager: Arc, + chatgpt_base_url: String, + login_id: Uuid, + success: bool, + error_msg: Option, + ) { + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted(payload_v2)) + .await; + + if success { + let auth_manager = thread_manager.auth_manager(); + auth_manager.reload().await; + config_manager + .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); + config_manager + .sync_default_client_residency_requirement() + .await; + + let auth = auth_manager.auth_cached(); + Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + &config_manager, + &thread_manager, + auth.clone(), + ) + .await; + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; + outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + } + + async fn logout_common(&self) -> std::result::Result, JSONRPCErrorError> { + // Cancel any active login attempt. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + match self.auth_manager.logout_with_revoke().await { + Ok(_) => {} + Err(err) => { + return Err(internal_error(format!("logout failed: {err}"))); + } + } + + Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + &self.config_manager, + &self.thread_manager, + self.auth_manager.auth_cached(), + ) + .await; + + // Reflect the current auth method after logout (likely None). + Ok(self + .auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode)) + } + + async fn logout_v2(&self, request_id: ConnectionRequestId) -> Result<(), JSONRPCErrorError> { + let result = self.logout_common().await; + let account_updated = + result + .as_ref() + .ok() + .cloned() + .map(|auth_mode| AccountUpdatedNotification { + auth_mode, + plan_type: None, + }); + self.outgoing + .send_result(request_id, result.map(|_| LogoutAccountResponse {})) + .await; + + if let Some(payload) = account_updated { + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload)) + .await; + } + Ok(()) + } + + async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { + if self.auth_manager.is_external_chatgpt_auth_active() { + return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; + } + if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { + let failed_reason = err.failed_reason(); + if failed_reason.is_none() { + tracing::warn!("failed to refresh token while getting account: {err}"); + return RefreshTokenRequestOutcome::FailedTransiently; + } + return RefreshTokenRequestOutcome::FailedPermanently; + } + RefreshTokenRequestOutcome::NotAttemptedOrSucceeded + } + + async fn get_auth_status_response( + &self, + params: GetAuthStatusParams, + ) -> Result { + let include_token = params.include_token.unwrap_or(false); + let do_refresh = params.refresh_token.unwrap_or(false); + + self.refresh_token_if_requested(do_refresh).await; + + // Determine whether auth is required based on the active model provider. + // If a custom provider is configured with `requires_openai_auth == false`, + // then no auth step is required; otherwise, default to requiring auth. + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + + let response = if !requires_openai_auth { + GetAuthStatusResponse { + auth_method: None, + auth_token: None, + requires_openai_auth: Some(false), + } + } else { + let auth = if do_refresh { + self.auth_manager.auth_cached() + } else { + self.auth_manager.auth().await + }; + match auth { + Some(auth) => { + let permanent_refresh_failure = + self.auth_manager.refresh_failure_for_auth(&auth).is_some(); + let auth_mode = auth.api_auth_mode(); + let (reported_auth_method, token_opt) = + if matches!(auth, CodexAuth::AgentIdentity(_)) + || include_token && permanent_refresh_failure + { + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) + } + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } + } + }; + GetAuthStatusResponse { + auth_method: reported_auth_method, + auth_token: token_opt, + requires_openai_auth: Some(true), + } + } + None => GetAuthStatusResponse { + auth_method: None, + auth_token: None, + requires_openai_auth: Some(true), + }, + } + }; + + Ok(response) + } + + async fn get_account_response( + &self, + params: GetAccountParams, + ) -> Result { + let do_refresh = params.refresh_token; + + self.refresh_token_if_requested(do_refresh).await; + + let provider = create_model_provider( + self.config.model_provider.clone(), + Some(self.auth_manager.clone()), + ); + let account_state = match provider.account_state() { + Ok(account_state) => account_state, + Err(ProviderAccountError::MissingChatgptAccountDetails) => { + return Err(invalid_request( + "email and plan type are required for chatgpt authentication", + )); + } + }; + let account = account_state.account.map(Account::from); + + Ok(GetAccountResponse { + account, + requires_openai_auth: account_state.requires_openai_auth, + }) + } + + async fn get_account_rate_limits_response( + &self, + ) -> Result { + self.fetch_account_rate_limits() + .await + .map( + |(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse { + rate_limits: rate_limits.into(), + rate_limits_by_limit_id: Some( + rate_limits_by_limit_id + .into_iter() + .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) + .collect(), + ), + }, + ) + } + + async fn send_add_credits_nudge_email_response( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + self.send_add_credits_nudge_email_inner(params) + .await + .map(|status| SendAddCreditsNudgeEmailResponse { status }) + } + + async fn send_add_credits_nudge_email_inner( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to notify workspace owner", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to notify workspace owner", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + + match client + .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) + .await + { + Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), + Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { + Ok(AddCreditsNudgeEmailStatus::CooldownActive) + } + Err(err) => Err(internal_error(format!( + "failed to notify workspace owner: {err}" + ))), + } + } + + fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { + match value { + AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, + AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, + } + } + + async fn fetch_account_rate_limits( + &self, + ) -> Result< + ( + CoreRateLimitSnapshot, + HashMap, + ), + JSONRPCErrorError, + > { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read rate limits", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read rate limits", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + + let snapshots = client + .get_rate_limits_many() + .await + .map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?; + if snapshots.is_empty() { + return Err(internal_error( + "failed to fetch codex rate limits: no snapshots returned", + )); + } + + let rate_limits_by_limit_id: HashMap = snapshots + .iter() + .cloned() + .map(|snapshot| { + let limit_id = snapshot + .limit_id + .clone() + .unwrap_or_else(|| "codex".to_string()); + (limit_id, snapshot) + }) + .collect(); + + let primary = snapshots + .iter() + .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) + .cloned() + .unwrap_or_else(|| snapshots[0].clone()); + + Ok((primary, rate_limits_by_limit_id)) + } +} diff --git a/code-rs/app-server/src/request_processors/apps_processor.rs b/code-rs/app-server/src/request_processors/apps_processor.rs new file mode 100644 index 00000000000..da2956dbab6 --- /dev/null +++ b/code-rs/app-server/src/request_processors/apps_processor.rs @@ -0,0 +1,337 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct AppsRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, + workspace_settings_cache: Arc, +} + +impl AppsRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, + workspace_settings_cache: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + config_manager, + workspace_settings_cache, + } + } + + pub(crate) async fn apps_list( + &self, + request_id: &ConnectionRequestId, + params: AppsListParams, + ) -> Result, JSONRPCErrorError> { + self.apps_list_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + async fn apps_list_inner( + &self, + request_id: &ConnectionRequestId, + params: AppsListParams, + ) -> Result, JSONRPCErrorError> { + let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; + + if let Some(thread_id) = params.thread_id.as_deref() { + let (_, thread) = self.load_thread(thread_id).await?; + + let _ = config + .features + .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); + } + + let auth = self.auth_manager.auth().await; + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) + { + return Ok(Some(AppsListResponse { + data: Vec::new(), + next_cursor: None, + })); + } + + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(Some(AppsListResponse { + data: Vec::new(), + next_cursor: None, + })); + } + + let request = request_id.clone(); + let outgoing = Arc::clone(&self.outgoing); + let environment_manager = self.thread_manager.environment_manager(); + tokio::spawn(async move { + Self::apps_list_task(outgoing, request, params, config, environment_manager).await; + }); + Ok(None) + } + + async fn apps_list_task( + outgoing: Arc, + request_id: ConnectionRequestId, + params: AppsListParams, + config: Config, + environment_manager: Arc, + ) { + let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await; + outgoing.send_result(request_id, result).await; + } + + async fn apps_list_response( + outgoing: &Arc, + params: AppsListParams, + config: Config, + environment_manager: Arc, + ) -> Result { + let AppsListParams { + cursor, + limit, + thread_id: _, + force_refetch, + } = params; + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }, + None => 0, + }; + + let (mut accessible_connectors, mut all_connectors) = tokio::join!( + connectors::list_cached_accessible_connectors_from_mcp_tools(&config), + connectors::list_cached_all_connectors(&config) + ); + let cached_all_connectors = all_connectors.clone(); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + let accessible_config = config.clone(); + let accessible_tx = tx.clone(); + tokio::spawn(async move { + let result = + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &accessible_config, + force_refetch, + &environment_manager, + ) + .await + .map(|status| status.connectors) + .map_err(|err| format!("failed to load accessible apps: {err}")); + let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); + }); + + let all_config = config.clone(); + tokio::spawn(async move { + let result = connectors::list_all_connectors_with_options(&all_config, force_refetch) + .await + .map_err(|err| format!("failed to list apps: {err}")); + let _ = tx.send(AppListLoadResult::Directory(result)); + }); + + let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT; + let mut accessible_loaded = false; + let mut all_loaded = false; + let mut last_notified_apps = None; + + if accessible_connectors.is_some() || all_connectors.is_some() { + let merged = connectors::with_app_enabled_state( + merge_loaded_apps(all_connectors.as_deref(), accessible_connectors.as_deref()), + &config, + ); + if should_send_app_list_updated_notification( + merged.as_slice(), + accessible_loaded, + all_loaded, + ) { + send_app_list_updated_notification(outgoing, merged.clone()).await; + last_notified_apps = Some(merged); + } + } + + loop { + let result = match tokio::time::timeout_at(app_list_deadline, rx.recv()).await { + Ok(Some(result)) => result, + Ok(None) => { + return Err(internal_error("failed to load app lists")); + } + Err(_) => { + let timeout_seconds = APP_LIST_LOAD_TIMEOUT.as_secs(); + return Err(internal_error(format!( + "timed out waiting for app lists after {timeout_seconds} seconds" + ))); + } + }; + + match result { + AppListLoadResult::Accessible(Ok(connectors)) => { + accessible_connectors = Some(connectors); + accessible_loaded = true; + } + AppListLoadResult::Accessible(Err(err)) => { + return Err(internal_error(err)); + } + AppListLoadResult::Directory(Ok(connectors)) => { + all_connectors = Some(connectors); + all_loaded = true; + } + AppListLoadResult::Directory(Err(err)) => { + return Err(internal_error(err)); + } + } + + let showing_interim_force_refetch = force_refetch && !(accessible_loaded && all_loaded); + let all_connectors_for_update = + if showing_interim_force_refetch && cached_all_connectors.is_some() { + cached_all_connectors.as_deref() + } else { + all_connectors.as_deref() + }; + let accessible_connectors_for_update = + if showing_interim_force_refetch && !accessible_loaded { + None + } else { + accessible_connectors.as_deref() + }; + let merged = connectors::with_app_enabled_state( + merge_loaded_apps(all_connectors_for_update, accessible_connectors_for_update), + &config, + ); + if should_send_app_list_updated_notification( + merged.as_slice(), + accessible_loaded, + all_loaded, + ) && last_notified_apps.as_ref() != Some(&merged) + { + send_app_list_updated_notification(outgoing, merged.clone()).await; + last_notified_apps = Some(merged.clone()); + } + + if accessible_loaded && all_loaded { + return paginate_apps(merged.as_slice(), start, limit); + } + } + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } +} + +const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); + +enum AppListLoadResult { + Accessible(Result, String>), + Directory(Result, String>), +} + +fn merge_loaded_apps( + all_connectors: Option<&[AppInfo]>, + accessible_connectors: Option<&[AppInfo]>, +) -> Vec { + let all_connectors_loaded = all_connectors.is_some(); + let all = all_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec); + let accessible = accessible_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec); + connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded) +} + +fn should_send_app_list_updated_notification( + connectors: &[AppInfo], + accessible_loaded: bool, + all_loaded: bool, +) -> bool { + connectors.iter().any(|connector| connector.is_accessible) || (accessible_loaded && all_loaded) +} + +fn paginate_apps( + connectors: &[AppInfo], + start: usize, + limit: Option, +) -> Result { + let total = connectors.len(); + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total apps {total}" + ))); + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let end = start.saturating_add(effective_limit).min(total); + let data = connectors[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + Ok(AppsListResponse { data, next_cursor }) +} + +async fn send_app_list_updated_notification( + outgoing: &Arc, + data: Vec, +) { + outgoing + .send_server_notification(ServerNotification::AppListUpdated( + AppListUpdatedNotification { data }, + )) + .await; +} diff --git a/code-rs/app-server/src/request_processors/catalog_processor.rs b/code-rs/app-server/src/request_processors/catalog_processor.rs new file mode 100644 index 00000000000..89082492c13 --- /dev/null +++ b/code-rs/app-server/src/request_processors/catalog_processor.rs @@ -0,0 +1,580 @@ +use super::*; +use futures::StreamExt; + +#[derive(Clone)] +pub(crate) struct CatalogRequestProcessor { + pub(super) auth_manager: Arc, + pub(super) thread_manager: Arc, + pub(super) config: Arc, + pub(super) config_manager: ConfigManager, + pub(super) workspace_settings_cache: Arc, +} + +const SKILLS_LIST_CWD_CONCURRENCY: usize = 5; + +fn skills_to_info( + skills: &[codex_core::skills::SkillMetadata], + disabled_paths: &HashSet, +) -> Vec { + skills + .iter() + .map(|skill| { + let enabled = !disabled_paths.contains(&skill.path_to_skills_md); + codex_app_server_protocol::SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill.short_description.clone(), + interface: skill.interface.clone().map(|interface| { + codex_app_server_protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), + dependencies: skill.dependencies.clone().map(|dependencies| { + codex_app_server_protocol::SkillDependencies { + tools: dependencies + .tools + .into_iter() + .map(|tool| codex_app_server_protocol::SkillToolDependency { + r#type: tool.r#type, + value: tool.value, + description: tool.description, + transport: tool.transport, + command: tool.command, + url: tool.url, + }) + .collect(), + } + }), + path: skill.path_to_skills_md.clone(), + scope: skill.scope.into(), + enabled, + } + }) + .collect() +} + +fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { + hooks + .iter() + .map(|hook| HookMetadata { + key: hook.key.clone(), + event_name: hook.event_name.into(), + handler_type: hook.handler_type.into(), + matcher: hook.matcher.clone(), + command: hook.command.clone(), + timeout_sec: hook.timeout_sec, + status_message: hook.status_message.clone(), + source_path: hook.source_path.clone(), + source: hook.source.into(), + plugin_id: hook.plugin_id.clone(), + display_order: hook.display_order, + enabled: hook.enabled, + is_managed: hook.is_managed, + current_hash: hook.current_hash.clone(), + trust_status: hook.trust_status.into(), + }) + .collect() +} + +fn errors_to_info( + errors: &[codex_core::skills::SkillError], +) -> Vec { + errors + .iter() + .map(|err| codex_app_server_protocol::SkillErrorInfo { + path: err.path.to_path_buf(), + message: err.message.clone(), + }) + .collect() +} + +impl CatalogRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + config: Arc, + config_manager: ConfigManager, + workspace_settings_cache: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + config, + config_manager, + workspace_settings_cache, + } + } + + pub(crate) async fn skills_list( + &self, + params: SkillsListParams, + ) -> Result, JSONRPCErrorError> { + self.skills_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn hooks_list( + &self, + params: HooksListParams, + ) -> Result, JSONRPCErrorError> { + self.hooks_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn skills_config_write( + &self, + params: SkillsConfigWriteParams, + ) -> Result, JSONRPCErrorError> { + self.skills_config_write_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn model_list( + &self, + params: ModelListParams, + ) -> Result, JSONRPCErrorError> { + Self::list_models(self.thread_manager.clone(), params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn experimental_feature_list( + &self, + params: ExperimentalFeatureListParams, + ) -> Result, JSONRPCErrorError> { + self.experimental_feature_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn collaboration_mode_list( + &self, + params: CollaborationModeListParams, + ) -> Result, JSONRPCErrorError> { + Self::list_collaboration_modes(self.thread_manager.clone(), params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn mock_experimental_method( + &self, + params: MockExperimentalMethodParams, + ) -> Result, JSONRPCErrorError> { + self.mock_experimental_method_inner(params) + .await + .map(|response| Some(response.into())) + } + + async fn resolve_cwd_config( + &self, + cwd: &Path, + ) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> { + let cwd_abs = + AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?; + let config_layer_stack = self + .config_manager + .load_config_layers_for_cwd(cwd_abs.clone()) + .await + .map_err(|err| err.to_string())?; + + Ok((cwd_abs, config_layer_stack)) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } + + async fn list_models( + thread_manager: Arc, + params: ModelListParams, + ) -> Result { + let ModelListParams { + limit, + cursor, + include_hidden, + } = params; + let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; + let total = models.len(); + + if total == 0 { + return Ok(ModelListResponse { + data: Vec::new(), + next_cursor: None, + }); + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total models {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let items = models[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + Ok(ModelListResponse { + data: items, + next_cursor, + }) + } + + async fn list_collaboration_modes( + thread_manager: Arc, + params: CollaborationModeListParams, + ) -> Result { + let CollaborationModeListParams {} = params; + let items = thread_manager + .list_collaboration_modes() + .into_iter() + .map(Into::into) + .collect(); + let response = CollaborationModeListResponse { data: items }; + Ok(response) + } + + async fn experimental_feature_list_response( + &self, + params: ExperimentalFeatureListParams, + ) -> Result { + let ExperimentalFeatureListParams { cursor, limit } = params; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + + let data = FEATURES + .iter() + .map(|spec| { + let (stage, display_name, description, announcement) = match spec.stage { + Stage::Experimental { + name, + menu_description, + announcement, + } => ( + ApiExperimentalFeatureStage::Beta, + Some(name.to_string()), + Some(menu_description.to_string()), + Some(announcement.to_string()), + ), + Stage::UnderDevelopment => ( + ApiExperimentalFeatureStage::UnderDevelopment, + None, + None, + None, + ), + Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None), + Stage::Deprecated => { + (ApiExperimentalFeatureStage::Deprecated, None, None, None) + } + Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None), + }; + + ApiExperimentalFeature { + name: spec.key.to_string(), + stage, + display_name, + description, + announcement, + enabled: config.features.enabled(spec.id) + && (workspace_codex_plugins_enabled + || !matches!(spec.id, Feature::Apps | Feature::Plugins)), + default_enabled: spec.default_enabled, + } + }) + .collect::>(); + + let total = data.len(); + if total == 0 { + return Ok(ExperimentalFeatureListResponse { + data: Vec::new(), + next_cursor: None, + }); + } + + // Clamp to 1 so limit=0 cannot return a non-advancing page. + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total feature flags {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let data = data[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + Ok(ExperimentalFeatureListResponse { data, next_cursor }) + } + + async fn mock_experimental_method_inner( + &self, + params: MockExperimentalMethodParams, + ) -> Result { + let MockExperimentalMethodParams { value } = params; + let response = MockExperimentalMethodResponse { echoed: value }; + Ok(response) + } + + async fn skills_list_response( + &self, + params: SkillsListParams, + ) -> Result { + let SkillsListParams { cwds, force_reload } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.to_path_buf()] + } else { + cwds + }; + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + let skills_manager = self.thread_manager.skills_manager(); + let plugins_manager = self.thread_manager.plugins_manager(); + let fs = self + .thread_manager + .environment_manager() + .default_environment() + .map(|environment| environment.get_filesystem()); + let mut data = futures::stream::iter(cwds.into_iter().enumerate()) + .map(|(index, cwd)| { + let config = &config; + let fs = fs.clone(); + let plugins_manager = &plugins_manager; + let skills_manager = &skills_manager; + async move { + let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { + Ok(resolved) => resolved, + Err(message) => { + let error_path = cwd.clone(); + return ( + index, + codex_app_server_protocol::SkillsListEntry { + cwd, + skills: Vec::new(), + errors: vec![codex_app_server_protocol::SkillErrorInfo { + path: error_path, + message, + }], + }, + ); + } + }; + let effective_skill_roots = if workspace_codex_plugins_enabled { + let plugins_input = config.plugins_config_input(); + plugins_manager + .effective_skill_roots_for_layer_stack( + &config_layer_stack, + &plugins_input, + ) + .await + } else { + Vec::new() + }; + let skills_input = codex_core::skills::SkillsLoadInput::new( + cwd_abs.clone(), + effective_skill_roots, + config_layer_stack, + config.bundled_skills_enabled(), + ); + let outcome = skills_manager + .skills_for_cwd(&skills_input, force_reload, fs) + .await; + let errors = errors_to_info(&outcome.errors); + let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); + ( + index, + codex_app_server_protocol::SkillsListEntry { + cwd, + skills, + errors, + }, + ) + } + }) + .buffer_unordered(SKILLS_LIST_CWD_CONCURRENCY) + .collect::>() + .await; + data.sort_unstable_by_key(|(index, _)| *index); + let data = data.into_iter().map(|(_, entry)| entry).collect(); + Ok(SkillsListResponse { data }) + } + + /// Handle `hooks/list` by resolving hooks for each requested cwd. + async fn hooks_list_response( + &self, + params: HooksListParams, + ) -> Result { + let HooksListParams { cwds } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.to_path_buf()] + } else { + cwds + }; + + let auth = self.auth_manager.auth().await; + let plugins_manager = self.thread_manager.plugins_manager(); + let mut data = Vec::new(); + for cwd in cwds { + let config = match self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(cwd.clone()), + ) + .await + { + Ok(config) => config, + Err(err) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: vec![codex_app_server_protocol::HookErrorInfo { + path: error_path, + message: err.to_string(), + }], + }); + continue; + } + }; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + let plugins_enabled = + config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; + let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks) + { + let plugins_input = config.plugins_config_input(); + plugins_manager + .plugins_for_layer_stack( + &config.config_layer_stack, + &plugins_input, + /*plugin_hooks_feature_enabled*/ true, + ) + .await + } else { + PluginLoadOutcome::default() + }; + let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: config.features.enabled(Feature::CodexHooks), + config_layer_stack: Some(config.config_layer_stack), + plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), + plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + ..Default::default() + }); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: hooks_to_info(&hooks.hooks), + warnings: hooks.warnings, + errors: Vec::new(), + }); + } + Ok(HooksListResponse { data }) + } + + async fn skills_config_write_response_inner( + &self, + params: SkillsConfigWriteParams, + ) -> Result { + let SkillsConfigWriteParams { + path, + name, + enabled, + } = params; + let edit = match (path, name) { + (Some(path), None) => ConfigEdit::SetSkillConfig { + path: path.into_path_buf(), + enabled, + }, + (None, Some(name)) if !name.trim().is_empty() => { + ConfigEdit::SetSkillConfigByName { name, enabled } + } + _ => { + return Err(invalid_params( + "skills/config/write requires exactly one of path or name", + )); + } + }; + let edits = vec![edit]; + ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(edits) + .apply() + .await + .map(|()| { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + SkillsConfigWriteResponse { + effective_enabled: enabled, + } + }) + .map_err(|err| internal_error(format!("failed to update skill settings: {err}"))) + } +} diff --git a/code-rs/app-server/src/request_processors/command_exec_processor.rs b/code-rs/app-server/src/request_processors/command_exec_processor.rs new file mode 100644 index 00000000000..3236a67627d --- /dev/null +++ b/code-rs/app-server/src/request_processors/command_exec_processor.rs @@ -0,0 +1,321 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct CommandExecRequestProcessor { + arg0_paths: Arg0DispatchPaths, + config: Arc, + outgoing: Arc, + command_exec_manager: CommandExecManager, +} + +impl CommandExecRequestProcessor { + pub(crate) fn new( + arg0_paths: Arg0DispatchPaths, + config: Arc, + outgoing: Arc, + ) -> Self { + Self { + arg0_paths, + config, + outgoing, + command_exec_manager: CommandExecManager::default(), + } + } + + pub(crate) async fn one_off_command_exec( + &self, + request_id: &ConnectionRequestId, + params: CommandExecParams, + ) -> Result, JSONRPCErrorError> { + self.exec_one_off_command(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn command_exec_write( + &self, + request_id: ConnectionRequestId, + params: CommandExecWriteParams, + ) -> Result, JSONRPCErrorError> { + self.command_exec_manager + .write(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn command_exec_resize( + &self, + request_id: ConnectionRequestId, + params: CommandExecResizeParams, + ) -> Result, JSONRPCErrorError> { + self.command_exec_manager + .resize(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn command_exec_terminate( + &self, + request_id: ConnectionRequestId, + params: CommandExecTerminateParams, + ) -> Result, JSONRPCErrorError> { + self.command_exec_manager + .terminate(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + self.command_exec_manager + .connection_closed(connection_id) + .await; + } + + async fn exec_one_off_command( + &self, + request_id: &ConnectionRequestId, + params: CommandExecParams, + ) -> Result<(), JSONRPCErrorError> { + self.exec_one_off_command_inner(request_id.clone(), params) + .await + } + + async fn exec_one_off_command_inner( + &self, + request_id: ConnectionRequestId, + params: CommandExecParams, + ) -> Result<(), JSONRPCErrorError> { + tracing::debug!("ExecOneOffCommand params: {params:?}"); + + let request = request_id.clone(); + + if params.command.is_empty() { + return Err(invalid_request("command must not be empty")); + } + + let CommandExecParams { + command, + process_id, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + disable_output_cap, + disable_timeout, + timeout_ms, + cwd, + env: env_overrides, + size, + sandbox_policy, + permission_profile, + } = params; + if sandbox_policy.is_some() && permission_profile.is_some() { + return Err(invalid_request( + "`permissionProfile` cannot be combined with `sandboxPolicy`", + )); + } + + if size.is_some() && !tty { + return Err(invalid_params("command/exec size requires tty: true")); + } + + if disable_output_cap && output_bytes_cap.is_some() { + return Err(invalid_params( + "command/exec cannot set both outputBytesCap and disableOutputCap", + )); + } + + if disable_timeout && timeout_ms.is_some() { + return Err(invalid_params( + "command/exec cannot set both timeoutMs and disableTimeout", + )); + } + + let cwd = cwd.map_or_else(|| self.config.cwd.clone(), |cwd| self.config.cwd.join(cwd)); + let mut env = create_env( + &self.config.permissions.shell_environment_policy, + /*thread_id*/ None, + ); + if let Some(env_overrides) = env_overrides { + for (key, value) in env_overrides { + match value { + Some(value) => { + env.insert(key, value); + } + None => { + env.remove(&key); + } + } + } + } + let timeout_ms = match timeout_ms { + Some(timeout_ms) => match u64::try_from(timeout_ms) { + Ok(timeout_ms) => Some(timeout_ms), + Err(_) => { + return Err(invalid_params(format!( + "command/exec timeoutMs must be non-negative, got {timeout_ms}" + ))); + } + }, + None => None, + }; + let managed_network_requirements_enabled = + self.config.managed_network_requirements_enabled(); + let started_network_proxy = match self.config.permissions.network.as_ref() { + Some(spec) => match spec + .start_proxy( + self.config.permissions.permission_profile.get(), + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + managed_network_requirements_enabled, + NetworkProxyAuditMetadata::default(), + ) + .await + { + Ok(started) => Some(started), + Err(err) => { + return Err(internal_error(format!( + "failed to start managed network proxy: {err}" + ))); + } + }, + None => None, + }; + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let output_bytes_cap = if disable_output_cap { + None + } else { + Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP)) + }; + let expiration = if disable_timeout { + ExecExpiration::Cancellation(CancellationToken::new()) + } else { + match timeout_ms { + Some(timeout_ms) => timeout_ms.into(), + None => ExecExpiration::DefaultTimeout, + } + }; + let capture_policy = if disable_output_cap { + ExecCapturePolicy::FullBuffer + } else { + ExecCapturePolicy::ShellTool + }; + let sandbox_cwd = if permission_profile.is_some() { + cwd.clone() + } else { + self.config.cwd.clone() + }; + let exec_params = ExecParams { + command, + cwd: cwd.clone(), + expiration, + capture_policy, + env, + network: started_network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy), + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level, + windows_sandbox_private_desktop: self + .config + .permissions + .windows_sandbox_private_desktop, + justification: None, + arg0: None, + }; + + let effective_permission_profile = if let Some(permission_profile) = permission_profile { + let permission_profile = + codex_protocol::models::PermissionProfile::from(permission_profile); + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + let configured_file_system_sandbox_policy = + self.config.permissions.file_system_sandbox_policy(); + Self::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + let effective_permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.config + .permissions + .permission_profile + .can_set(&effective_permission_profile) + .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; + effective_permission_profile + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + self.config + .permissions + .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + let file_system_sandbox_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); + let network_sandbox_policy = + codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); + let permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( + codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.config + .permissions + .permission_profile + .can_set(&permission_profile) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + permission_profile + } else { + self.config.permissions.permission_profile() + }; + + let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); + let outgoing = self.outgoing.clone(); + let request_for_task = request.clone(); + let started_network_proxy_for_task = started_network_proxy; + let use_legacy_landlock = self.config.features.use_legacy_landlock(); + let size = match size.map(crate::command_exec::terminal_size_from_protocol) { + Some(Ok(size)) => Some(size), + Some(Err(error)) => return Err(error), + None => None, + }; + + let exec_request = codex_core::exec::build_exec_request( + exec_params, + &effective_permission_profile, + &sandbox_cwd, + &codex_linux_sandbox_exe, + use_legacy_landlock, + ) + .map_err(|err| internal_error(format!("exec failed: {err}")))?; + self.command_exec_manager + .start(StartCommandExecParams { + outgoing, + request_id: request_for_task, + process_id, + exec_request, + started_network_proxy: started_network_proxy_for_task, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + }) + .await + } + + fn preserve_configured_deny_read_restrictions( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, + ) { + file_system_sandbox_policy + .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); + } +} + +#[cfg(test)] +#[path = "command_exec_processor_tests.rs"] +mod command_exec_processor_tests; diff --git a/code-rs/app-server/src/request_processors/command_exec_processor_tests.rs b/code-rs/app-server/src/request_processors/command_exec_processor_tests.rs new file mode 100644 index 00000000000..3e026a6a821 --- /dev/null +++ b/code-rs/app-server/src/request_processors/command_exec_processor_tests.rs @@ -0,0 +1,38 @@ +use super::*; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; + +#[test] +fn command_profile_preserves_configured_deny_read_restrictions() { + let readable_entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: test_path_buf("/tmp/project").abs(), + }, + access: FileSystemAccessMode::Read, + }; + let deny_entry = FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }; + let mut file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); + let mut configured_file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); + configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); + + CommandExecRequestProcessor::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + + let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); + expected.glob_scan_max_depth = Some(2); + assert_eq!(file_system_sandbox_policy, expected); +} diff --git a/code-rs/app-server/src/request_processors/config_errors.rs b/code-rs/app-server/src/request_processors/config_errors.rs new file mode 100644 index 00000000000..63fe2b3d2cf --- /dev/null +++ b/code-rs/app-server/src/request_processors/config_errors.rs @@ -0,0 +1,35 @@ +use super::*; + +fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { + let mut current: Option<&(dyn std::error::Error + 'static)> = err + .get_ref() + .map(|source| source as &(dyn std::error::Error + 'static)); + while let Some(source) = current { + if let Some(cloud_error) = source.downcast_ref::() { + return Some(cloud_error); + } + current = source.source(); + } + None +} + +pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { + let data = cloud_requirements_load_error(err).map(|cloud_error| { + let mut data = serde_json::json!({ + "reason": "cloudRequirements", + "errorCode": format!("{:?}", cloud_error.code()), + "detail": cloud_error.to_string(), + }); + if let Some(status_code) = cloud_error.status_code() { + data["statusCode"] = serde_json::json!(status_code); + } + if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + data["action"] = serde_json::json!("relogin"); + } + data + }); + + let mut error = invalid_request(format!("failed to load configuration: {err}")); + error.data = data; + error +} diff --git a/code-rs/app-server/src/request_processors/config_processor.rs b/code-rs/app-server/src/request_processors/config_processor.rs new file mode 100644 index 00000000000..cda2bbe61d8 --- /dev/null +++ b/code-rs/app-server/src/request_processors/config_processor.rs @@ -0,0 +1,630 @@ +use std::sync::Arc; + +use crate::config_manager::ConfigManager; +use crate::config_manager_service::ConfigManagerError; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; +use crate::transport::RemoteControlHandle; +use codex_analytics::AnalyticsEventsClient; +use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigRequirements; +use codex_app_server_protocol::ConfigRequirementsReadResponse; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteErrorCode; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ConfiguredHookHandler; +use codex_app_server_protocol::ConfiguredHookMatcherGroup; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ManagedHooksRequirements; +use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; +use codex_app_server_protocol::NetworkDomainPermission; +use codex_app_server_protocol::NetworkRequirements; +use codex_app_server_protocol::NetworkUnixSocketPermission; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ServerNotification; +use codex_chatgpt::connectors; +use codex_config::ConfigRequirementsToml; +use codex_config::HookEventsToml; +use codex_config::HookHandlerConfig as CoreHookHandlerConfig; +use codex_config::ManagedHooksRequirementsToml; +use codex_config::MatcherGroup as CoreMatcherGroup; +use codex_config::ResidencyRequirement as CoreResidencyRequirement; +use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; +use codex_core::ThreadManager; +use codex_features::Feature; +use codex_features::canonical_feature_for_key; +use codex_features::feature_for_key; +use codex_login::AuthManager; +use codex_model_provider::create_model_provider; +use codex_plugin::PluginId; +use codex_protocol::config_types::WebSearchMode; +use serde_json::json; +use std::path::PathBuf; + +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps", + "memories", + "plugins", + "remote_control", + "tool_search", + "tool_suggest", + "tool_call_mcp_elicitation", +]; + +#[derive(Clone)] +pub(crate) struct ConfigRequestProcessor { + outgoing: Arc, + config_manager: ConfigManager, + auth_manager: Arc, + thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, + remote_control_handle: Option, +} + +impl ConfigRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + config_manager: ConfigManager, + auth_manager: Arc, + thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, + remote_control_handle: Option, + ) -> Self { + Self { + outgoing, + config_manager, + auth_manager, + thread_manager, + analytics_events_client, + remote_control_handle, + } + } + + pub(crate) async fn read( + &self, + params: ConfigReadParams, + ) -> Result { + let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); + let mut response = self.config_manager.read(params).await.map_err(map_error)?; + let config = self.load_latest_config(fallback_cwd).await?; + for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { + let Some(feature) = feature_for_key(feature_key) else { + continue; + }; + let features = response + .config + .additional + .entry("features".to_string()) + .or_insert_with(|| json!({})); + if !features.is_object() { + *features = json!({}); + } + if let Some(features) = features.as_object_mut() { + features.insert( + (*feature_key).to_string(), + json!(config.features.enabled(feature)), + ); + } + } + Ok(response) + } + + pub(crate) async fn config_requirements_read( + &self, + ) -> Result { + let requirements = self + .config_manager + .read_requirements() + .await + .map_err(map_error)? + .map(map_requirements_toml_to_api); + + Ok(ConfigRequirementsReadResponse { requirements }) + } + + pub(crate) async fn value_write( + &self, + params: ConfigValueWriteParams, + ) -> Result { + self.handle_config_mutation_result(self.write_value(params).await) + .await + .map(ClientResponsePayload::ConfigValueWrite) + } + + pub(crate) async fn batch_write( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + self.handle_config_mutation_result(self.batch_write_inner(params).await) + .await + .map(ClientResponsePayload::ConfigBatchWrite) + } + + pub(crate) async fn experimental_feature_enablement_set( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result, JSONRPCErrorError> { + let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); + let response = self + .handle_config_mutation_result(self.set_experimental_feature_enablement(params).await) + .await?; + self.outgoing + .send_response_as( + request_id, + ClientResponsePayload::ExperimentalFeatureEnablementSet(response), + ) + .await; + if should_refresh_apps_list { + self.refresh_apps_list_after_experimental_feature_enablement_set() + .await; + } + Ok(None) + } + + pub(crate) async fn model_provider_capabilities_read( + &self, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let provider = create_model_provider(config.model_provider, /*auth_manager*/ None); + let capabilities = provider.capabilities(); + Ok(ModelProviderCapabilitiesReadResponse { + namespace_tools: capabilities.namespace_tools, + image_generation: capabilities.image_generation, + web_search: capabilities.web_search, + }) + } + + pub(crate) async fn handle_config_mutation(&self) { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + let Some(remote_control_handle) = &self.remote_control_handle else { + return; + }; + + match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => { + remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl)); + } + Err(error) => { + tracing::warn!( + "failed to load config for remote control enablement refresh after config mutation: {}", + error.message + ); + } + } + } + + async fn handle_config_mutation_result( + &self, + result: std::result::Result, + ) -> Result { + let response = result?; + self.handle_config_mutation().await; + Ok(response) + } + + async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(error) => { + tracing::warn!( + "failed to load config for apps list refresh after experimental feature enablement: {}", + error.message + ); + return; + } + }; + let auth = self.auth_manager.auth().await; + if !config.features.apps_enabled_for_auth( + auth.as_ref() + .is_some_and(codex_login::CodexAuth::uses_codex_backend), + ) { + return; + } + + let outgoing = Arc::clone(&self.outgoing); + let environment_manager = self.thread_manager.environment_manager(); + tokio::spawn(async move { + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, + /*force_refetch*/ true, + &environment_manager, + ), + ); + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh directory apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + let accessible_connectors = match accessible_connectors_result { + Ok(status) => status.connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + + let data = connectors::with_app_enabled_state( + connectors::merge_connectors_with_accessible( + all_connectors, + accessible_connectors, + /*all_connectors_loaded*/ true, + ), + &config, + ); + outgoing + .send_server_notification(ServerNotification::AppListUpdated( + AppListUpdatedNotification { data }, + )) + .await; + }); + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| { + internal_error(format!( + "failed to resolve feature override precedence: {err}" + )) + }) + } + + async fn write_value( + &self, + params: ConfigValueWriteParams, + ) -> Result { + let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates( + [(¶ms.key_path, ¶ms.value)].into_iter(), + ); + let response = self + .config_manager + .write_value(params) + .await + .map_err(map_error)?; + self.emit_plugin_toggle_events(pending_changes).await; + Ok(response) + } + + async fn batch_write_inner( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + let reload_user_config = params.reload_user_config; + let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates( + params + .edits + .iter() + .map(|edit| (&edit.key_path, &edit.value)), + ); + let response = self + .config_manager + .batch_write(params) + .await + .map_err(map_error)?; + self.emit_plugin_toggle_events(pending_changes).await; + if reload_user_config { + self.reload_user_config().await; + } + Ok(response) + } + + async fn set_experimental_feature_enablement( + &self, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result { + let ExperimentalFeatureEnablementSetParams { enablement } = params; + for key in enablement.keys() { + if canonical_feature_for_key(key).is_some() { + if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { + continue; + } + + return Err(invalid_request(format!( + "unsupported feature enablement `{key}`: currently supported features are {}", + SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + ))); + } + + let message = if let Some(feature) = feature_for_key(key) { + format!( + "invalid feature enablement `{key}`: use canonical feature key `{}`", + feature.key() + ) + } else { + format!("invalid feature enablement `{key}`") + }; + return Err(invalid_request(message)); + } + + if enablement.is_empty() { + return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); + } + + self.config_manager + .extend_runtime_feature_enablement( + enablement + .iter() + .map(|(name, enabled)| (name.clone(), *enabled)), + ) + .map_err(|_| internal_error("failed to update feature enablement"))?; + + self.load_latest_config(/*fallback_cwd*/ None).await?; + self.reload_user_config().await; + + Ok(ExperimentalFeatureEnablementSetResponse { enablement }) + } + + async fn reload_user_config(&self) { + let next_config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(err) => { + tracing::warn!( + "failed to rebuild user config for runtime refresh: {}", + err.message + ); + return; + } + }; + let thread_ids = self.thread_manager.list_thread_ids().await; + for thread_id in thread_ids { + let Ok(thread) = self.thread_manager.get_thread(thread_id).await else { + continue; + }; + thread.refresh_runtime_config(next_config.clone()).await; + } + } + + async fn emit_plugin_toggle_events( + &self, + pending_changes: std::collections::BTreeMap, + ) { + for (plugin_id, enabled) in pending_changes { + let Ok(plugin_id) = PluginId::parse(&plugin_id) else { + continue; + }; + let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata( + self.config_manager.codex_home(), + &plugin_id, + ) + .await; + if enabled { + self.analytics_events_client.track_plugin_enabled(metadata); + } else { + self.analytics_events_client.track_plugin_disabled(metadata); + } + } + } +} + +fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { + ConfigRequirements { + allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { + policies + .into_iter() + .map(codex_app_server_protocol::AskForApproval::from) + .collect() + }), + allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| { + reviewers + .into_iter() + .map(codex_app_server_protocol::ApprovalsReviewer::from) + .collect() + }), + allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| { + modes + .into_iter() + .filter_map(map_sandbox_mode_requirement_to_api) + .collect() + }), + allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { + let mut normalized = modes + .into_iter() + .map(Into::into) + .collect::>(); + if !normalized.contains(&WebSearchMode::Disabled) { + normalized.push(WebSearchMode::Disabled); + } + normalized + }), + feature_requirements: requirements + .feature_requirements + .map(|requirements| requirements.entries), + hooks: requirements.hooks.map(map_hooks_requirements_to_api), + enforce_residency: requirements + .enforce_residency + .map(map_residency_requirement_to_api), + network: requirements.network.map(map_network_requirements_to_api), + } +} + +fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements { + let ManagedHooksRequirementsToml { + managed_dir, + windows_managed_dir, + hooks, + } = hooks; + let HookEventsToml { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + } = hooks; + + ManagedHooksRequirements { + managed_dir, + windows_managed_dir, + pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use), + permission_request: map_hook_matcher_groups_to_api(permission_request), + post_tool_use: map_hook_matcher_groups_to_api(post_tool_use), + pre_compact: map_hook_matcher_groups_to_api(pre_compact), + post_compact: map_hook_matcher_groups_to_api(post_compact), + session_start: map_hook_matcher_groups_to_api(session_start), + user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), + stop: map_hook_matcher_groups_to_api(stop), + } +} + +fn map_hook_matcher_groups_to_api( + groups: Vec, +) -> Vec { + groups + .into_iter() + .map(map_hook_matcher_group_to_api) + .collect() +} + +fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup { + ConfiguredHookMatcherGroup { + matcher: group.matcher, + hooks: group + .hooks + .into_iter() + .map(map_hook_handler_to_api) + .collect(), + } +} + +fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler { + match handler { + CoreHookHandlerConfig::Command { + command, + timeout_sec, + r#async, + status_message, + } => ConfiguredHookHandler::Command { + command, + timeout_sec, + r#async, + status_message, + }, + CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {}, + CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {}, + } +} + +fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option { + match mode { + CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly), + CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite), + CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess), + CoreSandboxModeRequirement::ExternalSandbox => None, + } +} + +fn map_residency_requirement_to_api( + residency: CoreResidencyRequirement, +) -> codex_app_server_protocol::ResidencyRequirement { + match residency { + CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us, + } +} + +fn map_network_requirements_to_api( + network: codex_config::NetworkRequirementsToml, +) -> NetworkRequirements { + let allowed_domains = network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains); + let denied_domains = network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains); + let allow_unix_sockets = network + .unix_sockets + .as_ref() + .map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .filter(|entries| !entries.is_empty()); + + NetworkRequirements { + enabled: network.enabled, + http_port: network.http_port, + socks_port: network.socks_port, + allow_upstream_proxy: network.allow_upstream_proxy, + dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, + domains: network.domains.map(|domains| { + domains + .entries + .into_iter() + .map(|(pattern, permission)| { + (pattern, map_network_domain_permission_to_api(permission)) + }) + .collect() + }), + managed_allowed_domains_only: network.managed_allowed_domains_only, + allowed_domains, + denied_domains, + unix_sockets: network.unix_sockets.map(|unix_sockets| { + unix_sockets + .entries + .into_iter() + .map(|(path, permission)| { + (path, map_network_unix_socket_permission_to_api(permission)) + }) + .collect() + }), + allow_unix_sockets, + allow_local_binding: network.allow_local_binding, + } +} + +fn map_network_domain_permission_to_api( + permission: codex_config::NetworkDomainPermissionToml, +) -> NetworkDomainPermission { + match permission { + codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow, + codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny, + } +} + +fn map_network_unix_socket_permission_to_api( + permission: codex_config::NetworkUnixSocketPermissionToml, +) -> NetworkUnixSocketPermission { + match permission { + codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow, + codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None, + } +} + +fn map_error(err: ConfigManagerError) -> JSONRPCErrorError { + if let Some(code) = err.write_error_code() { + return config_write_error(code, err.to_string()); + } + + internal_error(err.to_string()) +} + +fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> JSONRPCErrorError { + let mut error = invalid_request(message); + error.data = Some(json!({ + "config_write_error_code": code, + })); + error +} diff --git a/code-rs/app-server/src/request_processors/external_agent_config_processor.rs b/code-rs/app-server/src/request_processors/external_agent_config_processor.rs new file mode 100644 index 00000000000..1c741944b51 --- /dev/null +++ b/code-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -0,0 +1,526 @@ +use std::sync::Arc; + +use crate::config::external_agent_config::ExternalAgentConfigDetectOptions; +use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem; +use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; +use crate::config::external_agent_config::ExternalAgentConfigService; +use crate::config::external_agent_config::NamedMigration as CoreNamedMigration; +use crate::config::external_agent_config::PendingPluginImport; +use crate::config_manager::ConfigManager; +use crate::error_code::internal_error; +use crate::error_code::invalid_params; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::CommandMigration; +use codex_app_server_protocol::ExternalAgentConfigDetectParams; +use codex_app_server_protocol::ExternalAgentConfigDetectResponse; +use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; +use codex_app_server_protocol::ExternalAgentConfigImportParams; +use codex_app_server_protocol::ExternalAgentConfigImportResponse; +use codex_app_server_protocol::ExternalAgentConfigMigrationItem; +use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; +use codex_app_server_protocol::HookMigration; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpServerMigration; +use codex_app_server_protocol::MigrationDetails; +use codex_app_server_protocol::PluginsMigration; +use codex_app_server_protocol::ServerNotification; +use codex_arg0::Arg0DispatchPaths; +use codex_core::StartThreadOptions; +use codex_core::ThreadManager; +use codex_core::config::ConfigOverrides; +use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration; +use codex_external_agent_sessions::ImportedExternalAgentSession; +use codex_external_agent_sessions::PendingSessionImport; +use codex_external_agent_sessions::prepare_validated_session_imports; +use codex_external_agent_sessions::record_imported_session; +use codex_protocol::ThreadId; +use codex_protocol::protocol::InitialHistory; +use codex_thread_store::ThreadMetadataPatch; +use std::collections::HashSet; +use std::path::PathBuf; +use tokio::sync::Semaphore; + +use super::ConfigRequestProcessor; + +#[derive(Clone)] +pub(crate) struct ExternalAgentConfigRequestProcessor { + outgoing: Arc, + codex_home: PathBuf, + migration_service: ExternalAgentConfigService, + session_import_permits: Arc, + thread_manager: Arc, + config_manager: ConfigManager, + config_processor: ConfigRequestProcessor, + arg0_paths: Arg0DispatchPaths, +} + +impl ExternalAgentConfigRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + thread_manager: Arc, + config_manager: ConfigManager, + config_processor: ConfigRequestProcessor, + arg0_paths: Arg0DispatchPaths, + codex_home: PathBuf, + ) -> Self { + Self { + outgoing, + migration_service: ExternalAgentConfigService::new(codex_home.clone()), + codex_home, + session_import_permits: Arc::new(Semaphore::new(1)), + thread_manager, + config_manager, + config_processor, + arg0_paths, + } + } + + pub(crate) async fn detect( + &self, + params: ExternalAgentConfigDetectParams, + ) -> Result { + let items = self + .migration_service + .detect(ExternalAgentConfigDetectOptions { + include_home: params.include_home, + cwds: params.cwds, + }) + .await + .map_err(|err| internal_error(err.to_string()))?; + + Ok(ExternalAgentConfigDetectResponse { + items: items + .into_iter() + .map(|migration_item| ExternalAgentConfigMigrationItem { + item_type: match migration_item.item_type { + CoreMigrationItemType::Config => { + ExternalAgentConfigMigrationItemType::Config + } + CoreMigrationItemType::Skills => { + ExternalAgentConfigMigrationItemType::Skills + } + CoreMigrationItemType::AgentsMd => { + ExternalAgentConfigMigrationItemType::AgentsMd + } + CoreMigrationItemType::Plugins => { + ExternalAgentConfigMigrationItemType::Plugins + } + CoreMigrationItemType::McpServerConfig => { + ExternalAgentConfigMigrationItemType::McpServerConfig + } + CoreMigrationItemType::Subagents => { + ExternalAgentConfigMigrationItemType::Subagents + } + CoreMigrationItemType::Hooks => ExternalAgentConfigMigrationItemType::Hooks, + CoreMigrationItemType::Commands => { + ExternalAgentConfigMigrationItemType::Commands + } + CoreMigrationItemType::Sessions => { + ExternalAgentConfigMigrationItemType::Sessions + } + }, + description: migration_item.description, + cwd: migration_item.cwd, + details: migration_item.details.map(|details| MigrationDetails { + plugins: details + .plugins + .into_iter() + .map(|plugin| PluginsMigration { + marketplace_name: plugin.marketplace_name, + plugin_names: plugin.plugin_names, + }) + .collect(), + sessions: details + .sessions + .into_iter() + .map(|session| codex_app_server_protocol::SessionMigration { + path: session.path, + cwd: session.cwd, + title: session.title, + }) + .collect(), + mcp_servers: details + .mcp_servers + .into_iter() + .map(|mcp_server| McpServerMigration { + name: mcp_server.name, + }) + .collect(), + hooks: details + .hooks + .into_iter() + .map(|hook| HookMigration { name: hook.name }) + .collect(), + subagents: details + .subagents + .into_iter() + .map(|subagent| codex_app_server_protocol::SubagentMigration { + name: subagent.name, + }) + .collect(), + commands: details + .commands + .into_iter() + .map(|command| CommandMigration { name: command.name }) + .collect(), + }), + }) + .collect(), + }) + } + + pub(crate) async fn import( + &self, + request_id: ConnectionRequestId, + params: ExternalAgentConfigImportParams, + ) -> Result<(), JSONRPCErrorError> { + let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); + let has_migration_items = !params.migration_items.is_empty(); + let has_plugin_imports = params.migration_items.iter().any(|item| { + matches!( + item.item_type, + ExternalAgentConfigMigrationItemType::Plugins + ) + }); + let pending_session_imports = self.validate_pending_session_imports(¶ms)?; + let pending_plugin_imports = self.import_external_agent_config(params).await?; + if needs_runtime_refresh { + self.config_processor.handle_config_mutation().await; + } + self.outgoing + .send_response(request_id, ExternalAgentConfigImportResponse {}) + .await; + + if !has_migration_items { + return Ok(()); + } + + let has_background_imports = + !pending_plugin_imports.is_empty() || !pending_session_imports.is_empty(); + if !has_background_imports { + self.outgoing + .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedNotification {}, + )) + .await; + return Ok(()); + } + + let session_import_permits = Arc::clone(&self.session_import_permits); + let session_processor = self.clone(); + let plugin_processor = self.clone(); + let outgoing = Arc::clone(&self.outgoing); + let thread_manager = Arc::clone(&self.thread_manager); + tokio::spawn(async move { + let session_imports = async move { + if !pending_session_imports.is_empty() { + let Ok(_session_import_permit) = session_import_permits.acquire_owned().await + else { + return; + }; + let pending_session_imports = session_processor + .prepare_validated_session_imports(pending_session_imports); + for pending_session_import in pending_session_imports { + match session_processor + .import_external_agent_session(pending_session_import.session) + .await + { + Ok(imported_thread_id) => { + session_processor.record_imported_session( + &pending_session_import.source_path, + imported_thread_id, + ); + } + Err(error) => { + tracing::warn!( + error = %error.message, + path = %pending_session_import.source_path.display(), + "external agent session import failed" + ); + } + } + } + } + }; + let plugin_imports = async move { + for pending_plugin_import in pending_plugin_imports { + match plugin_processor + .complete_pending_plugin_import(pending_plugin_import) + .await + { + Ok(()) => {} + Err(error) => { + tracing::warn!( + error = %error.message, + "external agent config plugin import failed" + ); + } + } + } + }; + tokio::join!(session_imports, plugin_imports); + if has_plugin_imports { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); + } + outgoing + .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedNotification {}, + )) + .await; + }); + + Ok(()) + } + + async fn import_external_agent_session( + &self, + session: ImportedExternalAgentSession, + ) -> Result { + let ImportedExternalAgentSession { + cwd, + title, + rollout_items, + } = session; + let config = self + .config_manager + .load_with_overrides( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(PathBuf::from(cwd.to_string_lossy().into_owned())), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }, + ) + .await + .map_err(|err| { + internal_error(format!("failed to load imported session config: {err}")) + })?; + let environments = self + .thread_manager + .default_environment_selections(&config.cwd); + let imported_thread = self + .thread_manager + .start_thread_with_options(StartThreadOptions { + config, + initial_history: InitialHistory::Forked(rollout_items), + session_source: None, + thread_source: None, + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + parent_trace: None, + environments, + }) + .await + .map_err(|err| internal_error(format!("failed to import session: {err}")))?; + if let Some(title) = title + && let Some(name) = codex_core::util::normalize_thread_name(&title) + { + imported_thread + .thread + .update_thread_metadata( + ThreadMetadataPatch { + name: Some(name), + ..Default::default() + }, + /*include_archived*/ false, + ) + .await + .map_err(|err| internal_error(format!("failed to name imported session: {err}")))?; + } + Ok(imported_thread.thread_id) + } + + fn validate_pending_session_imports( + &self, + params: &ExternalAgentConfigImportParams, + ) -> Result, JSONRPCErrorError> { + let sessions = params + .migration_items + .iter() + .filter(|item| { + matches!( + item.item_type, + ExternalAgentConfigMigrationItemType::Sessions + ) + }) + .filter_map(|item| item.details.as_ref()) + .flat_map(|details| details.sessions.clone()) + .map(|session| CoreSessionMigration { + path: session.path, + cwd: session.cwd, + title: session.title, + }) + .collect::>(); + let mut selected_session_paths = HashSet::new(); + let mut selected_sessions = Vec::new(); + for session in sessions { + let Some(canonical_path) = self + .migration_service + .external_agent_session_source_path(&session.path) + .map_err(|err| internal_error(err.to_string()))? + else { + return Err(session_not_detected_error(&session.path)); + }; + if selected_session_paths.insert(canonical_path) { + selected_sessions.push(session); + } + } + Ok(selected_sessions) + } + + fn prepare_validated_session_imports( + &self, + sessions: Vec, + ) -> Vec { + prepare_validated_session_imports(&self.codex_home, sessions) + } + + fn record_imported_session(&self, source_path: &std::path::Path, imported_thread_id: ThreadId) { + if let Err(err) = record_imported_session(&self.codex_home, source_path, imported_thread_id) + { + tracing::warn!( + error = %err, + path = %source_path.display(), + "external agent session import ledger update failed" + ); + } + } + + async fn import_external_agent_config( + &self, + params: ExternalAgentConfigImportParams, + ) -> Result, JSONRPCErrorError> { + self.migration_service + .import( + params + .migration_items + .into_iter() + .map(|migration_item| CoreMigrationItem { + item_type: match migration_item.item_type { + ExternalAgentConfigMigrationItemType::Config => { + CoreMigrationItemType::Config + } + ExternalAgentConfigMigrationItemType::Skills => { + CoreMigrationItemType::Skills + } + ExternalAgentConfigMigrationItemType::AgentsMd => { + CoreMigrationItemType::AgentsMd + } + ExternalAgentConfigMigrationItemType::Plugins => { + CoreMigrationItemType::Plugins + } + ExternalAgentConfigMigrationItemType::McpServerConfig => { + CoreMigrationItemType::McpServerConfig + } + ExternalAgentConfigMigrationItemType::Subagents => { + CoreMigrationItemType::Subagents + } + ExternalAgentConfigMigrationItemType::Hooks => { + CoreMigrationItemType::Hooks + } + ExternalAgentConfigMigrationItemType::Commands => { + CoreMigrationItemType::Commands + } + ExternalAgentConfigMigrationItemType::Sessions => { + CoreMigrationItemType::Sessions + } + }, + description: migration_item.description, + cwd: migration_item.cwd, + details: migration_item.details.map(|details| { + crate::config::external_agent_config::MigrationDetails { + plugins: details + .plugins + .into_iter() + .map(|plugin| { + crate::config::external_agent_config::PluginsMigration { + marketplace_name: plugin.marketplace_name, + plugin_names: plugin.plugin_names, + } + }) + .collect(), + sessions: details + .sessions + .into_iter() + .map(|session| CoreSessionMigration { + path: session.path, + cwd: session.cwd, + title: session.title, + }) + .collect(), + mcp_servers: details + .mcp_servers + .into_iter() + .map(|mcp_server| CoreNamedMigration { + name: mcp_server.name, + }) + .collect(), + hooks: details + .hooks + .into_iter() + .map(|hook| CoreNamedMigration { name: hook.name }) + .collect(), + subagents: details + .subagents + .into_iter() + .map(|subagent| CoreNamedMigration { + name: subagent.name, + }) + .collect(), + commands: details + .commands + .into_iter() + .map(|command| CoreNamedMigration { name: command.name }) + .collect(), + } + }), + }) + .collect(), + ) + .await + .map_err(|err| internal_error(err.to_string())) + } + + async fn complete_pending_plugin_import( + &self, + pending_plugin_import: PendingPluginImport, + ) -> Result<(), JSONRPCErrorError> { + self.migration_service + .import_plugins( + pending_plugin_import.cwd.as_deref(), + Some(pending_plugin_import.details), + ) + .await + .map(|_| ()) + .map_err(|err| internal_error(err.to_string())) + } +} + +fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationItem]) -> bool { + items.iter().any(|item| { + matches!( + item.item_type, + ExternalAgentConfigMigrationItemType::Config + | ExternalAgentConfigMigrationItemType::Skills + | ExternalAgentConfigMigrationItemType::McpServerConfig + | ExternalAgentConfigMigrationItemType::Hooks + | ExternalAgentConfigMigrationItemType::Commands + | ExternalAgentConfigMigrationItemType::Plugins + ) + }) +} + +fn session_not_detected_error(path: &std::path::Path) -> JSONRPCErrorError { + invalid_params(format!( + "external agent session was not detected for import: {}", + path.display() + )) +} + +#[cfg(test)] +#[path = "external_agent_config_processor_tests.rs"] +mod external_agent_config_processor_tests; diff --git a/code-rs/app-server/src/request_processors/external_agent_config_processor_tests.rs b/code-rs/app-server/src/request_processors/external_agent_config_processor_tests.rs new file mode 100644 index 00000000000..fb1b8ee6c1c --- /dev/null +++ b/code-rs/app-server/src/request_processors/external_agent_config_processor_tests.rs @@ -0,0 +1,37 @@ +use super::*; + +fn migration_item( + item_type: ExternalAgentConfigMigrationItemType, +) -> ExternalAgentConfigMigrationItem { + ExternalAgentConfigMigrationItem { + item_type, + description: String::new(), + cwd: None, + details: None, + } +} + +#[test] +fn migration_items_that_update_runtime_sources_trigger_refresh() { + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Config, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Skills, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::McpServerConfig, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Hooks, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Commands, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Plugins, + )])); + assert!(!migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Sessions, + )])); +} diff --git a/code-rs/app-server/src/request_processors/feedback_processor.rs b/code-rs/app-server/src/request_processors/feedback_processor.rs new file mode 100644 index 00000000000..5b9039b57d0 --- /dev/null +++ b/code-rs/app-server/src/request_processors/feedback_processor.rs @@ -0,0 +1,252 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct FeedbackRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + config: Arc, + feedback: CodexFeedback, + log_db: Option, + state_db: Option, +} + +impl FeedbackRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + config: Arc, + feedback: CodexFeedback, + log_db: Option, + state_db: Option, + ) -> Self { + Self { + auth_manager, + thread_manager, + config, + feedback, + log_db, + state_db, + } + } + + pub(crate) async fn feedback_upload( + &self, + params: FeedbackUploadParams, + ) -> Result, JSONRPCErrorError> { + self.upload_feedback_response(params) + .await + .map(|response| Some(response.into())) + } + + async fn upload_feedback_response( + &self, + params: FeedbackUploadParams, + ) -> Result { + if !self.config.feedback_enabled { + return Err(invalid_request( + "sending feedback is disabled by configuration", + )); + } + + let FeedbackUploadParams { + classification, + reason, + thread_id, + include_logs, + extra_log_files, + tags, + } = params; + + let conversation_id = match thread_id.as_deref() { + Some(thread_id) => match ThreadId::from_string(thread_id) { + Ok(conversation_id) => Some(conversation_id), + Err(err) => return Err(invalid_request(format!("invalid thread id: {err}"))), + }, + None => None, + }; + + if let Some(chatgpt_user_id) = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_chatgpt_user_id()) + { + tracing::info!(target: "feedback_tags", chatgpt_user_id); + } + if let Some(account_id) = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_account_id()) + { + tracing::info!(target: "feedback_tags", account_id); + } + let snapshot = self.feedback.snapshot(conversation_id); + let thread_id = snapshot.thread_id.clone(); + let (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) = if include_logs { + if let Some(log_db) = self.log_db.as_ref() { + log_db.flush().await; + } + let state_db_ctx = self.state_db.clone(); + let feedback_thread_ids = match conversation_id { + Some(conversation_id) => match self + .thread_manager + .list_agent_subtree_thread_ids(conversation_id) + .await + { + Ok(thread_ids) => thread_ids, + Err(err) => { + warn!( + "failed to list feedback subtree for thread_id={conversation_id}: {err}" + ); + let mut thread_ids = vec![conversation_id]; + if let Some(state_db_ctx) = state_db_ctx.as_ref() { + for status in [ + codex_state::DirectionalThreadSpawnEdgeStatus::Open, + codex_state::DirectionalThreadSpawnEdgeStatus::Closed, + ] { + match state_db_ctx + .list_thread_spawn_descendants_with_status( + conversation_id, + status, + ) + .await + { + Ok(descendant_ids) => thread_ids.extend(descendant_ids), + Err(err) => warn!( + "failed to list persisted feedback subtree for thread_id={conversation_id}: {err}" + ), + } + } + } + thread_ids + } + }, + None => Vec::new(), + }; + let sqlite_feedback_logs = if let Some(state_db_ctx) = state_db_ctx.as_ref() + && !feedback_thread_ids.is_empty() + { + let thread_id_texts = feedback_thread_ids + .iter() + .map(ToString::to_string) + .collect::>(); + let thread_id_refs = thread_id_texts + .iter() + .map(String::as_str) + .collect::>(); + match state_db_ctx + .query_feedback_logs_for_threads(&thread_id_refs) + .await + { + Ok(logs) if logs.is_empty() => None, + Ok(logs) => Some(logs), + Err(err) => { + let thread_ids = thread_id_texts.join(", "); + warn!( + "failed to query feedback logs from sqlite for thread_ids=[{thread_ids}]: {err}" + ); + None + } + } + } else { + None + }; + (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) + } else { + (Vec::new(), None, None) + }; + + let mut attachment_paths = Vec::new(); + let mut seen_attachment_paths = HashSet::new(); + if include_logs { + for feedback_thread_id in &feedback_thread_ids { + let Some(rollout_path) = self + .resolve_rollout_path(*feedback_thread_id, state_db_ctx.as_ref()) + .await + else { + continue; + }; + if seen_attachment_paths.insert(rollout_path.clone()) { + attachment_paths.push(FeedbackAttachmentPath { + path: rollout_path, + attachment_filename_override: None, + }); + } + } + if let Some(conversation_id) = conversation_id + && let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await + && let Some(guardian_rollout_path) = + conversation.guardian_trunk_rollout_path().await + && seen_attachment_paths.insert(guardian_rollout_path.clone()) + { + attachment_paths.push(FeedbackAttachmentPath { + path: guardian_rollout_path, + attachment_filename_override: Some(auto_review_rollout_filename( + conversation_id, + )), + }); + } + } + if let Some(extra_log_files) = extra_log_files { + for extra_log_file in extra_log_files { + if seen_attachment_paths.insert(extra_log_file.clone()) { + attachment_paths.push(FeedbackAttachmentPath { + path: extra_log_file, + attachment_filename_override: None, + }); + } + } + } + + let session_source = self.thread_manager.session_source(); + + let upload_result = tokio::task::spawn_blocking(move || { + snapshot.upload_feedback(FeedbackUploadOptions { + classification: &classification, + reason: reason.as_deref(), + tags: tags.as_ref(), + include_logs, + extra_attachment_paths: &attachment_paths, + session_source: Some(session_source), + logs_override: sqlite_feedback_logs, + }) + }) + .await; + + let upload_result = match upload_result { + Ok(result) => result, + Err(join_err) => { + return Err(internal_error(format!( + "failed to upload feedback: {join_err}" + ))); + } + }; + + upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?; + Ok(FeedbackUploadResponse { thread_id }) + } + + async fn resolve_rollout_path( + &self, + conversation_id: ThreadId, + state_db_ctx: Option<&StateDbHandle>, + ) -> Option { + if let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await + && let Some(rollout_path) = conversation.rollout_path() + { + return Some(rollout_path); + } + + let state_db_ctx = state_db_ctx?; + state_db_ctx + .find_rollout_path_by_id(conversation_id, /*archived_only*/ None) + .await + .unwrap_or_else(|err| { + warn!("failed to resolve rollout path for thread_id={conversation_id}: {err}"); + None + }) + } +} + +fn auto_review_rollout_filename(thread_id: ThreadId) -> String { + format!("auto-review-rollout-{thread_id}.jsonl") +} diff --git a/code-rs/app-server/src/request_processors/fs_processor.rs b/code-rs/app-server/src/request_processors/fs_processor.rs new file mode 100644 index 00000000000..01b9b20bfd6 --- /dev/null +++ b/code-rs/app-server/src/request_processors/fs_processor.rs @@ -0,0 +1,200 @@ +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::fs_watch::FsWatchManager; +use crate::outgoing_message::ConnectionId; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCopyResponse; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadDirectoryEntry; +use codex_app_server_protocol::FsReadDirectoryParams; +use codex_app_server_protocol::FsReadDirectoryResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsUnwatchResponse; +use codex_app_server_protocol::FsWatchParams; +use codex_app_server_protocol::FsWatchResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_exec_server::CopyOptions; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::RemoveOptions; +use std::io; +use std::sync::Arc; + +#[derive(Clone)] +pub(crate) struct FsRequestProcessor { + file_system: Arc, + fs_watch_manager: FsWatchManager, +} + +impl FsRequestProcessor { + pub(crate) fn new( + file_system: Arc, + fs_watch_manager: FsWatchManager, + ) -> Self { + Self { + file_system, + fs_watch_manager, + } + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + self.fs_watch_manager.connection_closed(connection_id).await; + } + + pub(crate) async fn read_file( + &self, + params: FsReadFileParams, + ) -> Result { + let bytes = self + .file_system + .read_file(¶ms.path, /*sandbox*/ None) + .await + .map_err(map_fs_error)?; + Ok(FsReadFileResponse { + data_base64: STANDARD.encode(bytes), + }) + } + + pub(crate) async fn write_file( + &self, + params: FsWriteFileParams, + ) -> Result { + let bytes = STANDARD.decode(params.data_base64).map_err(|err| { + invalid_request(format!( + "fs/writeFile requires valid base64 dataBase64: {err}" + )) + })?; + self.file_system + .write_file(¶ms.path, bytes, /*sandbox*/ None) + .await + .map_err(map_fs_error)?; + Ok(FsWriteFileResponse {}) + } + + pub(crate) async fn create_directory( + &self, + params: FsCreateDirectoryParams, + ) -> Result { + self.file_system + .create_directory( + ¶ms.path, + CreateDirectoryOptions { + recursive: params.recursive.unwrap_or(true), + }, + /*sandbox*/ None, + ) + .await + .map_err(map_fs_error)?; + Ok(FsCreateDirectoryResponse {}) + } + + pub(crate) async fn get_metadata( + &self, + params: FsGetMetadataParams, + ) -> Result { + let metadata = self + .file_system + .get_metadata(¶ms.path, /*sandbox*/ None) + .await + .map_err(map_fs_error)?; + Ok(FsGetMetadataResponse { + is_directory: metadata.is_directory, + is_file: metadata.is_file, + is_symlink: metadata.is_symlink, + created_at_ms: metadata.created_at_ms, + modified_at_ms: metadata.modified_at_ms, + }) + } + + pub(crate) async fn read_directory( + &self, + params: FsReadDirectoryParams, + ) -> Result { + let entries = self + .file_system + .read_directory(¶ms.path, /*sandbox*/ None) + .await + .map_err(map_fs_error)?; + Ok(FsReadDirectoryResponse { + entries: entries + .into_iter() + .map(|entry| FsReadDirectoryEntry { + file_name: entry.file_name, + is_directory: entry.is_directory, + is_file: entry.is_file, + }) + .collect(), + }) + } + + pub(crate) async fn remove( + &self, + params: FsRemoveParams, + ) -> Result { + self.file_system + .remove( + ¶ms.path, + RemoveOptions { + recursive: params.recursive.unwrap_or(true), + force: params.force.unwrap_or(true), + }, + /*sandbox*/ None, + ) + .await + .map_err(map_fs_error)?; + Ok(FsRemoveResponse {}) + } + + pub(crate) async fn copy( + &self, + params: FsCopyParams, + ) -> Result { + self.file_system + .copy( + ¶ms.source_path, + ¶ms.destination_path, + CopyOptions { + recursive: params.recursive, + }, + /*sandbox*/ None, + ) + .await + .map_err(map_fs_error)?; + Ok(FsCopyResponse {}) + } + + pub(crate) async fn watch( + &self, + connection_id: ConnectionId, + params: FsWatchParams, + ) -> Result { + self.fs_watch_manager.watch(connection_id, params).await + } + + pub(crate) async fn unwatch( + &self, + connection_id: ConnectionId, + params: FsUnwatchParams, + ) -> Result { + self.fs_watch_manager.unwatch(connection_id, params).await + } +} + +fn map_fs_error(err: io::Error) -> JSONRPCErrorError { + if err.kind() == io::ErrorKind::InvalidInput { + invalid_request(err.to_string()) + } else { + internal_error(err.to_string()) + } +} diff --git a/code-rs/app-server/src/request_processors/git_processor.rs b/code-rs/app-server/src/request_processors/git_processor.rs new file mode 100644 index 00000000000..b7c5fad6107 --- /dev/null +++ b/code-rs/app-server/src/request_processors/git_processor.rs @@ -0,0 +1,36 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct GitRequestProcessor; + +impl GitRequestProcessor { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) async fn git_diff_to_remote( + &self, + params: GitDiffToRemoteParams, + ) -> Result, JSONRPCErrorError> { + self.git_diff_to_origin(params.cwd) + .await + .map(|response| Some(response.into())) + } + + async fn git_diff_to_origin( + &self, + cwd: PathBuf, + ) -> Result { + git_diff_to_remote(&cwd) + .await + .map(|value| GitDiffToRemoteResponse { + sha: value.sha, + diff: value.diff, + }) + .ok_or_else(|| { + invalid_request(format!( + "failed to compute git diff to remote for cwd: {cwd:?}" + )) + }) + } +} diff --git a/code-rs/app-server/src/request_processors/initialize_processor.rs b/code-rs/app-server/src/request_processors/initialize_processor.rs new file mode 100644 index 00000000000..a206b2faa02 --- /dev/null +++ b/code-rs/app-server/src/request_processors/initialize_processor.rs @@ -0,0 +1,184 @@ +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use axum::http::HeaderValue; +use codex_analytics::AppServerRpcTransport; +use codex_login::default_client::SetOriginatorError; +use codex_login::default_client::USER_AGENT_SUFFIX; +use codex_login::default_client::get_codex_user_agent; +use codex_login::default_client::set_default_client_residency_requirement; +use codex_login::default_client::set_default_originator; + +use super::*; +use crate::message_processor::ConnectionSessionState; +use crate::message_processor::InitializedConnectionSessionState; + +#[derive(Clone)] +pub(crate) struct InitializeRequestProcessor { + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config: Arc, + config_warnings: Arc>, + rpc_transport: AppServerRpcTransport, +} + +impl InitializeRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config: Arc, + config_warnings: Vec, + rpc_transport: AppServerRpcTransport, + ) -> Self { + Self { + outgoing, + analytics_events_client, + config, + config_warnings: Arc::new(config_warnings), + rpc_transport, + } + } + + pub(crate) async fn initialize( + &self, + connection_id: ConnectionId, + request_id: RequestId, + params: InitializeParams, + session: &ConnectionSessionState, + // `Some(...)` means the caller wants initialize to immediately mark the + // connection outbound-ready. Websocket JSON-RPC calls pass `None` so + // lib.rs can deliver connection-scoped initialize notifications first. + outbound_initialized: Option<&AtomicBool>, + ) -> Result { + let connection_request_id = ConnectionRequestId { + connection_id, + request_id, + }; + if session.initialized() { + return Err(invalid_request("Already initialized")); + } + + // TODO(maxj): Revisit capability scoping for `experimental_api_enabled`. + // Current behavior is per-connection. Reviewer feedback notes this can + // create odd cross-client behavior (for example dynamic tool calls on a + // shared thread when another connected client did not opt into + // experimental API). Proposed direction is instance-global first-write-wins + // with initialize-time mismatch rejection. + let analytics_initialize_params = params.clone(); + let (experimental_api_enabled, opt_out_notification_methods) = match params.capabilities { + Some(capabilities) => ( + capabilities.experimental_api, + capabilities + .opt_out_notification_methods + .unwrap_or_default(), + ), + None => (false, Vec::new()), + }; + let ClientInfo { + name, + title: _title, + version, + } = params.client_info; + // Validate before committing; set_default_originator validates while + // mutating process-global metadata. + if HeaderValue::from_str(&name).is_err() { + return Err(invalid_request(format!( + "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." + ))); + } + let originator = name.clone(); + let user_agent_suffix = format!("{name}; {version}"); + let codex_home = self.config.codex_home.clone(); + if session + .initialize(InitializedConnectionSessionState { + experimental_api_enabled, + opted_out_notification_methods: opt_out_notification_methods.into_iter().collect(), + app_server_client_name: name.clone(), + client_version: version, + }) + .is_err() + { + return Err(invalid_request("Already initialized")); + } + + // Only the request that wins session initialization may mutate + // process-global client metadata. + if let Err(error) = set_default_originator(originator.clone()) { + match error { + SetOriginatorError::InvalidHeaderValue => { + tracing::warn!( + client_info_name = %name, + "validated clientInfo.name was rejected while setting originator" + ); + } + SetOriginatorError::AlreadyInitialized => { + // No-op. This is expected to happen if the originator is already set via env var. + // TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE, + // this will be an unexpected state and we can return a JSON-RPC error indicating + // internal server error. + } + } + } + self.analytics_events_client.track_initialize( + connection_id.0, + analytics_initialize_params, + originator, + self.rpc_transport, + ); + set_default_client_residency_requirement(self.config.enforce_residency.value()); + if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + *suffix = Some(user_agent_suffix); + } + + let user_agent = get_codex_user_agent(); + let response = InitializeResponse { + user_agent, + codex_home, + platform_family: std::env::consts::FAMILY.to_string(), + platform_os: std::env::consts::OS.to_string(), + }; + + self.outgoing + .send_response(connection_request_id, response) + .await; + + if let Some(outbound_initialized) = outbound_initialized { + outbound_initialized.store(true, Ordering::Release); + return Ok(true); + } + + Ok(false) + } + + pub(crate) async fn send_initialize_notifications_to_connection( + &self, + connection_id: ConnectionId, + ) { + for notification in self.config_warnings.iter().cloned() { + self.outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::ConfigWarning(notification), + ) + .await; + } + } + + pub(crate) async fn send_initialize_notifications(&self) { + for notification in self.config_warnings.iter().cloned() { + self.outgoing + .send_server_notification(ServerNotification::ConfigWarning(notification)) + .await; + } + } + + pub(crate) fn track_initialized_request( + &self, + connection_id: ConnectionId, + request_id: RequestId, + request: &ClientRequest, + ) { + self.analytics_events_client + .track_request(connection_id.0, request_id, request); + } +} diff --git a/code-rs/app-server/src/request_processors/marketplace_processor.rs b/code-rs/app-server/src/request_processors/marketplace_processor.rs new file mode 100644 index 00000000000..1a095074180 --- /dev/null +++ b/code-rs/app-server/src/request_processors/marketplace_processor.rs @@ -0,0 +1,137 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct MarketplaceRequestProcessor { + config: Arc, + config_manager: ConfigManager, + thread_manager: Arc, +} + +impl MarketplaceRequestProcessor { + pub(crate) fn new( + config: Arc, + config_manager: ConfigManager, + thread_manager: Arc, + ) -> Self { + Self { + config, + config_manager, + thread_manager, + } + } + + pub(crate) async fn marketplace_add( + &self, + params: MarketplaceAddParams, + ) -> Result, JSONRPCErrorError> { + self.marketplace_add_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn marketplace_remove( + &self, + params: MarketplaceRemoveParams, + ) -> Result, JSONRPCErrorError> { + self.marketplace_remove_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn marketplace_upgrade( + &self, + params: MarketplaceUpgradeParams, + ) -> Result, JSONRPCErrorError> { + self.marketplace_upgrade_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + async fn marketplace_remove_inner( + &self, + params: MarketplaceRemoveParams, + ) -> Result { + remove_marketplace( + self.config.codex_home.to_path_buf(), + CoreMarketplaceRemoveRequest { + marketplace_name: params.marketplace_name, + }, + ) + .await + .map(|outcome| MarketplaceRemoveResponse { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.removed_installed_root, + }) + .map_err(|err| match err { + MarketplaceRemoveError::InvalidRequest(message) => invalid_request(message), + MarketplaceRemoveError::Internal(message) => internal_error(message), + }) + } + + async fn marketplace_upgrade_response_inner( + &self, + params: MarketplaceUpgradeParams, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let plugins_manager = self.thread_manager.plugins_manager(); + let MarketplaceUpgradeParams { marketplace_name } = params; + let plugins_input = config.plugins_config_input(); + + let outcome = tokio::task::spawn_blocking(move || { + plugins_manager.upgrade_configured_marketplaces_for_config( + &plugins_input, + marketplace_name.as_deref(), + ) + }) + .await + .map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))? + .map_err(invalid_request)?; + + Ok(MarketplaceUpgradeResponse { + selected_marketplaces: outcome.selected_marketplaces, + upgraded_roots: outcome.upgraded_roots, + errors: outcome + .errors + .into_iter() + .map(|err| MarketplaceUpgradeErrorInfo { + marketplace_name: err.marketplace_name, + message: err.message, + }) + .collect(), + }) + } + + async fn marketplace_add_inner( + &self, + params: MarketplaceAddParams, + ) -> Result { + add_marketplace_to_codex_home( + self.config.codex_home.to_path_buf(), + MarketplaceAddRequest { + source: params.source, + ref_name: params.ref_name, + sparse_paths: params.sparse_paths.unwrap_or_default(), + }, + ) + .await + .map(|outcome| MarketplaceAddResponse { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.installed_root, + already_added: outcome.already_added, + }) + .map_err(|err| match err { + MarketplaceAddError::InvalidRequest(message) => invalid_request(message), + MarketplaceAddError::Internal(message) => internal_error(message), + }) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } +} diff --git a/code-rs/app-server/src/request_processors/mcp_processor.rs b/code-rs/app-server/src/request_processors/mcp_processor.rs new file mode 100644 index 00000000000..243506f6afd --- /dev/null +++ b/code-rs/app-server/src/request_processors/mcp_processor.rs @@ -0,0 +1,459 @@ +use super::*; + +const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; + +#[derive(Clone)] +pub(crate) struct McpRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, +} + +impl McpRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + config_manager, + } + } + + pub(crate) async fn mcp_server_oauth_login( + &self, + params: McpServerOauthLoginParams, + ) -> Result, JSONRPCErrorError> { + self.mcp_server_oauth_login_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn mcp_server_refresh( + &self, + params: Option<()>, + ) -> Result, JSONRPCErrorError> { + self.mcp_server_refresh_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn mcp_server_status_list( + &self, + request_id: &ConnectionRequestId, + params: ListMcpServerStatusParams, + ) -> Result, JSONRPCErrorError> { + self.list_mcp_server_status(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn mcp_resource_read( + &self, + request_id: &ConnectionRequestId, + params: McpResourceReadParams, + ) -> Result, JSONRPCErrorError> { + self.read_mcp_resource(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn mcp_server_tool_call( + &self, + request_id: &ConnectionRequestId, + params: McpServerToolCallParams, + ) -> Result, JSONRPCErrorError> { + self.call_mcp_server_tool(request_id, params) + .await + .map(|()| None) + } + + async fn mcp_server_refresh_response( + &self, + _params: Option<()>, + ) -> Result { + crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager) + .await + .map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?; + Ok(McpServerRefreshResponse {}) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + + async fn mcp_server_oauth_login_response( + &self, + params: McpServerOauthLoginParams, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let McpServerOauthLoginParams { + name, + scopes, + timeout_secs, + } = params; + + let configured_servers = self + .thread_manager + .mcp_manager() + .configured_servers(&config) + .await; + let Some(server) = configured_servers.get(&name) else { + return Err(invalid_request(format!( + "No MCP server named '{name}' found." + ))); + }; + + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), + _ => { + return Err(invalid_request( + "OAuth login is only supported for streamable HTTP servers.", + )); + } + }; + + let discovered_scopes = if scopes.is_none() && server.scopes.is_none() { + discover_supported_scopes(&server.transport).await + } else { + None + }; + let resolved_scopes = + resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes); + + let handle = perform_oauth_login_return_url( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + &resolved_scopes.scopes, + server.oauth_resource.as_deref(), + timeout_secs, + config.mcp_oauth_callback_port, + config.mcp_oauth_callback_url.as_deref(), + ) + .await + .map_err(|err| internal_error(format!("failed to login to MCP server '{name}': {err}")))?; + let authorization_url = handle.authorization_url().to_string(); + let notification_name = name.clone(); + let outgoing = Arc::clone(&self.outgoing); + + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + + Ok(McpServerOauthLoginResponse { authorization_url }) + } + + async fn list_mcp_server_status( + &self, + request_id: &ConnectionRequestId, + params: ListMcpServerStatusParams, + ) -> Result<(), JSONRPCErrorError> { + let request = request_id.clone(); + + let outgoing = Arc::clone(&self.outgoing); + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mcp_config = config + .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) + .await; + let auth = self.auth_manager.auth().await; + let environment_manager = self.thread_manager.environment_manager(); + let runtime_environment = match environment_manager.default_environment() { + Some(environment) => { + // Status listing has no turn cwd. This fallback is used only + // by executor-backed stdio MCPs whose config omits `cwd`. + McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) + } + None => McpRuntimeEnvironment::new( + environment_manager.local_environment(), + config.cwd.to_path_buf(), + ), + }; + + tokio::spawn(async move { + Self::list_mcp_server_status_task( + outgoing, + request, + params, + config, + mcp_config, + auth, + runtime_environment, + ) + .await; + }); + Ok(()) + } + + async fn list_mcp_server_status_task( + outgoing: Arc, + request_id: ConnectionRequestId, + params: ListMcpServerStatusParams, + config: Config, + mcp_config: codex_mcp::McpConfig, + auth: Option, + runtime_environment: McpRuntimeEnvironment, + ) { + let result = Self::list_mcp_server_status_response( + request_id.request_id.to_string(), + params, + config, + mcp_config, + auth, + runtime_environment, + ) + .await; + outgoing.send_result(request_id, result).await; + } + + async fn list_mcp_server_status_response( + request_id: String, + params: ListMcpServerStatusParams, + config: Config, + mcp_config: codex_mcp::McpConfig, + auth: Option, + runtime_environment: McpRuntimeEnvironment, + ) -> Result { + let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { + McpServerStatusDetail::Full => McpSnapshotDetail::Full, + McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, + }; + + let snapshot = collect_mcp_server_status_snapshot_with_detail( + &mcp_config, + auth.as_ref(), + request_id, + runtime_environment, + detail, + ) + .await; + + let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); + let McpServerStatusSnapshot { + tools_by_server, + resources, + resource_templates, + auth_statuses, + } = snapshot; + + let mut server_names: Vec = config + .mcp_servers + .keys() + .cloned() + // Include built-in/plugin MCP servers that are present in the + // effective runtime config even when they are not user-declared in + // `config.mcp_servers`. + .chain(effective_servers.keys().cloned()) + .chain(auth_statuses.keys().cloned()) + .chain(resources.keys().cloned()) + .chain(resource_templates.keys().cloned()) + .collect(); + server_names.sort(); + server_names.dedup(); + + let total = server_names.len(); + let limit = params.limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = limit.min(total); + let start = match params.cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total MCP servers {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + + let data: Vec = server_names[start..end] + .iter() + .map(|name| McpServerStatus { + name: name.clone(), + tools: tools_by_server.get(name).cloned().unwrap_or_default(), + resources: resources.get(name).cloned().unwrap_or_default(), + resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), + auth_status: auth_statuses + .get(name) + .cloned() + .unwrap_or(CoreMcpAuthStatus::Unsupported) + .into(), + }) + .collect(); + + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + Ok(ListMcpServerStatusResponse { data, next_cursor }) + } + + async fn read_mcp_resource( + &self, + request_id: &ConnectionRequestId, + params: McpResourceReadParams, + ) -> Result<(), JSONRPCErrorError> { + let outgoing = Arc::clone(&self.outgoing); + let McpResourceReadParams { + thread_id, + server, + uri, + } = params; + + if let Some(thread_id) = thread_id { + let (_, thread) = self.load_thread(&thread_id).await?; + let request_id = request_id.clone(); + + tokio::spawn(async move { + let result = thread.read_mcp_resource(&server, &uri).await; + Self::send_mcp_resource_read_response(outgoing, request_id, result).await; + }); + return Ok(()); + } + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mcp_config = config + .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) + .await; + let auth = self.auth_manager.auth().await; + let runtime_environment = { + let environment_manager = self.thread_manager.environment_manager(); + let environment = environment_manager + .default_environment() + .unwrap_or_else(|| environment_manager.local_environment()); + // Resource reads without a thread have no turn cwd. This fallback + // is used only by executor-backed stdio MCPs whose config omits `cwd`. + McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) + }; + let request_id = request_id.clone(); + + tokio::spawn(async move { + let result = read_mcp_resource_without_thread( + &mcp_config, + auth.as_ref(), + runtime_environment, + &server, + &uri, + ) + .await + .and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from)); + Self::send_mcp_resource_read_response(outgoing, request_id, result).await; + }); + Ok(()) + } + + async fn send_mcp_resource_read_response( + outgoing: Arc, + request_id: ConnectionRequestId, + result: anyhow::Result, + ) { + let result = result + .map_err(|error| internal_error(format!("{error:#}"))) + .and_then(|result| { + serde_json::from_value::(result).map_err(|error| { + internal_error(format!( + "failed to deserialize MCP resource read response: {error}" + )) + }) + }); + outgoing.send_result(request_id, result).await; + } + + async fn call_mcp_server_tool( + &self, + request_id: &ConnectionRequestId, + params: McpServerToolCallParams, + ) -> Result<(), JSONRPCErrorError> { + let outgoing = Arc::clone(&self.outgoing); + let thread_id = params.thread_id.clone(); + let (_, thread) = self.load_thread(&thread_id).await?; + let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id); + let request_id = request_id.clone(); + + tokio::spawn(async move { + let result = thread + .call_mcp_tool(¶ms.server, ¶ms.tool, params.arguments, meta) + .await + .map(McpServerToolCallResponse::from) + .map_err(|error| internal_error(format!("{error:#}"))); + outgoing.send_result(request_id, result).await; + }); + Ok(()) + } +} + +fn with_mcp_tool_call_thread_id_meta( + meta: Option, + thread_id: &str, +) -> Option { + match meta { + Some(serde_json::Value::Object(mut map)) => { + map.insert( + MCP_TOOL_THREAD_ID_META_KEY.to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + Some(serde_json::Value::Object(map)) + } + None => { + let mut map = serde_json::Map::new(); + map.insert( + MCP_TOOL_THREAD_ID_META_KEY.to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + Some(serde_json::Value::Object(map)) + } + other => other, + } +} diff --git a/code-rs/app-server/src/request_processors/plugins.rs b/code-rs/app-server/src/request_processors/plugins.rs new file mode 100644 index 00000000000..65bb390851a --- /dev/null +++ b/code-rs/app-server/src/request_processors/plugins.rs @@ -0,0 +1,1615 @@ +use super::*; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_config::types::McpServerConfig; +use codex_core_plugins::remote::is_valid_remote_plugin_id; +use codex_core_plugins::remote::validate_remote_plugin_id; +use codex_mcp::McpOAuthLoginSupport; +use codex_mcp::oauth_login_support; +use codex_mcp::should_retry_without_scopes; +use codex_rmcp_client::perform_oauth_login_silent; + +#[derive(Clone)] +pub(crate) struct PluginRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config_manager: ConfigManager, + workspace_settings_cache: Arc, +} + +fn plugin_skills_to_info( + skills: &[codex_core::skills::SkillMetadata], + disabled_skill_paths: &HashSet, +) -> Vec { + skills + .iter() + .map(|skill| SkillSummary { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill.short_description.clone(), + interface: skill.interface.clone().map(|interface| { + codex_app_server_protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), + path: Some(skill.path_to_skills_md.clone()), + enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md), + }) + .collect() +} + +fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { + PluginInterface { + display_name: interface.display_name, + short_description: interface.short_description, + long_description: interface.long_description, + developer_name: interface.developer_name, + category: interface.category, + capabilities: interface.capabilities, + website_url: interface.website_url, + privacy_policy_url: interface.privacy_policy_url, + terms_of_service_url: interface.terms_of_service_url, + default_prompt: interface.default_prompt, + brand_color: interface.brand_color, + composer_icon: interface.composer_icon, + composer_icon_url: None, + logo: interface.logo, + logo_url: None, + screenshots: interface.screenshots, + screenshot_urls: Vec::new(), + } +} + +fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource { + match source { + MarketplacePluginSource::Local { path } => PluginSource::Local { path }, + MarketplacePluginSource::Git { + url, + path, + ref_name, + sha, + } => PluginSource::Git { + url, + path, + ref_name, + sha, + }, + } +} + +fn load_shared_plugin_ids_by_local_path( + config: &Config, +) -> std::collections::BTreeMap { + codex_core_plugins::remote::load_plugin_share_remote_ids_by_local_path( + config.codex_home.as_path(), + ) + .unwrap_or_else(|err| { + warn!("failed to load plugin share local path mapping: {err}"); + std::collections::BTreeMap::new() + }) +} + +fn share_context_for_source( + source: &MarketplacePluginSource, + shared_plugin_ids_by_local_path: &std::collections::BTreeMap, +) -> Option { + match source { + MarketplacePluginSource::Local { path } => shared_plugin_ids_by_local_path + .get(path) + .cloned() + .map(|remote_plugin_id| PluginShareContext { + remote_plugin_id, + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_targets: None, + }), + MarketplacePluginSource::Git { .. } => None, + } +} + +fn remote_plugin_share_discoverability( + discoverability: PluginShareDiscoverability, +) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { + match discoverability { + PluginShareDiscoverability::Listed => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed + } + PluginShareDiscoverability::Unlisted => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted + } + PluginShareDiscoverability::Private => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Private + } + } +} + +fn remote_plugin_share_update_discoverability( + discoverability: PluginShareUpdateDiscoverability, +) -> codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability { + match discoverability { + PluginShareUpdateDiscoverability::Unlisted => { + codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability::Unlisted + } + PluginShareUpdateDiscoverability::Private => { + codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability::Private + } + } +} + +fn validate_client_plugin_share_targets( + targets: &[PluginShareTarget], +) -> Result<(), JSONRPCErrorError> { + if targets + .iter() + .any(|target| target.principal_type == PluginSharePrincipalType::Workspace) + { + return Err(invalid_request( + "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access", + )); + } + Ok(()) +} + +fn remote_plugin_share_targets( + targets: Vec, +) -> Vec { + targets + .into_iter() + .map( + |target| codex_core_plugins::remote::RemotePluginShareTarget { + principal_type: match target.principal_type { + PluginSharePrincipalType::User => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User + } + PluginSharePrincipalType::Group => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group + } + PluginSharePrincipalType::Workspace => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace + } + }, + principal_id: target.principal_id, + }, + ) + .collect() +} + +fn plugin_share_principal_from_remote( + principal: codex_core_plugins::remote::RemotePluginSharePrincipal, +) -> PluginSharePrincipal { + PluginSharePrincipal { + principal_type: match principal.principal_type { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User => { + PluginSharePrincipalType::User + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => { + PluginSharePrincipalType::Group + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => { + PluginSharePrincipalType::Workspace + } + }, + principal_id: principal.principal_id, + name: principal.name, + } +} + +impl PluginRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config_manager: ConfigManager, + workspace_settings_cache: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + analytics_events_client, + config_manager, + workspace_settings_cache, + } + } + + pub(crate) async fn plugin_list( + &self, + params: PluginListParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_read( + &self, + params: PluginReadParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_read_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_skill_read( + &self, + params: PluginSkillReadParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_skill_read_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_save( + &self, + params: PluginShareSaveParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_save_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_update_targets( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_update_targets_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_list( + &self, + params: PluginShareListParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_delete( + &self, + params: PluginShareDeleteParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_delete_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_install( + &self, + params: PluginInstallParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_install_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_uninstall( + &self, + params: PluginUninstallParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_uninstall_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) fn effective_plugins_changed_callback(&self) -> Arc { + let thread_manager = Arc::clone(&self.thread_manager); + let config_manager = self.config_manager.clone(); + Arc::new(move || { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&thread_manager), + config_manager.clone(), + ); + }) + } + + fn on_effective_plugins_changed(&self) { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&self.thread_manager), + self.config_manager.clone(), + ); + } + + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { + tokio::spawn(async move { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); + if thread_manager.list_thread_ids().await.is_empty() { + return; + } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; + }); + } + + fn clear_plugin_related_caches(&self) { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } + + async fn plugin_list_response( + &self, + params: PluginListParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginListParams { + cwds, + marketplace_kinds, + } = params; + let roots = cwds.unwrap_or_default(); + let explicit_marketplace_kinds = marketplace_kinds.is_some(); + let marketplace_kinds = + marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]); + let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local); + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let empty_response = || PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }; + if !config.features.enabled(Feature::Plugins) { + return Ok(empty_response()); + } + let auth = self.auth_manager.auth().await; + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(empty_response()); + } + let plugins_input = config.plugins_config_input(); + let (mut data, marketplace_load_errors) = if include_local { + plugins_manager.maybe_start_plugin_list_background_tasks_for_config( + &plugins_input, + auth.clone(), + &roots, + Some(self.effective_plugins_changed_callback()), + ); + + let config_for_marketplace_listing = plugins_input.clone(); + let plugins_manager_for_marketplace_listing = plugins_manager.clone(); + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config); + match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager_for_marketplace_listing + .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces + .into_iter() + .map(|marketplace| PluginMarketplaceEntry { + name: marketplace.name, + path: Some(marketplace.path), + interface: marketplace.interface.map(|interface| { + MarketplaceInterface { + display_name: interface.display_name, + } + }), + plugins: marketplace + .plugins + .into_iter() + .map(|plugin| { + let share_context = share_context_for_source( + &plugin.source, + &shared_plugin_ids_by_local_path, + ); + PluginSummary { + id: plugin.id, + installed: plugin.installed, + enabled: plugin.enabled, + name: plugin.name, + share_context, + source: marketplace_plugin_source_to_info(plugin.source), + install_policy: plugin.policy.installation.into(), + auth_policy: plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: plugin + .interface + .map(local_plugin_interface_to_info), + keywords: plugin.keywords, + } + }) + .collect(), + }) + .collect(), + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) + }) + .await + { + Ok(Ok(outcome)) => outcome, + Ok(Err(err)) => { + return Err(Self::marketplace_error(err, "list marketplace plugins")); + } + Err(err) => { + return Err(internal_error(format!( + "failed to list marketplace plugins: {err}" + ))); + } + } + } else { + (Vec::new(), Vec::new()) + }; + + let mut remote_sources = Vec::new(); + if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) { + remote_sources.push(RemoteMarketplaceSource::Global); + } + if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { + remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); + } + if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) { + remote_sources.push(RemoteMarketplaceSource::SharedWithMe); + } + if !remote_sources.is_empty() { + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + match codex_core_plugins::remote::fetch_remote_marketplaces( + &remote_plugin_service_config, + auth.as_ref(), + &remote_sources, + ) + .await + { + Ok(remote_marketplaces) => { + for remote_marketplace in remote_marketplaces + .into_iter() + .map(remote_marketplace_to_info) + { + if let Some(existing) = data + .iter_mut() + .find(|marketplace| marketplace.name == remote_marketplace.name) + { + *existing = remote_marketplace; + } else { + data.push(remote_marketplace); + } + } + } + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "plugin/list remote plugin catalog fetch failed; returning local marketplaces only" + ); + } + } + } + + let featured_plugin_ids = if data + .iter() + .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + { + match plugins_manager + .featured_plugin_ids_for_config(&plugins_input, auth.as_ref()) + .await + { + Ok(featured_plugin_ids) => featured_plugin_ids, + Err(err) => { + warn!( + error = %err, + "plugin/list featured plugin fetch failed; returning empty featured ids" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + + Ok(PluginListResponse { + marketplaces: data, + marketplace_load_errors, + featured_plugin_ids, + }) + } + + async fn plugin_read_response( + &self, + params: PluginReadParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let read_source = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => Ok(marketplace_path), + (None, Some(remote_marketplace_name)) => Err(remote_marketplace_name), + (Some(_), Some(_)) | (None, None) => { + return Err(invalid_request( + "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName", + )); + } + }; + let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| { + marketplace_path.as_path().parent().map(Path::to_path_buf) + }); + + let config = self.load_latest_config(config_cwd).await?; + let plugins_input = config.plugins_config_input(); + + let plugin = match read_source { + Ok(marketplace_path) => { + let request = PluginReadRequest { + plugin_name, + marketplace_path, + }; + let outcome = plugins_manager + .read_plugin_for_config(&plugins_input, &request) + .await + .map_err(|err| Self::marketplace_error(err, "read plugin details"))?; + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config); + let share_context = share_context_for_source( + &outcome.plugin.source, + &shared_plugin_ids_by_local_path, + ); + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = + load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager) + .await; + let visible_skills = outcome + .plugin + .skills + .iter() + .filter(|skill| { + skill.matches_product_restriction_for_product( + self.thread_manager.session_source().restriction_product(), + ) + }) + .cloned() + .collect::>(); + PluginDetail { + marketplace_name: outcome.marketplace_name, + marketplace_path: outcome.marketplace_path, + summary: PluginSummary { + id: outcome.plugin.id, + name: outcome.plugin.name, + share_context, + source: marketplace_plugin_source_to_info(outcome.plugin.source), + installed: outcome.plugin.installed, + enabled: outcome.plugin.enabled, + install_policy: outcome.plugin.policy.installation.into(), + auth_policy: outcome.plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: outcome.plugin.interface.map(local_plugin_interface_to_info), + keywords: outcome.plugin.keywords, + }, + description: outcome.plugin.description, + skills: plugin_skills_to_info( + &visible_skills, + &outcome.plugin.disabled_skill_paths, + ), + hooks: outcome + .plugin + .hooks + .into_iter() + .map(|hook| codex_app_server_protocol::PluginHookSummary { + key: hook.key, + event_name: hook.event_name.into(), + }) + .collect(), + apps: app_summaries, + mcp_servers: outcome.plugin.mcp_server_names, + } + } + Err(remote_marketplace_name) => { + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request(format!( + "remote plugin read is not enabled for marketplace {remote_marketplace_name}" + ))); + } + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + validate_remote_plugin_id(&plugin_name)?; + let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &plugin_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details") + })?; + let plugin_apps = remote_detail + .app_ids + .iter() + .cloned() + .map(codex_plugin::AppConnectorId) + .collect::>(); + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = + load_plugin_app_summaries(&config, &plugin_apps, &environment_manager).await; + remote_plugin_detail_to_info(remote_detail, app_summaries) + } + }; + + Ok(PluginReadResponse { plugin }) + } + + async fn plugin_skill_read_response( + &self, + params: PluginSkillReadParams, + ) -> Result { + let PluginSkillReadParams { + remote_marketplace_name, + remote_plugin_id, + skill_name, + } = params; + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request(format!( + "remote plugin skill read is not enabled for marketplace {remote_marketplace_name}" + ))); + } + validate_remote_plugin_id(&remote_plugin_id)?; + if skill_name.is_empty() { + return Err(invalid_request( + "invalid remote plugin skill name: cannot be empty", + )); + } + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &remote_plugin_id, + &skill_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details") + })?; + + Ok(PluginSkillReadResponse { + contents: remote_skill_detail.contents, + }) + } + + async fn plugin_share_save_response( + &self, + params: PluginShareSaveParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareSaveParams { + plugin_path, + remote_plugin_id, + discoverability, + share_targets, + } = params; + if let Some(remote_plugin_id) = remote_plugin_id.as_ref() + && (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id)) + { + return Err(invalid_request("invalid remote plugin id")); + } + if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) { + return Err(invalid_request( + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share settings", + )); + } + if discoverability == Some(PluginShareDiscoverability::Listed) { + return Err(invalid_request( + "discoverability LISTED is not supported for plugin/share/save; use UNLISTED or PRIVATE", + )); + } + if let Some(share_targets) = share_targets.as_ref() { + validate_client_plugin_share_targets(share_targets)?; + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { + discoverability: discoverability.map(remote_plugin_share_discoverability), + share_targets: share_targets.map(remote_plugin_share_targets), + }; + let result = codex_core_plugins::remote::save_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + &plugin_path, + remote_plugin_id.as_deref(), + access_policy, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; + let remote_plugin_id = result.remote_plugin_id; + self.clear_plugin_related_caches(); + Ok(PluginShareSaveResponse { + remote_plugin_id, + share_url: result.share_url.unwrap_or_default(), + }) + } + + async fn plugin_share_update_targets_response( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareUpdateTargetsParams { + remote_plugin_id, + discoverability, + share_targets, + } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + validate_client_plugin_share_targets(&share_targets)?; + let requested_share_targets = share_targets.clone(); + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let result = codex_core_plugins::remote::update_remote_plugin_share_targets( + &remote_plugin_service_config, + auth.as_ref(), + &remote_plugin_id, + remote_plugin_share_targets(share_targets), + remote_plugin_share_update_discoverability(discoverability), + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets") + })?; + self.clear_plugin_related_caches(); + Ok(PluginShareUpdateTargetsResponse { + principals: result + .principals + .into_iter() + .map(plugin_share_principal_from_remote) + .filter(|principal| { + requested_share_targets.iter().any(|target| { + target.principal_type == principal.principal_type + && target.principal_id == principal.principal_id + }) + }) + .collect(), + discoverability: remote_plugin_share_discoverability_to_info(result.discoverability), + }) + } + + async fn plugin_share_list_response( + &self, + _params: PluginShareListParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let data = codex_core_plugins::remote::list_remote_plugin_shares( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))? + .into_iter() + .map(|summary| { + let RemoteCatalogPluginShareSummary { + summary, + share_url, + local_plugin_path, + } = summary; + let plugin = remote_plugin_summary_to_info(summary); + PluginShareListItem { + plugin, + share_url: share_url.unwrap_or_default(), + local_plugin_path, + } + }) + .collect(); + Ok(PluginShareListResponse { data }) + } + + async fn plugin_share_delete_response( + &self, + params: PluginShareDeleteParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareDeleteParams { remote_plugin_id } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + codex_core_plugins::remote::delete_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "delete remote plugin share"))?; + self.clear_plugin_related_caches(); + Ok(PluginShareDeleteResponse {}) + } + + async fn load_plugin_share_config_and_auth( + &self, + ) -> Result<(Config, Option), JSONRPCErrorError> { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request("plugin sharing is not enabled")); + } + let auth = self.auth_manager.auth().await; + Ok((config, auth)) + } + + async fn plugin_install_response( + &self, + params: PluginInstallParams, + ) -> Result { + let PluginInstallParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + return self + .remote_plugin_install_response(remote_marketplace_name, plugin_name) + .await; + } + (Some(_), Some(_)) | (None, None) => { + return Err(invalid_request( + "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName", + )); + } + }; + let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); + let config = self.load_latest_config(config_cwd.clone()).await?; + let auth = self.auth_manager.auth().await; + + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Err(invalid_request( + "Codex plugins are disabled for this workspace", + )); + } + + let plugins_manager = self.thread_manager.plugins_manager(); + let request = PluginInstallRequest { + plugin_name, + marketplace_path, + }; + + let result = plugins_manager + .install_plugin(request) + .await + .map_err(Self::plugin_install_error)?; + let config = match self.load_latest_config(config_cwd).await { + Ok(config) => config, + Err(err) => { + warn!( + "failed to reload config after plugin install, using current config: {err:?}" + ); + config + } + }; + + self.on_effective_plugins_changed(); + + let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; + if !plugin_mcp_servers.is_empty() { + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + let auth = self.auth_manager.auth().await; + let apps_needing_auth = self + .plugin_apps_needing_auth_for_install( + &config, + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + &result.plugin_id.as_key(), + &plugin_apps, + ) + .await; + + Ok(PluginInstallResponse { + auth_policy: result.auth_policy.into(), + apps_needing_auth, + }) + } + + async fn remote_plugin_install_response( + &self, + remote_marketplace_name: String, + remote_plugin_id: String, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request(format!( + "remote plugin install is not enabled for marketplace {remote_marketplace_name}" + ))); + } + validate_remote_plugin_id(&remote_plugin_id)?; + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let remote_detail = + codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &remote_plugin_id, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc( + err, + "read remote plugin details before install", + ) + })?; + if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { + let remote_plugin_id = &remote_detail.summary.id; + return Err(invalid_request(format!( + "remote plugin {remote_plugin_id} is disabled by admin" + ))); + } + if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { + return Err(invalid_request(format!( + "remote plugin {remote_plugin_id} is not available for install" + ))); + } + let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); + // Direct install writes the same cache tree that installed-plugin sync + // prunes before the backend installed snapshot can include this plugin. + let _remote_plugin_cache_mutation = + codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight( + config.codex_home.as_path(), + &actual_remote_marketplace_name, + &remote_detail.summary.name, + ); + let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( + &remote_plugin_id, + &actual_remote_marketplace_name, + &remote_detail.summary.name, + remote_detail.release_version.as_deref(), + remote_detail.bundle_download_url.as_deref(), + ) + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + + let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( + config.codex_home.to_path_buf(), + validated_bundle, + ) + .await + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + + // Cache first so a backend install cannot succeed when local materialization fails. + // If this backend call fails, the cache entry is harmless because remote installed state + // is still backend-gated. + codex_core_plugins::remote::install_remote_plugin( + &remote_plugin_service_config, + auth.as_ref(), + &actual_remote_marketplace_name, + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + + self.thread_manager + .plugins_manager() + .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( + &config.plugins_config_input(), + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); + + let mut plugin_metadata = + plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; + plugin_metadata.remote_plugin_id = Some(remote_plugin_id); + self.analytics_events_client + .track_plugin_installed(plugin_metadata); + + let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; + if !plugin_mcp_servers.is_empty() { + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + let apps_needing_auth = self + .plugin_apps_needing_auth_for_install( + &config, + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + &result.plugin_id.as_key(), + &plugin_apps, + ) + .await; + + Ok(PluginInstallResponse { + auth_policy: remote_detail.summary.auth_policy, + apps_needing_auth, + }) + } + + async fn plugin_apps_needing_auth_for_install( + &self, + config: &Config, + is_chatgpt_auth: bool, + plugin_id: &str, + plugin_apps: &[codex_plugin::AppConnectorId], + ) -> Vec { + if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) { + return Vec::new(); + } + + let environment_manager = self.thread_manager.environment_manager(); + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + config, + /*force_refetch*/ true, + &environment_manager + ), + ); + + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + warn!( + plugin = plugin_id, + "failed to load app metadata after plugin install: {err:#}" + ); + connectors::list_cached_all_connectors(config) + .await + .unwrap_or_default() + } + }; + let all_connectors = connectors::connectors_for_plugin_apps(all_connectors, plugin_apps); + let (accessible_connectors, codex_apps_ready) = match accessible_connectors_result { + Ok(status) => (status.connectors, status.codex_apps_ready), + Err(err) => { + warn!( + plugin = plugin_id, + "failed to load accessible apps after plugin install: {err:#}" + ); + ( + connectors::list_cached_accessible_connectors_from_mcp_tools(config) + .await + .unwrap_or_default(), + false, + ) + } + }; + if !codex_apps_ready { + warn!( + plugin = plugin_id, + "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" + ); + } + + plugin_apps_needing_auth( + &all_connectors, + &accessible_connectors, + plugin_apps, + codex_apps_ready, + ) + } + + async fn start_plugin_mcp_oauth_logins( + &self, + config: &Config, + plugin_mcp_servers: HashMap, + ) { + for (name, server) in plugin_mcp_servers { + let oauth_config = match oauth_login_support(&server.transport).await { + McpOAuthLoginSupport::Supported(config) => config, + McpOAuthLoginSupport::Unsupported => continue, + McpOAuthLoginSupport::Unknown(err) => { + warn!( + "MCP server may or may not require login for plugin install {name}: {err}" + ); + continue; + } + }; + + let resolved_scopes = resolve_oauth_scopes( + /*explicit_scopes*/ None, + server.scopes.clone(), + oauth_config.discovered_scopes.clone(), + ); + + let store_mode = config.mcp_oauth_credentials_store_mode; + let callback_port = config.mcp_oauth_callback_port; + let callback_url = config.mcp_oauth_callback_url.clone(); + let outgoing = Arc::clone(&self.outgoing); + let notification_name = name.clone(); + + tokio::spawn(async move { + let first_attempt = perform_oauth_login_silent( + &name, + &oauth_config.url, + store_mode, + oauth_config.http_headers.clone(), + oauth_config.env_http_headers.clone(), + &resolved_scopes.scopes, + server.oauth_resource.as_deref(), + callback_port, + callback_url.as_deref(), + ) + .await; + + let final_result = match first_attempt { + Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => { + perform_oauth_login_silent( + &name, + &oauth_config.url, + store_mode, + oauth_config.http_headers, + oauth_config.env_http_headers, + &[], + server.oauth_resource.as_deref(), + callback_port, + callback_url.as_deref(), + ) + .await + } + result => result, + }; + + let (success, error) = match final_result { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + } + } + + async fn plugin_uninstall_response( + &self, + params: PluginUninstallParams, + ) -> Result { + let PluginUninstallParams { plugin_id } = params; + if codex_plugin::PluginId::parse(&plugin_id).is_err() + && !is_valid_remote_plugin_id(&plugin_id) + { + return Err(invalid_request("invalid remote plugin id")); + } + if is_valid_remote_plugin_id(&plugin_id) { + return self.remote_plugin_uninstall_response(plugin_id).await; + } + let plugins_manager = self.thread_manager.plugins_manager(); + + plugins_manager + .uninstall_plugin(plugin_id) + .await + .map_err(Self::plugin_uninstall_error)?; + match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(_) => self.on_effective_plugins_changed(), + Err(err) => { + warn!( + "failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}" + ); + self.clear_plugin_related_caches(); + } + } + Ok(PluginUninstallResponse {}) + } + + fn plugin_install_error(err: CorePluginInstallError) -> JSONRPCErrorError { + if err.is_invalid_request() { + return invalid_request(err.to_string()); + } + + match err { + CorePluginInstallError::Marketplace(err) => { + Self::marketplace_error(err, "install plugin") + } + CorePluginInstallError::Config(err) => { + internal_error(format!("failed to persist installed plugin config: {err}")) + } + CorePluginInstallError::Remote(err) => { + internal_error(format!("failed to enable remote plugin: {err}")) + } + CorePluginInstallError::Join(err) => { + internal_error(format!("failed to install plugin: {err}")) + } + CorePluginInstallError::Store(err) => { + internal_error(format!("failed to install plugin: {err}")) + } + } + } + + fn plugin_uninstall_error(err: CorePluginUninstallError) -> JSONRPCErrorError { + if err.is_invalid_request() { + return invalid_request(err.to_string()); + } + + match err { + CorePluginUninstallError::Config(err) => { + internal_error(format!("failed to clear plugin config: {err}")) + } + CorePluginUninstallError::Remote(err) => { + internal_error(format!("failed to uninstall remote plugin: {err}")) + } + CorePluginUninstallError::Join(err) => { + internal_error(format!("failed to uninstall plugin: {err}")) + } + CorePluginUninstallError::Store(err) => { + internal_error(format!("failed to uninstall plugin: {err}")) + } + CorePluginUninstallError::InvalidPluginId(_) => { + unreachable!("invalid plugin ids are handled above"); + } + } + } + + fn marketplace_error(err: MarketplaceError, action: &str) -> JSONRPCErrorError { + match err { + MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginNotFound { .. } + | MarketplaceError::PluginNotAvailable { .. } + | MarketplaceError::PluginsDisabled + | MarketplaceError::InvalidPlugin(_) => invalid_request(err.to_string()), + MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")), + } + } + + async fn remote_plugin_uninstall_response( + &self, + plugin_id: String, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request("remote plugin uninstall is not enabled")); + } + validate_remote_plugin_id(&plugin_id)?; + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.to_path_buf(), + &plugin_id, + ) + .await; + + if matches!( + &uninstall_result, + Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_)) + ) { + let plugins_manager = self.thread_manager.plugins_manager(); + if plugins_manager.clear_remote_installed_plugins_cache() { + self.on_effective_plugins_changed(); + } + plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( + &config.plugins_config_input(), + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); + } + + uninstall_result.map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "uninstall remote plugin") + })?; + Ok(PluginUninstallResponse {}) + } +} + +async fn load_plugin_app_summaries( + config: &Config, + plugin_apps: &[codex_plugin::AppConnectorId], + environment_manager: &EnvironmentManager, +) -> Vec { + if plugin_apps.is_empty() { + return Vec::new(); + } + + let connectors = + match connectors::list_all_connectors_with_options(config, /*force_refetch*/ false).await { + Ok(connectors) => connectors, + Err(err) => { + warn!("failed to load app metadata for plugin/read: {err:#}"); + connectors::list_cached_all_connectors(config) + .await + .unwrap_or_default() + } + }; + + let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps); + + let accessible_connectors = + match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + config, + /*force_refetch*/ false, + environment_manager, + ) + .await + { + Ok(status) if status.codex_apps_ready => status.connectors, + Ok(_) => { + return plugin_connectors + .into_iter() + .map(AppSummary::from) + .collect(); + } + Err(err) => { + warn!("failed to load app auth state for plugin/read: {err:#}"); + return plugin_connectors + .into_iter() + .map(AppSummary::from) + .collect(); + } + }; + + let accessible_ids = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect::>(); + + plugin_connectors + .into_iter() + .map(|connector| { + let needs_auth = !accessible_ids.contains(connector.id.as_str()); + AppSummary { + id: connector.id, + name: connector.name, + description: connector.description, + install_url: connector.install_url, + needs_auth, + } + }) + .collect() +} + +fn plugin_apps_needing_auth( + all_connectors: &[AppInfo], + accessible_connectors: &[AppInfo], + plugin_apps: &[codex_plugin::AppConnectorId], + codex_apps_ready: bool, +) -> Vec { + if !codex_apps_ready { + return Vec::new(); + } + + let accessible_ids = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect::>(); + let plugin_app_ids = plugin_apps + .iter() + .map(|connector_id| connector_id.0.as_str()) + .collect::>(); + + all_connectors + .iter() + .filter(|connector| { + plugin_app_ids.contains(connector.id.as_str()) + && !accessible_ids.contains(connector.id.as_str()) + }) + .cloned() + .map(|connector| AppSummary { + id: connector.id, + name: connector.name, + description: connector.description, + install_url: connector.install_url, + needs_auth: true, + }) + .collect() +} + +fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: marketplace.name, + path: None, + interface: Some(MarketplaceInterface { + display_name: Some(marketplace.display_name), + }), + plugins: marketplace + .plugins + .into_iter() + .map(remote_plugin_summary_to_info) + .collect(), + } +} + +fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary { + PluginSummary { + id: summary.id, + name: summary.name, + share_context: summary + .share_context + .map(remote_plugin_share_context_to_info), + source: PluginSource::Remote, + installed: summary.installed, + enabled: summary.enabled, + install_policy: summary.install_policy, + auth_policy: summary.auth_policy, + availability: summary.availability, + interface: summary.interface, + keywords: summary.keywords, + } +} + +fn remote_plugin_share_context_to_info( + context: RemoteCatalogPluginShareContext, +) -> PluginShareContext { + PluginShareContext { + remote_plugin_id: context.remote_plugin_id, + share_url: context.share_url, + creator_account_user_id: context.creator_account_user_id, + creator_name: context.creator_name, + share_targets: context.share_targets.map(|targets| { + targets + .into_iter() + .map(plugin_share_principal_from_remote) + .collect() + }), + } +} + +fn remote_plugin_share_discoverability_to_info( + discoverability: codex_core_plugins::remote::RemotePluginShareDiscoverability, +) -> PluginShareDiscoverability { + match discoverability { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed => { + PluginShareDiscoverability::Listed + } + codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted => { + PluginShareDiscoverability::Unlisted + } + codex_core_plugins::remote::RemotePluginShareDiscoverability::Private => { + PluginShareDiscoverability::Private + } + } +} + +fn remote_plugin_detail_to_info( + detail: RemoteCatalogPluginDetail, + apps: Vec, +) -> PluginDetail { + PluginDetail { + marketplace_name: detail.marketplace_name, + marketplace_path: None, + summary: remote_plugin_summary_to_info(detail.summary), + description: detail.description, + skills: detail + .skills + .into_iter() + .map(|skill| SkillSummary { + name: skill.name, + description: skill.description, + short_description: skill.short_description, + interface: skill.interface, + path: None, + enabled: skill.enabled, + }) + .collect(), + hooks: Vec::new(), + apps, + mcp_servers: Vec::new(), + } +} + +fn remote_plugin_catalog_error_to_jsonrpc( + err: RemotePluginCatalogError, + context: &str, +) -> JSONRPCErrorError { + let message = format!("{context}: {err}"); + match &err { + RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => { + invalid_request(message) + } + RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => { + invalid_request(message) + } + RemotePluginCatalogError::InvalidPluginPath { .. } + | RemotePluginCatalogError::ArchiveTooLarge { .. } + | RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message), + RemotePluginCatalogError::AuthToken(_) + | RemotePluginCatalogError::Request { .. } + | RemotePluginCatalogError::UnexpectedStatus { .. } + | RemotePluginCatalogError::Decode { .. } + | RemotePluginCatalogError::InvalidBaseUrl(_) + | RemotePluginCatalogError::InvalidBaseUrlPath + | RemotePluginCatalogError::UnexpectedPluginId { .. } + | RemotePluginCatalogError::UnexpectedSkillName { .. } + | RemotePluginCatalogError::UnexpectedEnabledState { .. } + | RemotePluginCatalogError::Archive { .. } + | RemotePluginCatalogError::ArchiveJoin(_) + | RemotePluginCatalogError::MissingUploadEtag + | RemotePluginCatalogError::UnexpectedResponse(_) + | RemotePluginCatalogError::CacheRemove(_) => internal_error(message), + } +} + +fn remote_plugin_bundle_install_error_to_jsonrpc( + err: codex_core_plugins::remote_bundle::RemotePluginBundleInstallError, +) -> JSONRPCErrorError { + internal_error(format!("install remote plugin bundle: {err}")) +} diff --git a/code-rs/app-server/src/request_processors/process_exec_processor.rs b/code-rs/app-server/src/request_processors/process_exec_processor.rs new file mode 100644 index 00000000000..5742d0e4d5f --- /dev/null +++ b/code-rs/app-server/src/request_processors/process_exec_processor.rs @@ -0,0 +1,708 @@ +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ProcessExitedNotification; +use codex_app_server_protocol::ProcessKillParams; +use codex_app_server_protocol::ProcessKillResponse; +use codex_app_server_protocol::ProcessOutputDeltaNotification; +use codex_app_server_protocol::ProcessOutputStream; +use codex_app_server_protocol::ProcessResizePtyParams; +use codex_app_server_protocol::ProcessResizePtyResponse; +use codex_app_server_protocol::ProcessSpawnParams; +use codex_app_server_protocol::ProcessSpawnResponse; +use codex_app_server_protocol::ProcessTerminalSize; +use codex_app_server_protocol::ProcessWriteStdinParams; +use codex_app_server_protocol::ProcessWriteStdinResponse; +use codex_app_server_protocol::ServerNotification; +use codex_core::exec::ExecExpiration; +use codex_core::exec::ExecExpirationOutcome; +use codex_core::exec::IO_DRAIN_TIMEOUT_MS; +use codex_protocol::exec_output::bytes_to_string_smart; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use codex_utils_pty::ProcessHandle; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use crate::error_code::internal_error; +use crate::error_code::invalid_params; +use crate::error_code::invalid_request; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; + +const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; +const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024; + +#[derive(Clone)] +pub(crate) struct ProcessExecRequestProcessor { + outgoing: Arc, + process_exec_manager: ProcessExecManager, +} + +impl ProcessExecRequestProcessor { + pub(crate) fn new(outgoing: Arc) -> Self { + Self { + outgoing, + process_exec_manager: ProcessExecManager::default(), + } + } + + pub(crate) async fn process_spawn( + &self, + request_id: ConnectionRequestId, + params: ProcessSpawnParams, + ) -> Result<(), JSONRPCErrorError> { + let ProcessSpawnParams { + command, + process_handle, + cwd, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + timeout_ms, + env: env_overrides, + size, + } = params; + let method_name = "process/spawn"; + tracing::debug!("{method_name} command: {command:?}"); + if command.is_empty() { + return Err(invalid_request("command must not be empty")); + } + if process_handle.is_empty() { + return Err(invalid_request("processHandle must not be empty")); + } + if size.is_some() && !tty { + return Err(invalid_params("process/spawn size requires tty: true")); + } + let mut env = std::env::vars().collect::>(); + if let Some(env_overrides) = env_overrides { + for (key, value) in env_overrides { + match value { + Some(value) => { + env.insert(key, value); + } + None => { + env.remove(&key); + } + } + } + } + let expiration = match timeout_ms { + Some(Some(timeout_ms)) => match u64::try_from(timeout_ms) { + Ok(timeout_ms) => timeout_ms.into(), + Err(_) => { + return Err(invalid_params(format!( + "{method_name} timeoutMs must be non-negative, got {timeout_ms}" + ))); + } + }, + Some(None) => ExecExpiration::Cancellation(CancellationToken::new()), + None => ExecExpiration::DefaultTimeout, + }; + let output_bytes_cap = output_bytes_cap.unwrap_or(Some(DEFAULT_OUTPUT_BYTES_CAP)); + let size = size.map(terminal_size_from_protocol).transpose()?; + + self.process_exec_manager + .start(StartProcessParams { + outgoing: self.outgoing.clone(), + request_id, + process_handle, + command, + cwd, + env, + expiration, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + }) + .await?; + + Ok(()) + } + + pub(crate) async fn process_write_stdin( + &self, + request_id: ConnectionRequestId, + params: ProcessWriteStdinParams, + ) -> Result, JSONRPCErrorError> { + self.process_exec_manager + .write_stdin(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn process_resize_pty( + &self, + request_id: ConnectionRequestId, + params: ProcessResizePtyParams, + ) -> Result, JSONRPCErrorError> { + self.process_exec_manager + .resize_pty(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn process_kill( + &self, + request_id: ConnectionRequestId, + params: ProcessKillParams, + ) -> Result, JSONRPCErrorError> { + self.process_exec_manager + .kill(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + self.process_exec_manager + .connection_closed(connection_id) + .await; + } +} + +#[derive(Clone, Default)] +struct ProcessExecManager { + sessions: Arc>>, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ConnectionProcessHandle { + connection_id: ConnectionId, + process_handle: String, +} + +#[derive(Clone)] +struct ProcessSession { + control_tx: mpsc::Sender, +} + +enum ProcessControl { + Write { delta: Vec, close_stdin: bool }, + Resize { size: TerminalSize }, + Kill, +} + +struct ProcessControlRequest { + control: ProcessControl, + response_tx: Option>>, +} + +struct StartProcessParams { + outgoing: Arc, + request_id: ConnectionRequestId, + process_handle: String, + command: Vec, + cwd: AbsolutePathBuf, + env: HashMap, + expiration: ExecExpiration, + tty: bool, + stream_stdin: bool, + stream_stdout_stderr: bool, + output_bytes_cap: Option, + size: Option, +} + +struct RunProcessParams { + outgoing: Arc, + request_id: ConnectionRequestId, + process_handle: String, + spawned: SpawnedProcess, + control_rx: mpsc::Receiver, + stream_stdin: bool, + stream_stdout_stderr: bool, + expiration: ExecExpiration, + output_bytes_cap: Option, +} + +struct SpawnProcessOutputParams { + connection_id: ConnectionId, + process_handle: String, + output_rx: mpsc::Receiver>, + stdio_timeout_rx: watch::Receiver, + outgoing: Arc, + stream: ProcessOutputStream, + stream_output: bool, + output_bytes_cap: Option, +} + +#[derive(Default)] +struct ProcessOutputCapture { + text: String, + cap_reached: bool, +} + +impl ProcessExecManager { + async fn start(&self, params: StartProcessParams) -> Result<(), JSONRPCErrorError> { + let StartProcessParams { + outgoing, + request_id, + process_handle, + command, + cwd, + env, + expiration, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + } = params; + + let (program, args) = command + .split_first() + .ok_or_else(|| invalid_request("command must not be empty"))?; + let stream_stdin = tty || stream_stdin; + let stream_stdout_stderr = tty || stream_stdout_stderr; + let arg0 = None; + let (control_tx, control_rx) = mpsc::channel(32); + let process_key = ConnectionProcessHandle { + connection_id: request_id.connection_id, + process_handle: process_handle.clone(), + }; + + { + let mut sessions = self.sessions.lock().await; + match sessions.entry(process_key.clone()) { + Entry::Occupied(_) => { + return Err(invalid_request(format!( + "duplicate active process handle: {process_handle:?}", + ))); + } + Entry::Vacant(entry) => { + entry.insert(ProcessSession { control_tx }); + } + } + } + + let spawned = if tty { + codex_utils_pty::spawn_pty_process( + program, + args, + cwd.as_path(), + &env, + &arg0, + size.unwrap_or_default(), + ) + .await + } else if stream_stdin { + codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0) + .await + }; + let spawned = match spawned { + Ok(spawned) => spawned, + Err(err) => { + self.sessions.lock().await.remove(&process_key); + return Err(internal_error(format!("failed to spawn process: {err}"))); + } + }; + + outgoing + .send_response(request_id.clone(), ProcessSpawnResponse {}) + .await; + + let sessions = Arc::clone(&self.sessions); + tokio::spawn(async move { + run_process(RunProcessParams { + outgoing, + request_id, + process_handle, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + }) + .await; + sessions.lock().await.remove(&process_key); + }); + + Ok(()) + } + + async fn write_stdin( + &self, + request_id: ConnectionRequestId, + params: ProcessWriteStdinParams, + ) -> Result { + if params.delta_base64.is_none() && !params.close_stdin { + return Err(invalid_params( + "process/writeStdin requires deltaBase64 or closeStdin", + )); + } + + let delta = match params.delta_base64 { + Some(delta_base64) => STANDARD + .decode(delta_base64) + .map_err(|err| invalid_params(format!("invalid deltaBase64: {err}")))?, + None => Vec::new(), + }; + + self.send_control( + request_id.connection_id, + params.process_handle, + ProcessControl::Write { + delta, + close_stdin: params.close_stdin, + }, + ) + .await?; + + Ok(ProcessWriteStdinResponse {}) + } + + async fn kill( + &self, + request_id: ConnectionRequestId, + params: ProcessKillParams, + ) -> Result { + self.send_control( + request_id.connection_id, + params.process_handle, + ProcessControl::Kill, + ) + .await?; + Ok(ProcessKillResponse {}) + } + + async fn resize_pty( + &self, + request_id: ConnectionRequestId, + params: ProcessResizePtyParams, + ) -> Result { + self.send_control( + request_id.connection_id, + params.process_handle, + ProcessControl::Resize { + size: terminal_size_from_protocol(params.size)?, + }, + ) + .await?; + Ok(ProcessResizePtyResponse {}) + } + + async fn connection_closed(&self, connection_id: ConnectionId) { + let controls = { + let mut sessions = self.sessions.lock().await; + let process_handles = sessions + .keys() + .filter(|process_handle| process_handle.connection_id == connection_id) + .cloned() + .collect::>(); + let mut controls = Vec::with_capacity(process_handles.len()); + for process_handle in process_handles { + if let Some(control) = sessions.remove(&process_handle) { + controls.push(control); + } + } + controls + }; + + for control in controls { + let _ = control + .control_tx + .send(ProcessControlRequest { + control: ProcessControl::Kill, + response_tx: None, + }) + .await; + } + } + + async fn send_control( + &self, + connection_id: ConnectionId, + process_handle: String, + control: ProcessControl, + ) -> Result<(), JSONRPCErrorError> { + let process_key = ConnectionProcessHandle { + connection_id, + process_handle, + }; + let session = self + .sessions + .lock() + .await + .get(&process_key) + .cloned() + .ok_or_else(|| no_active_process_error(&process_key.process_handle))?; + let (response_tx, response_rx) = oneshot::channel(); + session + .control_tx + .send(ProcessControlRequest { + control, + response_tx: Some(response_tx), + }) + .await + .map_err(|_| process_no_longer_running_error(&process_key.process_handle))?; + response_rx + .await + .map_err(|_| process_no_longer_running_error(&process_key.process_handle))? + } +} + +async fn run_process(params: RunProcessParams) { + let RunProcessParams { + outgoing, + request_id, + process_handle, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + } = params; + let mut control_rx = control_rx; + let mut control_open = true; + let expiration = expiration.wait_with_outcome(); + tokio::pin!(expiration); + let SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + tokio::pin!(exit_rx); + let mut expiration_outcome = None; + let (stdio_timeout_tx, stdio_timeout_rx) = watch::channel(false); + + let stdout_handle = collect_spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_handle: process_handle.clone(), + output_rx: stdout_rx, + stdio_timeout_rx: stdio_timeout_rx.clone(), + outgoing: Arc::clone(&outgoing), + stream: ProcessOutputStream::Stdout, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + let stderr_handle = collect_spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_handle: process_handle.clone(), + output_rx: stderr_rx, + stdio_timeout_rx, + outgoing: Arc::clone(&outgoing), + stream: ProcessOutputStream::Stderr, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + + let exit_code = loop { + tokio::select! { + control = control_rx.recv(), if control_open => { + match control { + Some(ProcessControlRequest { control, response_tx }) => { + let result = match control { + ProcessControl::Write { delta, close_stdin } => { + handle_process_write( + &session, + stream_stdin, + delta, + close_stdin, + ).await + } + ProcessControl::Resize { size } => { + handle_process_resize(&session, size) + } + ProcessControl::Kill => { + session.request_terminate(); + Ok(()) + } + }; + if let Some(response_tx) = response_tx + && response_tx.send(result).is_err() + { + tracing::debug!( + process_handle = %process_handle, + "process control response receiver dropped" + ); + } + }, + None => { + control_open = false; + session.request_terminate(); + } + } + } + outcome = &mut expiration, if expiration_outcome.is_none() => { + expiration_outcome = Some(outcome); + session.request_terminate(); + } + exit = &mut exit_rx => { + if matches!(expiration_outcome, Some(ExecExpirationOutcome::TimedOut)) { + break EXEC_TIMEOUT_EXIT_CODE; + } else { + break exit.unwrap_or(-1); + } + } + } + }; + + // Give stdout/stderr readers a bounded grace period to drain after process exit. + let timeout_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(IO_DRAIN_TIMEOUT_MS)).await; + let _ = stdio_timeout_tx.send(true); + }); + + let stdout = stdout_handle.await.unwrap_or_default(); + let stderr = stderr_handle.await.unwrap_or_default(); + timeout_handle.abort(); + + outgoing + .send_server_notification_to_connection_and_wait( + request_id.connection_id, + ServerNotification::ProcessExited(ProcessExitedNotification { + process_handle, + exit_code, + stdout: stdout.text, + stdout_cap_reached: stdout.cap_reached, + stderr: stderr.text, + stderr_cap_reached: stderr.cap_reached, + }), + ) + .await; +} + +fn collect_spawn_process_output( + params: SpawnProcessOutputParams, +) -> tokio::task::JoinHandle { + let SpawnProcessOutputParams { + connection_id, + process_handle, + mut output_rx, + mut stdio_timeout_rx, + outgoing, + stream, + stream_output, + output_bytes_cap, + } = params; + tokio::spawn(async move { + let mut buffer: Vec = Vec::new(); + let mut observed_num_bytes = 0usize; + let mut cap_reached = false; + loop { + let mut chunk = tokio::select! { + chunk = output_rx.recv() => match chunk { + Some(chunk) => chunk, + None => break, + }, + _ = stdio_timeout_rx.wait_for(|&v| v) => break, + }; + while chunk.len() < OUTPUT_CHUNK_SIZE_HINT + && let Ok(next_chunk) = output_rx.try_recv() + { + chunk.extend_from_slice(&next_chunk); + } + let capped_chunk = match output_bytes_cap { + Some(output_bytes_cap) => { + let capped_chunk_len = output_bytes_cap + .saturating_sub(observed_num_bytes) + .min(chunk.len()); + observed_num_bytes += capped_chunk_len; + &chunk[0..capped_chunk_len] + } + None => chunk.as_slice(), + }; + cap_reached = Some(observed_num_bytes) == output_bytes_cap; + if stream_output { + outgoing + .send_server_notification_to_connection_and_wait( + connection_id, + ServerNotification::ProcessOutputDelta(ProcessOutputDeltaNotification { + process_handle: process_handle.clone(), + stream, + delta_base64: STANDARD.encode(capped_chunk), + cap_reached, + }), + ) + .await; + } else { + buffer.extend_from_slice(capped_chunk); + } + if cap_reached { + break; + } + } + ProcessOutputCapture { + text: bytes_to_string_smart(&buffer), + cap_reached, + } + }) +} + +async fn handle_process_write( + session: &ProcessHandle, + stream_stdin: bool, + delta: Vec, + close_stdin: bool, +) -> Result<(), JSONRPCErrorError> { + if !stream_stdin { + return Err(invalid_request( + "stdin streaming is not enabled for this process", + )); + } + if !delta.is_empty() { + session + .writer_sender() + .send(delta) + .await + .map_err(|_| invalid_request("stdin is already closed"))?; + } + if close_stdin { + // Closing drops our sender; the writer task still drains any bytes + // accepted above before its receiver observes EOF and closes stdin. + session.close_stdin(); + } + Ok(()) +} + +fn handle_process_resize( + session: &ProcessHandle, + size: TerminalSize, +) -> Result<(), JSONRPCErrorError> { + session + .resize(size) + .map_err(|err| invalid_request(format!("failed to resize PTY: {err}"))) +} + +fn terminal_size_from_protocol( + size: ProcessTerminalSize, +) -> Result { + if size.rows == 0 || size.cols == 0 { + return Err(invalid_params( + "process size rows and cols must be greater than 0", + )); + } + Ok(TerminalSize { + rows: size.rows, + cols: size.cols, + }) +} + +fn no_active_process_error(process_handle: &str) -> JSONRPCErrorError { + invalid_request(format!( + "no active process for process handle {process_handle:?}" + )) +} + +fn process_no_longer_running_error(process_handle: &str) -> JSONRPCErrorError { + invalid_request(format!("process {process_handle:?} is no longer running")) +} diff --git a/code-rs/app-server/src/request_processors/request_errors.rs b/code-rs/app-server/src/request_processors/request_errors.rs new file mode 100644 index 00000000000..18082aebe81 --- /dev/null +++ b/code-rs/app-server/src/request_processors/request_errors.rs @@ -0,0 +1,8 @@ +use super::*; + +pub(super) fn environment_selection_error_message(err: CodexErr) -> String { + match err { + CodexErr::InvalidRequest(message) => message, + err => err.to_string(), + } +} diff --git a/code-rs/app-server/src/request_processors/search.rs b/code-rs/app-server/src/request_processors/search.rs new file mode 100644 index 00000000000..d683c6f10a8 --- /dev/null +++ b/code-rs/app-server/src/request_processors/search.rs @@ -0,0 +1,134 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::fuzzy_file_search::FuzzyFileSearchSession; +use crate::fuzzy_file_search::run_fuzzy_file_search; +use crate::fuzzy_file_search::start_fuzzy_file_search_session; +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::FuzzyFileSearchParams; +use codex_app_server_protocol::FuzzyFileSearchResponse; +use codex_app_server_protocol::FuzzyFileSearchSessionStartParams; +use codex_app_server_protocol::FuzzyFileSearchSessionStartResponse; +use codex_app_server_protocol::FuzzyFileSearchSessionStopParams; +use codex_app_server_protocol::FuzzyFileSearchSessionStopResponse; +use codex_app_server_protocol::FuzzyFileSearchSessionUpdateParams; +use codex_app_server_protocol::FuzzyFileSearchSessionUpdateResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub(crate) struct SearchRequestProcessor { + outgoing: Arc, + pending_fuzzy_searches: Arc>>>, + fuzzy_search_sessions: Arc>>, +} + +impl SearchRequestProcessor { + pub(crate) fn new(outgoing: Arc) -> Self { + Self { + outgoing, + pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), + fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub(crate) async fn fuzzy_file_search( + &self, + params: FuzzyFileSearchParams, + ) -> Result { + 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); + } + } + + Ok(FuzzyFileSearchResponse { files: results }) + } + + pub(crate) async fn fuzzy_file_search_session_start_response( + &self, + params: FuzzyFileSearchSessionStartParams, + ) -> Result { + let FuzzyFileSearchSessionStartParams { session_id, roots } = params; + if session_id.is_empty() { + return Err(invalid_request("sessionId must not be empty")); + } + + let session = + start_fuzzy_file_search_session(session_id.clone(), roots, self.outgoing.clone()) + .map_err(|err| { + internal_error(format!("failed to start fuzzy file search session: {err}")) + })?; + self.fuzzy_search_sessions + .lock() + .await + .insert(session_id, session); + Ok(FuzzyFileSearchSessionStartResponse {}) + } + + pub(crate) async fn fuzzy_file_search_session_update_response( + &self, + params: FuzzyFileSearchSessionUpdateParams, + ) -> Result { + let FuzzyFileSearchSessionUpdateParams { session_id, query } = params; + let found = { + let sessions = self.fuzzy_search_sessions.lock().await; + if let Some(session) = sessions.get(&session_id) { + session.update_query(query); + true + } else { + false + } + }; + if !found { + return Err(invalid_request(format!( + "fuzzy file search session not found: {session_id}" + ))); + } + + Ok(FuzzyFileSearchSessionUpdateResponse {}) + } + + pub(crate) async fn fuzzy_file_search_session_stop( + &self, + params: FuzzyFileSearchSessionStopParams, + ) -> Result { + let FuzzyFileSearchSessionStopParams { session_id } = params; + self.fuzzy_search_sessions.lock().await.remove(&session_id); + + Ok(FuzzyFileSearchSessionStopResponse {}) + } +} diff --git a/code-rs/app-server/src/request_processors/thread_goal_processor.rs b/code-rs/app-server/src/request_processors/thread_goal_processor.rs new file mode 100644 index 00000000000..0e12e44ce51 --- /dev/null +++ b/code-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -0,0 +1,481 @@ +use super::*; +use codex_protocol::protocol::validate_thread_goal_objective; + +#[derive(Clone)] +pub(crate) struct ThreadGoalRequestProcessor { + thread_manager: Arc, + outgoing: Arc, + config: Arc, + thread_state_manager: ThreadStateManager, + state_db: Option, +} + +impl ThreadGoalRequestProcessor { + pub(crate) fn new( + thread_manager: Arc, + outgoing: Arc, + config: Arc, + thread_state_manager: ThreadStateManager, + state_db: Option, + ) -> Self { + Self { + thread_manager, + outgoing, + config, + thread_state_manager, + state_db, + } + } + + pub(crate) async fn thread_goal_set( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalSetParams, + ) -> Result, JSONRPCErrorError> { + self.thread_goal_set_inner(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn thread_goal_get( + &self, + params: ThreadGoalGetParams, + ) -> Result, JSONRPCErrorError> { + self.thread_goal_get_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_goal_clear( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalClearParams, + ) -> Result, JSONRPCErrorError> { + self.thread_goal_clear_inner(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn emit_resume_goal_snapshot_and_continue( + &self, + thread_id: ThreadId, + thread: &CodexThread, + ) { + if !self.config.features.enabled(Feature::Goals) { + return; + } + self.emit_thread_goal_snapshot(thread_id).await; + // App-server owns resume response and snapshot ordering, so wait until + // those are sent before letting core start goal continuation. + if let Err(err) = thread.continue_active_goal_if_idle().await { + tracing::warn!("failed to continue active goal after resume: {err}"); + } + } + + pub(crate) async fn pending_resume_goal_state( + &self, + thread: &CodexThread, + ) -> (bool, Option) { + let emit_thread_goal_update = self.config.features.enabled(Feature::Goals); + let thread_goal_state_db = if emit_thread_goal_update { + if let Some(state_db) = thread.state_db() { + Some(state_db) + } else { + self.state_db.clone() + } + } else { + None + }; + (emit_thread_goal_update, thread_goal_state_db) + } + + async fn thread_goal_set_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalSetParams, + ) -> Result<(), JSONRPCErrorError> { + if !self.config.features.enabled(Feature::Goals) { + return Err(invalid_request("goals feature is disabled")); + } + + let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; + let state_db = self.state_db_for_materialized_thread(thread_id).await?; + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => thread.rollout_path().ok_or_else(|| { + invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + )) + })?, + None => find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, + }; + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let status = params.status.map(thread_goal_status_to_state); + let objective = params.objective.as_deref().map(str::trim); + + if let Some(objective) = objective { + validate_thread_goal_objective(objective).map_err(invalid_request)?; + } + if objective.is_some() || params.token_budget.is_some() { + validate_goal_budget(params.token_budget.flatten()).map_err(invalid_request)?; + } + + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + + let (goal, previous_status) = (if let Some(objective) = objective { + let existing_goal = state_db + .get_thread_goal(thread_id) + .await + .map_err(|err| invalid_request(err.to_string()))?; + if let Some(goal) = existing_goal.as_ref().filter(|goal| { + goal.objective == objective + && goal.status != codex_state::ThreadGoalStatus::Complete + }) { + let previous_status = ExternalGoalPreviousStatus::Existing(goal.status); + state_db + .update_thread_goal( + thread_id, + codex_state::ThreadGoalUpdate { + status, + token_budget: params.token_budget, + expected_goal_id: Some(goal.goal_id.clone()), + }, + ) + .await + .and_then(|goal| { + goal.ok_or_else(|| { + anyhow::anyhow!( + "cannot update goal for thread {thread_id}: no goal exists" + ) + }) + }) + .map(|goal| (goal, previous_status)) + } else { + let previous_status = ExternalGoalPreviousStatus::NewGoal; + state_db + .replace_thread_goal( + thread_id, + objective, + status.unwrap_or(codex_state::ThreadGoalStatus::Active), + params.token_budget.flatten(), + ) + .await + .map(|goal| (goal, previous_status)) + } + } else { + let existing_goal = state_db + .get_thread_goal(thread_id) + .await + .map_err(|err| invalid_request(err.to_string()))?; + let Some(existing_goal) = existing_goal else { + return Err(invalid_request(format!( + "cannot update goal for thread {thread_id}: no goal exists" + ))); + }; + let previous_status = ExternalGoalPreviousStatus::Existing(existing_goal.status); + state_db + .update_thread_goal( + thread_id, + codex_state::ThreadGoalUpdate { + status, + token_budget: params.token_budget, + expected_goal_id: None, + }, + ) + .await + .and_then(|goal| { + goal.ok_or_else(|| { + anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") + }) + }) + .map(|goal| (goal, previous_status)) + }) + .map_err(|err| invalid_request(err.to_string()))?; + let external_goal_set = ExternalGoalSet { + goal: goal.clone(), + previous_status, + }; + let goal = api_thread_goal_from_state(goal); + self.outgoing + .send_response( + request_id.clone(), + ThreadGoalSetResponse { goal: goal.clone() }, + ) + .await; + self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) + .await; + if let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_set(external_goal_set).await; + } + Ok(()) + } + + async fn thread_goal_get_inner( + &self, + params: ThreadGoalGetParams, + ) -> Result { + if !self.config.features.enabled(Feature::Goals) { + return Err(invalid_request("goals feature is disabled")); + } + + let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; + let state_db = self.state_db_for_materialized_thread(thread_id).await?; + let goal = state_db + .get_thread_goal(thread_id) + .await + .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? + .map(api_thread_goal_from_state); + Ok(ThreadGoalGetResponse { goal }) + } + + async fn thread_goal_clear_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalClearParams, + ) -> Result<(), JSONRPCErrorError> { + if !self.config.features.enabled(Feature::Goals) { + return Err(invalid_request("goals feature is disabled")); + } + + let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; + let state_db = self.state_db_for_materialized_thread(thread_id).await?; + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => thread.rollout_path().ok_or_else(|| { + invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + )) + })?, + None => find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, + }; + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let cleared = state_db + .delete_thread_goal(thread_id) + .await + .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; + + if cleared && let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_clear().await; + } + + self.outgoing + .send_response(request_id, ThreadGoalClearResponse { cleared }) + .await; + if cleared { + self.emit_thread_goal_cleared_ordered(thread_id, listener_command_tx) + .await; + } + Ok(()) + } + + async fn state_db_for_materialized_thread( + &self, + thread_id: ThreadId, + ) -> Result { + if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { + if thread.rollout_path().is_none() { + return Err(invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + ))); + } + if let Some(state_db) = thread.state_db() { + return Ok(state_db); + } + } else { + find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?; + } + + self.state_db + .clone() + .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) + } + + async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(err) => { + warn!( + "failed to open state db before emitting thread goal resume snapshot for {thread_id}: {}", + err.message + ); + return; + } + }; + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalSnapshot { + state_db: state_db.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal snapshot for {thread_id}: listener command channel is closed" + ); + } + send_thread_goal_snapshot_notification(&self.outgoing, thread_id, &state_db).await; + } + + async fn emit_thread_goal_updated_ordered( + &self, + thread_id: ThreadId, + goal: ThreadGoal, + listener_command_tx: Option>, + ) { + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalUpdated { + goal: goal.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal update for {thread_id}: listener command channel is closed" + ); + } + self.outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal, + }, + )) + .await; + } + + async fn emit_thread_goal_cleared_ordered( + &self, + thread_id: ThreadId, + listener_command_tx: Option>, + ) { + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalCleared; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal clear for {thread_id}: listener command channel is closed" + ); + } + self.outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: thread_id.to_string(), + }, + )) + .await; + } +} + +fn validate_goal_budget(value: Option) -> Result<(), String> { + if let Some(value) = value + && value <= 0 + { + return Err("goal budgets must be positive when provided".to_string()); + } + Ok(()) +} + +fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus { + match status { + ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, + ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, + ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, + } +} + +fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { + match status { + codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, + codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} + +pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id.to_string(), + objective: goal.objective, + status: thread_goal_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), + } +} + +fn parse_thread_id_for_request(thread_id: &str) -> Result { + ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}"))) +} diff --git a/code-rs/app-server/src/request_processors/thread_lifecycle.rs b/code-rs/app-server/src/request_processors/thread_lifecycle.rs new file mode 100644 index 00000000000..ef44a2b178c --- /dev/null +++ b/code-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -0,0 +1,749 @@ +use super::*; + +pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60); + +#[derive(Clone)] +pub(super) struct ListenerTaskContext { + pub(super) thread_manager: Arc, + pub(super) thread_state_manager: ThreadStateManager, + pub(super) outgoing: Arc, + pub(super) pending_thread_unloads: Arc>>, + pub(super) thread_watch_manager: ThreadWatchManager, + pub(super) thread_list_state_permit: Arc, + pub(super) fallback_model_provider: String, + pub(super) codex_home: PathBuf, +} + +struct UnloadingState { + delay: Duration, + has_subscribers_rx: watch::Receiver, + has_subscribers: (bool, Instant), + thread_status_rx: watch::Receiver, + is_active: (bool, Instant), +} + +impl UnloadingState { + async fn new( + listener_task_context: &ListenerTaskContext, + thread_id: ThreadId, + delay: Duration, + ) -> Option { + let has_subscribers_rx = listener_task_context + .thread_state_manager + .subscribe_to_has_connections(thread_id) + .await?; + let thread_status_rx = listener_task_context + .thread_watch_manager + .subscribe(thread_id) + .await?; + let has_subscribers = (*has_subscribers_rx.borrow(), Instant::now()); + let is_active = ( + matches!(*thread_status_rx.borrow(), ThreadStatus::Active { .. }), + Instant::now(), + ); + Some(Self { + delay, + has_subscribers_rx, + has_subscribers, + thread_status_rx, + is_active, + }) + } + + fn unloading_target(&self) -> Option { + match (self.has_subscribers, self.is_active) { + ((false, has_no_subscribers_since), (false, is_inactive_since)) => { + Some(std::cmp::max(has_no_subscribers_since, is_inactive_since) + self.delay) + } + _ => None, + } + } + + fn sync_receiver_values(&mut self) { + let has_subscribers = *self.has_subscribers_rx.borrow(); + if self.has_subscribers.0 != has_subscribers { + self.has_subscribers = (has_subscribers, Instant::now()); + } + + let is_active = matches!(*self.thread_status_rx.borrow(), ThreadStatus::Active { .. }); + if self.is_active.0 != is_active { + self.is_active = (is_active, Instant::now()); + } + } + + fn should_unload_now(&mut self) -> bool { + self.sync_receiver_values(); + self.unloading_target() + .is_some_and(|target| target <= Instant::now()) + } + + fn note_thread_activity_observed(&mut self) { + if !self.is_active.0 { + self.is_active = (false, Instant::now()); + } + } + + async fn wait_for_unloading_trigger(&mut self) -> bool { + loop { + self.sync_receiver_values(); + let unloading_target = self.unloading_target(); + if let Some(target) = unloading_target + && target <= Instant::now() + { + return true; + } + let unloading_sleep = async { + if let Some(target) = unloading_target { + tokio::time::sleep_until(target.into()).await; + } else { + futures::future::pending::<()>().await; + } + }; + tokio::select! { + _ = unloading_sleep => return true, + changed = self.has_subscribers_rx.changed() => { + if changed.is_err() { + return false; + } + self.sync_receiver_values(); + }, + changed = self.thread_status_rx.changed() => { + if changed.is_err() { + return false; + } + self.sync_receiver_values(); + }, + } + } + } +} + +pub(super) enum ThreadShutdownResult { + Complete, + SubmitFailed, + TimedOut, +} + +pub(super) enum EnsureConversationListenerResult { + Attached, + ConnectionClosed, +} + +#[expect( + clippy::await_holding_invalid_type, + reason = "listener subscription must be serialized against pending unloads" +)] +pub(super) async fn ensure_conversation_listener( + listener_task_context: ListenerTaskContext, + conversation_id: ThreadId, + connection_id: ConnectionId, + raw_events_enabled: bool, +) -> Result { + let conversation = match listener_task_context + .thread_manager + .get_thread(conversation_id) + .await + { + Ok(conv) => conv, + Err(_) => { + return Err(invalid_request(format!( + "thread not found: {conversation_id}" + ))); + } + }; + let thread_state = { + let pending_thread_unloads = listener_task_context.pending_thread_unloads.lock().await; + if pending_thread_unloads.contains(&conversation_id) { + return Err(invalid_request(format!( + "thread {conversation_id} is closing; retry after the thread is closed" + ))); + } + let Some(thread_state) = listener_task_context + .thread_state_manager + .try_ensure_connection_subscribed(conversation_id, connection_id, raw_events_enabled) + .await + else { + return Ok(EnsureConversationListenerResult::ConnectionClosed); + }; + thread_state + }; + if let Err(error) = ensure_listener_task_running( + listener_task_context.clone(), + conversation_id, + conversation, + thread_state, + ) + .await + { + let _ = listener_task_context + .thread_state_manager + .unsubscribe_connection_from_thread(conversation_id, connection_id) + .await; + return Err(error); + } + Ok(EnsureConversationListenerResult::Attached) +} + +pub(super) fn log_listener_attach_result( + result: Result, + thread_id: ThreadId, + connection_id: ConnectionId, + thread_kind: &'static str, +) { + match result { + Ok(EnsureConversationListenerResult::Attached) => {} + Ok(EnsureConversationListenerResult::ConnectionClosed) => { + tracing::debug!( + thread_id = %thread_id, + connection_id = ?connection_id, + "skipping auto-attach for closed connection" + ); + } + Err(err) => { + tracing::warn!( + "failed to attach listener for {thread_kind} {thread_id}: {message}", + message = err.message + ); + } + } +} + +pub(super) async fn ensure_listener_task_running( + listener_task_context: ListenerTaskContext, + conversation_id: ThreadId, + conversation: Arc, + thread_state: Arc>, +) -> Result<(), JSONRPCErrorError> { + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + let Some(mut unloading_state) = UnloadingState::new( + &listener_task_context, + conversation_id, + THREAD_UNLOADING_DELAY, + ) + .await + else { + return Err(invalid_request(format!( + "thread {conversation_id} is closing; retry after the thread is closed" + ))); + }; + let (mut listener_command_rx, listener_generation) = { + let mut thread_state = thread_state.lock().await; + if thread_state.listener_matches(&conversation) { + return Ok(()); + } + thread_state.set_listener(cancel_tx, &conversation) + }; + let ListenerTaskContext { + outgoing, + thread_manager, + thread_state_manager, + pending_thread_unloads, + thread_watch_manager, + thread_list_state_permit, + fallback_model_provider, + codex_home, + } = listener_task_context; + let outgoing_for_task = Arc::clone(&outgoing); + tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = &mut cancel_rx => { + // Listener was superseded or the thread is being torn down. + break; + } + listener_command = listener_command_rx.recv() => { + let Some(listener_command) = listener_command else { + break; + }; + handle_thread_listener_command( + conversation_id, + &conversation, + codex_home.as_path(), + &thread_state_manager, + &thread_state, + &thread_watch_manager, + &outgoing_for_task, + &pending_thread_unloads, + listener_command, + ) + .await; + } + event = conversation.next_event() => { + let event = match event { + Ok(event) => event, + Err(err) => { + tracing::warn!("thread.next_event() failed with: {err}"); + break; + } + }; + + // Track the event before emitting any typed translations + // so thread-local state such as raw event opt-in stays + // synchronized with the conversation. + let raw_events_enabled = { + let mut thread_state = thread_state.lock().await; + thread_state.track_current_turn_event(&event.id, &event.msg); + thread_state.experimental_raw_events + }; + let subscribed_connection_ids = thread_state_manager + .subscribed_connection_ids(conversation_id) + .await; + let thread_outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing_for_task.clone(), + subscribed_connection_ids, + conversation_id, + ); + + if let EventMsg::RawResponseItem(raw_response_item_event) = &event.msg + && !raw_events_enabled + { + maybe_emit_hook_prompt_item_completed( + conversation_id, + &event.id, + &raw_response_item_event.item, + &thread_outgoing, + ) + .await; + continue; + } + + apply_bespoke_event_handling( + event.clone(), + conversation_id, + conversation.clone(), + thread_manager.clone(), + thread_outgoing, + thread_state.clone(), + thread_watch_manager.clone(), + thread_list_state_permit.clone(), + fallback_model_provider.clone(), + ) + .await; + } + unloading_watchers_open = unloading_state.wait_for_unloading_trigger() => { + if !unloading_watchers_open { + break; + } + if !unloading_state.should_unload_now() { + continue; + } + if matches!(conversation.agent_status().await, AgentStatus::Running) { + unloading_state.note_thread_activity_observed(); + continue; + } + { + let mut pending_thread_unloads = pending_thread_unloads.lock().await; + if pending_thread_unloads.contains(&conversation_id) { + continue; + } + if !unloading_state.should_unload_now() { + continue; + } + pending_thread_unloads.insert(conversation_id); + } + unload_thread_without_subscribers( + thread_manager.clone(), + outgoing_for_task.clone(), + pending_thread_unloads.clone(), + thread_state_manager.clone(), + thread_watch_manager.clone(), + conversation_id, + conversation.clone(), + ) + .await; + break; + } + } + } + + let mut thread_state = thread_state.lock().await; + if thread_state.listener_generation == listener_generation { + thread_state.clear_listener(); + } + }); + Ok(()) +} + +pub(super) async fn wait_for_thread_shutdown(thread: &Arc) -> ThreadShutdownResult { + match tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()).await { + Ok(Ok(())) => ThreadShutdownResult::Complete, + Ok(Err(_)) => ThreadShutdownResult::SubmitFailed, + Err(_) => ThreadShutdownResult::TimedOut, + } +} + +pub(super) async fn unload_thread_without_subscribers( + thread_manager: Arc, + outgoing: Arc, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_id: ThreadId, + thread: Arc, +) { + info!("thread {thread_id} has no subscribers and is idle; shutting down"); + + // Any pending app-server -> client requests for this thread can no longer be + // answered; cancel their callbacks before shutdown/unload. + outgoing + .cancel_requests_for_thread(thread_id, /*error*/ None) + .await; + thread_state_manager.remove_thread_state(thread_id).await; + + tokio::spawn(async move { + match wait_for_thread_shutdown(&thread).await { + ThreadShutdownResult::Complete => { + if thread_manager.remove_thread(&thread_id).await.is_none() { + info!("thread {thread_id} was already removed before teardown finalized"); + thread_watch_manager + .remove_thread(&thread_id.to_string()) + .await; + pending_thread_unloads.lock().await.remove(&thread_id); + return; + } + thread_watch_manager + .remove_thread(&thread_id.to_string()) + .await; + let notification = ThreadClosedNotification { + thread_id: thread_id.to_string(), + }; + outgoing + .send_server_notification(ServerNotification::ThreadClosed(notification)) + .await; + pending_thread_unloads.lock().await.remove(&thread_id); + } + ThreadShutdownResult::SubmitFailed => { + pending_thread_unloads.lock().await.remove(&thread_id); + warn!("failed to submit Shutdown to thread {thread_id}"); + } + ThreadShutdownResult::TimedOut => { + pending_thread_unloads.lock().await.remove(&thread_id); + warn!("thread {thread_id} shutdown timed out; leaving thread loaded"); + } + } + }); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_thread_listener_command( + conversation_id: ThreadId, + conversation: &Arc, + codex_home: &Path, + thread_state_manager: &ThreadStateManager, + thread_state: &Arc>, + thread_watch_manager: &ThreadWatchManager, + outgoing: &Arc, + pending_thread_unloads: &Arc>>, + listener_command: ThreadListenerCommand, +) { + match listener_command { + ThreadListenerCommand::SendThreadResumeResponse(resume_request) => { + handle_pending_thread_resume_request( + conversation_id, + conversation, + codex_home, + thread_state_manager, + thread_state, + thread_watch_manager, + outgoing, + pending_thread_unloads, + *resume_request, + ) + .await; + } + ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: None, + goal, + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalCleared => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: conversation_id.to_string(), + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } + ThreadListenerCommand::ResolveServerRequest { + request_id, + completion_tx, + } => { + resolve_pending_server_request( + conversation_id, + thread_state_manager, + outgoing, + request_id, + ) + .await; + let _ = completion_tx.send(()); + } + } +} + +#[allow(clippy::too_many_arguments)] +#[expect( + clippy::await_holding_invalid_type, + reason = "running-thread resume subscription must be serialized against pending unloads" +)] +pub(super) async fn handle_pending_thread_resume_request( + conversation_id: ThreadId, + conversation: &Arc, + _codex_home: &Path, + thread_state_manager: &ThreadStateManager, + thread_state: &Arc>, + thread_watch_manager: &ThreadWatchManager, + outgoing: &Arc, + pending_thread_unloads: &Arc>>, + pending: crate::thread_state::PendingThreadResumeRequest, +) { + let active_turn = { + let state = thread_state.lock().await; + state.active_turn_snapshot() + }; + tracing::debug!( + thread_id = %conversation_id, + request_id = ?pending.request_id, + active_turn_present = active_turn.is_some(), + active_turn_id = ?active_turn.as_ref().map(|turn| turn.id.as_str()), + active_turn_status = ?active_turn.as_ref().map(|turn| &turn.status), + "composing running thread resume response" + ); + let has_live_in_progress_turn = + matches!(conversation.agent_status().await, AgentStatus::Running) + || active_turn + .as_ref() + .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); + + let request_id = pending.request_id; + let connection_id = request_id.connection_id; + let mut thread = pending.thread_summary; + if pending.include_turns { + populate_thread_turns_from_history( + &mut thread, + &pending.history_items, + active_turn.as_ref(), + ); + } + + let thread_status = thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + set_thread_status_and_interrupt_stale_turns( + &mut thread, + thread_status, + has_live_in_progress_turn, + ); + + { + let pending_thread_unloads = pending_thread_unloads.lock().await; + if pending_thread_unloads.contains(&conversation_id) { + drop(pending_thread_unloads); + outgoing + .send_error( + request_id, + invalid_request(format!( + "thread {conversation_id} is closing; retry thread/resume after the thread is closed" + )), + ) + .await; + return; + } + if !thread_state_manager + .try_add_connection_to_thread(conversation_id, connection_id) + .await + { + tracing::debug!( + thread_id = %conversation_id, + connection_id = ?connection_id, + "skipping running thread resume for closed connection" + ); + return; + } + } + + if pending.emit_thread_goal_update + && let Err(err) = conversation.apply_goal_resume_runtime_effects().await + { + tracing::warn!("failed to apply goal resume runtime effects: {err}"); + } + + let ThreadConfigSnapshot { + model, + model_provider_id, + service_tier, + approval_policy, + approvals_reviewer, + permission_profile, + active_permission_profile, + cwd, + reasoning_effort, + .. + } = pending.config_snapshot; + let instruction_sources = pending.instruction_sources; + let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); + let active_permission_profile = + thread_response_active_permission_profile(active_permission_profile); + let session_id = conversation.session_configured().session_id.to_string(); + thread.session_id = session_id; + + let response = ThreadResumeResponse { + thread, + model, + model_provider: model_provider_id, + service_tier, + cwd, + instruction_sources, + approval_policy: approval_policy.into(), + approvals_reviewer: approvals_reviewer.into(), + sandbox, + permission_profile: Some(permission_profile.into()), + active_permission_profile, + reasoning_effort, + }; + let token_usage_thread = pending.include_turns.then(|| response.thread.clone()); + outgoing.send_response(request_id, response).await; + // Match cold resume: metadata-only resume should attach the listener without + // paying the cost of turn reconstruction for historical usage replay. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &pending.history_items, + token_usage_thread.turns.as_slice(), + ); + // Rejoining a loaded thread has the same UI contract as a cold resume, but + // uses the live conversation state instead of reconstructing a new session. + send_thread_token_usage_update_to_connection( + outgoing, + connection_id, + conversation_id, + &token_usage_thread, + conversation.as_ref(), + token_usage_turn_id, + ) + .await; + } + if pending.emit_thread_goal_update { + if let Some(state_db) = pending.thread_goal_state_db { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } else { + tracing::warn!( + thread_id = %conversation_id, + "state db unavailable when reading thread goal for running thread resume" + ); + } + } + outgoing + .replay_requests_to_connection_for_thread(connection_id, conversation_id) + .await; + // App-server owns resume response and snapshot ordering, so wait until + // replay completes before letting core start goal continuation. + if pending.emit_thread_goal_update + && let Err(err) = conversation.continue_active_goal_if_idle().await + { + tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + } +} + +pub(super) async fn send_thread_goal_snapshot_notification( + outgoing: &Arc, + thread_id: ThreadId, + state_db: &StateDbHandle, +) { + match state_db.get_thread_goal(thread_id).await { + Ok(Some(goal)) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal: api_thread_goal_from_state(goal), + }, + )) + .await; + } + Ok(None) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: thread_id.to_string(), + }, + )) + .await; + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + "failed to read thread goal for resume snapshot: {err}" + ); + } + } +} + +pub(crate) fn populate_thread_turns_from_history( + thread: &mut Thread, + items: &[RolloutItem], + active_turn: Option<&Turn>, +) { + let mut turns = build_api_turns_from_rollout_items(items); + if let Some(active_turn) = active_turn { + merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); + } + thread.turns = turns; +} + +pub(super) async fn resolve_pending_server_request( + conversation_id: ThreadId, + thread_state_manager: &ThreadStateManager, + outgoing: &Arc, + request_id: RequestId, +) { + let thread_id = conversation_id.to_string(); + let subscribed_connection_ids = thread_state_manager + .subscribed_connection_ids(conversation_id) + .await; + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + subscribed_connection_ids, + conversation_id, + ); + outgoing + .send_server_notification(ServerNotification::ServerRequestResolved( + ServerRequestResolvedNotification { + thread_id, + request_id, + }, + )) + .await; +} + +pub(super) fn merge_turn_history_with_active_turn(turns: &mut Vec, active_turn: Turn) { + turns.retain(|turn| turn.id != active_turn.id); + turns.push(active_turn); +} + +pub(super) fn set_thread_status_and_interrupt_stale_turns( + thread: &mut Thread, + loaded_status: ThreadStatus, + has_live_in_progress_turn: bool, +) { + let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); + if !matches!(status, ThreadStatus::Active { .. }) { + for turn in &mut thread.turns { + if matches!(turn.status, TurnStatus::InProgress) { + turn.status = TurnStatus::Interrupted; + } + } + } + thread.status = status; +} diff --git a/code-rs/app-server/src/request_processors/thread_processor.rs b/code-rs/app-server/src/request_processors/thread_processor.rs new file mode 100644 index 00000000000..615e37f2c90 --- /dev/null +++ b/code-rs/app-server/src/request_processors/thread_processor.rs @@ -0,0 +1,3978 @@ +use super::*; +use crate::error_code::method_not_found; + +const THREAD_LIST_DEFAULT_LIMIT: usize = 25; +const THREAD_LIST_MAX_LIMIT: usize = 100; +const PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY: &str = + "persistExtendedHistory is deprecated and ignored"; +const PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS: &str = + "Remove this parameter. App-server always uses limited history persistence."; + +struct ThreadListFilters { + model_providers: Option>, + source_kinds: Option>, + archived: bool, + cwd_filters: Option>, + search_term: Option, + use_state_db_only: bool, +} + +fn collect_resume_override_mismatches( + request: &ThreadResumeParams, + config_snapshot: &ThreadConfigSnapshot, +) -> Vec { + let mut mismatch_details = Vec::new(); + + if let Some(requested_model) = request.model.as_deref() + && requested_model != config_snapshot.model + { + mismatch_details.push(format!( + "model requested={requested_model} active={}", + config_snapshot.model + )); + } + if let Some(requested_provider) = request.model_provider.as_deref() + && requested_provider != config_snapshot.model_provider_id + { + mismatch_details.push(format!( + "model_provider requested={requested_provider} active={}", + config_snapshot.model_provider_id + )); + } + if let Some(requested_service_tier) = request.service_tier.as_ref() + && requested_service_tier != &config_snapshot.service_tier + { + mismatch_details.push(format!( + "service_tier requested={requested_service_tier:?} active={:?}", + config_snapshot.service_tier + )); + } + if let Some(requested_cwd) = request.cwd.as_deref() { + let requested_cwd_path = std::path::PathBuf::from(requested_cwd); + if requested_cwd_path != config_snapshot.cwd.as_path() { + mismatch_details.push(format!( + "cwd requested={} active={}", + requested_cwd_path.display(), + config_snapshot.cwd.display() + )); + } + } + if let Some(requested_approval) = request.approval_policy.as_ref() { + let active_approval: AskForApproval = config_snapshot.approval_policy.into(); + if requested_approval != &active_approval { + mismatch_details.push(format!( + "approval_policy requested={requested_approval:?} active={active_approval:?}" + )); + } + } + if let Some(requested_review_policy) = request.approvals_reviewer.as_ref() { + let active_review_policy: codex_app_server_protocol::ApprovalsReviewer = + config_snapshot.approvals_reviewer.into(); + if requested_review_policy != &active_review_policy { + mismatch_details.push(format!( + "approvals_reviewer requested={requested_review_policy:?} active={active_review_policy:?}" + )); + } + } + if let Some(requested_sandbox) = request.sandbox.as_ref() { + let active_sandbox = config_snapshot.sandbox_policy(); + let sandbox_matches = matches!( + (requested_sandbox, &active_sandbox), + ( + SandboxMode::ReadOnly, + codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } + ) | ( + SandboxMode::WorkspaceWrite, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + ) + ); + if !sandbox_matches { + mismatch_details.push(format!( + "sandbox requested={requested_sandbox:?} active={active_sandbox:?}" + )); + } + } + if request.permissions.is_some() { + mismatch_details.push(format!( + "permissions override was provided and ignored while running; active={:?}", + config_snapshot.active_permission_profile + )); + } + if let Some(requested_personality) = request.personality.as_ref() + && config_snapshot.personality.as_ref() != Some(requested_personality) + { + mismatch_details.push(format!( + "personality requested={requested_personality:?} active={:?}", + config_snapshot.personality + )); + } + + if request.config.is_some() { + mismatch_details + .push("config overrides were provided and ignored while running".to_string()); + } + if request.base_instructions.is_some() { + mismatch_details + .push("baseInstructions override was provided and ignored while running".to_string()); + } + if request.developer_instructions.is_some() { + mismatch_details.push( + "developerInstructions override was provided and ignored while running".to_string(), + ); + } + mismatch_details +} + +fn merge_persisted_resume_metadata( + request_overrides: &mut Option>, + typesafe_overrides: &mut ConfigOverrides, + persisted_metadata: &ThreadMetadata, +) { + if has_model_resume_override(request_overrides.as_ref(), typesafe_overrides) { + return; + } + + typesafe_overrides.model = persisted_metadata.model.clone(); + typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone()); + + if let Some(reasoning_effort) = persisted_metadata.reasoning_effort { + request_overrides.get_or_insert_with(HashMap::new).insert( + "model_reasoning_effort".to_string(), + serde_json::Value::String(reasoning_effort.to_string()), + ); + } +} + +fn normalize_thread_list_cwd_filters( + cwd: Option, +) -> Result>, JSONRPCErrorError> { + let Some(cwd) = cwd else { + return Ok(None); + }; + + let cwds = match cwd { + ThreadListCwdFilter::One(cwd) => vec![cwd], + ThreadListCwdFilter::Many(cwds) => cwds, + }; + let mut normalized_cwds = Vec::with_capacity(cwds.len()); + for cwd in cwds { + let cwd = AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| { + invalid_params(format!("invalid thread/list cwd filter `{cwd}`: {err}")) + })?; + normalized_cwds.push(cwd); + } + + Ok(Some(normalized_cwds)) +} + +fn has_model_resume_override( + request_overrides: Option<&HashMap>, + typesafe_overrides: &ConfigOverrides, +) -> bool { + typesafe_overrides.model.is_some() + || typesafe_overrides.model_provider.is_some() + || request_overrides.is_some_and(|overrides| overrides.contains_key("model")) + || request_overrides + .is_some_and(|overrides| overrides.contains_key("model_reasoning_effort")) +} + +fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { + const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128; + const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64; + const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$"; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + fn escape_identifier_for_error(value: &str) -> String { + value.escape_default().to_string() + } + + fn validate_dynamic_tool_identifier( + value: &str, + label: &str, + max_len: usize, + ) -> Result<(), String> { + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + if value.chars().count() > max_len { + return Err(format!( + "{label} must be at most {max_len} characters to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + Ok(()) + } + + let mut seen = HashSet::new(); + for tool in tools { + let name = tool.name.trim(); + if name.is_empty() { + return Err("dynamic tool name must not be empty".to_string()); + } + if name != tool.name { + return Err(format!( + "dynamic tool name has leading/trailing whitespace: {}", + escape_identifier_for_error(&tool.name), + )); + } + validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?; + if name == "mcp" || name.starts_with("mcp__") { + return Err(format!("dynamic tool name is reserved: {name}")); + } + let namespace = tool.namespace.as_deref().map(str::trim); + if let Some(namespace) = namespace { + if namespace.is_empty() { + return Err(format!( + "dynamic tool namespace must not be empty for {name}" + )); + } + if Some(namespace) != tool.namespace.as_deref() { + return Err(format!( + "dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}", + name = escape_identifier_for_error(name), + namespace = escape_identifier_for_error(namespace), + )); + } + validate_dynamic_tool_identifier( + namespace, + "dynamic tool namespace", + DYNAMIC_TOOL_NAMESPACE_MAX_LEN, + )?; + if namespace == "mcp" || namespace.starts_with("mcp__") { + return Err(format!( + "dynamic tool namespace is reserved for {name}: {namespace}" + )); + } + if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) { + return Err(format!( + "dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}", + )); + } + } + if !seen.insert((namespace, name)) { + if let Some(namespace) = namespace { + return Err(format!( + "duplicate dynamic tool name in namespace {namespace}: {name}" + )); + } + return Err(format!("duplicate dynamic tool name: {name}")); + } + if tool.defer_loading && namespace.is_none() { + return Err(format!( + "deferred dynamic tool must include a namespace: {name}" + )); + } + + if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) { + return Err(format!( + "dynamic tool input schema is not supported for {name}: {err}" + )); + } + } + Ok(()) +} + +#[derive(Clone)] +pub(crate) struct ThreadRequestProcessor { + pub(super) auth_manager: Arc, + pub(super) thread_manager: Arc, + pub(super) outgoing: Arc, + pub(super) arg0_paths: Arg0DispatchPaths, + pub(super) config: Arc, + pub(super) config_manager: ConfigManager, + pub(super) thread_store: Arc, + pub(super) pending_thread_unloads: Arc>>, + pub(super) thread_state_manager: ThreadStateManager, + pub(super) thread_watch_manager: ThreadWatchManager, + pub(super) thread_list_state_permit: Arc, + pub(super) thread_goal_processor: ThreadGoalRequestProcessor, + pub(super) state_db: Option, + pub(super) background_tasks: TaskTracker, +} + +impl ThreadRequestProcessor { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + arg0_paths: Arg0DispatchPaths, + config: Arc, + config_manager: ConfigManager, + thread_store: Arc, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_list_state_permit: Arc, + thread_goal_processor: ThreadGoalRequestProcessor, + state_db: Option, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + arg0_paths, + config, + config_manager, + thread_store, + pending_thread_unloads, + thread_state_manager, + thread_watch_manager, + thread_list_state_permit, + thread_goal_processor, + state_db, + background_tasks: TaskTracker::new(), + } + } + + pub(crate) async fn thread_start( + &self, + request_id: ConnectionRequestId, + params: ThreadStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + request_context: RequestContext, + ) -> Result, JSONRPCErrorError> { + self.thread_start_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + request_context, + ) + .await + .map(|()| None) + } + + pub(crate) async fn thread_unsubscribe( + &self, + request_id: &ConnectionRequestId, + params: ThreadUnsubscribeParams, + ) -> Result, JSONRPCErrorError> { + self.thread_unsubscribe_response_inner(params, request_id.connection_id) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_resume( + &self, + request_id: ConnectionRequestId, + params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result, JSONRPCErrorError> { + self.thread_resume_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) + } + + pub(crate) async fn thread_fork( + &self, + request_id: ConnectionRequestId, + params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result, JSONRPCErrorError> { + self.thread_fork_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) + } + + pub(crate) async fn thread_archive( + &self, + request_id: ConnectionRequestId, + params: ThreadArchiveParams, + ) -> Result, JSONRPCErrorError> { + match self.thread_archive_inner(params).await { + Ok((response, archived_thread_ids)) => { + self.outgoing + .send_response(request_id.clone(), response) + .await; + for thread_id in archived_thread_ids { + self.outgoing + .send_server_notification(ServerNotification::ThreadArchived( + ThreadArchivedNotification { thread_id }, + )) + .await; + } + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(crate) async fn thread_increment_elicitation( + &self, + params: ThreadIncrementElicitationParams, + ) -> Result, JSONRPCErrorError> { + self.thread_increment_elicitation_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_decrement_elicitation( + &self, + params: ThreadDecrementElicitationParams, + ) -> Result, JSONRPCErrorError> { + self.thread_decrement_elicitation_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_set_name( + &self, + request_id: ConnectionRequestId, + params: ThreadSetNameParams, + ) -> Result, JSONRPCErrorError> { + match self.thread_set_name_response_inner(params).await { + Ok((response, notification)) => { + self.outgoing + .send_response(request_id.clone(), response) + .await; + if let Some(notification) = notification { + self.outgoing + .send_server_notification(ServerNotification::ThreadNameUpdated( + notification, + )) + .await; + } + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(crate) async fn thread_metadata_update( + &self, + params: ThreadMetadataUpdateParams, + ) -> Result, JSONRPCErrorError> { + self.thread_metadata_update_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_memory_mode_set( + &self, + params: ThreadMemoryModeSetParams, + ) -> Result, JSONRPCErrorError> { + self.thread_memory_mode_set_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn memory_reset( + &self, + ) -> Result, JSONRPCErrorError> { + self.memory_reset_response_inner() + .await + .map(|response: MemoryResetResponse| Some(response.into())) + } + + pub(crate) async fn thread_unarchive( + &self, + request_id: ConnectionRequestId, + params: ThreadUnarchiveParams, + ) -> Result, JSONRPCErrorError> { + match self.thread_unarchive_inner(params).await { + Ok((response, notification)) => { + self.outgoing + .send_response(request_id.clone(), response) + .await; + self.outgoing + .send_server_notification(ServerNotification::ThreadUnarchived(notification)) + .await; + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(crate) async fn thread_compact_start( + &self, + request_id: &ConnectionRequestId, + params: ThreadCompactStartParams, + ) -> Result, JSONRPCErrorError> { + self.thread_compact_start_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_background_terminals_clean( + &self, + request_id: &ConnectionRequestId, + params: ThreadBackgroundTerminalsCleanParams, + ) -> Result, JSONRPCErrorError> { + self.thread_background_terminals_clean_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_rollback( + &self, + request_id: &ConnectionRequestId, + params: ThreadRollbackParams, + ) -> Result, JSONRPCErrorError> { + self.thread_rollback_inner(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn thread_list( + &self, + params: ThreadListParams, + ) -> Result, JSONRPCErrorError> { + self.thread_list_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_loaded_list( + &self, + params: ThreadLoadedListParams, + ) -> Result, JSONRPCErrorError> { + self.thread_loaded_list_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_read( + &self, + params: ThreadReadParams, + ) -> Result, JSONRPCErrorError> { + self.thread_read_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_turns_list( + &self, + params: ThreadTurnsListParams, + ) -> Result, JSONRPCErrorError> { + self.thread_turns_list_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_turns_items_list( + &self, + _params: ThreadTurnsItemsListParams, + ) -> Result, JSONRPCErrorError> { + Err(method_not_found( + "thread/turns/items/list is not supported yet", + )) + } + + pub(crate) async fn thread_shell_command( + &self, + request_id: &ConnectionRequestId, + params: ThreadShellCommandParams, + ) -> Result, JSONRPCErrorError> { + self.thread_shell_command_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_approve_guardian_denied_action( + &self, + request_id: &ConnectionRequestId, + params: ThreadApproveGuardianDeniedActionParams, + ) -> Result, JSONRPCErrorError> { + self.thread_approve_guardian_denied_action_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn conversation_summary( + &self, + params: GetConversationSummaryParams, + ) -> Result, JSONRPCErrorError> { + self.get_thread_summary_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + async fn instruction_sources_from_config(config: &Config) -> Vec { + codex_core::AgentsMdManager::new(config) + .instruction_sources(LOCAL_FS.as_ref()) + .await + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + // Resolve the core conversation handle from a v2 thread id string. + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + async fn acquire_thread_list_state_permit( + &self, + ) -> Result, JSONRPCErrorError> { + self.thread_list_state_permit + .acquire() + .await + .map_err(|err| { + internal_error(format!("failed to acquire thread list state permit: {err}")) + }) + } + + async fn set_app_server_client_info( + thread: &CodexThread, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); + thread + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) + .await + .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) + } + + async fn finalize_thread_teardown(&self, thread_id: ThreadId) { + self.pending_thread_unloads.lock().await.remove(&thread_id); + self.outgoing + .cancel_requests_for_thread(thread_id, /*error*/ None) + .await; + self.thread_state_manager + .remove_thread_state(thread_id) + .await; + self.thread_watch_manager + .remove_thread(&thread_id.to_string()) + .await; + } + + async fn thread_unsubscribe_response_inner( + &self, + params: ThreadUnsubscribeParams, + connection_id: ConnectionId, + ) -> Result { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + if self.thread_manager.get_thread(thread_id).await.is_err() { + self.finalize_thread_teardown(thread_id).await; + return Ok(ThreadUnsubscribeResponse { + status: ThreadUnsubscribeStatus::NotLoaded, + }); + }; + + let was_subscribed = self + .thread_state_manager + .unsubscribe_connection_from_thread(thread_id, connection_id) + .await; + + let status = if was_subscribed { + ThreadUnsubscribeStatus::Unsubscribed + } else { + ThreadUnsubscribeStatus::NotSubscribed + }; + Ok(ThreadUnsubscribeResponse { status }) + } + + async fn prepare_thread_for_archive(&self, thread_id: ThreadId) { + let removed_conversation = self.thread_manager.remove_thread(&thread_id).await; + if let Some(conversation) = removed_conversation { + info!("thread {thread_id} was active; shutting down"); + match wait_for_thread_shutdown(&conversation).await { + ThreadShutdownResult::Complete => {} + ThreadShutdownResult::SubmitFailed => { + error!( + "failed to submit Shutdown to thread {thread_id}; proceeding with archive" + ); + } + ThreadShutdownResult::TimedOut => { + warn!("thread {thread_id} shutdown timed out; proceeding with archive"); + } + } + } + self.finalize_thread_teardown(thread_id).await; + } + + fn listener_task_context(&self) -> ListenerTaskContext { + ListenerTaskContext { + thread_manager: Arc::clone(&self.thread_manager), + thread_state_manager: self.thread_state_manager.clone(), + outgoing: Arc::clone(&self.outgoing), + pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), + thread_watch_manager: self.thread_watch_manager.clone(), + thread_list_state_permit: self.thread_list_state_permit.clone(), + fallback_model_provider: self.config.model_provider_id.clone(), + codex_home: self.config.codex_home.to_path_buf(), + } + } + + async fn ensure_conversation_listener( + &self, + conversation_id: ThreadId, + connection_id: ConnectionId, + raw_events_enabled: bool, + ) -> Result { + super::thread_lifecycle::ensure_conversation_listener( + self.listener_task_context(), + conversation_id, + connection_id, + raw_events_enabled, + ) + .await + } + + async fn ensure_listener_task_running( + &self, + conversation_id: ThreadId, + conversation: Arc, + thread_state: Arc>, + ) -> Result<(), JSONRPCErrorError> { + super::thread_lifecycle::ensure_listener_task_running( + self.listener_task_context(), + conversation_id, + conversation, + thread_state, + ) + .await + } + + async fn thread_start_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + request_context: RequestContext, + ) -> Result<(), JSONRPCErrorError> { + let ThreadStartParams { + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + config, + service_name, + base_instructions, + developer_instructions, + dynamic_tools, + mock_experimental_field: _mock_experimental_field, + experimental_raw_events, + personality, + ephemeral, + session_start_source, + thread_source, + environments, + persist_extended_history, + } = params; + if sandbox.is_some() && permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandbox`", + )); + } + if persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } + let environment_selections = self.parse_environment_selections(environments)?; + let mut typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + base_instructions, + developer_instructions, + personality, + ); + typesafe_overrides.ephemeral = ephemeral; + let listener_task_context = ListenerTaskContext { + thread_manager: Arc::clone(&self.thread_manager), + thread_state_manager: self.thread_state_manager.clone(), + outgoing: Arc::clone(&self.outgoing), + pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), + thread_watch_manager: self.thread_watch_manager.clone(), + thread_list_state_permit: self.thread_list_state_permit.clone(), + fallback_model_provider: self.config.model_provider_id.clone(), + codex_home: self.config.codex_home.to_path_buf(), + }; + let request_trace = request_context.request_trace(); + let config_manager = self.config_manager.clone(); + let outgoing = Arc::clone(&listener_task_context.outgoing); + let error_request_id = request_id.clone(); + let thread_start_task = async move { + if let Err(error) = Self::thread_start_task( + listener_task_context, + config_manager, + request_id, + app_server_client_name, + app_server_client_version, + config, + typesafe_overrides, + dynamic_tools, + session_start_source, + thread_source.map(Into::into), + environment_selections, + service_name, + experimental_raw_events, + request_trace, + ) + .await + { + outgoing.send_error(error_request_id, error).await; + } + }; + self.background_tasks + .spawn(thread_start_task.instrument(request_context.span())); + Ok(()) + } + + pub(crate) async fn drain_background_tasks(&self) { + self.background_tasks.close(); + if tokio::time::timeout(Duration::from_secs(10), self.background_tasks.wait()) + .await + .is_err() + { + warn!("timed out waiting for background tasks to shut down; proceeding"); + } + } + + pub(crate) async fn clear_all_thread_listeners(&self) { + self.thread_state_manager.clear_all_listeners().await; + } + + pub(crate) async fn shutdown_threads(&self) { + let report = self + .thread_manager + .shutdown_all_threads_bounded(Duration::from_secs(10)) + .await; + for thread_id in report.submit_failed { + warn!("failed to submit Shutdown to thread {thread_id}"); + } + for thread_id in report.timed_out { + warn!("timed out waiting for thread {thread_id} to shut down"); + } + } + + async fn request_trace_context( + &self, + request_id: &ConnectionRequestId, + ) -> Option { + self.outgoing.request_trace_context(request_id).await + } + + async fn send_persist_extended_history_deprecation_notice(&self, connection_id: ConnectionId) { + self.outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::DeprecationNotice(DeprecationNoticeNotification { + summary: PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY.to_string(), + details: Some(PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS.to_string()), + }), + ) + .await; + } + + async fn submit_core_op( + &self, + request_id: &ConnectionRequestId, + thread: &CodexThread, + op: Op, + ) -> CodexResult { + thread + .submit_with_trace(op, self.request_trace_context(request_id).await) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn thread_start_task( + listener_task_context: ListenerTaskContext, + config_manager: ConfigManager, + request_id: ConnectionRequestId, + app_server_client_name: Option, + app_server_client_version: Option, + config_overrides: Option>, + typesafe_overrides: ConfigOverrides, + dynamic_tools: Option>, + session_start_source: Option, + thread_source: Option, + environments: Option>, + service_name: Option, + experimental_raw_events: bool, + request_trace: Option, + ) -> Result<(), JSONRPCErrorError> { + let requested_cwd = typesafe_overrides.cwd.clone(); + let mut config = config_manager + .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) + .await + .map_err(|err| config_load_error(&err))?; + + // The user may have requested WorkspaceWrite or DangerFullAccess via + // the command line, though in the process of deriving the Config, it + // could be downgraded to ReadOnly (perhaps there is no sandbox + // available on Windows or the enterprise config disallows it). The cwd + // should still be considered "trusted" in this case. + let requested_permissions_trust_project = + requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); + let effective_permissions_trust_project = permission_profile_trusts_project( + &config.permissions.permission_profile(), + config.cwd.as_path(), + ); + + if requested_cwd.is_some() + && config.active_project.trust_level.is_none() + && (requested_permissions_trust_project || effective_permissions_trust_project) + { + let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) + .await + .unwrap_or_else(|| config.cwd.clone()); + let current_cli_overrides = config_manager.current_cli_overrides(); + let cli_overrides_with_trust; + let cli_overrides_for_reload = if let Err(err) = + codex_core::config::set_project_trust_level( + &listener_task_context.codex_home, + trust_target.as_path(), + TrustLevel::Trusted, + ) { + warn!( + "failed to persist trusted project state for {}; continuing with in-memory trust for this thread: {err}", + trust_target.display() + ); + let mut project = toml::map::Map::new(); + project.insert( + "trust_level".to_string(), + TomlValue::String("trusted".to_string()), + ); + let mut projects = toml::map::Map::new(); + projects.insert( + project_trust_key(trust_target.as_path()), + TomlValue::Table(project), + ); + cli_overrides_with_trust = current_cli_overrides + .iter() + .cloned() + .chain(std::iter::once(( + "projects".to_string(), + TomlValue::Table(projects), + ))) + .collect::>(); + cli_overrides_with_trust.as_slice() + } else { + current_cli_overrides.as_slice() + }; + + config = config_manager + .load_with_cli_overrides( + cli_overrides_for_reload, + config_overrides, + typesafe_overrides, + /*fallback_cwd*/ None, + ) + .await + .map_err(|err| config_load_error(&err))?; + } + + let instruction_sources = Self::instruction_sources_from_config(&config).await; + let environments = environments.unwrap_or_else(|| { + listener_task_context + .thread_manager + .default_environment_selections(&config.cwd) + }); + let dynamic_tools = dynamic_tools.unwrap_or_default(); + let core_dynamic_tools = if dynamic_tools.is_empty() { + Vec::new() + } else { + validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?; + dynamic_tools + .into_iter() + .map(|tool| CoreDynamicToolSpec { + namespace: tool.namespace, + name: tool.name, + description: tool.description, + input_schema: tool.input_schema, + defer_loading: tool.defer_loading, + }) + .collect() + }; + let core_dynamic_tool_count = core_dynamic_tools.len(); + + let NewThread { + thread_id, + thread, + session_configured, + .. + } = listener_task_context + .thread_manager + .start_thread_with_options(StartThreadOptions { + config, + initial_history: match session_start_source + .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) + { + codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New, + codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared, + }, + session_source: None, + thread_source, + dynamic_tools: core_dynamic_tools, + persist_extended_history: false, + metrics_service_name: service_name, + parent_trace: request_trace, + environments, + }) + .instrument(tracing::info_span!( + "app_server.thread_start.create_thread", + otel.name = "app_server.thread_start.create_thread", + thread_start.dynamic_tool_count = core_dynamic_tool_count, + thread_start.persist_extended_history = false, + )) + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("error creating thread: {err}")), + })?; + + Self::set_app_server_client_info( + thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + + let config_snapshot = thread + .config_snapshot() + .instrument(tracing::info_span!( + "app_server.thread_start.config_snapshot", + otel.name = "app_server.thread_start.config_snapshot", + )) + .await; + let mut thread = build_thread_from_snapshot( + thread_id, + session_configured.session_id.to_string(), + &config_snapshot, + session_configured.rollout_path.clone(), + ); + + // Auto-attach a thread listener when starting a thread. + log_listener_attach_result( + super::thread_lifecycle::ensure_conversation_listener( + listener_task_context.clone(), + thread_id, + request_id.connection_id, + experimental_raw_events, + ) + .instrument(tracing::info_span!( + "app_server.thread_start.attach_listener", + otel.name = "app_server.thread_start.attach_listener", + thread_start.experimental_raw_events = experimental_raw_events, + )) + .await, + thread_id, + request_id.connection_id, + "thread", + ); + + listener_task_context + .thread_watch_manager + .upsert_thread_silently(thread.clone()) + .instrument(tracing::info_span!( + "app_server.thread_start.upsert_thread", + otel.name = "app_server.thread_start.upsert_thread", + )) + .await; + + thread.status = resolve_thread_status( + listener_task_context + .thread_watch_manager + .loaded_status_for_thread(&thread.id) + .instrument(tracing::info_span!( + "app_server.thread_start.resolve_status", + otel.name = "app_server.thread_start.resolve_status", + )) + .await, + /*has_in_progress_turn*/ false, + ); + + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ); + let active_permission_profile = + thread_response_active_permission_profile(config_snapshot.active_permission_profile); + + let response = ThreadStartResponse { + thread: thread.clone(), + model: config_snapshot.model, + model_provider: config_snapshot.model_provider_id, + service_tier: config_snapshot.service_tier, + cwd: config_snapshot.cwd, + instruction_sources, + approval_policy: config_snapshot.approval_policy.into(), + approvals_reviewer: config_snapshot.approvals_reviewer.into(), + sandbox, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, + reasoning_effort: config_snapshot.reasoning_effort, + }; + let notif = thread_started_notification(thread); + listener_task_context + .outgoing + .send_response(request_id, response) + .instrument(tracing::info_span!( + "app_server.thread_start.send_response", + otel.name = "app_server.thread_start.send_response", + )) + .await; + + listener_task_context + .outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .instrument(tracing::info_span!( + "app_server.thread_start.notify_started", + otel.name = "app_server.thread_start.notify_started", + )) + .await; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn build_thread_config_overrides( + &self, + model: Option, + model_provider: Option, + service_tier: Option>, + cwd: Option, + approval_policy: Option, + approvals_reviewer: Option, + sandbox: Option, + permissions: Option, + base_instructions: Option, + developer_instructions: Option, + personality: Option, + ) -> ConfigOverrides { + let mut overrides = ConfigOverrides { + model, + model_provider, + service_tier, + cwd: cwd.map(PathBuf::from), + approval_policy: approval_policy + .map(codex_app_server_protocol::AskForApproval::to_core), + approvals_reviewer: approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core), + sandbox_mode: sandbox.map(SandboxMode::to_core), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + base_instructions, + developer_instructions, + personality, + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); + overrides + } + + fn parse_environment_selections( + &self, + environments: Option>, + ) -> Result>, JSONRPCErrorError> { + let environment_selections = environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect::>() + }); + if let Some(environment_selections) = environment_selections.as_ref() { + self.thread_manager + .validate_environment_selections(environment_selections) + .map_err(|err| invalid_request(environment_selection_error_message(err)))?; + } + Ok(environment_selections) + } + + async fn thread_archive_inner( + &self, + params: ThreadArchiveParams, + ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + self.thread_archive_response(params).await + } + + async fn thread_archive_response( + &self, + params: ThreadArchiveParams, + ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let mut thread_ids = vec![thread_id]; + if let Some(state_db_ctx) = self.state_db.as_ref() { + let descendants = state_db_ctx + .list_thread_spawn_descendants(thread_id) + .await + .map_err(|err| { + internal_error(format!( + "failed to list spawned descendants for thread id {thread_id}: {err}" + )) + })?; + let mut seen = HashSet::from([thread_id]); + for descendant_id in descendants { + if seen.insert(descendant_id) { + thread_ids.push(descendant_id); + } + } + } + + let mut archive_thread_ids = Vec::new(); + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }) + .await + { + Ok(thread) => { + if thread.archived_at.is_none() { + archive_thread_ids.push(thread_id); + } + } + Err(err) => return Err(thread_store_archive_error("archive", err)), + } + for descendant_thread_id in thread_ids.into_iter().skip(1) { + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id: descendant_thread_id, + include_archived: true, + include_history: false, + }) + .await + { + Ok(thread) => { + if thread.archived_at.is_none() { + archive_thread_ids.push(descendant_thread_id); + } + } + Err(err) => { + warn!( + "failed to read spawned descendant thread {descendant_thread_id} while archiving {thread_id}: {err}" + ); + } + } + } + + let mut archived_thread_ids = Vec::new(); + let Some((parent_thread_id, descendant_thread_ids)) = archive_thread_ids.split_first() + else { + return Ok((ThreadArchiveResponse {}, archived_thread_ids)); + }; + + self.prepare_thread_for_archive(*parent_thread_id).await; + match self + .thread_store + .archive_thread(StoreArchiveThreadParams { + thread_id: *parent_thread_id, + }) + .await + { + Ok(()) => { + archived_thread_ids.push(parent_thread_id.to_string()); + } + Err(err) => return Err(thread_store_archive_error("archive", err)), + } + + for descendant_thread_id in descendant_thread_ids.iter().rev().copied() { + self.prepare_thread_for_archive(descendant_thread_id).await; + match self + .thread_store + .archive_thread(StoreArchiveThreadParams { + thread_id: descendant_thread_id, + }) + .await + { + Ok(()) => { + archived_thread_ids.push(descendant_thread_id.to_string()); + } + Err(err) => { + warn!( + "failed to archive spawned descendant thread {descendant_thread_id} while archiving {thread_id}: {err}" + ); + } + } + } + + Ok((ThreadArchiveResponse {}, archived_thread_ids)) + } + + async fn thread_increment_elicitation_inner( + &self, + params: ThreadIncrementElicitationParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let count = thread + .increment_out_of_band_elicitation_count() + .await + .map_err(|err| { + internal_error(format!( + "failed to increment out-of-band elicitation counter: {err}" + )) + })?; + Ok(ThreadIncrementElicitationResponse { + count, + paused: count > 0, + }) + } + + async fn thread_decrement_elicitation_inner( + &self, + params: ThreadDecrementElicitationParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let count = thread + .decrement_out_of_band_elicitation_count() + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!( + "failed to decrement out-of-band elicitation counter: {err}" + )), + })?; + Ok(ThreadDecrementElicitationResponse { + count, + paused: count > 0, + }) + } + + async fn thread_set_name_response_inner( + &self, + params: ThreadSetNameParams, + ) -> Result<(ThreadSetNameResponse, Option), JSONRPCErrorError> + { + let ThreadSetNameParams { thread_id, name } = params; + let thread_id = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + let Some(name) = codex_core::util::normalize_thread_name(&name) else { + return Err(invalid_request("thread name must not be empty")); + }; + + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + self.thread_store + .update_thread_metadata(StoreUpdateThreadMetadataParams { + thread_id, + patch: StoreThreadMetadataPatch { + name: Some(name.clone()), + ..Default::default() + }, + include_archived: false, + }) + .await + .map_err(|err| thread_store_write_error("set thread name", err))?; + + Ok(( + ThreadSetNameResponse {}, + Some(ThreadNameUpdatedNotification { + thread_id: thread_id.to_string(), + thread_name: Some(name), + }), + )) + } + + async fn thread_memory_mode_set_response_inner( + &self, + params: ThreadMemoryModeSetParams, + ) -> Result { + let ThreadMemoryModeSetParams { thread_id, mode } = params; + let thread_id = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { + if thread.config_snapshot().await.ephemeral { + return Err(invalid_request(format!( + "ephemeral thread does not support memory mode updates: {thread_id}" + ))); + } + + thread + .set_thread_memory_mode(mode.to_core()) + .await + .map_err(|err| { + internal_error(format!("failed to set thread memory mode: {err}")) + })?; + return Ok(ThreadMemoryModeSetResponse {}); + } + + self.thread_store + .update_thread_metadata(StoreUpdateThreadMetadataParams { + thread_id, + patch: StoreThreadMetadataPatch { + memory_mode: Some(mode.to_core()), + ..Default::default() + }, + include_archived: false, + }) + .await + .map_err(|err| thread_store_write_error("set thread memory mode", err))?; + + Ok(ThreadMemoryModeSetResponse {}) + } + + async fn memory_reset_response_inner(&self) -> Result { + let state_db = self + .state_db + .clone() + .ok_or_else(|| internal_error("sqlite state db unavailable for memory reset"))?; + + state_db.clear_memory_data().await.map_err(|err| { + internal_error(format!("failed to clear memory rows in state db: {err}")) + })?; + + clear_memory_roots_contents(&self.config.codex_home) + .await + .map_err(|err| { + internal_error(format!( + "failed to clear memory directories under {}: {err}", + self.config.codex_home.display() + )) + })?; + + Ok(MemoryResetResponse {}) + } + + async fn thread_metadata_update_response_inner( + &self, + params: ThreadMetadataUpdateParams, + ) -> Result { + let ThreadMetadataUpdateParams { + thread_id, + git_info, + } = params; + + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let Some(ThreadMetadataGitInfoUpdateParams { + sha, + branch, + origin_url, + }) = git_info + else { + return Err(invalid_request("gitInfo must include at least one field")); + }; + + if sha.is_none() && branch.is_none() && origin_url.is_none() { + return Err(invalid_request("gitInfo must include at least one field")); + } + + let git_sha = Self::normalize_thread_metadata_git_field(sha, "gitInfo.sha")?; + let git_branch = Self::normalize_thread_metadata_git_field(branch, "gitInfo.branch")?; + let git_origin_url = + Self::normalize_thread_metadata_git_field(origin_url, "gitInfo.originUrl")?; + + let patch = StoreThreadMetadataPatch { + git_info: Some(StoreGitInfoPatch { + sha: git_sha, + branch: git_branch, + origin_url: git_origin_url, + }), + ..Default::default() + }; + + let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); + let updated_thread = { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + if let Some(loaded_thread) = loaded_thread.as_ref() { + if loaded_thread.config_snapshot().await.ephemeral { + return Err(invalid_request(format!( + "ephemeral thread does not support metadata updates: {thread_id}" + ))); + } + loaded_thread + .update_thread_metadata(patch, /*include_archived*/ true) + .await + } else { + self.thread_store + .update_thread_metadata(StoreUpdateThreadMetadataParams { + thread_id: thread_uuid, + patch, + include_archived: true, + }) + .await + } + .map_err(|err| thread_store_write_error("update thread metadata", err))? + }; + let (mut thread, _) = thread_from_stored_thread( + updated_thread, + self.config.model_provider_id.as_str(), + &self.config.cwd, + ); + if let Some(loaded_thread) = loaded_thread.as_ref() { + thread.session_id = loaded_thread.session_configured().session_id.to_string(); + } + self.attach_thread_name(thread_uuid, &mut thread).await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + + Ok(ThreadMetadataUpdateResponse { thread }) + } + + fn normalize_thread_metadata_git_field( + value: Option>, + name: &str, + ) -> Result>, JSONRPCErrorError> { + match value { + Some(Some(value)) => { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(invalid_request(format!("{name} must not be empty"))); + } + Ok(Some(Some(value))) + } + Some(None) => Ok(Some(None)), + None => Ok(None), + } + } + + async fn thread_unarchive_inner( + &self, + params: ThreadUnarchiveParams, + ) -> Result<(ThreadUnarchiveResponse, ThreadUnarchivedNotification), JSONRPCErrorError> { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + let (response, thread_id) = self.thread_unarchive_response(params).await?; + Ok((response, ThreadUnarchivedNotification { thread_id })) + } + + async fn thread_unarchive_response( + &self, + params: ThreadUnarchiveParams, + ) -> Result<(ThreadUnarchiveResponse, String), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let fallback_provider = self.config.model_provider_id.clone(); + let stored_thread = self + .thread_store + .unarchive_thread(StoreArchiveThreadParams { thread_id }) + .await + .map_err(|err| thread_store_archive_error("unarchive", err))?; + let (mut thread, _) = + thread_from_stored_thread(stored_thread, fallback_provider.as_str(), &self.config.cwd); + + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + self.attach_thread_name(thread_id, &mut thread).await; + let thread_id = thread.id.clone(); + Ok((ThreadUnarchiveResponse { thread }, thread_id)) + } + + async fn thread_rollback_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRollbackParams, + ) -> Result<(), JSONRPCErrorError> { + self.thread_rollback_start(request_id, params).await + } + + async fn thread_rollback_start( + &self, + request_id: &ConnectionRequestId, + params: ThreadRollbackParams, + ) -> Result<(), JSONRPCErrorError> { + let ThreadRollbackParams { + thread_id, + num_turns, + } = params; + + if num_turns == 0 { + return Err(invalid_request("numTurns must be >= 1")); + } + + let (thread_id, thread) = self.load_thread(&thread_id).await?; + + let request = request_id.clone(); + + let rollback_already_in_progress = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let mut thread_state = thread_state.lock().await; + if thread_state.pending_rollbacks.is_some() { + true + } else { + thread_state.pending_rollbacks = Some(request.clone()); + false + } + }; + if rollback_already_in_progress { + return Err(invalid_request( + "rollback already in progress for this thread", + )); + } + + if let Err(err) = self + .submit_core_op( + request_id, + thread.as_ref(), + Op::ThreadRollback { num_turns }, + ) + .await + { + // No ThreadRollback event will arrive if an error occurs. + // Clean up and reply immediately. + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + thread_state.lock().await.pending_rollbacks = None; + + return Err(internal_error(format!("failed to start rollback: {err}"))); + } + Ok(()) + } + + async fn thread_compact_start_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadCompactStartParams, + ) -> Result { + let ThreadCompactStartParams { thread_id } = params; + + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op(request_id, thread.as_ref(), Op::Compact) + .await + .map_err(|err| internal_error(format!("failed to start compaction: {err}")))?; + Ok(ThreadCompactStartResponse {}) + } + + async fn thread_background_terminals_clean_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadBackgroundTerminalsCleanParams, + ) -> Result { + let ThreadBackgroundTerminalsCleanParams { thread_id } = params; + + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op(request_id, thread.as_ref(), Op::CleanBackgroundTerminals) + .await + .map_err(|err| { + internal_error(format!("failed to clean background terminals: {err}")) + })?; + Ok(ThreadBackgroundTerminalsCleanResponse {}) + } + + async fn thread_shell_command_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadShellCommandParams, + ) -> Result { + let ThreadShellCommandParams { thread_id, command } = params; + let command = command.trim().to_string(); + if command.is_empty() { + return Err(invalid_request("command must not be empty")); + } + + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RunUserShellCommand { command }, + ) + .await + .map_err(|err| internal_error(format!("failed to start shell command: {err}")))?; + Ok(ThreadShellCommandResponse {}) + } + + async fn thread_approve_guardian_denied_action_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadApproveGuardianDeniedActionParams, + ) -> Result { + let ThreadApproveGuardianDeniedActionParams { thread_id, event } = params; + let event = serde_json::from_value(event) + .map_err(|err| invalid_request(format!("invalid Guardian denial event: {err}")))?; + let (_, thread) = self.load_thread(&thread_id).await?; + + self.submit_core_op( + request_id, + thread.as_ref(), + Op::ApproveGuardianDeniedAction { event }, + ) + .await + .map_err(|err| internal_error(format!("failed to approve Guardian denial: {err}")))?; + Ok(ThreadApproveGuardianDeniedActionResponse {}) + } + + async fn thread_list_response_inner( + &self, + params: ThreadListParams, + ) -> Result { + let ThreadListParams { + cursor, + limit, + sort_key, + sort_direction, + model_providers, + source_kinds, + archived, + cwd, + use_state_db_only, + search_term, + } = params; + let cwd_filters = normalize_thread_list_cwd_filters(cwd)?; + + let requested_page_size = limit + .map(|value| value as usize) + .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) + .clamp(1, THREAD_LIST_MAX_LIMIT); + let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { + ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + }; + let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); + let (stored_threads, next_cursor) = self + .list_threads_common( + requested_page_size, + cursor, + store_sort_key, + sort_direction, + ThreadListFilters { + model_providers, + source_kinds, + archived: archived.unwrap_or(false), + cwd_filters, + search_term, + use_state_db_only, + }, + ) + .await?; + let backwards_cursor = stored_threads.first().and_then(|thread| { + thread_backwards_cursor_for_sort_key(thread, store_sort_key, sort_direction) + }); + let mut threads = Vec::with_capacity(stored_threads.len()); + let mut status_ids = Vec::with_capacity(stored_threads.len()); + let fallback_provider = self.config.model_provider_id.clone(); + + for stored_thread in stored_threads { + let (thread, _) = thread_from_stored_thread( + stored_thread, + fallback_provider.as_str(), + &self.config.cwd, + ); + status_ids.push(thread.id.clone()); + threads.push(thread); + } + + let statuses = self + .thread_watch_manager + .loaded_statuses_for_threads(status_ids) + .await; + + let data: Vec<_> = threads + .into_iter() + .map(|mut thread| { + if let Some(status) = statuses.get(&thread.id) { + thread.status = status.clone(); + } + thread + }) + .collect(); + Ok(ThreadListResponse { + data, + next_cursor, + backwards_cursor, + }) + } + + async fn thread_loaded_list_response_inner( + &self, + params: ThreadLoadedListParams, + ) -> Result { + let ThreadLoadedListParams { cursor, limit } = params; + let mut data: Vec = self + .thread_manager + .list_thread_ids() + .await + .into_iter() + .map(|thread_id| thread_id.to_string()) + .collect(); + + if data.is_empty() { + return Ok(ThreadLoadedListResponse { + data, + next_cursor: None, + }); + } + + data.sort(); + let total = data.len(); + let start = match cursor { + Some(cursor) => { + let cursor = match ThreadId::from_string(&cursor) { + Ok(id) => id.to_string(), + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }; + match data.binary_search(&cursor) { + Ok(idx) => idx + 1, + Err(idx) => idx, + } + } + None => 0, + }; + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let end = start.saturating_add(effective_limit).min(total); + let page = data[start..end].to_vec(); + let next_cursor = page.last().filter(|_| end < total).cloned(); + + Ok(ThreadLoadedListResponse { + data: page, + next_cursor, + }) + } + + async fn thread_read_response_inner( + &self, + params: ThreadReadParams, + ) -> Result { + let ThreadReadParams { + thread_id, + include_turns, + } = params; + + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .read_thread_view(thread_uuid, include_turns) + .await + .map_err(thread_read_view_error)?; + Ok(ThreadReadResponse { thread }) + } + + /// Builds the API view for `thread/read` from persisted metadata plus optional live state. + async fn read_thread_view( + &self, + thread_id: ThreadId, + include_turns: bool, + ) -> Result { + let loaded_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let mut thread = if include_turns { + if let Some(loaded_thread) = loaded_thread.as_ref() { + // Loaded thread with turns: use persisted metadata when it exists, + // but reconstruct turns from the live ThreadStore history. + let persisted_thread = self + .load_persisted_thread_for_read(thread_id, /*include_turns*/ false) + .await?; + self.load_live_thread_view( + thread_id, + include_turns, + loaded_thread, + persisted_thread, + ) + .await? + } else if let Some(thread) = self + .load_persisted_thread_for_read(thread_id, include_turns) + .await? + { + // Unloaded thread with turns: load metadata and history together + // from the ThreadStore. + thread + } else { + return Err(ThreadReadViewError::InvalidRequest(format!( + "thread not loaded: {thread_id}" + ))); + } + } else if let Some(thread) = self + .load_persisted_thread_for_read(thread_id, include_turns) + .await? + { + // Persisted metadata-only read: no live thread state is needed. + thread + } else if let Some(loaded_thread) = loaded_thread.as_ref() { + // Loaded metadata-only read before persistence is materialized: build + // the response from the live thread snapshot. + self.load_live_thread_view( + thread_id, + include_turns, + loaded_thread, + /*persisted_thread*/ None, + ) + .await? + } else { + return Err(ThreadReadViewError::InvalidRequest(format!( + "thread not loaded: {thread_id}" + ))); + }; + + let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() { + matches!(loaded_thread.agent_status().await, AgentStatus::Running) + } else { + false + }; + + let thread_status = self + .thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + set_thread_status_and_interrupt_stale_turns( + &mut thread, + thread_status, + has_live_in_progress_turn, + ); + Ok(thread) + } + + async fn load_persisted_thread_for_read( + &self, + thread_id: ThreadId, + include_turns: bool, + ) -> Result, ThreadReadViewError> { + let fallback_provider = self.config.model_provider_id.as_str(); + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history: include_turns, + }) + .await + { + Ok(stored_thread) => { + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + if include_turns && let Some(history) = history { + thread.turns = build_api_turns_from_rollout_items(&history.items); + } + Ok(Some(thread)) + } + Err(ThreadStoreError::InvalidRequest { message }) + if message == format!("no rollout found for thread id {thread_id}") => + { + Ok(None) + } + Err(ThreadStoreError::ThreadNotFound { + thread_id: missing_thread_id, + }) if missing_thread_id == thread_id => Ok(None), + Err(ThreadStoreError::InvalidRequest { message }) => { + Err(ThreadReadViewError::InvalidRequest(message)) + } + Err(err) => Err(ThreadReadViewError::Internal(format!( + "failed to read thread: {err}" + ))), + } + } + + /// Builds a `thread/read` view from a loaded thread plus optional persisted metadata. + async fn load_live_thread_view( + &self, + thread_id: ThreadId, + include_turns: bool, + loaded_thread: &CodexThread, + persisted_thread: Option, + ) -> Result { + let config_snapshot = loaded_thread.config_snapshot().await; + if include_turns && config_snapshot.ephemeral { + return Err(ThreadReadViewError::InvalidRequest( + "ephemeral threads do not support includeTurns".to_string(), + )); + } + let fallback_thread = + build_thread_from_loaded_snapshot(thread_id, &config_snapshot, loaded_thread); + let mut thread = if let Some(mut thread) = persisted_thread { + if thread.path.is_none() { + thread.path = fallback_thread.path.clone(); + } + thread.session_id.clone_from(&fallback_thread.session_id); + thread.ephemeral = fallback_thread.ephemeral; + thread + } else { + fallback_thread + }; + self.apply_thread_read_store_fields(thread_id, &mut thread, include_turns, loaded_thread) + .await?; + Ok(thread) + } + + async fn apply_thread_read_store_fields( + &self, + thread_id: ThreadId, + thread: &mut Thread, + include_turns: bool, + loaded_thread: &CodexThread, + ) -> Result<(), ThreadReadViewError> { + self.attach_thread_name(thread_id, thread).await; + + if include_turns { + let history = loaded_thread + .load_history(/*include_archived*/ true) + .await + .map_err(|err| thread_read_history_load_error(thread_id, err))?; + thread.turns = build_api_turns_from_rollout_items(&history.items); + } + + Ok(()) + } + + async fn thread_turns_list_response_inner( + &self, + params: ThreadTurnsListParams, + ) -> Result { + let ThreadTurnsListParams { + thread_id, + cursor, + limit, + sort_direction, + items_view, + } = params; + let items_view = items_view.unwrap_or(TurnItemsView::Summary); + + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let items = self + .load_thread_turns_list_history(thread_uuid) + .await + .map_err(thread_read_view_error)?; + // This API optimizes network transfer by letting clients page through a + // thread's turns incrementally, but it still replays the entire rollout on + // every request. Rollback and compaction events can change earlier turns, so + // the server has to rebuild the full turn list until turn metadata is indexed + // separately. + let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); + let has_live_running_thread = match loaded_thread.as_ref() { + Some(thread) => matches!(thread.agent_status().await, AgentStatus::Running), + None => false, + }; + let active_turn = if loaded_thread.is_some() { + // Persisted history may not yet include the currently running turn. The + // app-server listener has already projected live turn events into ThreadState, + // so merge that in-memory snapshot before paginating. + let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + let state = thread_state.lock().await; + state.active_turn_snapshot() + } else { + None + }; + let mut turns = reconstruct_thread_turns_for_turns_list( + &items, + self.thread_watch_manager + .loaded_status_for_thread(&thread_uuid.to_string()) + .await, + has_live_running_thread, + active_turn, + ); + for turn in &mut turns { + match items_view { + TurnItemsView::NotLoaded => { + turn.items.clear(); + turn.items_view = TurnItemsView::NotLoaded; + } + TurnItemsView::Summary => { + let first_user_message = turn + .items + .iter() + .find(|item| matches!(item, ThreadItem::UserMessage { .. })) + .cloned(); + let final_agent_message = turn + .items + .iter() + .rev() + .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) + .cloned(); + turn.items = match (first_user_message, final_agent_message) { + (Some(user_message), Some(agent_message)) + if user_message.id() != agent_message.id() => + { + vec![user_message, agent_message] + } + (Some(user_message), _) => vec![user_message], + (None, Some(agent_message)) => vec![agent_message], + (None, None) => Vec::new(), + }; + turn.items_view = TurnItemsView::Summary; + } + TurnItemsView::Full => { + turn.items_view = TurnItemsView::Full; + } + } + } + let page = paginate_thread_turns( + turns, + cursor.as_deref(), + limit, + sort_direction.unwrap_or(SortDirection::Desc), + )?; + Ok(ThreadTurnsListResponse { + data: page.turns, + next_cursor: page.next_cursor, + backwards_cursor: page.backwards_cursor, + }) + } + + async fn load_thread_turns_list_history( + &self, + thread_id: ThreadId, + ) -> Result, ThreadReadViewError> { + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await + { + Ok(stored_thread) => { + let history = stored_thread.history.ok_or_else(|| { + ThreadReadViewError::Internal(format!( + "thread store did not return history for thread {thread_id}" + )) + })?; + return Ok(history.items); + } + Err(ThreadStoreError::InvalidRequest { message }) + if message == format!("no rollout found for thread id {thread_id}") => {} + Err(ThreadStoreError::ThreadNotFound { + thread_id: missing_thread_id, + }) if missing_thread_id == thread_id => {} + Err(ThreadStoreError::InvalidRequest { message }) => { + return Err(ThreadReadViewError::InvalidRequest(message)); + } + Err(err) => { + return Err(ThreadReadViewError::Internal(format!( + "failed to read thread: {err}" + ))); + } + } + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| { + ThreadReadViewError::InvalidRequest(format!("thread not loaded: {thread_id}")) + })?; + let config_snapshot = thread.config_snapshot().await; + if config_snapshot.ephemeral { + return Err(ThreadReadViewError::InvalidRequest( + "ephemeral threads do not support thread/turns/list".to_string(), + )); + } + + thread + .load_history(/*include_archived*/ true) + .await + .map(|history| history.items) + .map_err(|err| thread_turns_list_history_load_error(thread_id, err)) + } + + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { + self.thread_manager.subscribe_thread_created() + } + + pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { + self.thread_state_manager + .connection_initialized(connection_id) + .await; + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let thread_ids = self + .thread_state_manager + .remove_connection(connection_id) + .await; + + for thread_id in thread_ids { + if self.thread_manager.get_thread(thread_id).await.is_err() { + // Reconcile stale app-server bookkeeping when the thread has already been + // removed from the core manager. + self.finalize_thread_teardown(thread_id).await; + } + } + } + + pub(crate) fn subscribe_running_assistant_turn_count(&self) -> watch::Receiver { + self.thread_watch_manager.subscribe_running_turn_count() + } + + /// Best-effort: ensure initialized connections are subscribed to this thread. + pub(crate) async fn try_attach_thread_listener( + &self, + thread_id: ThreadId, + connection_ids: Vec, + ) { + if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { + let config_snapshot = thread.config_snapshot().await; + let loaded_thread = build_thread_from_snapshot( + thread_id, + thread.session_configured().session_id.to_string(), + &config_snapshot, + thread.rollout_path(), + ); + self.thread_watch_manager.upsert_thread(loaded_thread).await; + } + + for connection_id in connection_ids { + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + connection_id, + "thread", + ); + } + } + + async fn thread_resume_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) + && self + .pending_thread_unloads + .lock() + .await + .contains(&thread_id) + { + self.outgoing + .send_error( + request_id, + invalid_request(format!( + "thread {thread_id} is closing; retry thread/resume after the thread is closed" + )), + ) + .await; + return Ok(()); + } + + if params.sandbox.is_some() && params.permissions.is_some() { + self.outgoing + .send_error( + request_id, + invalid_request("`permissions` cannot be combined with `sandbox`"), + ) + .await; + return Ok(()); + } + if params.persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } + + let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await { + Ok(permit) => permit, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + }; + match self + .resume_running_thread( + &request_id, + ¶ms, + app_server_client_name.clone(), + app_server_client_version.clone(), + ) + .await + { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + + let ThreadResumeParams { + thread_id, + history, + path, + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + config: mut request_overrides, + base_instructions, + developer_instructions, + personality, + exclude_turns, + persist_extended_history: _persist_extended_history, + } = params; + let include_turns = !exclude_turns; + + let (thread_history, resume_source_thread) = match if let Some(history) = history { + self.resume_thread_from_history(history.as_slice()) + .await + .map(|thread_history| (thread_history, None)) + } else { + self.resume_thread_from_rollout(&thread_id, path.as_ref()) + .await + .map(|(thread_history, stored_thread)| (thread_history, Some(stored_thread))) + } { + Ok(value) => value, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + }; + + let history_cwd = thread_history.session_cwd(); + let mut typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + base_instructions, + developer_instructions, + personality, + ); + self.load_and_apply_persisted_resume_metadata( + &thread_history, + &mut request_overrides, + &mut typesafe_overrides, + ) + .await; + + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = match self + .config_manager + .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) + .await + { + Ok(config) => config, + Err(err) => { + let error = config_load_error(&err); + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + }; + + let instruction_sources = Self::instruction_sources_from_config(&config).await; + let response_history = thread_history.clone(); + + match self + .thread_manager + .resume_thread_with_history( + config.clone(), + thread_history, + self.auth_manager.clone(), + /*persist_extended_history*/ false, + self.request_trace_context(&request_id).await, + ) + .await + { + Ok(NewThread { + thread_id, + thread: codex_thread, + session_configured, + .. + }) => { + if let Err(err) = Self::set_app_server_client_info( + codex_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await + { + self.outgoing.send_error(request_id, err).await; + return Ok(()); + } + let SessionConfiguredEvent { rollout_path, .. } = session_configured; + let Some(rollout_path) = rollout_path else { + let error = + internal_error(format!("rollout path missing for thread {thread_id}")); + self.outgoing.send_error(request_id, error).await; + return Ok(()); + }; + // Auto-attach a thread listener when resuming a thread. + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + request_id.connection_id, + "thread", + ); + + let mut thread = match self + .load_thread_from_resume_source_or_send_internal( + thread_id, + codex_thread.as_ref(), + &response_history, + rollout_path.as_path(), + resume_source_thread, + include_turns, + ) + .await + { + Ok(thread) => thread, + Err(message) => { + self.outgoing + .send_error(request_id, internal_error(message)) + .await; + return Ok(()); + } + }; + thread.thread_source = codex_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); + + self.thread_watch_manager + .upsert_thread(thread.clone()) + .await; + + let thread_status = self + .thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + set_thread_status_and_interrupt_stale_turns( + &mut thread, + thread_status, + /*has_live_in_progress_turn*/ false, + ); + let config_snapshot = codex_thread.config_snapshot().await; + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); + + let response = ThreadResumeResponse { + thread, + model: session_configured.model, + model_provider: session_configured.model_provider_id, + service_tier: session_configured.service_tier, + cwd: session_configured.cwd, + instruction_sources, + approval_policy: session_configured.approval_policy.into(), + approvals_reviewer: session_configured.approvals_reviewer.into(), + sandbox, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, + reasoning_effort: session_configured.reasoning_effort, + }; + + let connection_id = request_id.connection_id; + let token_usage_thread = include_turns.then(|| response.thread.clone()); + self.outgoing.send_response(request_id, response).await; + // `excludeTurns` is explicitly the cheap resume path, so avoid + // rebuilding history only to attribute a replayed usage update. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &response_history.get_rollout_items(), + token_usage_thread.turns.as_slice(), + ); + // The client needs restored usage before it starts another turn. + // Sending after the response preserves JSON-RPC request ordering while + // still filling the status line before the next turn lifecycle begins. + send_thread_token_usage_update_to_connection( + &self.outgoing, + connection_id, + thread_id, + &token_usage_thread, + codex_thread.as_ref(), + token_usage_turn_id, + ) + .await; + } + self.thread_goal_processor + .emit_resume_goal_snapshot_and_continue(thread_id, codex_thread.as_ref()) + .await; + } + Err(err) => { + let error = internal_error(format!("error resuming thread: {err}")); + self.outgoing.send_error(request_id, error).await; + } + } + Ok(()) + } + + async fn load_and_apply_persisted_resume_metadata( + &self, + thread_history: &InitialHistory, + request_overrides: &mut Option>, + typesafe_overrides: &mut ConfigOverrides, + ) -> Option { + let InitialHistory::Resumed(resumed_history) = thread_history else { + return None; + }; + let state_db_ctx = self.state_db.clone()?; + let persisted_metadata = state_db_ctx + .get_thread(resumed_history.conversation_id) + .await + .ok() + .flatten()?; + merge_persisted_resume_metadata(request_overrides, typesafe_overrides, &persisted_metadata); + Some(persisted_metadata) + } + + async fn resume_running_thread( + &self, + request_id: &ConnectionRequestId, + params: &ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result { + let running_thread = if params.history.is_some() { + if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) + && self + .thread_manager + .get_thread(existing_thread_id) + .await + .is_ok() + { + return Err(invalid_request(format!( + "cannot resume thread {existing_thread_id} with history while it is already running" + ))); + } + None + } else if params.path.is_some() { + let source_thread = self + .read_stored_thread_for_resume( + ¶ms.thread_id, + params.path.as_ref(), + /*include_history*/ true, + ) + .await?; + let existing_thread_id = source_thread.thread_id; + if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { + if let (Some(requested_path), Some(active_path)) = ( + params.path.as_ref(), + existing_thread.rollout_path().as_ref(), + ) && requested_path != active_path + { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", + requested_path.display(), + active_path.display() + ))); + } + Some((existing_thread_id, existing_thread, source_thread)) + } else { + None + } + } else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) + && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await + { + let source_thread = self + .read_stored_thread_for_resume( + ¶ms.thread_id, + /*path*/ None, + /*include_history*/ true, + ) + .await?; + if source_thread.thread_id != existing_thread_id { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} from source thread {}", + source_thread.thread_id + ))); + } + Some((existing_thread_id, existing_thread, source_thread)) + } else { + None + }; + + if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { + let history_items = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {existing_thread_id} did not include persisted history" + )) + })?; + + let thread_state = self + .thread_state_manager + .thread_state(existing_thread_id) + .await; + self.ensure_listener_task_running( + existing_thread_id, + existing_thread.clone(), + thread_state.clone(), + ) + .await?; + Self::set_app_server_client_info( + existing_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + + let config_snapshot = existing_thread.config_snapshot().await; + let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); + if !mismatch_details.is_empty() { + tracing::warn!( + "thread/resume overrides ignored for running thread {}: {}", + existing_thread_id, + mismatch_details.join("; ") + ); + } + let mut summary_source_thread = source_thread; + summary_source_thread.history = None; + let mut thread_summary = self.stored_thread_to_api_thread( + summary_source_thread, + config_snapshot.model_provider_id.as_str(), + /*include_turns*/ false, + ); + thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); + let mut config_for_instruction_sources = self.config.as_ref().clone(); + config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); + let instruction_sources = + Self::instruction_sources_from_config(&config_for_instruction_sources).await; + + let listener_command_tx = { + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let Some(listener_command_tx) = listener_command_tx else { + return Err(internal_error(format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running" + ))); + }; + + let (emit_thread_goal_update, thread_goal_state_db) = self + .thread_goal_processor + .pending_resume_goal_state(existing_thread.as_ref()) + .await; + + let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( + Box::new(crate::thread_state::PendingThreadResumeRequest { + request_id: request_id.clone(), + history_items, + config_snapshot, + instruction_sources, + thread_summary, + emit_thread_goal_update, + thread_goal_state_db, + include_turns: !params.exclude_turns, + }), + ); + if listener_command_tx.send(command).is_err() { + return Err(internal_error(format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed" + ))); + } + return Ok(true); + } + Ok(false) + } + + async fn resume_thread_from_history( + &self, + history: &[ResponseItem], + ) -> Result { + if history.is_empty() { + return Err(invalid_request("history must not be empty")); + } + Ok(InitialHistory::Forked( + history + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(), + )) + } + + async fn resume_thread_from_rollout( + &self, + thread_id: &str, + path: Option<&PathBuf>, + ) -> Result<(InitialHistory, StoredThread), JSONRPCErrorError> { + let stored_thread = self + .read_stored_thread_for_resume(thread_id, path, /*include_history*/ true) + .await?; + let history = self + .stored_thread_to_initial_history(&stored_thread) + .await?; + Ok((history, stored_thread)) + } + + async fn read_stored_thread_for_resume( + &self, + thread_id: &str, + path: Option<&PathBuf>, + include_history: bool, + ) -> Result { + let result = if let Some(path) = path { + self.thread_store + .read_thread_by_rollout_path(StoreReadThreadByRolloutPathParams { + rollout_path: path.clone(), + include_archived: true, + include_history, + }) + .await + } else { + let existing_thread_id = match ThreadId::from_string(thread_id) { + Ok(id) => id, + Err(err) => { + return Err(invalid_request(format!("invalid thread id: {err}"))); + } + }; + let params = StoreReadThreadParams { + thread_id: existing_thread_id, + include_archived: true, + include_history, + }; + self.thread_store.read_thread(params).await + }; + + result.map_err(thread_store_resume_read_error) + } + + async fn stored_thread_to_initial_history( + &self, + stored_thread: &StoredThread, + ) -> Result { + let thread_id = stored_thread.thread_id; + let history = stored_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {thread_id} did not include persisted history" + )) + })?; + Ok(InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path.clone(), + })) + } + + fn stored_thread_to_api_thread( + &self, + stored_thread: StoredThread, + fallback_provider: &str, + include_turns: bool, + ) -> Thread { + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + if include_turns && let Some(history) = history { + populate_thread_turns_from_history( + &mut thread, + &history.items, + /*active_turn*/ None, + ); + } + thread + } + + async fn read_stored_thread_for_new_fork( + &self, + thread_id: ThreadId, + include_history: bool, + ) -> Result { + self.thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history, + }) + .await + .map_err(thread_store_resume_read_error) + } + + async fn load_thread_from_resume_source_or_send_internal( + &self, + thread_id: ThreadId, + thread: &CodexThread, + thread_history: &InitialHistory, + rollout_path: &Path, + resume_source_thread: Option, + include_turns: bool, + ) -> std::result::Result { + let config_snapshot = thread.config_snapshot().await; + let session_id = thread.session_configured().session_id.to_string(); + let thread = match thread_history { + InitialHistory::Resumed(resumed) => { + let fallback_provider = config_snapshot.model_provider_id.as_str(); + if let Some(stored_thread) = resume_source_thread { + let stored_thread = + if let Some(rollout_path) = stored_thread.rollout_path.clone() { + self.thread_store + .read_thread_by_rollout_path(StoreReadThreadByRolloutPathParams { + rollout_path, + include_archived: true, + include_history: false, + }) + .await + .unwrap_or(StoredThread { + history: None, + ..stored_thread + }) + } else { + self.thread_store + .read_thread(StoreReadThreadParams { + thread_id: stored_thread.thread_id, + include_archived: true, + include_history: false, + }) + .await + .unwrap_or(StoredThread { + history: None, + ..stored_thread + }) + }; + Ok(thread_from_stored_thread( + stored_thread, + fallback_provider, + &self.config.cwd, + ) + .0) + } else { + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id: resumed.conversation_id, + include_archived: true, + include_history: false, + }) + .await + { + Ok(stored_thread) => Ok(thread_from_stored_thread( + stored_thread, + fallback_provider, + &self.config.cwd, + ) + .0), + Err(read_err) => { + Err(format!("failed to read thread from store: {read_err}")) + } + } + } + } + InitialHistory::Forked(items) => { + let mut thread = build_thread_from_snapshot( + thread_id, + session_id.clone(), + &config_snapshot, + Some(rollout_path.into()), + ); + thread.preview = preview_from_rollout_items(items); + Ok(thread) + } + InitialHistory::New | InitialHistory::Cleared => Err(format!( + "failed to build resume response for thread {thread_id}: initial history missing" + )), + }; + let mut thread = thread?; + thread.id = thread_id.to_string(); + thread.session_id = session_id; + thread.path = Some(rollout_path.to_path_buf()); + if include_turns { + let history_items = thread_history.get_rollout_items(); + populate_thread_turns_from_history( + &mut thread, + &history_items, + /*active_turn*/ None, + ); + } + self.attach_thread_name(thread_id, &mut thread).await; + Ok(thread) + } + + async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) { + if let Ok(stored_thread) = self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history: false, + }) + .await + && let Some(title) = stored_thread.name.as_deref().map(str::trim) + && !title.is_empty() + && stored_thread.preview.trim() != title + { + set_thread_name_from_title(thread, title.to_string()); + } + } + + async fn thread_fork_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + let ThreadForkParams { + thread_id, + path, + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + config: cli_overrides, + base_instructions, + developer_instructions, + ephemeral, + thread_source, + exclude_turns, + persist_extended_history, + } = params; + let include_turns = !exclude_turns; + if sandbox.is_some() && permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandbox`", + )); + } + if persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } + + let source_thread = self + .read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true) + .await?; + let source_thread_id = source_thread.thread_id; + let history_items = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {source_thread_id} did not include persisted history" + )) + })?; + let history_cwd = Some(source_thread.cwd.clone()); + + // Persist Windows sandbox mode. + let mut cli_overrides = cli_overrides.unwrap_or_default(); + if cfg!(windows) { + match WindowsSandboxLevel::from_config(&self.config) { + WindowsSandboxLevel::Elevated => { + cli_overrides + .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); + } + WindowsSandboxLevel::RestrictedToken => { + cli_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("unelevated"), + ); + } + WindowsSandboxLevel::Disabled => {} + } + } + let request_overrides = if cli_overrides.is_empty() { + None + } else { + Some(cli_overrides) + }; + let mut typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + base_instructions, + developer_instructions, + /*personality*/ None, + ); + typesafe_overrides.ephemeral = ephemeral.then_some(true); + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = self + .config_manager + .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) + .await + .map_err(|err| config_load_error(&err))?; + + let fallback_model_provider = config.model_provider_id.clone(); + let instruction_sources = Self::instruction_sources_from_config(&config).await; + + let NewThread { + thread_id, + thread: forked_thread, + session_configured, + .. + } = self + .thread_manager + .fork_thread_from_history( + ForkSnapshot::Interrupted, + config, + InitialHistory::Resumed(ResumedHistory { + conversation_id: source_thread_id, + history: history_items.clone(), + rollout_path: source_thread.rollout_path.clone(), + }), + thread_source.map(Into::into), + /*persist_extended_history*/ false, + self.request_trace_context(&request_id).await, + ) + .await + .map_err(|err| match err { + CodexErr::Io(_) | CodexErr::Json(_) => { + invalid_request(format!("failed to load thread {source_thread_id}: {err}")) + } + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("error forking thread: {err}")), + })?; + + Self::set_app_server_client_info( + forked_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + + // Auto-attach a conversation listener when forking a thread. + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + request_id.connection_id, + "thread", + ); + + // Persistent forks materialize their own rollout immediately. Ephemeral forks stay + // pathless, so they rebuild their visible history from the copied source history instead. + let mut thread = if session_configured.rollout_path.is_some() { + let stored_thread = self + .read_stored_thread_for_new_fork(thread_id, include_turns) + .await?; + self.stored_thread_to_api_thread( + stored_thread, + fallback_model_provider.as_str(), + include_turns, + ) + } else { + let config_snapshot = forked_thread.config_snapshot().await; + // forked thread names do not inherit the source thread name + let mut thread = build_thread_from_snapshot( + thread_id, + session_configured.session_id.to_string(), + &config_snapshot, + /*path*/ None, + ); + thread.preview = preview_from_rollout_items(&history_items); + thread.forked_from_id = Some(source_thread_id.to_string()); + if include_turns { + populate_thread_turns_from_history( + &mut thread, + &history_items, + /*active_turn*/ None, + ); + } + thread + }; + thread.session_id = session_configured.session_id.to_string(); + thread.thread_source = forked_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); + + self.thread_watch_manager + .upsert_thread_silently(thread.clone()) + .await; + + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + let config_snapshot = forked_thread.config_snapshot().await; + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ); + let active_permission_profile = + thread_response_active_permission_profile(config_snapshot.active_permission_profile); + + let response = ThreadForkResponse { + thread: thread.clone(), + model: session_configured.model, + model_provider: session_configured.model_provider_id, + service_tier: session_configured.service_tier, + cwd: session_configured.cwd, + instruction_sources, + approval_policy: session_configured.approval_policy.into(), + approvals_reviewer: session_configured.approvals_reviewer.into(), + sandbox, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, + reasoning_effort: session_configured.reasoning_effort, + }; + + let notif = thread_started_notification(thread); + let connection_id = request_id.connection_id; + let token_usage_thread = include_turns.then(|| response.thread.clone()); + self.outgoing.send_response(request_id, response).await; + // `excludeTurns` is the cheap fork path, so skip restored usage replay + // instead of rebuilding history only to attribute a historical update. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &history_items, + token_usage_thread.turns.as_slice(), + ); + // Mirror the resume contract for forks: the new thread is usable as soon + // as the response arrives, so restored usage must follow immediately. + send_thread_token_usage_update_to_connection( + &self.outgoing, + connection_id, + thread_id, + &token_usage_thread, + forked_thread.as_ref(), + token_usage_turn_id, + ) + .await; + } + + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + Ok(()) + } + + async fn get_thread_summary_response_inner( + &self, + params: GetConversationSummaryParams, + ) -> Result { + let fallback_provider = self.config.model_provider_id.as_str(); + let read_result = match params { + GetConversationSummaryParams::ThreadId { conversation_id } => self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id: conversation_id, + include_archived: true, + include_history: false, + }) + .await + .map_err(|err| conversation_summary_thread_id_read_error(conversation_id, err)), + GetConversationSummaryParams::RolloutPath { rollout_path } => { + let Some(local_thread_store) = self + .thread_store + .as_any() + .downcast_ref::() + else { + return Err(invalid_request( + "rollout path queries are only supported with the local thread store", + )); + }; + + local_thread_store + .read_thread_by_rollout_path( + rollout_path.clone(), + /*include_archived*/ true, + /*include_history*/ false, + ) + .await + .map_err(|err| conversation_summary_rollout_path_read_error(&rollout_path, err)) + } + }; + + let stored_thread = read_result?; + let summary = summary_from_stored_thread(stored_thread, fallback_provider); + Ok(GetConversationSummaryResponse { summary }) + } + + async fn list_threads_common( + &self, + requested_page_size: usize, + cursor: Option, + sort_key: StoreThreadSortKey, + sort_direction: SortDirection, + filters: ThreadListFilters, + ) -> Result<(Vec, Option), JSONRPCErrorError> { + let ThreadListFilters { + model_providers, + source_kinds, + archived, + cwd_filters, + search_term, + use_state_db_only, + } = filters; + let mut cursor_obj = cursor; + let mut last_cursor = cursor_obj.clone(); + let mut remaining = requested_page_size; + let mut items = Vec::with_capacity(requested_page_size); + let mut next_cursor: Option = None; + + let model_provider_filter = match model_providers { + Some(providers) => { + if providers.is_empty() { + None + } else { + Some(providers) + } + } + None => Some(vec![self.config.model_provider_id.clone()]), + }; + let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds); + let allowed_sources = allowed_sources_vec.as_slice(); + let store_sort_direction = match sort_direction { + SortDirection::Asc => StoreSortDirection::Asc, + SortDirection::Desc => StoreSortDirection::Desc, + }; + + while remaining > 0 { + let page_size = remaining.min(THREAD_LIST_MAX_LIMIT); + let page = self + .thread_store + .list_threads(StoreListThreadsParams { + page_size, + cursor: cursor_obj.clone(), + sort_key, + sort_direction: store_sort_direction, + allowed_sources: allowed_sources.to_vec(), + model_providers: model_provider_filter.clone(), + cwd_filters: cwd_filters.clone(), + archived, + search_term: search_term.clone(), + use_state_db_only, + }) + .await + .map_err(thread_store_list_error)?; + + let mut filtered = Vec::with_capacity(page.items.len()); + for it in page.items { + let source = with_thread_spawn_agent_metadata( + it.source.clone(), + it.agent_nickname.clone(), + it.agent_role.clone(), + ); + if source_kind_filter + .as_ref() + .is_none_or(|filter| source_kind_matches(&source, filter)) + && cwd_filters.as_ref().is_none_or(|expected_cwds| { + expected_cwds.iter().any(|expected_cwd| { + path_utils::paths_match_after_normalization(&it.cwd, expected_cwd) + }) + }) + { + filtered.push(it); + if filtered.len() >= remaining { + break; + } + } + } + items.extend(filtered); + remaining = requested_page_size.saturating_sub(items.len()); + + next_cursor = page.next_cursor; + if remaining == 0 { + break; + } + + let Some(cursor_val) = next_cursor.clone() else { + break; + }; + // Break if our pagination would reuse the same cursor again; this avoids + // an infinite loop when filtering drops everything on the page. + if last_cursor.as_ref() == Some(&cursor_val) { + next_cursor = None; + break; + } + last_cursor = Some(cursor_val.clone()); + cursor_obj = Some(cursor_val); + } + + Ok((items, next_cursor)) + } +} + +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} + +const THREAD_TURNS_DEFAULT_LIMIT: usize = 25; +const THREAD_TURNS_MAX_LIMIT: usize = 100; + +fn thread_backwards_cursor_for_sort_key( + thread: &StoredThread, + sort_key: StoreThreadSortKey, + sort_direction: SortDirection, +) -> Option { + let timestamp = match sort_key { + StoreThreadSortKey::CreatedAt => thread.created_at, + StoreThreadSortKey::UpdatedAt => thread.updated_at, + }; + // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one + // millisecond so the opposite-direction query includes the page anchor. + let timestamp = match sort_direction { + SortDirection::Asc => timestamp.checked_add_signed(ChronoDuration::milliseconds(1))?, + SortDirection::Desc => timestamp.checked_sub_signed(ChronoDuration::milliseconds(1))?, + }; + Some(timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)) +} + +struct ThreadTurnsPage { + pub(super) turns: Vec, + pub(super) next_cursor: Option, + pub(super) backwards_cursor: Option, +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ThreadTurnsCursor { + turn_id: String, + include_anchor: bool, +} + +fn paginate_thread_turns( + turns: Vec, + cursor: Option<&str>, + limit: Option, + sort_direction: SortDirection, +) -> Result { + if turns.is_empty() { + return Ok(ThreadTurnsPage { + turns: Vec::new(), + next_cursor: None, + backwards_cursor: None, + }); + } + + let anchor = cursor.map(parse_thread_turns_cursor).transpose()?; + let page_size = limit + .map(|value| value as usize) + .unwrap_or(THREAD_TURNS_DEFAULT_LIMIT) + .clamp(1, THREAD_TURNS_MAX_LIMIT); + + let anchor_index = anchor + .as_ref() + .and_then(|anchor| turns.iter().position(|turn| turn.id == anchor.turn_id)); + if anchor.is_some() && anchor_index.is_none() { + return Err(invalid_request( + "invalid cursor: anchor turn is no longer present", + )); + } + + let mut keyed_turns: Vec<_> = turns.into_iter().enumerate().collect(); + match sort_direction { + SortDirection::Asc => { + if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { + keyed_turns.retain(|(index, _)| { + if anchor.include_anchor { + *index >= anchor_index + } else { + *index > anchor_index + } + }); + } + } + SortDirection::Desc => { + keyed_turns.reverse(); + if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { + keyed_turns.retain(|(index, _)| { + if anchor.include_anchor { + *index <= anchor_index + } else { + *index < anchor_index + } + }); + } + } + } + + let more_turns_available = keyed_turns.len() > page_size; + keyed_turns.truncate(page_size); + let backwards_cursor = keyed_turns + .first() + .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ true)) + .transpose()?; + let next_cursor = if more_turns_available { + keyed_turns + .last() + .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ false)) + .transpose()? + } else { + None + }; + let turns = keyed_turns.into_iter().map(|(_, turn)| turn).collect(); + + Ok(ThreadTurnsPage { + turns, + next_cursor, + backwards_cursor, + }) +} + +fn serialize_thread_turns_cursor( + turn_id: &str, + include_anchor: bool, +) -> Result { + serde_json::to_string(&ThreadTurnsCursor { + turn_id: turn_id.to_string(), + include_anchor, + }) + .map_err(|err| internal_error(format!("failed to serialize cursor: {err}"))) +} + +fn parse_thread_turns_cursor(cursor: &str) -> Result { + serde_json::from_str(cursor).map_err(|_| invalid_request(format!("invalid cursor: {cursor}"))) +} + +fn reconstruct_thread_turns_for_turns_list( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, +) -> Vec { + let has_live_in_progress_turn = has_live_running_thread + || active_turn + .as_ref() + .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); + let mut turns = build_api_turns_from_rollout_items(items); + normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn); + if let Some(active_turn) = active_turn { + merge_turn_history_with_active_turn(&mut turns, active_turn); + } + turns +} + +fn normalize_thread_turns_status( + turns: &mut [Turn], + loaded_status: ThreadStatus, + has_live_in_progress_turn: bool, +) { + let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); + if matches!(status, ThreadStatus::Active { .. }) { + return; + } + for turn in turns { + if matches!(turn.status, TurnStatus::InProgress) { + turn.status = TurnStatus::Interrupted; + } + } +} + +enum ThreadReadViewError { + InvalidRequest(String), + Unsupported(&'static str), + Internal(String), +} + +fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError { + match err { + ThreadReadViewError::InvalidRequest(message) => invalid_request(message), + ThreadReadViewError::Unsupported(operation) => { + unsupported_thread_store_operation(operation) + } + ThreadReadViewError::Internal(message) => internal_error(message), + } +} + +fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError { + method_not_found(format!("{operation} is not supported yet")) +} + +fn thread_store_list_error(err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + err => internal_error(format!("failed to list threads: {err}")), + } +} + +fn thread_store_resume_read_error(err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + ThreadStoreError::ThreadNotFound { thread_id } => { + invalid_request(format!("no rollout found for thread id {thread_id}")) + } + err => internal_error(format!("failed to read thread: {err}")), + } +} + +fn thread_turns_list_history_load_error( + thread_id: ThreadId, + err: ThreadStoreError, +) -> ThreadReadViewError { + match err { + ThreadStoreError::InvalidRequest { message } + if message.starts_with("failed to resolve rollout path `") => + { + ThreadReadViewError::InvalidRequest(format!( + "thread {thread_id} is not materialized yet; thread/turns/list is unavailable before first user message" + )) + } + ThreadStoreError::InvalidRequest { message } => { + ThreadReadViewError::InvalidRequest(message) + } + ThreadStoreError::Unsupported { operation } => ThreadReadViewError::Unsupported(operation), + err => ThreadReadViewError::Internal(format!( + "failed to load thread history for thread {thread_id}: {err}" + )), + } +} + +fn thread_read_history_load_error( + thread_id: ThreadId, + err: ThreadStoreError, +) -> ThreadReadViewError { + match err { + ThreadStoreError::InvalidRequest { message } + if message.starts_with("failed to resolve rollout path `") => + { + ThreadReadViewError::InvalidRequest(format!( + "thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message" + )) + } + ThreadStoreError::ThreadNotFound { + thread_id: missing_thread_id, + } if missing_thread_id == thread_id => ThreadReadViewError::InvalidRequest(format!( + "thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message" + )), + ThreadStoreError::InvalidRequest { message } => { + ThreadReadViewError::InvalidRequest(message) + } + ThreadStoreError::Unsupported { operation } => ThreadReadViewError::Unsupported(operation), + err => ThreadReadViewError::Internal(format!( + "failed to load thread history for thread {thread_id}: {err}" + )), + } +} + +fn conversation_summary_thread_id_read_error( + conversation_id: ThreadId, + err: ThreadStoreError, +) -> JSONRPCErrorError { + let no_rollout_message = format!("no rollout found for thread id {conversation_id}"); + match err { + ThreadStoreError::InvalidRequest { message } if message == no_rollout_message => { + conversation_summary_not_found_error(conversation_id) + } + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + ThreadStoreError::ThreadNotFound { thread_id } if thread_id == conversation_id => { + conversation_summary_not_found_error(conversation_id) + } + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + err => internal_error(format!( + "failed to load conversation summary for {conversation_id}: {err}" + )), + } +} + +fn conversation_summary_not_found_error(conversation_id: ThreadId) -> JSONRPCErrorError { + invalid_request(format!( + "no rollout found for conversation id {conversation_id}" + )) +} + +fn conversation_summary_rollout_path_read_error( + path: &Path, + err: ThreadStoreError, +) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + err => internal_error(format!( + "failed to load conversation summary from {}: {}", + path.display(), + err + )), + } +} + +fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::ThreadNotFound { thread_id } => { + invalid_request(format!("thread not found: {thread_id}")) + } + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + err => internal_error(format!("failed to {operation}: {err}")), + } +} + +fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { + operation: unsupported_operation, + } => unsupported_thread_store_operation(unsupported_operation), + err => internal_error(format!("failed to {operation} thread: {err}")), + } +} + +fn set_thread_name_from_title(thread: &mut Thread, title: String) { + if title.trim().is_empty() || thread.preview.trim() == title.trim() { + return; + } + thread.name = Some(title); +} + +pub(crate) fn thread_from_stored_thread( + thread: StoredThread, + fallback_provider: &str, + fallback_cwd: &AbsolutePathBuf, +) -> (Thread, Option) { + let path = thread.rollout_path; + let git_info = thread.git_info.map(|info| ApiGitInfo { + sha: info.commit_hash.map(|sha| sha.0), + branch: info.branch, + origin_url: info.repository_url, + }); + let cwd = AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir( + thread.cwd, + )) + .unwrap_or_else(|err| { + warn!("failed to normalize thread cwd while reading stored thread: {err}"); + fallback_cwd.clone() + }); + let source = with_thread_spawn_agent_metadata( + thread.source, + thread.agent_nickname.clone(), + thread.agent_role.clone(), + ); + let history = thread.history; + let thread_id = thread.thread_id.to_string(); + let thread = Thread { + id: thread_id.clone(), + session_id: thread_id, + forked_from_id: thread.forked_from_id.map(|id| id.to_string()), + preview: thread.first_user_message.unwrap_or(thread.preview), + ephemeral: false, + model_provider: if thread.model_provider.is_empty() { + fallback_provider.to_string() + } else { + thread.model_provider + }, + created_at: thread.created_at.timestamp(), + updated_at: thread.updated_at.timestamp(), + status: ThreadStatus::NotLoaded, + path, + cwd, + cli_version: thread.cli_version, + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), + source: source.into(), + thread_source: thread.thread_source.map(Into::into), + git_info, + name: thread.name, + turns: Vec::new(), + }; + (thread, history) +} + +fn summary_from_stored_thread( + thread: StoredThread, + fallback_provider: &str, +) -> ConversationSummary { + let path = thread.rollout_path.unwrap_or_default(); + let source = with_thread_spawn_agent_metadata( + thread.source, + thread.agent_nickname.clone(), + thread.agent_role.clone(), + ); + let git_info = thread.git_info.map(|git| ConversationGitInfo { + sha: git.commit_hash.map(|sha| sha.0), + branch: git.branch, + origin_url: git.repository_url, + }); + ConversationSummary { + conversation_id: thread.thread_id, + path, + preview: thread.first_user_message.unwrap_or(thread.preview), + // Preserve millisecond precision from the thread store so thread/list cursors + // round-trip the same ordering key used by pagination queries. + timestamp: Some( + thread + .created_at + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + updated_at: Some( + thread + .updated_at + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + model_provider: if thread.model_provider.is_empty() { + fallback_provider.to_string() + } else { + thread.model_provider + }, + cwd: thread.cwd, + cli_version: thread.cli_version, + source, + git_info, + } +} + +#[allow(clippy::too_many_arguments)] +#[cfg(test)] +fn summary_from_state_db_metadata( + conversation_id: ThreadId, + path: PathBuf, + first_user_message: Option, + timestamp: String, + updated_at: String, + model_provider: String, + cwd: PathBuf, + cli_version: String, + source: String, + _thread_source: Option, + agent_nickname: Option, + agent_role: Option, + git_sha: Option, + git_branch: Option, + git_origin_url: Option, +) -> ConversationSummary { + let preview = first_user_message.unwrap_or_default(); + let source = serde_json::from_str(&source) + .or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone()))) + .unwrap_or(codex_protocol::protocol::SessionSource::Unknown); + let source = with_thread_spawn_agent_metadata(source, agent_nickname, agent_role); + let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() { + None + } else { + Some(ConversationGitInfo { + sha: git_sha, + branch: git_branch, + origin_url: git_origin_url, + }) + }; + ConversationSummary { + conversation_id, + path, + preview, + timestamp: Some(timestamp), + updated_at: Some(updated_at), + model_provider, + cwd, + cli_version, + source, + git_info, + } +} + +#[cfg(test)] +fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummary { + summary_from_state_db_metadata( + metadata.id, + metadata.rollout_path.clone(), + metadata.first_user_message.clone(), + metadata + .created_at + .to_rfc3339_opts(SecondsFormat::Secs, true), + metadata + .updated_at + .to_rfc3339_opts(SecondsFormat::Secs, true), + metadata.model_provider.clone(), + metadata.cwd.clone(), + metadata.cli_version.clone(), + metadata.source.clone(), + metadata.thread_source, + metadata.agent_nickname.clone(), + metadata.agent_role.clone(), + metadata.git_sha.clone(), + metadata.git_branch.clone(), + metadata.git_origin_url.clone(), + ) +} + +fn preview_from_rollout_items(items: &[RolloutItem]) -> String { + items + .iter() + .find_map(|item| match item { + RolloutItem::ResponseItem(item) => match codex_core::parse_turn_item(item) { + Some(codex_protocol::items::TurnItem::UserMessage(user)) => Some(user.message()), + _ => None, + }, + _ => None, + }) + .map(|preview| match preview.find(USER_MESSAGE_BEGIN) { + Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string(), + None => preview, + }) + .unwrap_or_default() +} + +fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) -> bool { + if matches!( + overrides.sandbox_mode, + Some( + codex_protocol::config_types::SandboxMode::WorkspaceWrite + | codex_protocol::config_types::SandboxMode::DangerFullAccess + ) + ) { + return true; + } + + if matches!( + overrides.default_permissions.as_deref(), + Some(":workspace" | ":danger-no-sandbox") + ) { + return true; + } + + overrides + .permission_profile + .as_ref() + .is_some_and(|profile| permission_profile_trusts_project(profile, cwd)) +} + +fn permission_profile_trusts_project( + profile: &codex_protocol::models::PermissionProfile, + cwd: &Path, +) -> bool { + match profile { + codex_protocol::models::PermissionProfile::Disabled + | codex_protocol::models::PermissionProfile::External { .. } => true, + codex_protocol::models::PermissionProfile::Managed { .. } => profile + .file_system_sandbox_policy() + .can_write_path_with_cwd(cwd, cwd), + } +} + +fn build_thread_from_snapshot( + thread_id: ThreadId, + session_id: String, + config_snapshot: &ThreadConfigSnapshot, + path: Option, +) -> Thread { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + Thread { + id: thread_id.to_string(), + session_id, + forked_from_id: None, + preview: String::new(), + ephemeral: config_snapshot.ephemeral, + model_provider: config_snapshot.model_provider_id.clone(), + created_at: now, + updated_at: now, + status: ThreadStatus::NotLoaded, + path, + cwd: config_snapshot.cwd.clone(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: config_snapshot.session_source.get_nickname(), + agent_role: config_snapshot.session_source.get_agent_role(), + source: config_snapshot.session_source.clone().into(), + thread_source: config_snapshot.thread_source.map(Into::into), + git_info: None, + name: None, + turns: Vec::new(), + } +} + +fn build_thread_from_loaded_snapshot( + thread_id: ThreadId, + config_snapshot: &ThreadConfigSnapshot, + loaded_thread: &CodexThread, +) -> Thread { + build_thread_from_snapshot( + thread_id, + loaded_thread.session_configured().session_id.to_string(), + config_snapshot, + loaded_thread.rollout_path(), + ) +} + +#[cfg(test)] +#[path = "thread_processor_tests.rs"] +mod thread_processor_tests; diff --git a/code-rs/app-server/src/request_processors/thread_processor_tests.rs b/code-rs/app-server/src/request_processors/thread_processor_tests.rs new file mode 100644 index 00000000000..5642dbbe81b --- /dev/null +++ b/code-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -0,0 +1,1267 @@ +mod thread_list_cwd_filter_tests { + use super::super::normalize_thread_list_cwd_filters; + use codex_app_server_protocol::ThreadListCwdFilter; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn normalize_thread_list_cwd_filter_preserves_absolute_paths() { + let cwd = if cfg!(windows) { + String::from(r"C:\srv\repo-b") + } else { + String::from("/srv/repo-b") + }; + + assert_eq!( + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::One(cwd.clone()))) + .expect("cwd filter should parse"), + Some(vec![PathBuf::from(cwd)]) + ); + } + + #[test] + fn normalize_thread_list_cwd_filter_resolves_relative_paths_against_server_cwd() + -> std::io::Result<()> { + let expected = AbsolutePathBuf::relative_to_current_dir("repo-b")?.to_path_buf(); + + assert_eq!( + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::Many(vec![String::from( + "repo-b" + ),]))) + .expect("cwd filter should parse"), + Some(vec![expected]) + ); + Ok(()) + } +} + +mod thread_processor_behavior_tests { + async fn forked_from_id_from_rollout(path: &Path) -> Option { + codex_core::read_session_meta_line(path) + .await + .ok() + .and_then(|meta_line| meta_line.meta.forked_from_id) + .map(|thread_id| thread_id.to_string()) + } + + use super::super::*; + use crate::outgoing_message::OutgoingEnvelope; + use crate::outgoing_message::OutgoingMessage; + use anyhow::Result; + use chrono::DateTime; + use chrono::Utc; + use codex_app_server_protocol::ServerRequestPayload; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::SessionThreadConfig; + use codex_config::StaticThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use codex_protocol::ThreadId; + use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::NetworkSandboxPolicy; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::SubAgentSource; + use codex_state::ThreadMetadataBuilder; + use codex_thread_store::StoredThread; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::collections::BTreeMap; + use std::path::PathBuf; + use std::sync::Arc; + use tempfile::TempDir; + + #[test] + fn validate_dynamic_tools_rejects_unsupported_input_schema() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({"type": "null"}), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid schema"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_accepts_sanitizable_input_schema() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "my_tool".to_string(), + description: "test".to_string(), + // Missing `type` is common; core sanitizes these to a supported schema. + input_schema: json!({"properties": {}}), + defer_loading: false, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_accepts_nullable_field_schema() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": ["string", "null"]} + }, + "required": ["query"], + "additionalProperties": false + }), + defer_loading: false, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_accepts_same_name_in_different_namespaces() { + let tools = vec![ + ApiDynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ApiDynamicToolSpec { + namespace: Some("other_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_accepts_responses_compatible_identifiers() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("Codex-App_2".to_string()), + name: "lookup-ticket_2".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() { + let tools = vec![ + ApiDynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ApiDynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ]; + let err = validate_dynamic_tools(&tools).expect_err("duplicate name"); + assert!(err.contains("codex_app"), "unexpected error: {err}"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + } + + #[test] + fn thread_turns_list_merges_in_progress_active_turn_before_agent_status_running() { + let persisted_items = vec![RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "persisted".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]; + let active_turn = Turn { + id: "live-turn".to_string(), + items: vec![ThreadItem::UserMessage { + id: "live-user-message".to_string(), + content: vec![V2UserInput::Text { + text: "live".to_string(), + text_elements: Vec::new(), + }], + }], + items_view: TurnItemsView::Full, + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + }; + + let turns = reconstruct_thread_turns_for_turns_list( + &persisted_items, + ThreadStatus::Idle, + /*has_live_running_thread*/ false, + Some(active_turn.clone()), + ); + + assert_eq!(turns.last(), Some(&active_turn)); + } + + #[test] + fn validate_dynamic_tools_rejects_empty_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("empty namespace"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + assert!(err.contains("namespace"), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("mcp__server__".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved namespace"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + assert!(err.contains("reserved"), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_name_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "lookup.ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid name"); + assert!(err.contains("lookup.ticket"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid namespace"); + assert!(err.contains("codex.app"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() { + let long_name = "a".repeat(129); + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: long_name.clone(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("name too long"); + assert!(err.contains("at most 128"), "unexpected error: {err}"); + assert!(err.contains(&long_name), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() { + let long_namespace = "a".repeat(65); + let tools = vec![ApiDynamicToolSpec { + namespace: Some(long_namespace.clone()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("namespace too long"); + assert!(err.contains("at most 64"), "unexpected error: {err}"); + assert!(err.contains(&long_namespace), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_responses_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("functions".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace"); + assert!(err.contains("functions"), "unexpected error: {err}"); + assert!(err.contains("Responses API"), "unexpected error: {err}"); + } + + #[test] + fn summary_from_stored_thread_preserves_millisecond_precision() { + let created_at = + DateTime::parse_from_rfc3339("2025-01-02T03:04:05.678Z").expect("valid timestamp"); + let updated_at = + DateTime::parse_from_rfc3339("2025-01-02T03:04:06.789Z").expect("valid timestamp"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread"); + let stored_thread = StoredThread { + thread_id, + rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), + forked_from_id: None, + preview: "preview".to_string(), + name: None, + model_provider: "openai".to_string(), + model: None, + reasoning_effort: None, + created_at: created_at.with_timezone(&Utc), + updated_at: updated_at.with_timezone(&Utc), + archived_at: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + thread_source: Some(codex_protocol::protocol::ThreadSource::User), + agent_nickname: None, + agent_role: None, + agent_path: None, + git_info: None, + approval_mode: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + token_usage: None, + first_user_message: Some("first user message".to_string()), + history: None, + }; + + let summary = summary_from_stored_thread(stored_thread, "fallback"); + + assert_eq!( + summary.timestamp.as_deref(), + Some("2025-01-02T03:04:05.678Z") + ); + assert_eq!( + summary.updated_at.as_deref(), + Some("2025-01-02T03:04:06.789Z") + ); + } + + #[test] + fn requested_permissions_trust_project_uses_permission_profile_intent() { + let cwd = test_path_buf("/tmp/project").abs(); + let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; + let workspace_write_profile = codex_protocol::models::PermissionProfile::workspace_write(); + let read_only_profile = codex_protocol::models::PermissionProfile::read_only(); + let split_write_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: cwd.clone() }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(full_access_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(workspace_write_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(split_write_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":workspace".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(read_only_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":read-only".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + } + + #[test] + fn config_load_error_marks_cloud_requirements_failures_for_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + Some(401), + "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + })) + ); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + let err = std::io::Error::other("required MCP servers failed to initialize"); + + let error = config_load_error(&err); + + assert_eq!(error.data, None); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::RequestFailed, + /*status_code*/ None, + "Failed to load cloud requirements (workspace-managed policies).", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "RequestFailed", + "detail": "Failed to load cloud requirements (workspace-managed policies).", + })) + ); + } + + #[tokio::test] + async fn derive_config_from_params_uses_session_thread_config_model_provider() -> Result<()> { + let temp_dir = TempDir::new()?; + let session_provider = ModelProviderInfo { + name: "session".to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + aws: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: true, + }; + let config_manager = ConfigManager::new( + temp_dir.path().to_path_buf(), + Vec::new(), + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + Arc::new(StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("session".to_string()), + model_providers: HashMap::from([( + "session".to_string(), + session_provider.clone(), + )]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ])), + ); + let config = config_manager + .load_with_overrides( + Some(HashMap::from([ + ("model_provider".to_string(), json!("request")), + ("features.plugins".to_string(), json!(true)), + ( + "model_providers.session".to_string(), + json!({ + "name": "request", + "base_url": "http://127.0.0.1:9999/api/codex", + "wire_api": "responses", + }), + ), + ])), + ConfigOverrides::default(), + ) + .await?; + + assert_eq!(config.model_provider_id, "session"); + assert_eq!(config.model_provider, session_provider); + assert!(!config.features.enabled(Feature::Plugins)); + Ok(()) + } + + #[test] + fn collect_resume_override_mismatches_includes_service_tier() { + let cwd = test_path_buf("/tmp").abs(); + let request = ThreadResumeParams { + thread_id: "thread-1".to_string(), + history: None, + path: None, + model: None, + model_provider: None, + service_tier: Some(Some("priority".to_string())), + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox: None, + permissions: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + exclude_turns: false, + persist_extended_history: false, + }; + let config_snapshot = ThreadConfigSnapshot { + model: "gpt-5".to_string(), + model_provider_id: "openai".to_string(), + service_tier: Some("flex".to_string()), + approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, + permission_profile: codex_protocol::models::PermissionProfile::Disabled, + active_permission_profile: None, + cwd, + ephemeral: false, + reasoning_effort: None, + personality: None, + session_source: SessionSource::Cli, + thread_source: None, + }; + + assert_eq!( + collect_resume_override_mismatches(&request, &config_snapshot), + vec!["service_tier requested=Some(\"priority\") active=Some(\"flex\")".to_string()] + ); + } + + fn test_thread_metadata( + model: Option<&str>, + reasoning_effort: Option, + ) -> Result { + let thread_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; + let mut builder = ThreadMetadataBuilder::new( + thread_id, + PathBuf::from("/tmp/rollout.jsonl"), + Utc::now(), + codex_protocol::protocol::SessionSource::default(), + ); + builder.model_provider = Some("mock_provider".to_string()); + let mut metadata = builder.build("mock_provider"); + metadata.model = model.map(ToString::to_string); + metadata.reasoning_effort = reasoning_effort; + Ok(metadata) + } + + #[test] + fn summary_from_thread_metadata_formats_protocol_timestamps_as_seconds() -> Result<()> { + let mut metadata = + test_thread_metadata(/*model*/ None, /*reasoning_effort*/ None)?; + metadata.created_at = + DateTime::parse_from_rfc3339("2025-09-05T16:53:11.123Z")?.with_timezone(&Utc); + metadata.updated_at = + DateTime::parse_from_rfc3339("2025-09-05T16:53:12.456Z")?.with_timezone(&Utc); + + let summary = summary_from_thread_metadata(&metadata); + + assert_eq!(summary.timestamp, Some("2025-09-05T16:53:11Z".to_string())); + assert_eq!(summary.updated_at, Some("2025-09-05T16:53:12Z".to_string())); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_prefers_persisted_model_and_reasoning_effort() -> Result<()> + { + let mut request_overrides = None; + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!( + typesafe_overrides.model, + Some("gpt-5.1-codex-max".to_string()) + ); + assert_eq!( + typesafe_overrides.model_provider, + Some("mock_provider".to_string()) + ); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("high".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_preserves_explicit_overrides() -> Result<()> { + let mut request_overrides = Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])); + let mut typesafe_overrides = ConfigOverrides { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }; + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, Some("gpt-5.2-codex".to_string())); + assert_eq!(typesafe_overrides.model_provider, None); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_persisted_values_when_model_overridden() -> Result<()> + { + let mut request_overrides = Some(HashMap::from([( + "model".to_string(), + serde_json::Value::String("gpt-5.2-codex".to_string()), + )])); + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model".to_string(), + serde_json::Value::String("gpt-5.2-codex".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_persisted_values_when_provider_overridden() + -> Result<()> { + let mut request_overrides = None; + let mut typesafe_overrides = ConfigOverrides { + model_provider: Some("oss".to_string()), + ..Default::default() + }; + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, Some("oss".to_string())); + assert_eq!(request_overrides, None); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_persisted_values_when_reasoning_effort_overridden() + -> Result<()> { + let mut request_overrides = Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])); + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_missing_values() -> Result<()> { + let mut request_overrides = None; + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(/*model*/ None, /*reasoning_effort*/ None)?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!( + typesafe_overrides.model_provider, + Some("mock_provider".to_string()) + ); + assert_eq!(request_overrides, None); + Ok(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_returns_empty_preview_when_no_user_message() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + use std::fs::FileTimes; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + model_provider: None, + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta.clone(), + git: None, + }), + }; + + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + let parsed = chrono::DateTime::parse_from_rfc3339(×tamp)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(&path)? + .set_times(times)?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + + let expected = ConversationSummary { + conversation_id, + timestamp: Some(timestamp.clone()), + updated_at: Some(timestamp), + path: path.clone(), + preview: String::new(), + model_provider: "fallback".to_string(), + cwd: PathBuf::new(), + cli_version: String::new(), + source: SessionSource::VSCode, + git_info: None, + }; + + assert_eq!(summary, expected); + Ok(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_preserves_agent_nickname() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let parent_thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + thread_source: Some(codex_protocol::protocol::ThreadSource::Subagent), + agent_nickname: Some("atlas".to_string()), + agent_role: Some("explorer".to_string()), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + let fallback_cwd = AbsolutePathBuf::from_absolute_path("/")?; + let thread = summary_to_thread(summary, &fallback_cwd); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + assert_eq!(thread.thread_source, None); + Ok(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_preserves_forked_from_id() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let forked_from_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + forked_from_id: Some(forked_from_id), + timestamp: timestamp.clone(), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + assert_eq!( + forked_from_id_from_rollout(path.as_path()).await, + Some(forked_from_id.to_string()) + ); + Ok(()) + } + + #[tokio::test] + async fn aborting_pending_request_clears_pending_state() -> Result<()> { + let thread_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let connection_id = ConnectionId(7); + + let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); + let outgoing = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let thread_outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + vec![connection_id], + thread_id, + ); + + let (request_id, client_request_rx) = thread_outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput( + ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![], + }, + )) + .await; + thread_outgoing.abort_pending_server_requests().await; + + let request_message = outgoing_rx.recv().await.expect("request should be sent"); + let OutgoingEnvelope::ToConnection { + connection_id: request_connection_id, + message: + OutgoingMessage::Request(ServerRequest::ToolRequestUserInput { + request_id: sent_request_id, + .. + }), + .. + } = request_message + else { + panic!("expected tool request to be sent to the subscribed connection"); + }; + assert_eq!(request_connection_id, connection_id); + assert_eq!(sent_request_id, request_id); + + let response = client_request_rx + .await + .expect("callback should be resolved"); + let error = response.expect_err("request should be aborted during cleanup"); + assert_eq!( + error.message, + "client request resolved because the turn state was changed" + ); + assert_eq!(error.data, Some(json!({ "reason": "turnTransition" }))); + assert!( + outgoing + .pending_requests_for_thread(thread_id) + .await + .is_empty() + ); + assert!(outgoing_rx.try_recv().is_err()); + Ok(()) + } + + #[test] + fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> { + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let source = + serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }))?; + + let summary = summary_from_state_db_metadata( + conversation_id, + PathBuf::from("/tmp/rollout.jsonl"), + Some("hi".to_string()), + "2025-09-05T16:53:11Z".to_string(), + "2025-09-05T16:53:12Z".to_string(), + "test-provider".to_string(), + PathBuf::from("/"), + "0.0.0".to_string(), + source, + Some(codex_protocol::protocol::ThreadSource::Subagent), + Some("atlas".to_string()), + Some("explorer".to_string()), + /*git_sha*/ None, + /*git_branch*/ None, + /*git_origin_url*/ None, + ); + + let fallback_cwd = AbsolutePathBuf::from_absolute_path("/")?; + let thread = summary_to_thread(summary, &fallback_cwd); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + Ok(()) + } + + #[tokio::test] + async fn removing_thread_state_clears_listener_and_active_turn_history() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection = ConnectionId(1); + let (cancel_tx, cancel_rx) = oneshot::channel(); + + manager.connection_initialized(connection).await; + manager + .try_ensure_connection_subscribed( + thread_id, connection, /*experimental_raw_events*/ false, + ) + .await + .expect("connection should be live"); + { + let state = manager.thread_state(thread_id).await; + let mut state = state.lock().await; + state.cancel_tx = Some(cancel_tx); + state.track_current_turn_event( + "turn-1", + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + ); + } + + manager.remove_thread_state(thread_id).await; + assert_eq!(cancel_rx.await, Ok(())); + + let state = manager.thread_state(thread_id).await; + let subscribed_connection_ids = manager.subscribed_connection_ids(thread_id).await; + assert!(subscribed_connection_ids.is_empty()); + let state = state.lock().await; + assert!(state.cancel_tx.is_none()); + assert!(state.active_turn_snapshot().is_none()); + Ok(()) + } + + #[tokio::test] + async fn removing_auto_attached_connection_preserves_listener_for_other_connections() + -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection_a = ConnectionId(1); + let connection_b = ConnectionId(2); + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + + manager.connection_initialized(connection_a).await; + manager.connection_initialized(connection_b).await; + manager + .try_ensure_connection_subscribed( + thread_id, + connection_a, + /*experimental_raw_events*/ false, + ) + .await + .expect("connection_a should be live"); + manager + .try_ensure_connection_subscribed( + thread_id, + connection_b, + /*experimental_raw_events*/ false, + ) + .await + .expect("connection_b should be live"); + { + let state = manager.thread_state(thread_id).await; + state.lock().await.cancel_tx = Some(cancel_tx); + } + + let threads_to_unload = manager.remove_connection(connection_a).await; + assert_eq!(threads_to_unload, Vec::::new()); + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut cancel_rx) + .await + .is_err() + ); + + assert_eq!( + manager.subscribed_connection_ids(thread_id).await, + vec![connection_b] + ); + Ok(()) + } + + #[tokio::test] + async fn adding_connection_to_thread_updates_has_connections_watcher() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection_a = ConnectionId(1); + let connection_b = ConnectionId(2); + + manager.connection_initialized(connection_a).await; + manager.connection_initialized(connection_b).await; + manager + .try_ensure_connection_subscribed( + thread_id, + connection_a, + /*experimental_raw_events*/ false, + ) + .await + .expect("connection_a should be live"); + let mut has_connections = manager + .subscribe_to_has_connections(thread_id) + .await + .expect("thread should have a has-connections watcher"); + assert!(*has_connections.borrow()); + + assert!( + manager + .unsubscribe_connection_from_thread(thread_id, connection_a) + .await + ); + tokio::time::timeout(Duration::from_secs(1), has_connections.changed()) + .await + .expect("timed out waiting for no-subscriber update") + .expect("has-connections watcher should remain open"); + assert!(!*has_connections.borrow()); + + assert!( + manager + .try_add_connection_to_thread(thread_id, connection_b) + .await + ); + tokio::time::timeout(Duration::from_secs(1), has_connections.changed()) + .await + .expect("timed out waiting for subscriber update") + .expect("has-connections watcher should remain open"); + assert!(*has_connections.borrow()); + Ok(()) + } + + #[tokio::test] + async fn closed_connection_cannot_be_reintroduced_by_auto_subscribe() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection = ConnectionId(1); + + manager.connection_initialized(connection).await; + let threads_to_unload = manager.remove_connection(connection).await; + assert_eq!(threads_to_unload, Vec::::new()); + + assert!( + manager + .try_ensure_connection_subscribed( + thread_id, connection, /*experimental_raw_events*/ false + ) + .await + .is_none() + ); + assert!(!manager.has_subscribers(thread_id).await); + Ok(()) + } +} diff --git a/code-rs/app-server/src/request_processors/thread_summary.rs b/code-rs/app-server/src/request_processors/thread_summary.rs new file mode 100644 index 00000000000..875bd3deaf9 --- /dev/null +++ b/code-rs/app-server/src/request_processors/thread_summary.rs @@ -0,0 +1,300 @@ +use super::*; + +#[cfg(test)] +use chrono::DateTime; +#[cfg(test)] +use chrono::Utc; + +#[cfg(test)] +pub(crate) async fn read_summary_from_rollout( + path: &Path, + fallback_provider: &str, +) -> std::io::Result { + let head = read_head_for_summary(path).await?; + + let Some(first) = head.first() else { + return Err(IoError::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + + let session_meta_line = + serde_json::from_value::(first.clone()).map_err(|_| { + IoError::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + })?; + let SessionMetaLine { + meta: session_meta, + git, + } = session_meta_line; + let mut session_meta = session_meta; + session_meta.source = with_thread_spawn_agent_metadata( + session_meta.source.clone(), + session_meta.agent_nickname.clone(), + session_meta.agent_role.clone(), + ); + + let created_at = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.as_str()) + }; + let updated_at = read_updated_at(path, created_at).await; + if let Some(summary) = extract_conversation_summary( + path.to_path_buf(), + &head, + &session_meta, + git.as_ref(), + fallback_provider, + updated_at.clone(), + ) { + return Ok(summary); + } + + let timestamp = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.clone()) + }; + let model_provider = session_meta + .model_provider + .clone() + .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.as_ref().map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); + + Ok(ConversationSummary { + conversation_id: session_meta.id, + timestamp, + updated_at, + path: path.to_path_buf(), + preview: String::new(), + model_provider, + cwd: session_meta.cwd, + cli_version: session_meta.cli_version, + source: session_meta.source, + git_info, + }) +} + +#[cfg(test)] +fn extract_conversation_summary( + path: PathBuf, + head: &[serde_json::Value], + session_meta: &SessionMeta, + git: Option<&CoreGitInfo>, + fallback_provider: &str, + updated_at: Option, +) -> Option { + let preview = head + .iter() + .filter_map(|value| serde_json::from_value::(value.clone()).ok()) + .find_map(|item| match codex_core::parse_turn_item(&item) { + Some(TurnItem::UserMessage(user)) => Some(user.message()), + _ => None, + })?; + + let preview = match preview.find(USER_MESSAGE_BEGIN) { + Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(), + None => preview.as_str(), + }; + + let timestamp = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.clone()) + }; + let conversation_id = session_meta.id; + let model_provider = session_meta + .model_provider + .clone() + .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); + + Some(ConversationSummary { + conversation_id, + timestamp, + updated_at, + path, + preview: preview.to_string(), + model_provider, + cwd: session_meta.cwd.clone(), + cli_version: session_meta.cli_version.clone(), + source: session_meta.source.clone(), + git_info, + }) +} + +#[cfg(test)] +fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { + ConversationGitInfo { + sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()), + branch: git_info.branch.clone(), + origin_url: git_info.repository_url.clone(), + } +} + +pub(super) fn with_thread_spawn_agent_metadata( + source: codex_protocol::protocol::SessionSource, + agent_nickname: Option, + agent_role: Option, +) -> codex_protocol::protocol::SessionSource { + if agent_nickname.is_none() && agent_role.is_none() { + return source; + } + + match source { + codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: existing_agent_nickname, + agent_role: existing_agent_role, + }, + ) => codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: agent_nickname.or(existing_agent_nickname), + agent_role: agent_role.or(existing_agent_role), + }, + ), + _ => source, + } +} + +pub(super) fn thread_response_active_permission_profile( + active_permission_profile: Option, +) -> Option { + active_permission_profile.map(Into::into) +} + +pub(super) fn apply_permission_profile_selection_to_config_overrides( + overrides: &mut ConfigOverrides, + permissions: Option, +) { + let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + return; + }; + overrides.default_permissions = Some(id); + overrides + .additional_writable_roots + .extend(modifications.unwrap_or_default().into_iter().map( + |modification| match modification { + PermissionProfileModificationParams::AdditionalWritableRoot { path } => { + path.to_path_buf() + } + }, + )); +} + +pub(super) fn thread_response_sandbox_policy( + permission_profile: &codex_protocol::models::PermissionProfile, + cwd: &Path, +) -> codex_app_server_protocol::SandboxPolicy { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + permission_profile, + &file_system_policy, + permission_profile.network_sandbox_policy(), + cwd, + ); + sandbox_policy.into() +} + +#[cfg(test)] +fn parse_datetime(timestamp: Option<&str>) -> Option> { + timestamp.and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) + }) +} + +#[cfg(test)] +async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option { + let updated_at = tokio::fs::metadata(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(|modified| { + let updated_at: DateTime = modified.into(); + updated_at.to_rfc3339_opts(SecondsFormat::Millis, true) + }); + updated_at.or_else(|| created_at.map(str::to_string)) +} + +pub(super) fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification { + thread.turns.clear(); + ThreadStartedNotification { thread } +} + +#[cfg(test)] +pub(crate) fn summary_to_thread( + summary: ConversationSummary, + fallback_cwd: &AbsolutePathBuf, +) -> Thread { + let ConversationSummary { + conversation_id, + path, + preview, + timestamp, + updated_at, + model_provider, + cwd, + cli_version, + source, + git_info, + } = summary; + + let created_at = parse_datetime(timestamp.as_deref()); + let updated_at = parse_datetime(updated_at.as_deref()).or(created_at); + let git_info = git_info.map(|info| ApiGitInfo { + sha: info.sha, + branch: info.branch, + origin_url: info.origin_url, + }); + let cwd = + AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) + .unwrap_or_else(|err| { + warn!( + conversation_id = %conversation_id, + path = %path.display(), + "failed to normalize thread cwd while summarizing thread: {err}" + ); + fallback_cwd.clone() + }); + + let thread_id = conversation_id.to_string(); + Thread { + id: thread_id.clone(), + session_id: thread_id, + forked_from_id: None, + preview, + ephemeral: false, + model_provider, + created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), + updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), + status: ThreadStatus::NotLoaded, + path: (!path.as_os_str().is_empty()).then_some(path), + cwd, + cli_version, + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), + source: source.into(), + thread_source: None, + git_info, + name: None, + turns: Vec::new(), + } +} + +#[cfg(test)] +#[path = "thread_summary_tests.rs"] +mod thread_summary_tests; diff --git a/code-rs/app-server/src/request_processors/thread_summary_tests.rs b/code-rs/app-server/src/request_processors/thread_summary_tests.rs new file mode 100644 index 00000000000..f8902e132d5 --- /dev/null +++ b/code-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -0,0 +1,68 @@ +use super::*; + +use anyhow::Result; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; + +#[test] +fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { + let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; + let timestamp = Some("2025-09-05T16:53:11.850Z".to_string()); + let path = PathBuf::from("rollout.jsonl"); + + let head = vec![ + json!({ + "id": conversation_id.to_string(), + "timestamp": timestamp, + "cwd": "/", + "originator": "codex", + "cli_version": "0.0.0", + "model_provider": "test-provider" + }), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions for project\n\n\n\n".to_string(), + }], + }), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": format!(" {USER_MESSAGE_BEGIN}Count to 5"), + }], + }), + ]; + + let session_meta = serde_json::from_value::(head[0].clone())?; + + let summary = extract_conversation_summary( + path.clone(), + &head, + &session_meta, + /*git*/ None, + "test-provider", + timestamp.clone(), + ) + .expect("summary"); + + let expected = ConversationSummary { + conversation_id, + timestamp: timestamp.clone(), + updated_at: timestamp, + path, + preview: "Count to 5".to_string(), + model_provider: "test-provider".to_string(), + cwd: PathBuf::from("/"), + cli_version: "0.0.0".to_string(), + source: codex_protocol::protocol::SessionSource::VSCode, + git_info: None, + }; + + assert_eq!(summary, expected); + Ok(()) +} diff --git a/code-rs/app-server/src/request_processors/token_usage_replay.rs b/code-rs/app-server/src/request_processors/token_usage_replay.rs new file mode 100644 index 00000000000..b19c4a61a0a --- /dev/null +++ b/code-rs/app-server/src/request_processors/token_usage_replay.rs @@ -0,0 +1,173 @@ +//! Replays persisted token usage snapshots when a client attaches to an existing thread. +//! +//! The message processor decides when replay is allowed and preserves JSON-RPC response +//! ordering. This module owns notification construction and the attribution rules that +//! map the latest persisted `TokenCount` back to a v2 turn id. +//! +//! Rollout histories can contain explicit turn ids or generated turn ids. When explicit +//! ids do not match the rebuilt thread, replay falls back to the active turn position at +//! the time the `TokenCount` was persisted so the notification still targets the +//! corresponding rebuilt turn. + +use std::sync::Arc; + +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnStatus; +use codex_core::CodexThread; +use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; + +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; + +/// Sends a restored token usage update to the connection that attached to a thread. +/// +/// This is lifecycle replay rather than a model event: the rollout already contains +/// the original `TokenCount`, and emitting through `send_event` here would duplicate +/// persisted usage records. Keeping replay connection-scoped also avoids +/// surprising other subscribers with a historical usage update while they may be +/// rendering live turn events. +pub(super) async fn send_thread_token_usage_update_to_connection( + outgoing: &Arc, + connection_id: ConnectionId, + thread_id: ThreadId, + thread: &Thread, + conversation: &CodexThread, + token_usage_turn_id: Option, +) { + let Some(info) = conversation.token_usage_info().await else { + return; + }; + let notification = ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: token_usage_turn_id.unwrap_or_else(|| latest_token_usage_turn_id(thread)), + token_usage: ThreadTokenUsage::from(info), + }; + outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::ThreadTokenUsageUpdated(notification), + ) + .await; +} + +/// Identifies the turn that was active when a `TokenCount` record appeared. +/// +/// The id is preferred when it still appears in the rebuilt thread. The position is a +/// fallback for histories whose implicit turn ids are regenerated during reconstruction. +struct TokenUsageTurnOwner { + id: String, + position: Option, +} + +pub(super) fn latest_token_usage_turn_id_from_rollout_items( + rollout_items: &[RolloutItem], + turns: &[Turn], +) -> Option { + let mut builder = ThreadHistoryBuilder::new(); + let mut token_usage_turn_owner = None; + + for item in rollout_items { + if matches!(item, RolloutItem::EventMsg(EventMsg::TokenCount(_))) { + token_usage_turn_owner = + builder + .active_turn_snapshot() + .map(|turn| TokenUsageTurnOwner { + id: turn.id, + position: builder.active_turn_position(), + }); + } + builder.handle_rollout_item(item); + } + + let owner = token_usage_turn_owner?; + if turns.iter().any(|turn| turn.id == owner.id) { + Some(owner.id) + } else { + owner + .position + .and_then(|position| turns.get(position)) + .map(|turn| turn.id.clone()) + } +} + +/// Chooses a fallback turn id that should own a replayed token usage update. +/// +/// Normal replay derives the owner from the rollout position of the latest +/// `TokenCount` event. This fallback only preserves a stable wire shape for +/// unusual histories where that rollout information cannot be read. +fn latest_token_usage_turn_id(thread: &Thread) -> String { + thread + .turns + .iter() + .rev() + .find(|turn| matches!(turn.status, TurnStatus::Completed | TurnStatus::Failed)) + .or_else(|| thread.turns.last()) + .map(|turn| turn.id.clone()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::build_turns_from_rollout_items; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::TokenCountEvent; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + + #[test] + fn replay_attribution_uses_already_loaded_history() { + let rollout_items = token_usage_history(); + let turns = build_turns_from_rollout_items(&rollout_items); + + assert_eq!( + latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns.as_slice()), + Some(turns[0].id.clone()) + ); + } + + #[test] + fn replay_attribution_falls_back_to_rebuilt_turn_position() { + let rollout_items = token_usage_history(); + let mut turns = build_turns_from_rollout_items(&rollout_items); + turns[0].id = "rebuilt-turn-id".to_string(); + + assert_eq!( + latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns.as_slice()), + Some("rebuilt-turn-id".to_string()) + ); + } + + fn token_usage_history() -> Vec { + vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "first turn".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "first answer".to_string(), + phase: None, + memory_citation: None, + })), + RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent { + info: None, + rate_limits: None, + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "second turn".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + ] + } +} diff --git a/code-rs/app-server/src/request_processors/turn_processor.rs b/code-rs/app-server/src/request_processors/turn_processor.rs new file mode 100644 index 00000000000..bdc5847b0d0 --- /dev/null +++ b/code-rs/app-server/src/request_processors/turn_processor.rs @@ -0,0 +1,1118 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct TurnRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + arg0_paths: Arg0DispatchPaths, + config: Arc, + config_manager: ConfigManager, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_list_state_permit: Arc, +} + +impl TurnRequestProcessor { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + arg0_paths: Arg0DispatchPaths, + config: Arc, + config_manager: ConfigManager, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_list_state_permit: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + analytics_events_client, + arg0_paths, + config, + config_manager, + pending_thread_unloads, + thread_state_manager, + thread_watch_manager, + thread_list_state_permit, + } + } + + pub(crate) async fn turn_start( + &self, + request_id: ConnectionRequestId, + params: TurnStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result, JSONRPCErrorError> { + self.turn_start_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_inject_items( + &self, + params: ThreadInjectItemsParams, + ) -> Result, JSONRPCErrorError> { + self.thread_inject_items_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn turn_steer( + &self, + request_id: &ConnectionRequestId, + params: TurnSteerParams, + ) -> Result, JSONRPCErrorError> { + self.turn_steer_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn turn_interrupt( + &self, + request_id: &ConnectionRequestId, + params: TurnInterruptParams, + ) -> Result, JSONRPCErrorError> { + self.turn_interrupt_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_start( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStartParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_start_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_append_audio( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendAudioParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_append_audio_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_append_text( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendTextParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_append_text_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_stop( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStopParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_stop_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_list_voices( + &self, + ) -> Result, JSONRPCErrorError> { + Ok(Some( + ThreadRealtimeListVoicesResponse { + voices: RealtimeVoicesList::builtin(), + } + .into(), + )) + } + + pub(crate) async fn review_start( + &self, + request_id: &ConnectionRequestId, + params: ReviewStartParams, + ) -> Result, JSONRPCErrorError> { + self.review_start_inner(request_id, params) + .await + .map(|()| None) + } + + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + // Resolve the core conversation handle from a v2 thread id string. + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + fn normalize_turn_start_collaboration_mode( + &self, + mut collaboration_mode: CollaborationMode, + ) -> CollaborationMode { + if collaboration_mode.settings.developer_instructions.is_none() + && let Some(instructions) = builtin_collaboration_mode_presets() + .into_iter() + .find(|preset| preset.mode == Some(collaboration_mode.mode)) + .and_then(|preset| preset.developer_instructions.flatten()) + .filter(|instructions| !instructions.is_empty()) + { + collaboration_mode.settings.developer_instructions = Some(instructions); + } + + collaboration_mode + } + + fn review_request_from_target( + target: ApiReviewTarget, + ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { + let cleaned_target = match target { + ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, + ApiReviewTarget::BaseBranch { branch } => { + let branch = branch.trim().to_string(); + if branch.is_empty() { + return Err(invalid_request("branch must not be empty".to_string())); + } + ApiReviewTarget::BaseBranch { branch } + } + ApiReviewTarget::Commit { sha, title } => { + let sha = sha.trim().to_string(); + if sha.is_empty() { + return Err(invalid_request("sha must not be empty".to_string())); + } + let title = title + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); + ApiReviewTarget::Commit { sha, title } + } + ApiReviewTarget::Custom { instructions } => { + let trimmed = instructions.trim().to_string(); + if trimmed.is_empty() { + return Err(invalid_request( + "instructions must not be empty".to_string(), + )); + } + ApiReviewTarget::Custom { + instructions: trimmed, + } + } + }; + + let core_target = match cleaned_target { + ApiReviewTarget::UncommittedChanges => CoreReviewTarget::UncommittedChanges, + ApiReviewTarget::BaseBranch { branch } => CoreReviewTarget::BaseBranch { branch }, + ApiReviewTarget::Commit { sha, title } => CoreReviewTarget::Commit { sha, title }, + ApiReviewTarget::Custom { instructions } => CoreReviewTarget::Custom { instructions }, + }; + + let hint = codex_core::review_prompts::user_facing_hint(&core_target); + let review_request = ReviewRequest { + target: core_target, + user_facing_hint: Some(hint.clone()), + }; + + Ok((review_request, hint)) + } + + fn parse_environment_selections( + &self, + environments: Option>, + ) -> Result>, JSONRPCErrorError> { + let environment_selections = environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect::>() + }); + if let Some(environment_selections) = environment_selections.as_ref() { + self.thread_manager + .validate_environment_selections(environment_selections) + .map_err(|err| invalid_request(environment_selection_error_message(err)))?; + } + Ok(environment_selections) + } + + async fn request_trace_context( + &self, + request_id: &ConnectionRequestId, + ) -> Option { + self.outgoing.request_trace_context(request_id).await + } + + async fn submit_core_op( + &self, + request_id: &ConnectionRequestId, + thread: &CodexThread, + op: Op, + ) -> CodexResult { + thread + .submit_with_trace(op, self.request_trace_context(request_id).await) + .await + } + + fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { + let mut error = invalid_params(format!( + "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." + )); + error.data = Some(serde_json::json!({ + "input_error_code": INPUT_TOO_LARGE_ERROR_CODE, + "max_chars": MAX_USER_INPUT_TEXT_CHARS, + "actual_chars": actual_chars, + })); + error + } + + fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { + let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum(); + if actual_chars > MAX_USER_INPUT_TEXT_CHARS { + return Err(Self::input_too_large_error(actual_chars)); + } + Ok(()) + } + + async fn turn_start_inner( + &self, + request_id: ConnectionRequestId, + params: TurnStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result { + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); + return Err(error); + } + let (thread_id, thread) = + self.load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; + Self::set_app_server_client_info( + thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; + + let collaboration_mode = params + .collaboration_mode + .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); + let environment_selections = self.parse_environment_selections(params.environments)?; + + // Map v2 input items to core input items. + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); + let turn_has_input = !mapped_items.is_empty(); + + let has_any_overrides = params.cwd.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox_policy.is_some() + || params.permissions.is_some() + || params.model.is_some() + || params.service_tier.is_some() + || params.effort.is_some() + || params.summary.is_some() + || collaboration_mode.is_some() + || params.personality.is_some(); + + if params.sandbox_policy.is_some() && params.permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandboxPolicy`", + )); + } + + let cwd = params.cwd; + let approval_policy = params.approval_policy.map(AskForApproval::to_core); + let approvals_reviewer = params + .approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); + let (permission_profile, active_permission_profile) = + if let Some(permissions) = params.permissions { + let snapshot = thread.config_snapshot().await; + let mut overrides = ConfigOverrides { + cwd: cwd.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides( + &mut overrides, + Some(permissions), + ); + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit turn request + // is different: reject it before accepting user input. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid turn context override: {warning}" + ))); + } + ( + Some(config.permissions.permission_profile()), + config.permissions.active_permission_profile(), + ) + } else { + (None, None) + }; + let model = params.model; + let effort = params.effort.map(Some); + let summary = params.summary; + let service_tier = params.service_tier; + let personality = params.personality; + + // If any overrides are provided, validate them synchronously so the + // request can fail before accepting user input. The actual update is + // still queued together with the input below to preserve submission order. + if has_any_overrides { + thread + .validate_turn_context_overrides(CodexThreadTurnContextOverrides { + cwd: cwd.clone(), + approval_policy, + approvals_reviewer, + sandbox_policy: sandbox_policy.clone(), + permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), + windows_sandbox_level: None, + model: model.clone(), + effort, + summary, + service_tier: service_tier.clone(), + collaboration_mode: collaboration_mode.clone(), + personality, + }) + .await + .map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?; + } + + // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_op = if has_any_overrides { + Op::UserInputWithTurnContext { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } + } else { + Op::UserInput { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + } + }; + let turn_id = self + .submit_core_op(&request_id, thread.as_ref(), turn_op) + .await + .map_err(|err| { + let error = internal_error(format!("failed to start turn: {err}")); + self.track_error_response(&request_id, &error, /*error_type*/ None); + error + })?; + + if turn_has_input { + let config_snapshot = thread.config_snapshot().await; + codex_memories_write::start_memories_startup_task( + Arc::clone(&self.thread_manager), + Arc::clone(&self.auth_manager), + thread_id, + Arc::clone(&thread), + thread.config().await, + &config_snapshot.session_source, + ); + } + + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; + let turn = Turn { + id: turn_id, + items: vec![], + items_view: TurnItemsView::NotLoaded, + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + }; + + Ok(TurnStartResponse { turn }) + } + + async fn thread_inject_items_response_inner( + &self, + params: ThreadInjectItemsParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + + let items = params + .items + .into_iter() + .enumerate() + .map(|(index, value)| { + serde_json::from_value::(value) + .map_err(|err| format!("items[{index}] is not a valid response item: {err}")) + }) + .collect::, _>>() + .map_err(invalid_request)?; + + thread + .inject_response_items(items) + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("failed to inject response items: {err}")), + })?; + Ok(ThreadInjectItemsResponse {}) + } + + async fn set_app_server_client_info( + thread: &CodexThread, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); + thread + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) + .await + .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) + } + + async fn turn_steer_inner( + &self, + request_id: &ConnectionRequestId, + params: TurnSteerParams, + ) -> Result { + let (_, thread) = self + .load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(request_id, error, /*error_type*/ None); + })?; + + if params.expected_turn_id.is_empty() { + return Err(invalid_request("expectedTurnId must not be empty")); + } + self.outgoing + .record_request_turn_id(request_id, ¶ms.expected_turn_id) + .await; + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); + return Err(error); + } + + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); + + let turn_id = thread + .steer_input( + mapped_items, + Some(¶ms.expected_turn_id), + params.responsesapi_client_metadata, + ) + .await + .map_err(|err| { + let (message, data, error_type) = match err { + SteerInputError::NoActiveTurn(_) => ( + "no active turn to steer".to_string(), + None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), + ), + SteerInputError::ExpectedTurnMismatch { expected, actual } => ( + format!("expected active turn id `{expected}` but found `{actual}`"), + None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), + ), + SteerInputError::ActiveTurnNotSteerable { turn_kind } => { + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), + }; + let error = TurnError { + message: message.clone(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), + additional_details: None, + }; + let data = match serde_json::to_value(error) { + Ok(data) => Some(data), + Err(error) => { + tracing::error!( + ?error, + "failed to serialize active-turn-not-steerable turn error" + ); + None + } + }; + ( + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) + } + SteerInputError::EmptyInput => ( + "input must not be empty".to_string(), + None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), + ), + }; + let mut error = invalid_request(message); + error.data = data; + self.track_error_response(request_id, &error, error_type); + error + })?; + Ok(TurnSteerResponse { turn_id }) + } + + async fn prepare_realtime_conversation_thread( + &self, + request_id: &ConnectionRequestId, + thread_id: &str, + ) -> Result)>, JSONRPCErrorError> { + let (thread_id, thread) = self.load_thread(thread_id).await?; + + match self + .ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await + { + Ok(EnsureConversationListenerResult::Attached) => {} + Ok(EnsureConversationListenerResult::ConnectionClosed) => { + return Ok(None); + } + Err(error) => return Err(error), + } + + if !thread.enabled(Feature::RealtimeConversation) { + return Err(invalid_request(format!( + "thread {thread_id} does not support realtime conversation" + ))); + } + + Ok(Some((thread_id, thread))) + } + + async fn thread_realtime_start_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStartParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RealtimeConversationStart(ConversationStartParams { + output_modality: params.output_modality, + prompt: params.prompt, + realtime_session_id: params.realtime_session_id, + transport: params.transport.map(|transport| match transport { + ThreadRealtimeStartTransport::Websocket => { + ConversationStartTransport::Websocket + } + ThreadRealtimeStartTransport::Webrtc { sdp } => { + ConversationStartTransport::Webrtc { sdp } + } + }), + voice: params.voice, + }), + ) + .await + .map_err(|err| internal_error(format!("failed to start realtime conversation: {err}")))?; + Ok(Some(ThreadRealtimeStartResponse::default())) + } + + async fn thread_realtime_append_audio_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendAudioParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RealtimeConversationAudio(ConversationAudioParams { + frame: params.audio.into(), + }), + ) + .await + .map_err(|err| { + internal_error(format!( + "failed to append realtime conversation audio: {err}" + )) + })?; + Ok(Some(ThreadRealtimeAppendAudioResponse::default())) + } + + async fn thread_realtime_append_text_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendTextParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RealtimeConversationText(ConversationTextParams { text: params.text }), + ) + .await + .map_err(|err| { + internal_error(format!( + "failed to append realtime conversation text: {err}" + )) + })?; + Ok(Some(ThreadRealtimeAppendTextResponse::default())) + } + + async fn thread_realtime_stop_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStopParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op(request_id, thread.as_ref(), Op::RealtimeConversationClose) + .await + .map_err(|err| { + internal_error(format!("failed to stop realtime conversation: {err}")) + })?; + Ok(Some(ThreadRealtimeStopResponse::default())) + } + + fn build_review_turn(turn_id: String, display_text: &str) -> Turn { + let items = if display_text.is_empty() { + Vec::new() + } else { + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + content: vec![V2UserInput::Text { + text: display_text.to_string(), + // Review prompt display text is synthesized; no UI element ranges to preserve. + text_elements: Vec::new(), + }], + }] + }; + + Turn { + id: turn_id, + items, + items_view: TurnItemsView::NotLoaded, + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + } + } + + async fn emit_review_started( + &self, + request_id: &ConnectionRequestId, + turn: Turn, + review_thread_id: String, + ) { + let response = ReviewStartResponse { + turn, + review_thread_id, + }; + self.outgoing + .send_response(request_id.clone(), response) + .await; + } + + async fn start_inline_review( + &self, + request_id: &ConnectionRequestId, + parent_thread: Arc, + review_request: ReviewRequest, + display_text: &str, + parent_thread_id: String, + ) -> std::result::Result<(), JSONRPCErrorError> { + let turn_id = self + .submit_core_op( + request_id, + parent_thread.as_ref(), + Op::Review { review_request }, + ) + .await + .map_err(|err| internal_error(format!("failed to start review: {err}")))?; + let turn = Self::build_review_turn(turn_id, display_text); + self.emit_review_started(request_id, turn, parent_thread_id) + .await; + Ok(()) + } + + async fn start_detached_review( + &self, + request_id: &ConnectionRequestId, + parent_thread_id: ThreadId, + parent_thread: Arc, + review_request: ReviewRequest, + display_text: &str, + ) -> std::result::Result<(), JSONRPCErrorError> { + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await.map_err(|err| { + internal_error(format!( + "failed to flush parent thread {parent_thread_id}: {err}" + )) + })?; + let parent_history = parent_thread + .load_history(/*include_archived*/ true) + .await + .map_err(|err| { + internal_error(format!( + "failed to load parent thread {parent_thread_id}: {err}" + )) + })?; + + let mut config = self.config.as_ref().clone(); + if let Some(review_model) = &config.review_model { + config.model = Some(review_model.clone()); + } + + let NewThread { + thread_id, + thread: review_thread, + .. + } = self + .thread_manager + .fork_thread_from_history( + ForkSnapshot::Interrupted, + config.clone(), + InitialHistory::Resumed(ResumedHistory { + conversation_id: parent_thread_id, + history: parent_history.items, + rollout_path: parent_thread.rollout_path(), + }), + /*thread_source*/ None, + /*persist_extended_history*/ false, + self.request_trace_context(request_id).await, + ) + .await + .map_err(|err| { + internal_error(format!("error creating detached review thread: {err}")) + })?; + + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + request_id.connection_id, + "review thread", + ); + + let fallback_provider = self.config.model_provider_id.as_str(); + match review_thread + .read_thread( + /*include_archived*/ true, /*include_history*/ false, + ) + .await + { + Ok(stored_thread) => { + let (mut thread, _) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + thread.session_id = review_thread.session_configured().session_id.to_string(); + self.thread_watch_manager + .upsert_thread_silently(thread.clone()) + .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + let notif = thread_started_notification(thread); + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + } + Err(err) => { + tracing::warn!("failed to load summary for review thread {thread_id}: {err}"); + } + } + + let turn_id = self + .submit_core_op( + request_id, + review_thread.as_ref(), + Op::Review { review_request }, + ) + .await + .map_err(|err| { + internal_error(format!("failed to start detached review turn: {err}")) + })?; + + let turn = Self::build_review_turn(turn_id, display_text); + let review_thread_id = thread_id.to_string(); + self.emit_review_started(request_id, turn, review_thread_id) + .await; + + Ok(()) + } + + async fn review_start_inner( + &self, + request_id: &ConnectionRequestId, + params: ReviewStartParams, + ) -> Result<(), JSONRPCErrorError> { + let ReviewStartParams { + thread_id, + target, + delivery, + } = params; + + let (parent_thread_id, parent_thread) = self.load_thread(&thread_id).await?; + let (review_request, display_text) = Self::review_request_from_target(target)?; + match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() { + CoreReviewDelivery::Inline => { + self.start_inline_review( + request_id, + parent_thread, + review_request, + &display_text, + thread_id, + ) + .await?; + } + CoreReviewDelivery::Detached => { + self.start_detached_review( + request_id, + parent_thread_id, + parent_thread, + review_request, + &display_text, + ) + .await?; + } + } + Ok(()) + } + + async fn turn_interrupt_inner( + &self, + request_id: &ConnectionRequestId, + params: TurnInterruptParams, + ) -> Result, JSONRPCErrorError> { + let TurnInterruptParams { thread_id, turn_id } = params; + let is_startup_interrupt = turn_id.is_empty(); + + let (thread_uuid, thread) = self.load_thread(&thread_id).await?; + + // Record turn interrupts so we can reply when TurnAborted arrives. Startup + // interrupts do not have a turn and are acknowledged after submission. + if !is_startup_interrupt { + let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + let is_running = matches!(thread.agent_status().await, AgentStatus::Running); + { + let mut thread_state = thread_state.lock().await; + if let Some(active_turn) = thread_state.active_turn_snapshot() { + if active_turn.id != turn_id { + return Err(invalid_request(format!( + "expected active turn id {turn_id} but found {}", + active_turn.id + ))); + } + } else if thread_state.last_terminal_turn_id.as_deref() == Some(turn_id.as_str()) + || !is_running + { + return Err(invalid_request("no active turn to interrupt")); + } + thread_state.pending_interrupts.push(request_id.clone()); + } + + self.outgoing + .record_request_turn_id(request_id, &turn_id) + .await; + } + + // Submit the interrupt. Turn interrupts respond upon TurnAborted; startup + // interrupts respond here because startup cancellation has no turn event. + match self + .submit_core_op(request_id, thread.as_ref(), Op::Interrupt) + .await + { + Ok(_) if is_startup_interrupt => Ok(Some(TurnInterruptResponse {})), + Ok(_) => Ok(None), + Err(err) => { + if !is_startup_interrupt { + let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + let mut thread_state = thread_state.lock().await; + thread_state + .pending_interrupts + .retain(|pending_request_id| pending_request_id != request_id); + } + let interrupt_target = if is_startup_interrupt { + "startup" + } else { + "turn" + }; + Err(internal_error(format!( + "failed to interrupt {interrupt_target}: {err}" + ))) + } + } + } + + fn listener_task_context(&self) -> ListenerTaskContext { + ListenerTaskContext { + thread_manager: Arc::clone(&self.thread_manager), + thread_state_manager: self.thread_state_manager.clone(), + outgoing: Arc::clone(&self.outgoing), + pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), + thread_watch_manager: self.thread_watch_manager.clone(), + thread_list_state_permit: self.thread_list_state_permit.clone(), + fallback_model_provider: self.config.model_provider_id.clone(), + codex_home: self.config.codex_home.to_path_buf(), + } + } + + async fn ensure_conversation_listener( + &self, + conversation_id: ThreadId, + connection_id: ConnectionId, + raw_events_enabled: bool, + ) -> Result { + super::thread_lifecycle::ensure_conversation_listener( + self.listener_task_context(), + conversation_id, + connection_id, + raw_events_enabled, + ) + .await + } +} + +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} diff --git a/code-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/code-rs/app-server/src/request_processors/windows_sandbox_processor.rs new file mode 100644 index 00000000000..2392cc80784 --- /dev/null +++ b/code-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -0,0 +1,186 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct WindowsSandboxRequestProcessor { + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, +} + +impl WindowsSandboxRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, + ) -> Self { + Self { + outgoing, + config, + config_manager, + } + } + + pub(crate) async fn windows_sandbox_readiness( + &self, + ) -> Result { + Ok(determine_windows_sandbox_readiness(&self.config)) + } + + pub(crate) async fn windows_sandbox_setup_start( + &self, + request_id: &ConnectionRequestId, + params: WindowsSandboxSetupStartParams, + ) -> Result, JSONRPCErrorError> { + self.windows_sandbox_setup_start_inner(request_id, params) + .await + .map(|()| None) + } + + async fn windows_sandbox_setup_start_inner( + &self, + request_id: &ConnectionRequestId, + params: WindowsSandboxSetupStartParams, + ) -> Result<(), JSONRPCErrorError> { + self.outgoing + .send_response( + request_id.clone(), + WindowsSandboxSetupStartResponse { started: true }, + ) + .await; + + let mode = match params.mode { + WindowsSandboxSetupMode::Elevated => CoreWindowsSandboxSetupMode::Elevated, + WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, + }; + let config = Arc::clone(&self.config); + let config_manager = self.config_manager.clone(); + let command_cwd = params + .cwd + .map(PathBuf::from) + .unwrap_or_else(|| config.cwd.to_path_buf()); + let outgoing = Arc::clone(&self.outgoing); + let connection_id = request_id.connection_id; + + tokio::spawn(async move { + let derived_config = config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(command_cwd.clone()), + ..Default::default() + }, + Some(command_cwd.clone()), + ) + .await; + let setup_result = match derived_config { + Ok(config) => { + let setup_request = WindowsSandboxSetupRequest { + mode, + policy: config + .permissions + .legacy_sandbox_policy(config.cwd.as_path()), + policy_cwd: config.cwd.to_path_buf(), + command_cwd, + env_map: std::env::vars().collect(), + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }; + codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await + } + Err(err) => Err(err.into()), + }; + let notification = WindowsSandboxSetupCompletedNotification { + mode: match mode { + CoreWindowsSandboxSetupMode::Elevated => WindowsSandboxSetupMode::Elevated, + CoreWindowsSandboxSetupMode::Unelevated => WindowsSandboxSetupMode::Unelevated, + }, + success: setup_result.is_ok(), + error: setup_result.err().map(|err| err.to_string()), + }; + outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::WindowsSandboxSetupCompleted(notification), + ) + .await; + }); + Ok(()) + } +} + +fn determine_windows_sandbox_readiness(config: &Config) -> WindowsSandboxReadinessResponse { + if !cfg!(windows) { + return WindowsSandboxReadinessResponse { + status: WindowsSandboxReadiness::NotConfigured, + }; + } + + determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::from_config(config), + sandbox_setup_is_complete(config.codex_home.as_path()), + ) +} + +fn determine_windows_sandbox_readiness_from_state( + windows_sandbox_level: WindowsSandboxLevel, + sandbox_setup_is_complete: bool, +) -> WindowsSandboxReadinessResponse { + let status = match windows_sandbox_level { + WindowsSandboxLevel::Disabled => WindowsSandboxReadiness::NotConfigured, + WindowsSandboxLevel::RestrictedToken => WindowsSandboxReadiness::Ready, + WindowsSandboxLevel::Elevated => { + if sandbox_setup_is_complete { + WindowsSandboxReadiness::Ready + } else { + WindowsSandboxReadiness::UpdateRequired + } + } + }; + + WindowsSandboxReadinessResponse { status } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn determine_windows_sandbox_readiness_reports_not_configured_when_disabled() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Disabled, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::NotConfigured); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_ready_for_unelevated_mode() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::RestrictedToken, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::Ready); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_ready_for_complete_elevated_mode() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Elevated, + /*sandbox_setup_is_complete*/ true, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::Ready); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_update_required_when_elevated_setup_is_stale() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Elevated, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::UpdateRequired); + } +} diff --git a/code-rs/app-server/src/request_serialization.rs b/code-rs/app-server/src/request_serialization.rs new file mode 100644 index 00000000000..0dd167b74dc --- /dev/null +++ b/code-rs/app-server/src/request_serialization.rs @@ -0,0 +1,682 @@ +use std::collections::HashMap; +use std::collections::VecDeque; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use codex_app_server_protocol::ClientRequestSerializationScope; +use futures::future::join_all; +use tokio::sync::Mutex; +use tracing::Instrument; + +use crate::connection_rpc_gate::ConnectionRpcGate; +use crate::outgoing_message::ConnectionId; + +type BoxFutureUnit = Pin + Send + 'static>>; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) enum RequestSerializationQueueKey { + Global(&'static str), + Thread { + thread_id: String, + }, + ThreadPath { + path: PathBuf, + }, + CommandExecProcess { + connection_id: ConnectionId, + process_id: String, + }, + Process { + connection_id: ConnectionId, + process_handle: String, + }, + FuzzyFileSearchSession { + session_id: String, + }, + FsWatch { + connection_id: ConnectionId, + watch_id: String, + }, + McpOauth { + server_name: String, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RequestSerializationAccess { + Exclusive, + SharedRead, +} + +impl RequestSerializationQueueKey { + pub(crate) fn from_scope( + connection_id: ConnectionId, + scope: ClientRequestSerializationScope, + ) -> (Self, RequestSerializationAccess) { + match scope { + ClientRequestSerializationScope::Global(name) => { + (Self::Global(name), RequestSerializationAccess::Exclusive) + } + ClientRequestSerializationScope::GlobalSharedRead(name) => { + (Self::Global(name), RequestSerializationAccess::SharedRead) + } + ClientRequestSerializationScope::Thread { thread_id } => ( + Self::Thread { thread_id }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::ThreadPath { path } => ( + Self::ThreadPath { path }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::CommandExecProcess { process_id } => ( + Self::CommandExecProcess { + connection_id, + process_id, + }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::Process { process_handle } => ( + Self::Process { + connection_id, + process_handle, + }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::FuzzyFileSearchSession { session_id } => ( + Self::FuzzyFileSearchSession { session_id }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::FsWatch { watch_id } => ( + Self::FsWatch { + connection_id, + watch_id, + }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::McpOauth { server_name } => ( + Self::McpOauth { server_name }, + RequestSerializationAccess::Exclusive, + ), + } + } +} + +pub(crate) struct QueuedInitializedRequest { + gate: Arc, + future: BoxFutureUnit, +} + +impl QueuedInitializedRequest { + pub(crate) fn new( + gate: Arc, + future: impl Future + Send + 'static, + ) -> Self { + Self { + gate, + future: Box::pin(future), + } + } + + pub(crate) async fn run(self) { + let Self { gate, future } = self; + gate.run(future).await; + } +} + +struct QueuedSerializedRequest { + access: RequestSerializationAccess, + request: QueuedInitializedRequest, +} + +#[derive(Clone, Default)] +pub(crate) struct RequestSerializationQueues { + inner: Arc>>>, +} + +impl RequestSerializationQueues { + pub(crate) async fn enqueue( + &self, + key: RequestSerializationQueueKey, + access: RequestSerializationAccess, + request: QueuedInitializedRequest, + ) { + let request = QueuedSerializedRequest { access, request }; + let should_spawn = { + let mut queues = self.inner.lock().await; + match queues.get_mut(&key) { + Some(queue) => { + queue.push_back(request); + false + } + None => { + let mut queue = VecDeque::new(); + queue.push_back(request); + queues.insert(key.clone(), queue); + true + } + } + }; + + if should_spawn { + let queues = self.clone(); + let span = tracing::debug_span!("app_server.serialized_request_queue", ?key); + tokio::spawn(async move { queues.drain(key).await }.instrument(span)); + } + } + + async fn drain(self, key: RequestSerializationQueueKey) { + loop { + let requests = { + let mut queues = self.inner.lock().await; + let Some(queue) = queues.get_mut(&key) else { + return; + }; + match queue.pop_front() { + Some(request) => { + let access = request.access; + let mut requests = vec![request]; + if access == RequestSerializationAccess::SharedRead { + while queue.front().is_some_and(|request| { + request.access == RequestSerializationAccess::SharedRead + }) { + let Some(request) = queue.pop_front() else { + break; + }; + requests.push(request); + } + } + requests + } + None => { + queues.remove(&key); + return; + } + } + }; + + join_all(requests.into_iter().map(|request| request.request.run())).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use tokio::sync::broadcast; + use tokio::sync::mpsc; + use tokio::sync::oneshot; + use tokio::time::Duration; + use tokio::time::timeout; + + const FIRST_REQUEST_VALUE: i32 = 1; + const SECOND_REQUEST_VALUE: i32 = 2; + const THIRD_REQUEST_VALUE: i32 = 3; + + fn gate() -> Arc { + Arc::new(ConnectionRpcGate::new()) + } + + fn queue_drain_timeout() -> Duration { + Duration::from_secs(/*secs*/ 1) + } + + fn shutdown_wait_timeout() -> Duration { + Duration::from_millis(/*millis*/ 50) + } + + #[tokio::test] + async fn same_key_requests_run_fifo() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let gate = gate(); + let (tx, mut rx) = mpsc::unbounded_channel(); + + for value in [ + FIRST_REQUEST_VALUE, + SECOND_REQUEST_VALUE, + THIRD_REQUEST_VALUE, + ] { + let tx = tx.clone(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(Arc::clone(&gate), async move { + tx.send(value).expect("receiver should be open"); + }), + ) + .await; + } + drop(tx); + + let mut values = Vec::new(); + while let Some(value) = timeout(queue_drain_timeout(), rx.recv()) + .await + .expect("timed out waiting for queued request") + { + values.push(value); + } + + assert_eq!( + values, + vec![ + FIRST_REQUEST_VALUE, + SECOND_REQUEST_VALUE, + THIRD_REQUEST_VALUE + ] + ); + } + + #[tokio::test] + async fn different_keys_run_concurrently() { + let queues = RequestSerializationQueues::default(); + let (blocked_tx, blocked_rx) = oneshot::channel::<()>(); + let (ran_tx, ran_rx) = oneshot::channel::<()>(); + + queues + .enqueue( + RequestSerializationQueueKey::Global("blocked"), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + let _ = blocked_rx.await; + }), + ) + .await; + queues + .enqueue( + RequestSerializationQueueKey::Global("other"), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + ran_tx.send(()).expect("receiver should be open"); + }), + ) + .await; + + timeout(queue_drain_timeout(), ran_rx) + .await + .expect("other key should not be blocked") + .expect("sender should be open"); + blocked_tx + .send(()) + .expect("blocked request should be waiting"); + } + + #[tokio::test] + async fn closed_gate_request_is_skipped_and_following_requests_continue() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let live_gate = gate(); + let closed_gate = gate(); + closed_gate.shutdown().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let (blocked_tx, blocked_rx) = oneshot::channel::<()>(); + + { + let tx = tx.clone(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(Arc::clone(&live_gate), async move { + tx.send(FIRST_REQUEST_VALUE) + .expect("receiver should be open"); + let _ = blocked_rx.await; + }), + ) + .await; + } + { + let tx = tx.clone(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(closed_gate, async move { + tx.send(SECOND_REQUEST_VALUE) + .expect("receiver should be open"); + }), + ) + .await; + } + { + let tx = tx.clone(); + queues + .enqueue( + key, + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(live_gate, async move { + tx.send(THIRD_REQUEST_VALUE) + .expect("receiver should be open"); + }), + ) + .await; + } + drop(tx); + + assert_eq!( + timeout(queue_drain_timeout(), rx.recv()) + .await + .expect("timed out waiting for first request"), + Some(FIRST_REQUEST_VALUE) + ); + blocked_tx + .send(()) + .expect("blocked request should be waiting"); + + let mut values = Vec::new(); + while let Some(value) = timeout(queue_drain_timeout(), rx.recv()) + .await + .expect("timed out waiting for queue to drain") + { + values.push(value); + } + + assert_eq!(values, vec![THIRD_REQUEST_VALUE]); + } + + #[tokio::test] + async fn shutdown_of_live_gate_skips_already_queued_requests() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let live_gate = gate(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let (blocked_tx, blocked_rx) = oneshot::channel::<()>(); + + { + let tx = tx.clone(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(Arc::clone(&live_gate), async move { + tx.send(FIRST_REQUEST_VALUE) + .expect("receiver should be open"); + let _ = blocked_rx.await; + }), + ) + .await; + } + { + let tx = tx.clone(); + queues + .enqueue( + key, + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(live_gate.clone(), async move { + tx.send(SECOND_REQUEST_VALUE) + .expect("receiver should be open"); + }), + ) + .await; + } + drop(tx); + + assert_eq!( + timeout(queue_drain_timeout(), rx.recv()) + .await + .expect("timed out waiting for first request"), + Some(FIRST_REQUEST_VALUE) + ); + + let gate_for_shutdown = Arc::clone(&live_gate); + let shutdown_task = tokio::spawn(async move { + gate_for_shutdown.shutdown().await; + }); + + timeout(shutdown_wait_timeout(), shutdown_task) + .await + .expect_err("shutdown should wait for the running request"); + + blocked_tx + .send(()) + .expect("blocked request should still be waiting"); + + assert_eq!( + timeout(queue_drain_timeout(), rx.recv()) + .await + .expect("timed out waiting for queue to drain"), + None + ); + } + + #[tokio::test] + async fn same_key_shared_reads_run_concurrently() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let (blocker_started_tx, blocker_started_rx) = oneshot::channel::<()>(); + let (blocker_release_tx, blocker_release_rx) = oneshot::channel::<()>(); + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let (release_tx, _) = broadcast::channel::<()>(/*capacity*/ 1); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + blocker_started_tx + .send(()) + .expect("receiver should be open"); + let _ = blocker_release_rx.await; + }), + ) + .await; + timeout(queue_drain_timeout(), blocker_started_rx) + .await + .expect("blocker should start") + .expect("sender should be open"); + + for value in [FIRST_REQUEST_VALUE, SECOND_REQUEST_VALUE] { + let started_tx = started_tx.clone(); + let mut release_rx = release_tx.subscribe(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + started_tx.send(value).expect("receiver should be open"); + let _ = release_rx.recv().await; + }), + ) + .await; + } + drop(started_tx); + blocker_release_tx + .send(()) + .expect("blocker should still be waiting"); + + let mut started = Vec::new(); + for _ in 0..2 { + started.push( + timeout(queue_drain_timeout(), started_rx.recv()) + .await + .expect("timed out waiting for shared read") + .expect("sender should be open"), + ); + } + assert_eq!(started, vec![FIRST_REQUEST_VALUE, SECOND_REQUEST_VALUE]); + + release_tx + .send(()) + .expect("shared reads should still be waiting"); + } + + #[tokio::test] + async fn exclusive_write_waits_for_running_shared_reads() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let (blocker_started_tx, blocker_started_rx) = oneshot::channel::<()>(); + let (blocker_release_tx, blocker_release_rx) = oneshot::channel::<()>(); + let (read_started_tx, mut read_started_rx) = mpsc::unbounded_channel(); + let (read_release_tx, _) = broadcast::channel::<()>(/*capacity*/ 1); + let (write_started_tx, write_started_rx) = oneshot::channel::<()>(); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + blocker_started_tx + .send(()) + .expect("receiver should be open"); + let _ = blocker_release_rx.await; + }), + ) + .await; + timeout(queue_drain_timeout(), blocker_started_rx) + .await + .expect("blocker should start") + .expect("sender should be open"); + + for value in [FIRST_REQUEST_VALUE, SECOND_REQUEST_VALUE] { + let read_started_tx = read_started_tx.clone(); + let mut read_release_rx = read_release_tx.subscribe(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + read_started_tx + .send(value) + .expect("receiver should be open"); + let _ = read_release_rx.recv().await; + }), + ) + .await; + } + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + write_started_tx.send(()).expect("receiver should be open"); + }), + ) + .await; + drop(read_started_tx); + blocker_release_tx + .send(()) + .expect("blocker should still be waiting"); + + for _ in 0..2 { + timeout(queue_drain_timeout(), read_started_rx.recv()) + .await + .expect("timed out waiting for shared read") + .expect("sender should be open"); + } + let mut write_started_rx = Box::pin(write_started_rx); + timeout(shutdown_wait_timeout(), &mut write_started_rx) + .await + .expect_err("write should wait for running shared reads"); + + read_release_tx + .send(()) + .expect("shared reads should still be waiting"); + timeout(queue_drain_timeout(), &mut write_started_rx) + .await + .expect("write should start after shared reads finish") + .expect("sender should be open"); + } + + #[tokio::test] + async fn later_shared_reads_do_not_jump_ahead_of_queued_write() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let (blocker_started_tx, blocker_started_rx) = oneshot::channel::<()>(); + let (blocker_release_tx, blocker_release_rx) = oneshot::channel::<()>(); + let (first_read_started_tx, first_read_started_rx) = oneshot::channel::<()>(); + let (first_read_release_tx, first_read_release_rx) = oneshot::channel::<()>(); + let (write_started_tx, write_started_rx) = oneshot::channel::<()>(); + let (write_release_tx, write_release_rx) = oneshot::channel::<()>(); + let (later_read_started_tx, later_read_started_rx) = oneshot::channel::<()>(); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + blocker_started_tx + .send(()) + .expect("receiver should be open"); + let _ = blocker_release_rx.await; + }), + ) + .await; + timeout(queue_drain_timeout(), blocker_started_rx) + .await + .expect("blocker should start") + .expect("sender should be open"); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + first_read_started_tx + .send(()) + .expect("receiver should be open"); + let _ = first_read_release_rx.await; + }), + ) + .await; + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + write_started_tx.send(()).expect("receiver should be open"); + let _ = write_release_rx.await; + }), + ) + .await; + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + later_read_started_tx + .send(()) + .expect("receiver should be open"); + }), + ) + .await; + blocker_release_tx + .send(()) + .expect("blocker should still be waiting"); + + timeout(queue_drain_timeout(), first_read_started_rx) + .await + .expect("first read should start") + .expect("sender should be open"); + let mut write_started_rx = Box::pin(write_started_rx); + timeout(shutdown_wait_timeout(), &mut write_started_rx) + .await + .expect_err("write should wait for the first read"); + let mut later_read_started_rx = Box::pin(later_read_started_rx); + timeout(shutdown_wait_timeout(), &mut later_read_started_rx) + .await + .expect_err("later read should wait behind the queued write"); + + first_read_release_tx + .send(()) + .expect("first read should still be waiting"); + timeout(queue_drain_timeout(), &mut write_started_rx) + .await + .expect("write should start after the first read") + .expect("sender should be open"); + timeout(shutdown_wait_timeout(), &mut later_read_started_rx) + .await + .expect_err("later read should still wait while the write is running"); + + write_release_tx + .send(()) + .expect("write should still be waiting"); + timeout(queue_drain_timeout(), &mut later_read_started_rx) + .await + .expect("later read should start after the write") + .expect("sender should be open"); + } +} diff --git a/code-rs/app-server/src/server_request_error.rs b/code-rs/app-server/src/server_request_error.rs new file mode 100644 index 00000000000..524b50e44b5 --- /dev/null +++ b/code-rs/app-server/src/server_request_error.rs @@ -0,0 +1,42 @@ +use codex_app_server_protocol::JSONRPCErrorError; + +pub(crate) const TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON: &str = "turnTransition"; + +pub(crate) fn is_turn_transition_server_request_error(error: &JSONRPCErrorError) -> bool { + error + .data + .as_ref() + .and_then(|data| data.get("reason")) + .and_then(serde_json::Value::as_str) + == Some(TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON) +} + +#[cfg(test)] +mod tests { + use super::is_turn_transition_server_request_error; + use codex_app_server_protocol::JSONRPCErrorError; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn turn_transition_error_is_detected() { + let error = JSONRPCErrorError { + code: -1, + message: "client request resolved because the turn state was changed".to_string(), + data: Some(json!({ "reason": "turnTransition" })), + }; + + assert_eq!(is_turn_transition_server_request_error(&error), true); + } + + #[test] + fn unrelated_error_is_not_detected() { + let error = JSONRPCErrorError { + code: -1, + message: "boom".to_string(), + data: Some(json!({ "reason": "other" })), + }; + + assert_eq!(is_turn_transition_server_request_error(&error), false); + } +} diff --git a/code-rs/app-server/src/thread_state.rs b/code-rs/app-server/src/thread_state.rs new file mode 100644 index 00000000000..dddbcf483b0 --- /dev/null +++ b/code-rs/app-server/src/thread_state.rs @@ -0,0 +1,419 @@ +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadGoal; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnError; +use codex_core::CodexThread; +use codex_core::ThreadConfigSnapshot; +use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; +use codex_rollout::state_db::StateDbHandle; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Weak; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tracing::error; + +type PendingInterruptQueue = Vec; + +pub(crate) struct PendingThreadResumeRequest { + pub(crate) request_id: ConnectionRequestId, + pub(crate) history_items: Vec, + pub(crate) config_snapshot: ThreadConfigSnapshot, + pub(crate) instruction_sources: Vec, + pub(crate) thread_summary: codex_app_server_protocol::Thread, + pub(crate) emit_thread_goal_update: bool, + pub(crate) thread_goal_state_db: Option, + pub(crate) include_turns: bool, +} + +// ThreadListenerCommand is used to perform operations in the context of the thread listener, for serialization purposes. +pub(crate) enum ThreadListenerCommand { + // SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates. + SendThreadResumeResponse(Box), + // EmitThreadGoalUpdated is used to order app-server goal updates with running-thread resume responses. + EmitThreadGoalUpdated { + goal: ThreadGoal, + }, + // EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses. + EmitThreadGoalCleared, + // EmitThreadGoalSnapshot is used to read and emit the latest goal state in the listener order. + EmitThreadGoalSnapshot { + state_db: StateDbHandle, + }, + // ResolveServerRequest is used to notify the client that the request has been resolved. + // It is executed in the thread listener's context to ensure that the resolved notification is ordered with regard to the request itself. + ResolveServerRequest { + request_id: RequestId, + completion_tx: oneshot::Sender<()>, + }, +} + +/// Per-conversation accumulation of the latest states e.g. error message while a turn runs. +#[derive(Default, Clone)] +pub(crate) struct TurnSummary { + pub(crate) started_at: Option, + pub(crate) command_execution_started: HashSet, + pub(crate) last_error: Option, +} + +#[derive(Default)] +pub(crate) struct ThreadState { + pub(crate) pending_interrupts: PendingInterruptQueue, + pub(crate) pending_rollbacks: Option, + pub(crate) turn_summary: TurnSummary, + pub(crate) last_terminal_turn_id: Option, + pub(crate) cancel_tx: Option>, + pub(crate) experimental_raw_events: bool, + pub(crate) listener_generation: u64, + listener_command_tx: Option>, + current_turn_history: ThreadHistoryBuilder, + listener_thread: Option>, +} + +impl ThreadState { + pub(crate) fn listener_matches(&self, conversation: &Arc) -> bool { + self.listener_thread + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|existing| Arc::ptr_eq(&existing, conversation)) + } + + pub(crate) fn set_listener( + &mut self, + cancel_tx: oneshot::Sender<()>, + conversation: &Arc, + ) -> (mpsc::UnboundedReceiver, u64) { + if let Some(previous) = self.cancel_tx.replace(cancel_tx) { + let _ = previous.send(()); + } + self.listener_generation = self.listener_generation.wrapping_add(1); + let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); + self.listener_command_tx = Some(listener_command_tx); + self.listener_thread = Some(Arc::downgrade(conversation)); + (listener_command_rx, self.listener_generation) + } + + pub(crate) fn clear_listener(&mut self) { + if let Some(cancel_tx) = self.cancel_tx.take() { + let _ = cancel_tx.send(()); + } + self.listener_command_tx = None; + self.current_turn_history.reset(); + self.listener_thread = None; + } + + pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) { + self.experimental_raw_events = enabled; + } + + pub(crate) fn listener_command_tx( + &self, + ) -> Option> { + self.listener_command_tx.clone() + } + + pub(crate) fn active_turn_snapshot(&self) -> Option { + self.current_turn_history.active_turn_snapshot() + } + + pub(crate) fn track_current_turn_event(&mut self, event_turn_id: &str, event: &EventMsg) { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } + self.current_turn_history.handle_event(event); + if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) + && !self.current_turn_history.has_active_turn() + { + self.last_terminal_turn_id = Some(event_turn_id.to_string()); + self.current_turn_history.reset(); + } + } +} + +pub(crate) async fn resolve_server_request_on_thread_listener( + thread_state: &Arc>, + request_id: RequestId, +) { + let (completion_tx, completion_rx) = oneshot::channel(); + let listener_command_tx = { + let state = thread_state.lock().await; + state.listener_command_tx() + }; + let Some(listener_command_tx) = listener_command_tx else { + error!("failed to remove pending client request: thread listener is not running"); + return; + }; + + if listener_command_tx + .send(ThreadListenerCommand::ResolveServerRequest { + request_id, + completion_tx, + }) + .is_err() + { + error!( + "failed to remove pending client request: thread listener command channel is closed" + ); + return; + } + + if let Err(err) = completion_rx.await { + error!("failed to remove pending client request: {err}"); + } +} + +struct ThreadEntry { + state: Arc>, + connection_ids: HashSet, + has_connections_watcher: watch::Sender, +} + +impl Default for ThreadEntry { + fn default() -> Self { + Self { + state: Arc::new(Mutex::new(ThreadState::default())), + connection_ids: HashSet::new(), + has_connections_watcher: watch::channel(false).0, + } + } +} + +impl ThreadEntry { + fn update_has_connections(&self) { + let _ = self.has_connections_watcher.send_if_modified(|current| { + let prev = *current; + *current = !self.connection_ids.is_empty(); + prev != *current + }); + } +} + +#[derive(Default)] +struct ThreadStateManagerInner { + live_connections: HashSet, + threads: HashMap, + thread_ids_by_connection: HashMap>, +} + +#[derive(Clone, Default)] +pub(crate) struct ThreadStateManager { + state: Arc>, +} + +impl ThreadStateManager { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { + self.state + .lock() + .await + .live_connections + .insert(connection_id); + } + + pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec { + let state = self.state.lock().await; + state + .threads + .get(&thread_id) + .map(|thread_entry| thread_entry.connection_ids.iter().copied().collect()) + .unwrap_or_default() + } + + pub(crate) async fn thread_state(&self, thread_id: ThreadId) -> Arc> { + let mut state = self.state.lock().await; + state.threads.entry(thread_id).or_default().state.clone() + } + + pub(crate) async fn remove_thread_state(&self, thread_id: ThreadId) { + let thread_state = { + let mut state = self.state.lock().await; + let thread_state = state + .threads + .remove(&thread_id) + .map(|thread_entry| thread_entry.state); + state.thread_ids_by_connection.retain(|_, thread_ids| { + thread_ids.remove(&thread_id); + !thread_ids.is_empty() + }); + thread_state + }; + + if let Some(thread_state) = thread_state { + let mut thread_state = thread_state.lock().await; + tracing::debug!( + thread_id = %thread_id, + listener_generation = thread_state.listener_generation, + had_listener = thread_state.cancel_tx.is_some(), + had_active_turn = thread_state.active_turn_snapshot().is_some(), + "clearing thread listener during thread-state teardown" + ); + thread_state.clear_listener(); + } + } + + pub(crate) async fn clear_all_listeners(&self) { + let thread_states = { + let state = self.state.lock().await; + state + .threads + .iter() + .map(|(thread_id, thread_entry)| (*thread_id, thread_entry.state.clone())) + .collect::>() + }; + + for (thread_id, thread_state) in thread_states { + let mut thread_state = thread_state.lock().await; + tracing::debug!( + thread_id = %thread_id, + listener_generation = thread_state.listener_generation, + had_listener = thread_state.cancel_tx.is_some(), + had_active_turn = thread_state.active_turn_snapshot().is_some(), + "clearing thread listener during app-server shutdown" + ); + thread_state.clear_listener(); + } + } + + pub(crate) async fn unsubscribe_connection_from_thread( + &self, + thread_id: ThreadId, + connection_id: ConnectionId, + ) -> bool { + { + let mut state = self.state.lock().await; + if !state.threads.contains_key(&thread_id) { + return false; + } + + if !state + .thread_ids_by_connection + .get(&connection_id) + .is_some_and(|thread_ids| thread_ids.contains(&thread_id)) + { + return false; + } + + if let Some(thread_ids) = state.thread_ids_by_connection.get_mut(&connection_id) { + thread_ids.remove(&thread_id); + if thread_ids.is_empty() { + state.thread_ids_by_connection.remove(&connection_id); + } + } + if let Some(thread_entry) = state.threads.get_mut(&thread_id) { + thread_entry.connection_ids.remove(&connection_id); + thread_entry.update_has_connections(); + } + }; + + true + } + + #[cfg(test)] + pub(crate) async fn has_subscribers(&self, thread_id: ThreadId) -> bool { + self.state + .lock() + .await + .threads + .get(&thread_id) + .is_some_and(|thread_entry| !thread_entry.connection_ids.is_empty()) + } + + pub(crate) async fn try_ensure_connection_subscribed( + &self, + thread_id: ThreadId, + connection_id: ConnectionId, + experimental_raw_events: bool, + ) -> Option>> { + let thread_state = { + let mut state = self.state.lock().await; + if !state.live_connections.contains(&connection_id) { + return None; + } + state + .thread_ids_by_connection + .entry(connection_id) + .or_default() + .insert(thread_id); + let thread_entry = state.threads.entry(thread_id).or_default(); + thread_entry.connection_ids.insert(connection_id); + thread_entry.update_has_connections(); + thread_entry.state.clone() + }; + { + let mut thread_state_guard = thread_state.lock().await; + if experimental_raw_events { + thread_state_guard.set_experimental_raw_events(/*enabled*/ true); + } + } + Some(thread_state) + } + + pub(crate) async fn try_add_connection_to_thread( + &self, + thread_id: ThreadId, + connection_id: ConnectionId, + ) -> bool { + let mut state = self.state.lock().await; + if !state.live_connections.contains(&connection_id) { + return false; + } + state + .thread_ids_by_connection + .entry(connection_id) + .or_default() + .insert(thread_id); + let thread_entry = state.threads.entry(thread_id).or_default(); + thread_entry.connection_ids.insert(connection_id); + thread_entry.update_has_connections(); + true + } + + pub(crate) async fn remove_connection(&self, connection_id: ConnectionId) -> Vec { + { + let mut state = self.state.lock().await; + state.live_connections.remove(&connection_id); + let thread_ids = state + .thread_ids_by_connection + .remove(&connection_id) + .unwrap_or_default(); + for thread_id in &thread_ids { + if let Some(thread_entry) = state.threads.get_mut(thread_id) { + thread_entry.connection_ids.remove(&connection_id); + thread_entry.update_has_connections(); + } + } + thread_ids + .into_iter() + .filter(|thread_id| { + state + .threads + .get(thread_id) + .is_some_and(|thread_entry| thread_entry.connection_ids.is_empty()) + }) + .collect::>() + } + } + + pub(crate) async fn subscribe_to_has_connections( + &self, + thread_id: ThreadId, + ) -> Option> { + let state = self.state.lock().await; + state + .threads + .get(&thread_id) + .map(|thread_entry| thread_entry.has_connections_watcher.subscribe()) + } +} diff --git a/code-rs/app-server/src/thread_status.rs b/code-rs/app-server/src/thread_status.rs new file mode 100644 index 00000000000..7315a13c027 --- /dev/null +++ b/code-rs/app-server/src/thread_status.rs @@ -0,0 +1,912 @@ +#[cfg(test)] +use crate::outgoing_message::OutgoingEnvelope; +#[cfg(test)] +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadActiveFlag; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_protocol::ThreadId; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +#[cfg(test)] +use tokio::sync::mpsc; +use tokio::sync::watch; + +#[derive(Clone)] +pub(crate) struct ThreadWatchManager { + state: Arc>, + outgoing: Option>, + running_turn_count_tx: watch::Sender, +} + +pub(crate) struct ThreadWatchActiveGuard { + manager: ThreadWatchManager, + thread_id: String, + guard_type: ThreadWatchActiveGuardType, + handle: tokio::runtime::Handle, +} + +impl ThreadWatchActiveGuard { + fn new( + manager: ThreadWatchManager, + thread_id: String, + guard_type: ThreadWatchActiveGuardType, + ) -> Self { + Self { + manager, + thread_id, + guard_type, + handle: tokio::runtime::Handle::current(), + } + } +} + +impl Drop for ThreadWatchActiveGuard { + fn drop(&mut self) { + let manager = self.manager.clone(); + let thread_id = self.thread_id.clone(); + let guard_type = self.guard_type; + self.handle.spawn(async move { + manager + .note_active_guard_released(thread_id, guard_type) + .await; + }); + } +} + +#[derive(Clone, Copy)] +enum ThreadWatchActiveGuardType { + Permission, + UserInput, +} + +impl Default for ThreadWatchManager { + fn default() -> Self { + Self::new() + } +} + +impl ThreadWatchManager { + pub(crate) fn new() -> Self { + let (running_turn_count_tx, _running_turn_count_rx) = watch::channel(0); + Self { + state: Arc::new(Mutex::new(ThreadWatchState::default())), + outgoing: None, + running_turn_count_tx, + } + } + + pub(crate) fn new_with_outgoing(outgoing: Arc) -> Self { + let (running_turn_count_tx, _running_turn_count_rx) = watch::channel(0); + Self { + state: Arc::new(Mutex::new(ThreadWatchState::default())), + outgoing: Some(outgoing), + running_turn_count_tx, + } + } + + pub(crate) async fn upsert_thread(&self, thread: Thread) { + self.mutate_and_publish(move |state| { + state.upsert_thread(thread.id, /*emit_notification*/ true) + }) + .await; + } + + pub(crate) async fn upsert_thread_silently(&self, thread: Thread) { + self.mutate_and_publish(move |state| { + state.upsert_thread(thread.id, /*emit_notification*/ false) + }) + .await; + } + + pub(crate) async fn remove_thread(&self, thread_id: &str) { + let thread_id = thread_id.to_string(); + self.mutate_and_publish(move |state| state.remove_thread(&thread_id)) + .await; + } + + pub(crate) async fn loaded_status_for_thread(&self, thread_id: &str) -> ThreadStatus { + self.state.lock().await.loaded_status_for_thread(thread_id) + } + + pub(crate) async fn loaded_statuses_for_threads( + &self, + thread_ids: Vec, + ) -> HashMap { + let state = self.state.lock().await; + thread_ids + .into_iter() + .map(|thread_id| { + let status = state.loaded_status_for_thread(&thread_id); + (thread_id, status) + }) + .collect() + } + + #[cfg(test)] + pub(crate) async fn running_turn_count(&self) -> usize { + self.state + .lock() + .await + .runtime_by_thread_id + .values() + .filter(|runtime| runtime.running) + .count() + } + + pub(crate) fn subscribe_running_turn_count(&self) -> watch::Receiver { + self.running_turn_count_tx.subscribe() + } + + pub(crate) async fn note_turn_started(&self, thread_id: &str) { + self.update_runtime_for_thread(thread_id, |runtime| { + runtime.is_loaded = true; + runtime.running = true; + runtime.has_system_error = false; + }) + .await; + } + + pub(crate) async fn note_turn_completed(&self, thread_id: &str, _failed: bool) { + self.clear_active_state(thread_id).await; + } + + pub(crate) async fn note_turn_interrupted(&self, thread_id: &str) { + self.clear_active_state(thread_id).await; + } + + pub(crate) async fn note_thread_shutdown(&self, thread_id: &str) { + self.update_runtime_for_thread(thread_id, |runtime| { + runtime.running = false; + runtime.pending_permission_requests = 0; + runtime.pending_user_input_requests = 0; + runtime.is_loaded = false; + }) + .await; + } + + pub(crate) async fn note_system_error(&self, thread_id: &str) { + self.update_runtime_for_thread(thread_id, |runtime| { + runtime.running = false; + runtime.pending_permission_requests = 0; + runtime.pending_user_input_requests = 0; + runtime.has_system_error = true; + }) + .await; + } + + async fn clear_active_state(&self, thread_id: &str) { + self.update_runtime_for_thread(thread_id, move |runtime| { + runtime.running = false; + runtime.pending_permission_requests = 0; + runtime.pending_user_input_requests = 0; + }) + .await; + } + + pub(crate) async fn note_permission_requested( + &self, + thread_id: &str, + ) -> ThreadWatchActiveGuard { + self.note_pending_request(thread_id, ThreadWatchActiveGuardType::Permission) + .await + } + + pub(crate) async fn note_user_input_requested( + &self, + thread_id: &str, + ) -> ThreadWatchActiveGuard { + self.note_pending_request(thread_id, ThreadWatchActiveGuardType::UserInput) + .await + } + + async fn note_pending_request( + &self, + thread_id: &str, + guard_type: ThreadWatchActiveGuardType, + ) -> ThreadWatchActiveGuard { + self.update_runtime_for_thread(thread_id, move |runtime| { + runtime.is_loaded = true; + let counter = Self::pending_counter(runtime, guard_type); + *counter = counter.saturating_add(1); + }) + .await; + ThreadWatchActiveGuard::new(self.clone(), thread_id.to_string(), guard_type) + } + + async fn mutate_and_publish(&self, mutate: F) + where + F: FnOnce(&mut ThreadWatchState) -> Option, + { + let (notification, running_turn_count) = { + let mut state = self.state.lock().await; + let notification = mutate(&mut state); + let running_turn_count = state + .runtime_by_thread_id + .values() + .filter(|runtime| runtime.running) + .count(); + (notification, running_turn_count) + }; + let _ = self.running_turn_count_tx.send(running_turn_count); + + if let Some(notification) = notification + && let Some(outgoing) = &self.outgoing + { + outgoing + .send_server_notification(ServerNotification::ThreadStatusChanged(notification)) + .await; + } + } + + pub(crate) async fn subscribe( + &self, + thread_id: ThreadId, + ) -> Option> { + Some(self.state.lock().await.subscribe(thread_id.to_string())) + } + + async fn note_active_guard_released( + &self, + thread_id: String, + guard_type: ThreadWatchActiveGuardType, + ) { + self.update_runtime_for_thread(&thread_id, move |runtime| { + let counter = Self::pending_counter(runtime, guard_type); + *counter = counter.saturating_sub(1); + }) + .await; + } + + async fn update_runtime_for_thread(&self, thread_id: &str, update: F) + where + F: FnOnce(&mut RuntimeFacts), + { + let thread_id = thread_id.to_string(); + self.mutate_and_publish(move |state| state.update_runtime(&thread_id, update)) + .await; + } + + fn pending_counter( + runtime: &mut RuntimeFacts, + guard_type: ThreadWatchActiveGuardType, + ) -> &mut u32 { + match guard_type { + ThreadWatchActiveGuardType::Permission => &mut runtime.pending_permission_requests, + ThreadWatchActiveGuardType::UserInput => &mut runtime.pending_user_input_requests, + } + } +} + +pub(crate) fn resolve_thread_status( + status: ThreadStatus, + has_in_progress_turn: bool, +) -> ThreadStatus { + // Running-turn events can arrive before the watch runtime state is observed by + // the listener loop. In that window we prefer to reflect a real active turn as + // `Active` instead of `Idle`/`NotLoaded`. + if has_in_progress_turn && matches!(status, ThreadStatus::Idle | ThreadStatus::NotLoaded) { + return ThreadStatus::Active { + active_flags: Vec::new(), + }; + } + + status +} + +#[derive(Default)] +struct ThreadWatchState { + runtime_by_thread_id: HashMap, + status_watcher_by_thread_id: HashMap>, +} + +impl ThreadWatchState { + fn upsert_thread( + &mut self, + thread_id: String, + emit_notification: bool, + ) -> Option { + let previous_status = self.status_for(&thread_id); + let runtime = self + .runtime_by_thread_id + .entry(thread_id.clone()) + .or_default(); + runtime.is_loaded = true; + self.update_status_watcher_for_thread(&thread_id); + if emit_notification { + self.status_changed_notification(thread_id, previous_status) + } else { + None + } + } + + fn remove_thread(&mut self, thread_id: &str) -> Option { + let previous_status = self.status_for(thread_id); + self.runtime_by_thread_id.remove(thread_id); + self.update_status_watcher(thread_id, &ThreadStatus::NotLoaded); + if previous_status.is_some() && previous_status != Some(ThreadStatus::NotLoaded) { + Some(ThreadStatusChangedNotification { + thread_id: thread_id.to_string(), + status: ThreadStatus::NotLoaded, + }) + } else { + None + } + } + + fn update_runtime( + &mut self, + thread_id: &str, + mutate: F, + ) -> Option + where + F: FnOnce(&mut RuntimeFacts), + { + let previous_status = self.status_for(thread_id); + let runtime = self + .runtime_by_thread_id + .entry(thread_id.to_string()) + .or_default(); + runtime.is_loaded = true; + mutate(runtime); + self.update_status_watcher_for_thread(thread_id); + self.status_changed_notification(thread_id.to_string(), previous_status) + } + + fn status_for(&self, thread_id: &str) -> Option { + self.runtime_by_thread_id + .get(thread_id) + .map(loaded_thread_status) + } + + fn loaded_status_for_thread(&self, thread_id: &str) -> ThreadStatus { + self.status_for(thread_id) + .unwrap_or(ThreadStatus::NotLoaded) + } + + fn subscribe(&mut self, thread_id: String) -> watch::Receiver { + let status = self.loaded_status_for_thread(&thread_id); + let sender = self + .status_watcher_by_thread_id + .entry(thread_id) + .or_insert_with(|| watch::channel(status.clone()).0); + sender.subscribe() + } + + fn update_status_watcher_for_thread(&mut self, thread_id: &str) { + let status = self.loaded_status_for_thread(thread_id); + self.update_status_watcher(thread_id, &status); + } + + fn update_status_watcher(&mut self, thread_id: &str, status: &ThreadStatus) { + let remove_watcher = if let Some(sender) = self.status_watcher_by_thread_id.get(thread_id) { + let status = status.clone(); + let _ = sender.send_if_modified(|current| { + if *current == status { + false + } else { + *current = status; + true + } + }); + sender.receiver_count() == 0 + } else { + false + }; + if remove_watcher { + self.status_watcher_by_thread_id.remove(thread_id); + } + } + + fn status_changed_notification( + &self, + thread_id: String, + previous_status: Option, + ) -> Option { + let status = self.status_for(&thread_id)?; + + if previous_status.as_ref() == Some(&status) { + return None; + } + + Some(ThreadStatusChangedNotification { thread_id, status }) + } +} + +#[derive(Clone, Default)] +struct RuntimeFacts { + is_loaded: bool, + running: bool, + pending_permission_requests: u32, + pending_user_input_requests: u32, + has_system_error: bool, +} + +fn loaded_thread_status(runtime: &RuntimeFacts) -> ThreadStatus { + if !runtime.is_loaded { + return ThreadStatus::NotLoaded; + } + + let mut active_flags = Vec::new(); + if runtime.pending_permission_requests > 0 { + active_flags.push(ThreadActiveFlag::WaitingOnApproval); + } + if runtime.pending_user_input_requests > 0 { + active_flags.push(ThreadActiveFlag::WaitingOnUserInput); + } + + if runtime.running || !active_flags.is_empty() { + return ThreadStatus::Active { active_flags }; + } + + if runtime.has_system_error { + return ThreadStatus::SystemError; + } + + ThreadStatus::Idle +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + use tokio::time::Duration; + use tokio::time::timeout; + + const INTERACTIVE_THREAD_ID: &str = "00000000-0000-0000-0000-000000000001"; + const NON_INTERACTIVE_THREAD_ID: &str = "00000000-0000-0000-0000-000000000002"; + + #[tokio::test] + async fn loaded_status_defaults_to_not_loaded_for_untracked_threads() { + let manager = ThreadWatchManager::new(); + + assert_eq!( + manager + .loaded_status_for_thread("00000000-0000-0000-0000-000000000003") + .await, + ThreadStatus::NotLoaded, + ); + } + + #[tokio::test] + async fn tracks_non_interactive_thread_status() { + let manager = ThreadWatchManager::new(); + manager + .upsert_thread(test_thread( + NON_INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::AppServer, + )) + .await; + + manager.note_turn_started(NON_INTERACTIVE_THREAD_ID).await; + + assert_eq!( + manager + .loaded_status_for_thread(NON_INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Active { + active_flags: vec![], + }, + ); + } + + #[tokio::test] + async fn status_updates_track_single_thread() { + let manager = ThreadWatchManager::new(); + manager + .upsert_thread(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Active { + active_flags: vec![], + }, + ); + + let permission_guard = manager + .note_permission_requested(INTERACTIVE_THREAD_ID) + .await; + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Active { + active_flags: vec![ThreadActiveFlag::WaitingOnApproval], + }, + ); + + let user_input_guard = manager + .note_user_input_requested(INTERACTIVE_THREAD_ID) + .await; + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Active { + active_flags: vec![ + ThreadActiveFlag::WaitingOnApproval, + ThreadActiveFlag::WaitingOnUserInput, + ], + }, + ); + + drop(permission_guard); + wait_for_status( + &manager, + INTERACTIVE_THREAD_ID, + ThreadStatus::Active { + active_flags: vec![ThreadActiveFlag::WaitingOnUserInput], + }, + ) + .await; + + drop(user_input_guard); + wait_for_status( + &manager, + INTERACTIVE_THREAD_ID, + ThreadStatus::Active { + active_flags: vec![], + }, + ) + .await; + + manager + .note_turn_completed(INTERACTIVE_THREAD_ID, false) + .await; + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Idle, + ); + } + + #[test] + fn resolves_in_progress_turn_to_active_status() { + let status = resolve_thread_status(ThreadStatus::Idle, /*has_in_progress_turn*/ true); + assert_eq!( + status, + ThreadStatus::Active { + active_flags: Vec::new(), + } + ); + + let status = + resolve_thread_status(ThreadStatus::NotLoaded, /*has_in_progress_turn*/ true); + assert_eq!( + status, + ThreadStatus::Active { + active_flags: Vec::new(), + } + ); + } + + #[test] + fn keeps_status_when_no_in_progress_turn() { + assert_eq!( + resolve_thread_status(ThreadStatus::Idle, /*has_in_progress_turn*/ false), + ThreadStatus::Idle + ); + assert_eq!( + resolve_thread_status( + ThreadStatus::SystemError, + /*has_in_progress_turn*/ false + ), + ThreadStatus::SystemError + ); + } + + #[tokio::test] + async fn system_error_sets_idle_flag_until_next_turn() { + let manager = ThreadWatchManager::new(); + manager + .upsert_thread(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + manager.note_system_error(INTERACTIVE_THREAD_ID).await; + + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::SystemError, + ); + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Active { + active_flags: vec![], + }, + ); + } + + #[tokio::test] + async fn shutdown_marks_thread_not_loaded() { + let manager = ThreadWatchManager::new(); + manager + .upsert_thread(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + manager.note_thread_shutdown(INTERACTIVE_THREAD_ID).await; + + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::NotLoaded, + ); + } + + #[tokio::test] + async fn loaded_statuses_default_to_not_loaded_for_untracked_threads() { + let manager = ThreadWatchManager::new(); + manager + .upsert_thread(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + + let statuses = manager + .loaded_statuses_for_threads(vec![ + INTERACTIVE_THREAD_ID.to_string(), + NON_INTERACTIVE_THREAD_ID.to_string(), + ]) + .await; + + assert_eq!( + statuses.get(INTERACTIVE_THREAD_ID), + Some(&ThreadStatus::Active { + active_flags: vec![], + }), + ); + assert_eq!( + statuses.get(NON_INTERACTIVE_THREAD_ID), + Some(&ThreadStatus::NotLoaded), + ); + } + + #[tokio::test] + async fn has_running_turns_tracks_runtime_running_flag_only() { + let manager = ThreadWatchManager::new(); + manager + .upsert_thread(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + + assert_eq!(manager.running_turn_count().await, 0); + + let _permission_guard = manager + .note_permission_requested(INTERACTIVE_THREAD_ID) + .await; + assert_eq!(manager.running_turn_count().await, 0); + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + assert_eq!(manager.running_turn_count().await, 1); + + manager + .note_turn_completed(INTERACTIVE_THREAD_ID, false) + .await; + assert_eq!(manager.running_turn_count().await, 0); + } + + #[tokio::test] + async fn status_change_emits_notification() { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( + outgoing_tx, + codex_analytics::AnalyticsEventsClient::disabled(), + ))); + + manager + .upsert_thread(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + assert_eq!( + recv_status_changed_notification(&mut outgoing_rx).await, + ThreadStatusChangedNotification { + thread_id: INTERACTIVE_THREAD_ID.to_string(), + status: ThreadStatus::Idle, + }, + ); + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + assert_eq!( + recv_status_changed_notification(&mut outgoing_rx).await, + ThreadStatusChangedNotification { + thread_id: INTERACTIVE_THREAD_ID.to_string(), + status: ThreadStatus::Active { + active_flags: vec![], + }, + }, + ); + + manager.remove_thread(INTERACTIVE_THREAD_ID).await; + assert_eq!( + recv_status_changed_notification(&mut outgoing_rx).await, + ThreadStatusChangedNotification { + thread_id: INTERACTIVE_THREAD_ID.to_string(), + status: ThreadStatus::NotLoaded, + }, + ); + } + + #[tokio::test] + async fn silent_upsert_skips_initial_notification() { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( + outgoing_tx, + codex_analytics::AnalyticsEventsClient::disabled(), + ))); + + manager + .upsert_thread_silently(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + + assert_eq!( + manager + .loaded_status_for_thread(INTERACTIVE_THREAD_ID) + .await, + ThreadStatus::Idle, + ); + assert!( + timeout(Duration::from_millis(100), outgoing_rx.recv()) + .await + .is_err(), + "silent upsert should not emit thread/status/changed" + ); + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + assert_eq!( + recv_status_changed_notification(&mut outgoing_rx).await, + ThreadStatusChangedNotification { + thread_id: INTERACTIVE_THREAD_ID.to_string(), + status: ThreadStatus::Active { + active_flags: vec![], + }, + }, + ); + } + + #[tokio::test] + async fn status_watchers_receive_only_their_thread_updates() { + let manager = ThreadWatchManager::new(); + manager + .upsert_thread(test_thread( + INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::Cli, + )) + .await; + manager + .upsert_thread(test_thread( + NON_INTERACTIVE_THREAD_ID, + codex_app_server_protocol::SessionSource::AppServer, + )) + .await; + let interactive_thread_id = ThreadId::from_string(INTERACTIVE_THREAD_ID) + .expect("interactive thread id should parse"); + let non_interactive_thread_id = ThreadId::from_string(NON_INTERACTIVE_THREAD_ID) + .expect("non-interactive thread id should parse"); + let mut interactive_rx = manager + .subscribe(interactive_thread_id) + .await + .expect("interactive status watcher should subscribe"); + let mut non_interactive_rx = manager + .subscribe(non_interactive_thread_id) + .await + .expect("non-interactive status watcher should subscribe"); + + manager.note_turn_started(INTERACTIVE_THREAD_ID).await; + + timeout(Duration::from_secs(1), interactive_rx.changed()) + .await + .expect("timed out waiting for interactive status update") + .expect("interactive status watcher should remain open"); + assert_eq!( + *interactive_rx.borrow(), + ThreadStatus::Active { + active_flags: vec![], + }, + ); + assert!( + timeout(Duration::from_millis(100), non_interactive_rx.changed()) + .await + .is_err(), + "unrelated thread watcher should not receive an update" + ); + assert_eq!(*non_interactive_rx.borrow(), ThreadStatus::Idle); + } + + async fn wait_for_status( + manager: &ThreadWatchManager, + thread_id: &str, + expected_status: ThreadStatus, + ) { + timeout(Duration::from_secs(1), async { + loop { + let status = manager.loaded_status_for_thread(thread_id).await; + if status == expected_status { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("timed out waiting for status"); + } + + async fn recv_status_changed_notification( + outgoing_rx: &mut mpsc::Receiver, + ) -> ThreadStatusChangedNotification { + let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv()) + .await + .expect("timed out waiting for outgoing notification") + .expect("outgoing channel closed unexpectedly"); + let OutgoingEnvelope::Broadcast { message } = envelope else { + panic!("expected broadcast notification"); + }; + let OutgoingMessage::AppServerNotification(ServerNotification::ThreadStatusChanged( + notification, + )) = message + else { + panic!("expected thread/status/changed notification"); + }; + notification + } + + fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { + Thread { + id: thread_id.to_string(), + session_id: thread_id.to_string(), + forked_from_id: None, + preview: String::new(), + ephemeral: false, + model_provider: "mock-provider".to_string(), + created_at: 0, + updated_at: 0, + status: ThreadStatus::NotLoaded, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: "test".to_string(), + agent_nickname: None, + agent_role: None, + source, + thread_source: None, + git_info: None, + name: None, + turns: Vec::new(), + } + } +} diff --git a/code-rs/app-server/src/transport.rs b/code-rs/app-server/src/transport.rs index 2cdef31ed3a..8d61ac5f56d 100644 --- a/code-rs/app-server/src/transport.rs +++ b/code-rs/app-server/src/transport.rs @@ -1,528 +1,204 @@ -use crate::error_code::OVERLOADED_ERROR_CODE; use crate::message_processor::ConnectionSessionState; -use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; -use crate::outgoing_message::OutgoingError; -use crate::outgoing_message::OutgoingMessage; -use mcp_types::JSONRPCErrorError; -use mcp_types::JSONRPCMessage; -use futures::SinkExt; -use futures::StreamExt; -use owo_colors::OwoColorize; -use owo_colors::Stream; -use owo_colors::Style; +use codex_app_server_protocol::ExperimentalApi; +use codex_app_server_protocol::ServerRequest; use std::collections::HashMap; use std::collections::HashSet; -use std::io::ErrorKind; -use std::io::Result as IoResult; -use std::net::SocketAddr; -use std::str::FromStr; use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio::io::{self}; -use tokio::net::TcpListener; -use tokio::net::TcpStream; use tokio::sync::mpsc; -use tokio::sync::Notify; -use tokio::task::JoinHandle; -use tokio_tungstenite::accept_hdr_async; -use tokio_tungstenite::tungstenite::handshake::server::ErrorResponse; -use tokio_tungstenite::tungstenite::handshake::server::Request; -use tokio_tungstenite::tungstenite::handshake::server::Response; -use tokio_tungstenite::tungstenite::http::StatusCode; -use tokio_tungstenite::tungstenite::http::header::ORIGIN; -use tokio_tungstenite::tungstenite::Message as WebSocketMessage; -use tracing::debug; -use tracing::error; -use tracing::info; +use tokio_util::sync::CancellationToken; use tracing::warn; -/// Size of the bounded channels used to communicate between tasks. -pub(crate) const CHANNEL_CAPACITY: usize = 128; - -fn colorize(text: &str, style: Style) -> String { - text.if_supports_color(Stream::Stderr, |value| value.style(style)) - .to_string() -} - -#[allow(clippy::print_stderr)] -fn print_websocket_startup_banner(addr: SocketAddr) { - let title = colorize("code app-server (WebSockets)", Style::new().bold().cyan()); - let listening_label = colorize("listening on:", Style::new().dimmed()); - let listen_url = colorize(&format!("ws://{addr}"), Style::new().green()); - let note_label = colorize("note:", Style::new().dimmed()); - eprintln!("{title}"); - eprintln!(" {listening_label} {listen_url}"); - eprintln!(" {note_label} binds localhost only (use SSH port-forwarding for remote access)"); -} - -#[allow(clippy::print_stderr)] -fn print_websocket_connection(peer_addr: SocketAddr) { - let connected_label = colorize("websocket client connected from", Style::new().dimmed()); - eprintln!("{connected_label} {peer_addr}"); -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AppServerTransport { - Stdio, - WebSocket { bind_address: SocketAddr }, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum AppServerTransportParseError { - UnsupportedListenUrl(String), - InvalidWebSocketListenUrl(String), - NonLoopbackWebSocketListenUrl(String), -} - -impl std::fmt::Display for AppServerTransportParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!( - f, - "unsupported --listen URL `{listen_url}`; expected `stdio://` or `ws://IP:PORT`" - ), - AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!( - f, - "invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`" - ), - AppServerTransportParseError::NonLoopbackWebSocketListenUrl(listen_url) => write!( - f, - "unsupported websocket --listen URL `{listen_url}`; websocket app-server binds must use a loopback IP address" - ), - } - } -} - -impl std::error::Error for AppServerTransportParseError {} - -impl AppServerTransport { - pub const DEFAULT_LISTEN_URL: &'static str = "stdio://"; - - pub fn from_listen_url(listen_url: &str) -> Result { - if listen_url == Self::DEFAULT_LISTEN_URL { - return Ok(Self::Stdio); - } - - if let Some(socket_addr) = listen_url.strip_prefix("ws://") { - let bind_address = socket_addr.parse::().map_err(|_| { - AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string()) - })?; - if !bind_address.ip().is_loopback() { - return Err(AppServerTransportParseError::NonLoopbackWebSocketListenUrl( - listen_url.to_string(), - )); - } - return Ok(Self::WebSocket { bind_address }); - } - - Err(AppServerTransportParseError::UnsupportedListenUrl( - listen_url.to_string(), - )) - } -} - -impl FromStr for AppServerTransport { - type Err = AppServerTransportParseError; - - fn from_str(s: &str) -> Result { - Self::from_listen_url(s) - } -} - -#[derive(Debug)] -pub(crate) enum TransportEvent { - ConnectionOpened { - connection_id: ConnectionId, - writer: mpsc::Sender, - disconnect_notify: Option>, - }, - ConnectionClosed { - connection_id: ConnectionId, - }, - IncomingMessage { - connection_id: ConnectionId, - message: JSONRPCMessage, - }, -} +pub use codex_app_server_transport::AppServerTransport; +pub(crate) use codex_app_server_transport::CHANNEL_CAPACITY; +pub(crate) use codex_app_server_transport::ConnectionId; +pub(crate) use codex_app_server_transport::ConnectionOrigin; +pub(crate) use codex_app_server_transport::OutgoingMessage; +pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; +pub(crate) use codex_app_server_transport::RemoteControlHandle; +pub(crate) use codex_app_server_transport::TransportEvent; +pub use codex_app_server_transport::app_server_control_socket_path; +pub use codex_app_server_transport::auth; +pub(crate) use codex_app_server_transport::start_control_socket_acceptor; +pub(crate) use codex_app_server_transport::start_remote_control; +pub(crate) use codex_app_server_transport::start_stdio_connection; +pub(crate) use codex_app_server_transport::start_websocket_acceptor; pub(crate) struct ConnectionState { pub(crate) outbound_initialized: Arc, + pub(crate) outbound_experimental_api_enabled: Arc, pub(crate) outbound_opted_out_notification_methods: Arc>>, - pub(crate) session: ConnectionSessionState, + pub(crate) session: Arc, } impl ConnectionState { pub(crate) fn new( + _origin: ConnectionOrigin, outbound_initialized: Arc, + outbound_experimental_api_enabled: Arc, outbound_opted_out_notification_methods: Arc>>, ) -> Self { Self { outbound_initialized, + outbound_experimental_api_enabled, outbound_opted_out_notification_methods, - session: ConnectionSessionState::default(), + session: Arc::new(ConnectionSessionState::new()), } } } pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, + pub(crate) experimental_api_enabled: Arc, pub(crate) opted_out_notification_methods: Arc>>, - pub(crate) writer: mpsc::Sender, - pub(crate) disconnect_notify: Option>, + pub(crate) writer: mpsc::Sender, + disconnect_sender: Option, } impl OutboundConnectionState { pub(crate) fn new( - writer: mpsc::Sender, + writer: mpsc::Sender, initialized: Arc, + experimental_api_enabled: Arc, opted_out_notification_methods: Arc>>, - disconnect_notify: Option>, + disconnect_sender: Option, ) -> Self { Self { initialized, + experimental_api_enabled, opted_out_notification_methods, writer, - disconnect_notify, + disconnect_sender, } } -} - -pub(crate) async fn start_stdio_connection( - transport_event_tx: mpsc::Sender, - stdio_handles: &mut Vec>, -) -> IoResult<()> { - let connection_id = ConnectionId(0); - let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); - let writer_tx_for_reader = writer_tx.clone(); - transport_event_tx - .send(TransportEvent::ConnectionOpened { - connection_id, - writer: writer_tx, - disconnect_notify: None, - }) - .await - .map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?; - - let transport_event_tx_for_reader = transport_event_tx.clone(); - stdio_handles.push(tokio::spawn(async move { - let stdin = io::stdin(); - let reader = BufReader::new(stdin); - let mut lines = reader.lines(); - - loop { - match lines.next_line().await { - Ok(Some(line)) => { - if !forward_incoming_message( - &transport_event_tx_for_reader, - &writer_tx_for_reader, - connection_id, - &line, - ) - .await - { - break; - } - } - Ok(None) => break, - Err(err) => { - error!("Failed reading stdin: {err}"); - break; - } - } - } - - let _ = transport_event_tx_for_reader - .send(TransportEvent::ConnectionClosed { connection_id }) - .await; - debug!("stdin reader finished (EOF)"); - })); - - stdio_handles.push(tokio::spawn(async move { - let mut stdout = io::stdout(); - while let Some(outgoing_message) = writer_rx.recv().await { - let Some(mut json) = serialize_outgoing_message(outgoing_message) else { - continue; - }; - json.push('\n'); - if let Err(err) = stdout.write_all(json.as_bytes()).await { - error!("Failed to write to stdout: {err}"); - break; - } - } - info!("stdout writer exited (channel closed)"); - })); - Ok(()) -} - -pub(crate) async fn start_websocket_acceptor( - bind_address: SocketAddr, - transport_event_tx: mpsc::Sender, -) -> IoResult> { - let listener = TcpListener::bind(bind_address).await?; - let local_addr = listener.local_addr()?; - print_websocket_startup_banner(local_addr); - info!("app-server websocket listening on ws://{local_addr}"); + fn can_disconnect(&self) -> bool { + self.disconnect_sender.is_some() + } - let connection_counter = Arc::new(AtomicU64::new(1)); - Ok(tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, peer_addr)) => { - print_websocket_connection(peer_addr); - let connection_id = - ConnectionId(connection_counter.fetch_add(1, Ordering::Relaxed)); - let transport_event_tx_for_connection = transport_event_tx.clone(); - tokio::spawn(async move { - run_websocket_connection( - connection_id, - stream, - transport_event_tx_for_connection, - ) - .await; - }); - } - Err(err) => { - error!("failed to accept websocket connection: {err}"); - } - } + pub(crate) fn request_disconnect(&self) { + if let Some(disconnect_sender) = &self.disconnect_sender { + disconnect_sender.cancel(); } - })) + } } -async fn run_websocket_connection( - connection_id: ConnectionId, - stream: TcpStream, - transport_event_tx: mpsc::Sender, -) { - let websocket_stream = match accept_hdr_async(stream, reject_origin_websocket_requests).await { - Ok(stream) => stream, - Err(err) => { - warn!("failed to complete websocket handshake: {err}"); - return; - } +fn should_skip_notification_for_connection( + connection_state: &OutboundConnectionState, + message: &OutgoingMessage, +) -> bool { + let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read() + else { + warn!("failed to read outbound opted-out notifications"); + return false; }; - - let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); - let writer_tx_for_reader = writer_tx.clone(); - let disconnect_notify = Arc::new(Notify::new()); - if transport_event_tx - .send(TransportEvent::ConnectionOpened { - connection_id, - writer: writer_tx, - disconnect_notify: Some(Arc::clone(&disconnect_notify)), - }) - .await - .is_err() - { - return; - } - - let (mut websocket_writer, mut websocket_reader) = websocket_stream.split(); - loop { - tokio::select! { - _ = disconnect_notify.notified() => { - break; - } - outgoing_message = writer_rx.recv() => { - let Some(outgoing_message) = outgoing_message else { - break; - }; - let Some(json) = serialize_outgoing_message(outgoing_message) else { - continue; - }; - let send = websocket_writer.send(WebSocketMessage::Text(json.into())); - tokio::pin!(send); - let send_result = tokio::select! { - result = &mut send => Some(result), - _ = disconnect_notify.notified() => None, - }; - - if !matches!(send_result, Some(Ok(()))) { - break; - } - } - incoming_message = websocket_reader.next() => { - match incoming_message { - Some(Ok(WebSocketMessage::Text(text))) => { - if !forward_incoming_message( - &transport_event_tx, - &writer_tx_for_reader, - connection_id, - &text, - ) - .await - { - break; - } - } - Some(Ok(WebSocketMessage::Ping(payload))) => { - let send_pong = websocket_writer.send(WebSocketMessage::Pong(payload)); - tokio::pin!(send_pong); - let send_pong_result = tokio::select! { - result = &mut send_pong => Some(result), - _ = disconnect_notify.notified() => None, - }; - - if !matches!(send_pong_result, Some(Ok(()))) { - break; - } - } - Some(Ok(WebSocketMessage::Pong(_))) => {} - Some(Ok(WebSocketMessage::Close(_))) | None => break, - Some(Ok(WebSocketMessage::Binary(_))) => { - warn!("dropping unsupported binary websocket message"); - } - Some(Ok(WebSocketMessage::Frame(_))) => {} - Some(Err(err)) => { - warn!("websocket receive error: {err}"); - break; - } - } + match message { + OutgoingMessage::AppServerNotification(notification) => { + if notification.experimental_reason().is_some() + && !connection_state + .experimental_api_enabled + .load(Ordering::Acquire) + { + return true; } + let method = notification.to_string(); + opted_out_notification_methods.contains(method.as_str()) } + _ => false, } - - let _ = transport_event_tx - .send(TransportEvent::ConnectionClosed { connection_id }) - .await; -} - -fn reject_origin_websocket_requests( - request: &Request, - response: Response, -) -> Result { - if request.headers().contains_key(ORIGIN) { - warn!( - uri = %request.uri(), - "rejecting app-server websocket request with Origin header" - ); - return Err(Response::builder() - .status(StatusCode::FORBIDDEN) - .body(Some("WebSocket Origin requests are not accepted".to_string())) - .expect("static websocket error response should build")); - } - - Ok(response) } -async fn forward_incoming_message( - transport_event_tx: &mpsc::Sender, - writer: &mpsc::Sender, +fn disconnect_connection( + connections: &mut HashMap, connection_id: ConnectionId, - payload: &str, ) -> bool { - match serde_json::from_str::(payload) { - Ok(message) => { - enqueue_incoming_message(transport_event_tx, writer, connection_id, message).await - } - Err(err) => { - error!("Failed to deserialize JSONRPCMessage: {err}"); - true - } + if let Some(connection_state) = connections.remove(&connection_id) { + connection_state.request_disconnect(); + return true; } + false } -async fn enqueue_incoming_message( - transport_event_tx: &mpsc::Sender, - writer: &mpsc::Sender, +async fn send_message_to_connection( + connections: &mut HashMap, connection_id: ConnectionId, - message: JSONRPCMessage, + message: OutgoingMessage, + write_complete_tx: Option>, ) -> bool { - let event = TransportEvent::IncomingMessage { - connection_id, - message, + let Some(connection_state) = connections.get(&connection_id) else { + warn!("dropping message for disconnected connection: {connection_id:?}"); + return false; }; - match transport_event_tx.try_send(event) { - Ok(()) => true, - Err(mpsc::error::TrySendError::Closed(_)) => false, - Err(mpsc::error::TrySendError::Full(TransportEvent::IncomingMessage { - connection_id, - message: JSONRPCMessage::Request(request), - })) => { - let overload_error = OutgoingMessage::Error(OutgoingError { - id: request.id, - error: JSONRPCErrorError { - code: OVERLOADED_ERROR_CODE, - message: "Server overloaded; retry later.".to_string(), - data: None, - }, - }); - match writer.try_send(overload_error) { - Ok(()) => true, - Err(mpsc::error::TrySendError::Closed(_)) => false, - Err(mpsc::error::TrySendError::Full(_overload_error)) => { - warn!( - "dropping overload response for connection {:?}: outbound queue is full", - connection_id - ); - true - } - } - } - Err(mpsc::error::TrySendError::Full(event)) => transport_event_tx.send(event).await.is_ok(), + let message = filter_outgoing_message_for_connection(connection_state, message); + if should_skip_notification_for_connection(connection_state, &message) { + return false; } -} -fn serialize_outgoing_message(outgoing_message: OutgoingMessage) -> Option { - let jsonrpc: JSONRPCMessage = outgoing_message.into(); - match serde_json::to_string(&jsonrpc) { - Ok(json) => Some(json), - Err(err) => { - error!("Failed to serialize JSONRPCMessage: {err}"); - None + let writer = connection_state.writer.clone(); + let queued_message = QueuedOutgoingMessage { + message, + write_complete_tx, + }; + if connection_state.can_disconnect() { + match writer.try_send(queued_message) { + Ok(()) => false, + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + "disconnecting slow connection after outbound queue filled: {connection_id:?}" + ); + disconnect_connection(connections, connection_id) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + disconnect_connection(connections, connection_id) + } } + } else if writer.send(queued_message).await.is_err() { + disconnect_connection(connections, connection_id) + } else { + false } } -fn should_skip_notification_for_connection( +fn filter_outgoing_message_for_connection( connection_state: &OutboundConnectionState, - message: &OutgoingMessage, -) -> bool { - let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read() - else { - warn!("failed to read outbound opted-out notifications"); - return false; - }; + message: OutgoingMessage, +) -> OutgoingMessage { + let experimental_api_enabled = connection_state + .experimental_api_enabled + .load(Ordering::Acquire); match message { - OutgoingMessage::Notification(notification) => { - opted_out_notification_methods.contains(notification.method.as_str()) + OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { + request_id, + mut params, + }) => { + if !experimental_api_enabled { + params.strip_experimental_fields(); + } + OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { + request_id, + params, + }) } - _ => false, + _ => message, } } pub(crate) async fn route_outgoing_envelope( connections: &mut HashMap, envelope: OutgoingEnvelope, -) -> Vec { - let mut disconnected = Vec::new(); +) { match envelope { OutgoingEnvelope::ToConnection { connection_id, message, + write_complete_tx, } => { - let Some(connection_state) = connections.get(&connection_id) else { - warn!( - "dropping message for disconnected connection: {:?}", - connection_id - ); - return disconnected; - }; - if should_skip_notification_for_connection(connection_state, &message) { - return disconnected; - } - if is_connection_write_failed(connection_id, connection_state, message).await { - connections.remove(&connection_id); - disconnected.push(connection_id); - } + let _ = + send_message_to_connection(connections, connection_id, message, write_complete_tx) + .await; } OutgoingEnvelope::Broadcast { message } => { let target_connections: Vec = connections @@ -539,483 +215,18 @@ pub(crate) async fn route_outgoing_envelope( .collect(); for connection_id in target_connections { - let Some(connection_state) = connections.get(&connection_id) else { - continue; - }; - if is_connection_write_failed(connection_id, connection_state, message.clone()).await { - connections.remove(&connection_id); - disconnected.push(connection_id); - } - } - } - } - disconnected -} - -async fn is_connection_write_failed( - connection_id: ConnectionId, - connection_state: &OutboundConnectionState, - message: OutgoingMessage, -) -> bool { - match connection_state.writer.try_send(message) { - Ok(()) => false, - Err(mpsc::error::TrySendError::Closed(_)) => true, - Err(mpsc::error::TrySendError::Full(message)) => { - if let Some(disconnect_notify) = &connection_state.disconnect_notify { - // For websocket clients, prevent a single slow peer from stalling all outbound traffic. - warn!( - "disconnecting slow connection {:?}: outbound queue is full", - connection_id - ); - disconnect_notify.notify_one(); - true - } else { - // Preserve stdio behavior: apply backpressure rather than disconnecting. - connection_state.writer.send(message).await.is_err() + let _ = send_message_to_connection( + connections, + connection_id, + message.clone(), + /*write_complete_tx*/ None, + ) + .await; } } } } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use tokio::time::sleep; - use tokio::time::Duration; - use tokio::time::timeout; - - #[test] - fn app_server_transport_parses_stdio_listen_url() { - let transport = AppServerTransport::from_listen_url(AppServerTransport::DEFAULT_LISTEN_URL) - .expect("stdio listen URL should parse"); - assert_eq!(transport, AppServerTransport::Stdio); - } - - #[test] - fn app_server_transport_parses_websocket_listen_url() { - let transport = AppServerTransport::from_listen_url("ws://127.0.0.1:1234") - .expect("websocket listen URL should parse"); - assert_eq!( - transport, - AppServerTransport::WebSocket { - bind_address: "127.0.0.1:1234".parse().expect("valid socket address"), - } - ); - } - - #[test] - fn app_server_transport_rejects_invalid_websocket_listen_url() { - let err = AppServerTransport::from_listen_url("ws://localhost:1234") - .expect_err("hostname bind address should be rejected"); - assert_eq!( - err.to_string(), - "invalid websocket --listen URL `ws://localhost:1234`; expected `ws://IP:PORT`" - ); - } - - #[test] - fn app_server_transport_rejects_unsupported_listen_url() { - let err = AppServerTransport::from_listen_url("http://127.0.0.1:1234") - .expect_err("unsupported scheme should fail"); - assert_eq!( - err.to_string(), - "unsupported --listen URL `http://127.0.0.1:1234`; expected `stdio://` or `ws://IP:PORT`" - ); - } - - #[tokio::test] - async fn enqueue_incoming_request_returns_overload_error_when_queue_is_full() { - let connection_id = ConnectionId(42); - let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); - let (writer_tx, mut writer_rx) = mpsc::channel(1); - - let first_message = JSONRPCMessage::Notification(mcp_types::JSONRPCNotification { - jsonrpc: mcp_types::JSONRPC_VERSION.to_string(), - method: "initialized".to_string(), - params: None, - }); - transport_event_tx - .send(TransportEvent::IncomingMessage { - connection_id, - message: first_message.clone(), - }) - .await - .expect("queue should accept first message"); - - let request = JSONRPCMessage::Request(mcp_types::JSONRPCRequest { - jsonrpc: mcp_types::JSONRPC_VERSION.to_string(), - id: mcp_types::RequestId::Integer(7), - method: "config/read".to_string(), - params: Some(json!({ "includeLayers": false })), - }); - assert!( - enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request).await - ); - - let queued_event = transport_event_rx - .recv() - .await - .expect("first event should stay queued"); - match queued_event { - TransportEvent::IncomingMessage { - connection_id: queued_connection_id, - message, - } => { - assert_eq!(queued_connection_id, connection_id); - assert_eq!(message, first_message); - } - _ => panic!("expected queued incoming message"), - } - - let overload = writer_rx - .recv() - .await - .expect("request should receive overload error"); - let overload_json = - serde_json::to_value::(overload.into()).expect("serialize overload error"); - assert_eq!( - overload_json, - json!({ - "jsonrpc": mcp_types::JSONRPC_VERSION, - "id": 7, - "error": { - "code": OVERLOADED_ERROR_CODE, - "message": "Server overloaded; retry later." - } - }) - ); - } - - #[tokio::test] - async fn enqueue_incoming_response_waits_instead_of_dropping_when_queue_is_full() { - let connection_id = ConnectionId(42); - let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); - let (writer_tx, _writer_rx) = mpsc::channel(1); - - let first_message = JSONRPCMessage::Notification(mcp_types::JSONRPCNotification { - jsonrpc: mcp_types::JSONRPC_VERSION.to_string(), - method: "initialized".to_string(), - params: None, - }); - transport_event_tx - .send(TransportEvent::IncomingMessage { - connection_id, - message: first_message.clone(), - }) - .await - .expect("queue should accept first message"); - - let response = JSONRPCMessage::Response(mcp_types::JSONRPCResponse { - jsonrpc: mcp_types::JSONRPC_VERSION.to_string(), - id: mcp_types::RequestId::Integer(7), - result: json!({"ok": true}), - }); - let transport_event_tx_for_enqueue = transport_event_tx.clone(); - let writer_tx_for_enqueue = writer_tx.clone(); - let enqueue_handle = tokio::spawn(async move { - enqueue_incoming_message( - &transport_event_tx_for_enqueue, - &writer_tx_for_enqueue, - connection_id, - response, - ) - .await - }); - - let queued_event = transport_event_rx - .recv() - .await - .expect("first event should be dequeued"); - match queued_event { - TransportEvent::IncomingMessage { - connection_id: queued_connection_id, - message, - } => { - assert_eq!(queued_connection_id, connection_id); - assert_eq!(message, first_message); - } - _ => panic!("expected queued incoming message"), - } - - let enqueue_result = enqueue_handle.await.expect("enqueue task should not panic"); - assert!(enqueue_result); - - let forwarded_event = transport_event_rx - .recv() - .await - .expect("response should be forwarded instead of dropped"); - match forwarded_event { - TransportEvent::IncomingMessage { - connection_id: queued_connection_id, - message: - JSONRPCMessage::Response(mcp_types::JSONRPCResponse { id, result, .. }), - } => { - assert_eq!(queued_connection_id, connection_id); - assert_eq!(id, mcp_types::RequestId::Integer(7)); - assert_eq!(result, json!({"ok": true})); - } - _ => panic!("expected forwarded response message"), - } - } - - #[tokio::test] - async fn enqueue_incoming_request_does_not_block_when_writer_queue_is_full() { - let connection_id = ConnectionId(42); - let (transport_event_tx, _transport_event_rx) = mpsc::channel(1); - let (writer_tx, mut writer_rx) = mpsc::channel(1); - - transport_event_tx - .send(TransportEvent::IncomingMessage { - connection_id, - message: JSONRPCMessage::Notification(mcp_types::JSONRPCNotification { - jsonrpc: mcp_types::JSONRPC_VERSION.to_string(), - method: "initialized".to_string(), - params: None, - }), - }) - .await - .expect("transport queue should accept first message"); - - writer_tx - .send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "queued".to_string(), - params: None, - }, - )) - .await - .expect("writer queue should accept first message"); - - let request = JSONRPCMessage::Request(mcp_types::JSONRPCRequest { - jsonrpc: mcp_types::JSONRPC_VERSION.to_string(), - id: mcp_types::RequestId::Integer(7), - method: "config/read".to_string(), - params: Some(json!({ "includeLayers": false })), - }); - - let enqueue_result = timeout( - Duration::from_millis(100), - enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request), - ) - .await - .expect("enqueue should not block while writer queue is full"); - assert!(enqueue_result); - - let queued_outgoing = writer_rx - .recv() - .await - .expect("writer queue should still contain original message"); - let queued_json = - serde_json::to_value::(queued_outgoing.into()).expect("serialize queued message"); - assert_eq!(queued_json, json!({ "jsonrpc": "2.0", "method": "queued" })); - } - - #[tokio::test] - async fn routed_notification_respects_opt_out_on_target_connection() { - let connection_id = ConnectionId(7); - let (writer_tx, mut writer_rx) = mpsc::channel(1); - let mut connections = HashMap::new(); - let initialized = Arc::new(AtomicBool::new(true)); - let opted_out = Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))); - connections.insert( - connection_id, - OutboundConnectionState::new(writer_tx, initialized, opted_out, None), - ); - - let envelope = OutgoingEnvelope::ToConnection { - connection_id, - message: OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "configWarning".to_string(), - params: Some(json!({ "summary": "warning" })), - }), - }; - - let disconnected = route_outgoing_envelope(&mut connections, envelope).await; - assert!(disconnected.is_empty(), "connection should remain active"); - - assert!( - timeout(Duration::from_millis(25), writer_rx.recv()) - .await - .is_err(), - "notification should be suppressed by opt-out" - ); - } - - #[tokio::test] - async fn slow_connection_does_not_block_other_clients() { - let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel::(1); - let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel::(1); - let slow_disconnect_notify = Arc::new(Notify::new()); - - // Fill the slow client's queue first so later writes would block if awaited. - slow_writer_tx - .try_send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "prefill".to_string(), - params: None, - }, - )) - .expect("slow queue should accept prefill"); - - let mut connections = HashMap::new(); - connections.insert( - ConnectionId(1), - OutboundConnectionState::new( - slow_writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - Some(slow_disconnect_notify), - ), - ); - connections.insert( - ConnectionId(2), - OutboundConnectionState::new( - fast_writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - None, - ), - ); - - let envelope = OutgoingEnvelope::Broadcast { - message: OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/item_started".to_string(), - params: Some(json!({ "ok": true })), - }), - }; - - let disconnected = timeout( - Duration::from_millis(50), - route_outgoing_envelope(&mut connections, envelope), - ) - .await - .expect("routing should finish promptly even when one client is slow"); - - assert_eq!(disconnected, vec![ConnectionId(1)]); - assert!(connections.contains_key(&ConnectionId(2))); - - let fast_outgoing = fast_writer_rx - .recv() - .await - .expect("fast connection should still receive the broadcast"); - let OutgoingMessage::Notification(notification) = fast_outgoing else { - panic!("expected broadcast notification for fast connection"); - }; - assert_eq!(notification.method, "codex/event/item_started"); - - let slow_prefill = slow_writer_rx - .recv() - .await - .expect("slow queue should only contain prefilled message"); - let OutgoingMessage::Notification(notification) = slow_prefill else { - panic!("expected prefilled notification"); - }; - assert_eq!(notification.method, "prefill"); - } - - #[tokio::test] - async fn slow_connection_notifies_disconnect_signal() { - let (slow_writer_tx, _slow_writer_rx) = mpsc::channel::(1); - slow_writer_tx - .try_send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "prefill".to_string(), - params: None, - }, - )) - .expect("slow queue should accept prefill"); - - let disconnect_notify = Arc::new(Notify::new()); - let mut connections = HashMap::new(); - connections.insert( - ConnectionId(9), - OutboundConnectionState::new( - slow_writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - Some(Arc::clone(&disconnect_notify)), - ), - ); - - let envelope = OutgoingEnvelope::ToConnection { - connection_id: ConnectionId(9), - message: OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/item_started".to_string(), - params: Some(json!({ "ok": true })), - }), - }; - - let notified = disconnect_notify.notified(); - let disconnected = route_outgoing_envelope(&mut connections, envelope).await; - assert_eq!(disconnected, vec![ConnectionId(9)]); - - timeout(Duration::from_millis(50), notified) - .await - .expect("disconnect notification should fire"); - } - - #[tokio::test] - async fn stdio_full_queue_backpressures_instead_of_disconnect() { - let (writer_tx, mut writer_rx) = mpsc::channel::(1); - writer_tx - .try_send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "prefill".to_string(), - params: None, - }, - )) - .expect("queue should accept prefill"); - - let mut connections = HashMap::new(); - connections.insert( - ConnectionId(11), - OutboundConnectionState::new( - writer_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - None, - ), - ); - - let route_task = tokio::spawn(async move { - route_outgoing_envelope( - &mut connections, - OutgoingEnvelope::ToConnection { - connection_id: ConnectionId(11), - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "second".to_string(), - params: None, - }, - ), - }, - ) - .await - }); - - sleep(Duration::from_millis(20)).await; - assert!( - !route_task.is_finished(), - "stdio routing should backpressure while queue is full" - ); - - let prefill = writer_rx.recv().await.expect("prefill should be queued"); - let OutgoingMessage::Notification(prefill_notification) = prefill else { - panic!("expected prefill notification"); - }; - assert_eq!(prefill_notification.method, "prefill"); - - let disconnected = route_task.await.expect("route task should complete"); - assert!( - disconnected.is_empty(), - "stdio connection should not be disconnected on backpressure" - ); - - let second = writer_rx.recv().await.expect("second notification should enqueue"); - let OutgoingMessage::Notification(second_notification) = second else { - panic!("expected second notification"); - }; - assert_eq!(second_notification.method, "second"); - } -} +#[path = "transport_tests.rs"] +mod tests; diff --git a/code-rs/app-server/src/transport_tests.rs b/code-rs/app-server/src/transport_tests.rs new file mode 100644 index 00000000000..5790e46a174 --- /dev/null +++ b/code-rs/app-server/src/transport_tests.rs @@ -0,0 +1,534 @@ +use super::*; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadGoal; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::time::Duration; +use tokio::time::timeout; + +fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") +} + +fn thread_goal_updated_notification() -> ServerNotification { + ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: None, + goal: ThreadGoal { + thread_id: "thread-1".to_string(), + objective: "ship goal mode".to_string(), + status: ThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 1, + }, + }) +} + +#[tokio::test] +async fn to_connection_notification_respects_opt_out_filters() { + let connection_id = ConnectionId(7); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + let initialized = Arc::new(AtomicBool::new(true)); + let opted_out_notification_methods = + Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + initialized, + Arc::new(AtomicBool::new(true)), + opted_out_notification_methods, + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, + }, + )), + write_complete_tx: None, + }, + ) + .await; + + assert!( + writer_rx.try_recv().is_err(), + "opted-out notification should be dropped" + ); +} + +#[tokio::test] +async fn to_connection_notifications_are_dropped_for_opted_out_clients() { + let connection_id = ConnectionId(10); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, + }, + )), + write_complete_tx: None, + }, + ) + .await; + + assert!( + writer_rx.try_recv().is_err(), + "opted-out notifications should not reach clients" + ); +} + +#[tokio::test] +async fn to_connection_notifications_are_preserved_for_non_opted_out_clients() { + let connection_id = ConnectionId(11); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, + }, + )), + write_complete_tx: None, + }, + ) + .await; + + let message = writer_rx + .recv() + .await + .expect("notification should reach non-opted-out clients"); + assert!(matches!( + message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "task_started" + )); +} + +#[tokio::test] +async fn experimental_notifications_are_dropped_without_capability() { + let connection_id = ConnectionId(12); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(false)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + write_complete_tx: None, + }, + ) + .await; + + assert!( + writer_rx.try_recv().is_err(), + "experimental notifications should not reach clients without capability" + ); +} + +#[tokio::test] +async fn experimental_notifications_are_preserved_with_capability() { + let connection_id = ConnectionId(13); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + write_complete_tx: None, + }, + ) + .await; + + let message = writer_rx + .recv() + .await + .expect("experimental notification should reach opted-in client"); + assert!(matches!( + message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_)) + )); +} + +#[tokio::test] +async fn command_execution_request_approval_strips_additional_permissions_without_capability() { + let connection_id = ConnectionId(8); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(false)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::CommandExecutionRequestApprovalParams { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + item_id: "call_123".to_string(), + started_at_ms: 0, + approval_id: None, + reason: Some("Need extra read access".to_string()), + network_approval_context: None, + command: Some("cat file".to_string()), + cwd: Some(absolute_path("/tmp")), + command_actions: None, + additional_permissions: Some( + codex_app_server_protocol::AdditionalPermissionProfile { + network: None, + file_system: Some( + codex_app_server_protocol::AdditionalFileSystemPermissions { + read: Some(vec![absolute_path("/tmp/allowed")]), + write: None, + glob_scan_max_depth: None, + entries: None, + }, + ), + }, + ), + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }), + write_complete_tx: None, + }, + ) + .await; + + let message = writer_rx + .recv() + .await + .expect("request should be delivered to the connection"); + let json = serde_json::to_value(message.message).expect("request should serialize"); + assert_eq!(json["params"].get("additionalPermissions"), None); +} + +#[tokio::test] +async fn command_execution_request_approval_keeps_additional_permissions_with_capability() { + let connection_id = ConnectionId(9); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::CommandExecutionRequestApprovalParams { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + item_id: "call_123".to_string(), + started_at_ms: 0, + approval_id: None, + reason: Some("Need extra read access".to_string()), + network_approval_context: None, + command: Some("cat file".to_string()), + cwd: Some(absolute_path("/tmp")), + command_actions: None, + additional_permissions: Some( + codex_app_server_protocol::AdditionalPermissionProfile { + network: None, + file_system: Some( + codex_app_server_protocol::AdditionalFileSystemPermissions { + read: Some(vec![absolute_path("/tmp/allowed")]), + write: None, + glob_scan_max_depth: None, + entries: None, + }, + ), + }, + ), + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }), + write_complete_tx: None, + }, + ) + .await; + + let message = writer_rx + .recv() + .await + .expect("request should be delivered to the connection"); + let json = serde_json::to_value(message.message).expect("request should serialize"); + let allowed_path = absolute_path("/tmp/allowed").to_string_lossy().into_owned(); + assert_eq!( + json["params"]["additionalPermissions"], + json!({ + "network": null, + "fileSystem": { + "read": [allowed_path], + "write": null, + }, + }) + ); +} + +#[tokio::test] +async fn broadcast_does_not_block_on_slow_connection() { + let fast_connection_id = ConnectionId(1); + let slow_connection_id = ConnectionId(2); + + let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel(1); + let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel(1); + let fast_disconnect_token = CancellationToken::new(); + let slow_disconnect_token = CancellationToken::new(); + + let mut connections = HashMap::new(); + connections.insert( + fast_connection_id, + OutboundConnectionState::new( + fast_writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(fast_disconnect_token.clone()), + ), + ); + connections.insert( + slow_connection_id, + OutboundConnectionState::new( + slow_writer_tx.clone(), + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(slow_disconnect_token.clone()), + ), + ); + + let queued_message = OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "already-buffered".to_string(), + details: None, + path: None, + range: None, + }, + )); + slow_writer_tx + .try_send(QueuedOutgoingMessage::new(queued_message)) + .expect("channel should have room"); + + let broadcast_message = OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }), + ); + timeout( + Duration::from_millis(100), + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::Broadcast { + message: broadcast_message, + }, + ), + ) + .await + .expect("broadcast should return even when one connection is slow"); + assert!(!connections.contains_key(&slow_connection_id)); + assert!(slow_disconnect_token.is_cancelled()); + assert!(!fast_disconnect_token.is_cancelled()); + let fast_message = fast_writer_rx + .try_recv() + .expect("fast connection should receive the broadcast notification"); + assert!(matches!( + fast_message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "test" + )); + + let slow_message = slow_writer_rx + .try_recv() + .expect("slow connection should retain its original buffered message"); + assert!(matches!( + slow_message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "already-buffered" + )); +} + +#[tokio::test] +async fn to_connection_stdio_waits_instead_of_disconnecting_when_writer_queue_is_full() { + let connection_id = ConnectionId(3); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + writer_tx + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("channel should accept the first queued message"); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + let route_task = tokio::spawn(async move { + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "second".to_string(), + details: None, + path: None, + range: None, + }, + )), + write_complete_tx: None, + }, + ) + .await + }); + + let first = timeout(Duration::from_millis(100), writer_rx.recv()) + .await + .expect("first queued message should be readable") + .expect("first queued message should exist"); + timeout(Duration::from_millis(100), route_task) + .await + .expect("routing should finish after the first queued message is drained") + .expect("routing task should succeed"); + + assert!(matches!( + first.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "queued" + )); + let second = writer_rx + .try_recv() + .expect("second notification should be delivered once the queue has room"); + assert!(matches!( + second.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "second" + )); +} diff --git a/code-rs/execpolicy/tests/all.rs b/code-rs/app-server/tests/all.rs similarity index 100% rename from code-rs/execpolicy/tests/all.rs rename to code-rs/app-server/tests/all.rs diff --git a/code-rs/app-server/tests/binary_smoke.rs b/code-rs/app-server/tests/binary_smoke.rs deleted file mode 100644 index 9a56cfffcbe..00000000000 --- a/code-rs/app-server/tests/binary_smoke.rs +++ /dev/null @@ -1,199 +0,0 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::process::Command; -use std::process::Stdio; - -use serde_json::Value; -use serde_json::json; - -fn app_server_bin() -> PathBuf { - PathBuf::from(assert_cmd::cargo::cargo_bin!("code-app-server")) -} - -fn run_jsonrpc_script_with_args(args: &[&str], requests: &[Value]) -> BTreeMap { - let mut child = Command::new(app_server_bin()) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("failed to spawn code-app-server"); - - let mut stdin = child.stdin.take().expect("child stdin is not piped"); - for request in requests { - let line = serde_json::to_string(request).expect("request must be valid JSON"); - use std::io::Write as _; - writeln!(stdin, "{line}").expect("failed to write JSON-RPC request line"); - } - drop(stdin); - - let output = child - .wait_with_output() - .expect("failed waiting for code-app-server output"); - - assert!( - output.status.success(), - "code-app-server exited with {status}; stderr:\n{stderr}", - status = output.status, - stderr = String::from_utf8_lossy(&output.stderr) - ); - - String::from_utf8_lossy(&output.stdout) - .lines() - .map(|line| { - let message: Value = serde_json::from_str(line) - .unwrap_or_else(|e| panic!("invalid JSON-RPC line `{line}`: {e}")); - let id = message - .get("id") - .and_then(Value::as_i64) - .unwrap_or_else(|| panic!("JSON-RPC message missing numeric id: {message}")); - (id, message) - }) - .collect() -} - -fn run_jsonrpc_script(requests: &[Value]) -> BTreeMap { - run_jsonrpc_script_with_args(&[], requests) -} - -#[test] -fn binary_smoke_requires_init_and_executes_command() { - let marker = "hello-from-app-server-binary-smoke"; - let requests = vec![ - json!({"jsonrpc":"2.0","id":1,"method":"getUserAgent"}), - json!({ - "jsonrpc":"2.0", - "id":2, - "method":"initialize", - "params":{ - "clientInfo":{ - "name":"app-server-binary-smoke", - "version":"0.1.0" - } - } - }), - json!({"jsonrpc":"2.0","id":3,"method":"getUserAgent"}), - json!({ - "jsonrpc":"2.0", - "id":4, - "method":"execOneOffCommand", - "params":{ - "command":["bash","-lc", format!("echo {marker}")], - "timeoutMs":5000 - } - }), - ]; - - let responses = run_jsonrpc_script(&requests); - - let pre_init_error = responses - .get(&1) - .and_then(|v| v.get("error")) - .and_then(|v| v.get("message")) - .and_then(Value::as_str) - .expect("expected error response for pre-initialize getUserAgent"); - assert!( - pre_init_error.contains("Not initialized"), - "unexpected pre-init error message: {pre_init_error}" - ); - - let user_agent = responses - .get(&3) - .and_then(|v| v.get("result")) - .and_then(|v| v.get("userAgent")) - .and_then(Value::as_str) - .expect("expected getUserAgent response after initialize"); - assert!( - user_agent.contains("(app-server-binary-smoke; 0.1.0)"), - "user agent did not include initialize client info: {user_agent}" - ); - - let exec_result = responses - .get(&4) - .and_then(|v| v.get("result")) - .expect("expected execOneOffCommand response"); - let exit_code = exec_result - .get("exitCode") - .and_then(Value::as_i64) - .expect("execOneOffCommand result missing exitCode"); - let stdout = exec_result - .get("stdout") - .and_then(Value::as_str) - .expect("execOneOffCommand result missing stdout"); - - assert_eq!(exit_code, 0, "execOneOffCommand returned non-zero exit"); - assert!( - stdout.contains(marker), - "execOneOffCommand stdout missing marker. stdout was: {stdout}" - ); -} - -#[test] -fn binary_smoke_accepts_desktop_startup_polling_methods() { - let requests = vec![ - json!({ - "jsonrpc":"2.0", - "id":1, - "method":"initialize", - "params":{ - "clientInfo":{ - "name":"codex-desktop-smoke", - "version":"0.1.0" - } - } - }), - json!({"jsonrpc":"2.0","id":2,"method":"thread/list","params":{}}), - json!({"jsonrpc":"2.0","id":3,"method":"model/list","params":{}}), - json!({"jsonrpc":"2.0","id":4,"method":"skills/list","params":{}}), - json!({"jsonrpc":"2.0","id":5,"method":"plugin/list","params":{}}), - json!({"jsonrpc":"2.0","id":6,"method":"hooks/list","params":{}}), - json!({"jsonrpc":"2.0","id":7,"method":"mcpServerStatus/list","params":{}}), - json!({"jsonrpc":"2.0","id":8,"method":"remoteControl/status/read","params":{}}), - json!({"jsonrpc":"2.0","id":9,"method":"remoteControl/enable","params":{"enabled":true}}), - json!({"jsonrpc":"2.0","id":10,"method":"collaborationMode/list","params":{}}), - json!({"jsonrpc":"2.0","id":11,"method":"experimentalFeature/list","params":{}}), - json!({"jsonrpc":"2.0","id":12,"method":"experimentalFeature/enablement/set","params":{"featureId":"desktop-smoke","enabled":true}}), - ]; - - let responses = run_jsonrpc_script_with_args(&["--analytics-default-enabled"], &requests); - - for id in 2..=12 { - let response = responses - .get(&id) - .unwrap_or_else(|| panic!("missing response for request id {id}")); - assert!( - response.get("error").is_none(), - "desktop startup method returned error for id {id}: {response}" - ); - } - - for id in [2, 3, 4, 5, 6, 7, 10, 11] { - let result = responses - .get(&id) - .and_then(|response| response.get("result")) - .expect("expected list result"); - assert_eq!(result.get("data"), Some(&json!([]))); - assert_eq!(result.get("nextCursor"), Some(&json!(null))); - } - assert_eq!( - responses - .get(&8) - .and_then(|response| response.get("result")) - .and_then(|result| result.get("enabled")), - Some(&json!(false)) - ); - assert_eq!( - responses - .get(&9) - .and_then(|response| response.get("result")) - .and_then(|result| result.get("unsupported")), - Some(&json!(true)) - ); - assert_eq!( - responses - .get(&12) - .and_then(|response| response.get("result")) - .and_then(|result| result.get("unsupported")), - Some(&json!(true)) - ); -} diff --git a/code-rs/app-server/tests/common/BUILD.bazel b/code-rs/app-server/tests/common/BUILD.bazel new file mode 100644 index 00000000000..bf4e465aee3 --- /dev/null +++ b/code-rs/app-server/tests/common/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "common", + crate_name = "app_test_support", + crate_srcs = glob(["*.rs"]), +) \ No newline at end of file diff --git a/code-rs/app-server/tests/common/Cargo.toml b/code-rs/app-server/tests/common/Cargo.toml new file mode 100644 index 00000000000..5b245f40d29 --- /dev/null +++ b/code-rs/app-server/tests/common/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "app_test_support" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-features = { workspace = true } +codex-login = { workspace = true } +codex-models-manager = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-cargo-bin = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", +] } +uuid = { workspace = true } +wiremock = { workspace = true } +core_test_support = { path = "../../../core/tests/common" } +shlex = { workspace = true } diff --git a/code-rs/app-server/tests/common/analytics_server.rs b/code-rs/app-server/tests/common/analytics_server.rs new file mode 100644 index 00000000000..75b8df60ec2 --- /dev/null +++ b/code-rs/app-server/tests/common/analytics_server.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +pub async fn start_analytics_events_server() -> Result { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Ok(server) +} diff --git a/code-rs/app-server/tests/common/auth_fixtures.rs b/code-rs/app-server/tests/common/auth_fixtures.rs new file mode 100644 index 00000000000..86f0fb456dd --- /dev/null +++ b/code-rs/app-server/tests/common/auth_fixtures.rs @@ -0,0 +1,170 @@ +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::DateTime; +use chrono::Utc; +use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::save_auth; +use codex_login::token_data::TokenData; +use codex_login::token_data::parse_chatgpt_jwt_claims; +use serde_json::json; + +/// Builder for writing a fake ChatGPT auth.json in tests. +#[derive(Debug, Clone)] +pub struct ChatGptAuthFixture { + access_token: String, + refresh_token: String, + account_id: Option, + claims: ChatGptIdTokenClaims, + last_refresh: Option>>, +} + +impl ChatGptAuthFixture { + pub fn new(access_token: impl Into) -> Self { + Self { + access_token: access_token.into(), + refresh_token: "refresh-token".to_string(), + account_id: None, + claims: ChatGptIdTokenClaims::default(), + last_refresh: None, + } + } + + pub fn refresh_token(mut self, refresh_token: impl Into) -> Self { + self.refresh_token = refresh_token.into(); + self + } + + pub fn account_id(mut self, account_id: impl Into) -> Self { + self.account_id = Some(account_id.into()); + self + } + + pub fn plan_type(mut self, plan_type: impl Into) -> Self { + self.claims.plan_type = Some(plan_type.into()); + self + } + + pub fn chatgpt_user_id(mut self, chatgpt_user_id: impl Into) -> Self { + self.claims.chatgpt_user_id = Some(chatgpt_user_id.into()); + self + } + + pub fn chatgpt_account_id(mut self, chatgpt_account_id: impl Into) -> Self { + self.claims.chatgpt_account_id = Some(chatgpt_account_id.into()); + self + } + + pub fn email(mut self, email: impl Into) -> Self { + self.claims.email = Some(email.into()); + self + } + + pub fn last_refresh(mut self, last_refresh: Option>) -> Self { + self.last_refresh = Some(last_refresh); + self + } + + pub fn claims(mut self, claims: ChatGptIdTokenClaims) -> Self { + self.claims = claims; + self + } +} + +#[derive(Debug, Clone, Default)] +pub struct ChatGptIdTokenClaims { + pub email: Option, + pub plan_type: Option, + pub chatgpt_user_id: Option, + pub chatgpt_account_id: Option, +} + +impl ChatGptIdTokenClaims { + pub fn new() -> Self { + Self::default() + } + + pub fn email(mut self, email: impl Into) -> Self { + self.email = Some(email.into()); + self + } + + pub fn plan_type(mut self, plan_type: impl Into) -> Self { + self.plan_type = Some(plan_type.into()); + self + } + + pub fn chatgpt_user_id(mut self, chatgpt_user_id: impl Into) -> Self { + self.chatgpt_user_id = Some(chatgpt_user_id.into()); + self + } + + pub fn chatgpt_account_id(mut self, chatgpt_account_id: impl Into) -> Self { + self.chatgpt_account_id = Some(chatgpt_account_id.into()); + self + } +} + +pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { + let header = json!({ "alg": "none", "typ": "JWT" }); + let mut payload = serde_json::Map::new(); + if let Some(email) = &claims.email { + payload.insert("email".to_string(), json!(email)); + } + let mut auth_payload = serde_json::Map::new(); + if let Some(plan_type) = &claims.plan_type { + auth_payload.insert("chatgpt_plan_type".to_string(), json!(plan_type)); + } + if let Some(chatgpt_user_id) = &claims.chatgpt_user_id { + auth_payload.insert("chatgpt_user_id".to_string(), json!(chatgpt_user_id)); + } + if let Some(chatgpt_account_id) = &claims.chatgpt_account_id { + auth_payload.insert("chatgpt_account_id".to_string(), json!(chatgpt_account_id)); + } + if !auth_payload.is_empty() { + payload.insert( + "https://api.openai.com/auth".to_string(), + serde_json::Value::Object(auth_payload), + ); + } + let payload = serde_json::Value::Object(payload); + + let header_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).context("serialize jwt header")?); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).context("serialize jwt payload")?); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"signature"); + Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) +} + +pub fn write_chatgpt_auth( + codex_home: &Path, + fixture: ChatGptAuthFixture, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> Result<()> { + let id_token_raw = encode_id_token(&fixture.claims)?; + let id_token = parse_chatgpt_jwt_claims(&id_token_raw).context("parse id token")?; + let tokens = TokenData { + id_token, + access_token: fixture.access_token, + refresh_token: fixture.refresh_token, + account_id: fixture.account_id, + }; + + let last_refresh = fixture.last_refresh.unwrap_or_else(|| Some(Utc::now())); + + let auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(tokens), + last_refresh, + agent_identity: None, + }; + + save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") +} diff --git a/code-rs/app-server/tests/common/config.rs b/code-rs/app-server/tests/common/config.rs new file mode 100644 index 00000000000..1ac2572fa25 --- /dev/null +++ b/code-rs/app-server/tests/common/config.rs @@ -0,0 +1,108 @@ +use codex_features::FEATURES; +use codex_features::Feature; +use std::collections::BTreeMap; +use std::path::Path; + +pub fn write_mock_responses_config_toml( + codex_home: &Path, + server_uri: &str, + feature_flags: &BTreeMap, + auto_compact_limit: i64, + requires_openai_auth: Option, + model_provider_id: &str, + compact_prompt: &str, +) -> std::io::Result<()> { + // Phase 1: build the features block for config.toml. + let mut features = BTreeMap::new(); + for (feature, enabled) in feature_flags { + features.insert(*feature, *enabled); + } + let feature_entries = features + .into_iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + // Phase 2: build provider-specific config bits. + let requires_line = match requires_openai_auth { + Some(true) => "requires_openai_auth = true\n".to_string(), + Some(false) | None => String::new(), + }; + let provider_name = if matches!(requires_openai_auth, Some(true)) { + "OpenAI" + } else { + "Mock provider for test" + }; + let provider_block = format!( + r#" +[model_providers.{model_provider_id}] +name = "{provider_name}" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +supports_websockets = false +{requires_line} +"# + ); + let openai_base_url_line = if model_provider_id == "openai" { + format!("openai_base_url = \"{server_uri}/v1\"\n") + } else { + String::new() + }; + // Phase 3: write the final config file. + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +compact_prompt = "{compact_prompt}" +model_auto_compact_token_limit = {auto_compact_limit} + +model_provider = "{model_provider_id}" +{openai_base_url_line} + +[features] +{feature_entries} +{provider_block} +"# + ), + ) +} + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/common/lib.rs b/code-rs/app-server/tests/common/lib.rs new file mode 100644 index 00000000000..6bb600bd823 --- /dev/null +++ b/code-rs/app-server/tests/common/lib.rs @@ -0,0 +1,52 @@ +mod analytics_server; +mod auth_fixtures; +mod config; +mod mcp_process; +mod mock_model_server; +mod models_cache; +mod responses; +mod rollout; + +pub use analytics_server::start_analytics_events_server; +pub use auth_fixtures::ChatGptAuthFixture; +pub use auth_fixtures::ChatGptIdTokenClaims; +pub use auth_fixtures::encode_id_token; +pub use auth_fixtures::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCResponse; +pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; +pub use core_test_support::PathBufExt; +pub use core_test_support::format_with_current_shell; +pub use core_test_support::format_with_current_shell_display; +pub use core_test_support::format_with_current_shell_display_non_login; +pub use core_test_support::format_with_current_shell_non_login; +pub use core_test_support::test_absolute_path; +pub use core_test_support::test_path_buf_with_windows; +pub use core_test_support::test_tmp_path; +pub use core_test_support::test_tmp_path_buf; +pub use mcp_process::DEFAULT_CLIENT_NAME; +pub use mcp_process::DISABLE_PLUGIN_STARTUP_TASKS_ARG; +pub use mcp_process::McpProcess; +pub use mock_model_server::create_mock_responses_server_repeating_assistant; +pub use mock_model_server::create_mock_responses_server_sequence; +pub use mock_model_server::create_mock_responses_server_sequence_unchecked; +pub use models_cache::write_models_cache; +pub use models_cache::write_models_cache_with_models; +pub use responses::create_apply_patch_sse_response; +pub use responses::create_exec_command_sse_response; +pub use responses::create_final_assistant_message_sse_response; +pub use responses::create_request_permissions_sse_response; +pub use responses::create_request_user_input_sse_response; +pub use responses::create_shell_command_sse_response; +pub use rollout::create_fake_rollout; +pub use rollout::create_fake_rollout_with_source; +pub use rollout::create_fake_rollout_with_text_elements; +pub use rollout::create_fake_rollout_with_token_usage; +pub use rollout::rollout_path; +use serde::de::DeserializeOwned; + +pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { + let value = serde_json::to_value(response.result)?; + let codex_response = serde_json::from_value(value)?; + Ok(codex_response) +} diff --git a/code-rs/app-server/tests/common/mcp_process.rs b/code-rs/app-server/tests/common/mcp_process.rs new file mode 100644 index 00000000000..81a5b2b4016 --- /dev/null +++ b/code-rs/app-server/tests/common/mcp_process.rs @@ -0,0 +1,1467 @@ +use std::collections::VecDeque; +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 codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ExperimentalFeatureListParams; +use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsReadDirectoryParams; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchParams; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::MarketplaceRemoveParams; +use codex_app_server_protocol::MarketplaceUpgradeParams; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::MockExperimentalMethodParams; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginSkillReadParams; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::ProcessKillParams; +use codex_app_server_protocol::ProcessResizePtyParams; +use codex_app_server_protocol::ProcessSpawnParams; +use codex_app_server_protocol::ProcessWriteStdinParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SkillsListParams; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadCompactStartParams; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadInjectItemsParams; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadMemoryModeSetParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; +use codex_app_server_protocol::ThreadRealtimeAppendTextParams; +use codex_app_server_protocol::ThreadRealtimeListVoicesParams; +use codex_app_server_protocol::ThreadRealtimeStartParams; +use codex_app_server_protocol::ThreadRealtimeStopParams; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadTurnsItemsListParams; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::WindowsSandboxSetupStartParams; +use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; +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: Option, + stdout: BufReader, + pending_messages: VecDeque, +} + +pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tasks-for-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; + +impl McpProcess { + pub async fn new(codex_home: &Path) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, &[], &[DISABLE_PLUGIN_STARTUP_TASKS_ARG]).await + } + + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + + pub async fn new_without_managed_config_with_env( + codex_home: &Path, + env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { + let mut all_env_overrides = vec![(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]; + all_env_overrides.extend_from_slice(env_overrides); + Self::new_with_env(codex_home, &all_env_overrides).await + } + + pub async fn new_with_plugin_startup_tasks(codex_home: &Path) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, &[], &[]).await + } + + pub async fn new_with_env_and_plugin_startup_tasks( + codex_home: &Path, + env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, env_overrides, &[]).await + } + + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { + let mut all_args = vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG]; + all_args.extend_from_slice(args); + Self::new_with_env_and_args(codex_home, &[], &all_args).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( + codex_home: &Path, + env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { + Self::new_with_env_and_args( + codex_home, + env_overrides, + &[DISABLE_PLUGIN_STARTUP_TASKS_ARG], + ) + .await + } + + async fn new_with_env_and_args( + codex_home: &Path, + env_overrides: &[(&str, Option<&str>)], + args: &[&str], + ) -> anyhow::Result { + let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") + .context("should find binary for codex-app-server")?; + let mut cmd = Command::new(program); + + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + cmd.current_dir(codex_home); + cmd.env("CODEX_HOME", codex_home); + cmd.env("RUST_LOG", "warn"); + // Keep integration tests isolated from host managed configuration. + cmd.env( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + codex_home.join("managed_config.toml"), + ); + cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); + cmd.args(args); + + 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: Some(stdin), + stdout, + pending_messages: VecDeque::new(), + }) + } + + /// Performs the initialization handshake with the MCP server. + pub async fn initialize(&mut self) -> anyhow::Result<()> { + let initialized = self + .initialize_with_client_info(ClientInfo { + name: DEFAULT_CLIENT_NAME.to_string(), + title: None, + version: "0.1.0".to_string(), + }) + .await?; + let JSONRPCMessage::Response(_) = initialized else { + unreachable!("expected JSONRPCMessage::Response for initialize, got {initialized:?}"); + }; + Ok(()) + } + + /// Sends initialize with the provided client info and returns the response/error message. + pub async fn initialize_with_client_info( + &mut self, + client_info: ClientInfo, + ) -> anyhow::Result { + self.initialize_with_capabilities( + client_info, + Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + ) + .await + } + + pub async fn initialize_with_capabilities( + &mut self, + client_info: ClientInfo, + capabilities: Option, + ) -> anyhow::Result { + self.initialize_with_params(InitializeParams { + client_info, + capabilities, + }) + .await + } + + async fn initialize_with_params( + &mut self, + params: InitializeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + let request_id = self.send_request("initialize", params).await?; + let message = self.read_jsonrpc_message().await?; + match message { + JSONRPCMessage::Response(response) => { + if response.id != RequestId::Integer(request_id) { + anyhow::bail!( + "initialize response id mismatch: expected {}, got {:?}", + request_id, + response.id + ); + } + + // Send notifications/initialized to ack the response. + self.send_notification(ClientNotification::Initialized) + .await?; + + Ok(JSONRPCMessage::Response(response)) + } + JSONRPCMessage::Error(error) => { + if error.id != RequestId::Integer(request_id) { + anyhow::bail!( + "initialize error id mismatch: expected {}, got {:?}", + request_id, + error.id + ); + } + Ok(JSONRPCMessage::Error(error)) + } + JSONRPCMessage::Notification(notification) => { + anyhow::bail!("unexpected JSONRPCMessage::Notification: {notification:?}"); + } + JSONRPCMessage::Request(request) => { + anyhow::bail!("unexpected JSONRPCMessage::Request: {request:?}"); + } + } + } + + /// Send a `getAuthStatus` JSON-RPC request. + pub async fn send_get_auth_status_request( + &mut self, + params: GetAuthStatusParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("getAuthStatus", params).await + } + + /// Send a `getConversationSummary` JSON-RPC request. + pub async fn send_get_conversation_summary_request( + &mut self, + params: GetConversationSummaryParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("getConversationSummary", params).await + } + + /// Send an `account/rateLimits/read` JSON-RPC request. + pub async fn send_get_account_rate_limits_request(&mut self) -> anyhow::Result { + self.send_request("account/rateLimits/read", /*params*/ None) + .await + } + + /// Send an `account/sendAddCreditsNudgeEmail` JSON-RPC request. + pub async fn send_add_credits_nudge_email_request( + &mut self, + params: SendAddCreditsNudgeEmailParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/sendAddCreditsNudgeEmail", params) + .await + } + + /// Send an `account/read` JSON-RPC request. + pub async fn send_get_account_request( + &mut self, + params: GetAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/read", params).await + } + + /// Send an `account/login/start` JSON-RPC request with ChatGPT auth tokens. + pub async fn send_chatgpt_auth_tokens_login_request( + &mut self, + access_token: String, + chatgpt_account_id: String, + chatgpt_plan_type: Option, + ) -> anyhow::Result { + let params = LoginAccountParams::ChatgptAuthTokens { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + }; + let params = Some(serde_json::to_value(params)?); + self.send_request("account/login/start", params).await + } + + /// Send a `feedback/upload` JSON-RPC request. + pub async fn send_feedback_upload_request( + &mut self, + params: FeedbackUploadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("feedback/upload", params).await + } + + /// Send a `thread/start` JSON-RPC request. + pub async fn send_thread_start_request( + &mut self, + params: ThreadStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/start", params).await + } + + /// Send a `thread/resume` JSON-RPC request. + pub async fn send_thread_resume_request( + &mut self, + params: ThreadResumeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/resume", params).await + } + + /// Send a `thread/fork` JSON-RPC request. + pub async fn send_thread_fork_request( + &mut self, + params: ThreadForkParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/fork", params).await + } + + /// Send a `thread/archive` JSON-RPC request. + pub async fn send_thread_archive_request( + &mut self, + params: ThreadArchiveParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/archive", params).await + } + + /// Send a `thread/name/set` JSON-RPC request. + pub async fn send_thread_set_name_request( + &mut self, + params: ThreadSetNameParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/name/set", params).await + } + + /// Send a `thread/metadata/update` JSON-RPC request. + pub async fn send_thread_metadata_update_request( + &mut self, + params: ThreadMetadataUpdateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/metadata/update", params).await + } + + /// Send a `thread/unsubscribe` JSON-RPC request. + pub async fn send_thread_unsubscribe_request( + &mut self, + params: ThreadUnsubscribeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/unsubscribe", params).await + } + + /// Send a `thread/unarchive` JSON-RPC request. + pub async fn send_thread_unarchive_request( + &mut self, + params: ThreadUnarchiveParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/unarchive", params).await + } + + /// Send a `thread/compact/start` JSON-RPC request. + pub async fn send_thread_compact_start_request( + &mut self, + params: ThreadCompactStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/compact/start", params).await + } + + /// Send a `thread/shellCommand` JSON-RPC request. + pub async fn send_thread_shell_command_request( + &mut self, + params: ThreadShellCommandParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/shellCommand", params).await + } + + /// Send a `thread/rollback` JSON-RPC request. + pub async fn send_thread_rollback_request( + &mut self, + params: ThreadRollbackParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/rollback", params).await + } + + /// Send a `thread/list` JSON-RPC request. + pub async fn send_thread_list_request( + &mut self, + params: ThreadListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/list", params).await + } + + /// Send a `thread/loaded/list` JSON-RPC request. + pub async fn send_thread_loaded_list_request( + &mut self, + params: ThreadLoadedListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/loaded/list", params).await + } + + /// Send a `thread/read` JSON-RPC request. + pub async fn send_thread_read_request( + &mut self, + params: ThreadReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/read", params).await + } + + /// Send a `thread/turns/list` JSON-RPC request. + pub async fn send_thread_turns_list_request( + &mut self, + params: ThreadTurnsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/turns/list", params).await + } + + /// Send a `thread/turns/items/list` JSON-RPC request. + pub async fn send_thread_turns_items_list_request( + &mut self, + params: ThreadTurnsItemsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/turns/items/list", params).await + } + + /// Send a `model/list` JSON-RPC request. + pub async fn send_list_models_request( + &mut self, + params: ModelListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("model/list", params).await + } + + /// Send a `modelProvider/capabilities/read` JSON-RPC request. + pub async fn send_model_provider_capabilities_read_request( + &mut self, + params: ModelProviderCapabilitiesReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("modelProvider/capabilities/read", params) + .await + } + + /// Send an `experimentalFeature/list` JSON-RPC request. + pub async fn send_experimental_feature_list_request( + &mut self, + params: ExperimentalFeatureListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/list", params).await + } + + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. + pub async fn send_experimental_feature_enablement_set_request( + &mut self, + params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/enablement/set", params) + .await + } + + /// Send an `app/list` JSON-RPC request. + pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("app/list", params).await + } + + /// Send an `mcpServer/resource/read` JSON-RPC request. + pub async fn send_mcp_resource_read_request( + &mut self, + params: McpResourceReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("mcpServer/resource/read", params).await + } + + /// Send an `mcpServer/tool/call` JSON-RPC request. + pub async fn send_mcp_server_tool_call_request( + &mut self, + params: McpServerToolCallParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("mcpServer/tool/call", params).await + } + + /// Send a `skills/list` JSON-RPC request. + pub async fn send_skills_list_request( + &mut self, + params: SkillsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("skills/list", params).await + } + + /// Send a `hooks/list` JSON-RPC request. + pub async fn send_hooks_list_request( + &mut self, + params: HooksListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("hooks/list", params).await + } + + /// Send a `marketplace/add` JSON-RPC request. + pub async fn send_marketplace_add_request( + &mut self, + params: MarketplaceAddParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("marketplace/add", params).await + } + + /// Send a `marketplace/remove` JSON-RPC request. + pub async fn send_marketplace_remove_request( + &mut self, + params: MarketplaceRemoveParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("marketplace/remove", params).await + } + + /// Send a `marketplace/upgrade` JSON-RPC request. + pub async fn send_marketplace_upgrade_request( + &mut self, + params: MarketplaceUpgradeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("marketplace/upgrade", params).await + } + + /// Send a `plugin/install` JSON-RPC request. + pub async fn send_plugin_install_request( + &mut self, + params: PluginInstallParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/install", params).await + } + + /// Send a `plugin/uninstall` JSON-RPC request. + pub async fn send_plugin_uninstall_request( + &mut self, + params: PluginUninstallParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/uninstall", params).await + } + + /// Send a `plugin/list` JSON-RPC request. + pub async fn send_plugin_list_request( + &mut self, + params: PluginListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/list", params).await + } + + /// Send a `plugin/read` JSON-RPC request. + pub async fn send_plugin_read_request( + &mut self, + params: PluginReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/read", params).await + } + + /// Send a `plugin/skill/read` JSON-RPC request. + pub async fn send_plugin_skill_read_request( + &mut self, + params: PluginSkillReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/skill/read", params).await + } + + /// Send an `mcpServerStatus/list` JSON-RPC request. + pub async fn send_list_mcp_server_status_request( + &mut self, + params: ListMcpServerStatusParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("mcpServerStatus/list", params).await + } + + /// Send a JSON-RPC request with raw params for protocol-level validation tests. + pub async fn send_raw_request( + &mut self, + method: &str, + params: Option, + ) -> anyhow::Result { + self.send_request(method, params).await + } + /// Send a `collaborationMode/list` JSON-RPC request. + pub async fn send_list_collaboration_modes_request( + &mut self, + params: CollaborationModeListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("collaborationMode/list", params).await + } + + /// Send a `mock/experimentalMethod` JSON-RPC request. + pub async fn send_mock_experimental_method_request( + &mut self, + params: MockExperimentalMethodParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("mock/experimentalMethod", params).await + } + + /// Send a `thread/memoryMode/set` JSON-RPC request (v2, experimental). + pub async fn send_thread_memory_mode_set_request( + &mut self, + params: ThreadMemoryModeSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/memoryMode/set", params).await + } + + /// Send a `turn/start` JSON-RPC request (v2). + pub async fn send_turn_start_request( + &mut self, + params: TurnStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("turn/start", params).await + } + + /// Send a `thread/inject_items` JSON-RPC request (v2). + pub async fn send_thread_inject_items_request( + &mut self, + params: ThreadInjectItemsParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/inject_items", params).await + } + + /// Send a `command/exec` JSON-RPC request (v2). + pub async fn send_command_exec_request( + &mut self, + params: CommandExecParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec", params).await + } + + /// Send a `process/spawn` JSON-RPC request (v2). + pub async fn send_process_spawn_request( + &mut self, + params: ProcessSpawnParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/spawn", params).await + } + + /// Send a `process/writeStdin` JSON-RPC request (v2). + pub async fn send_process_write_stdin_request( + &mut self, + params: ProcessWriteStdinParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/writeStdin", params).await + } + + /// Send a `process/resizePty` JSON-RPC request (v2). + pub async fn send_process_resize_pty_request( + &mut self, + params: ProcessResizePtyParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/resizePty", params).await + } + + /// Send a `process/kill` JSON-RPC request (v2). + pub async fn send_process_kill_request( + &mut self, + params: ProcessKillParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/kill", params).await + } + + /// Send a `command/exec/write` JSON-RPC request (v2). + pub async fn send_command_exec_write_request( + &mut self, + params: CommandExecWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/write", params).await + } + + /// Send a `command/exec/resize` JSON-RPC request (v2). + pub async fn send_command_exec_resize_request( + &mut self, + params: CommandExecResizeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/resize", params).await + } + + /// Send a `command/exec/terminate` JSON-RPC request (v2). + pub async fn send_command_exec_terminate_request( + &mut self, + params: CommandExecTerminateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/terminate", params).await + } + + /// Send a `turn/interrupt` JSON-RPC request (v2). + pub async fn send_turn_interrupt_request( + &mut self, + params: TurnInterruptParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("turn/interrupt", params).await + } + + /// Send a `thread/realtime/start` JSON-RPC request (v2). + pub async fn send_thread_realtime_start_request( + &mut self, + params: ThreadRealtimeStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/realtime/start", params).await + } + + /// Send a `thread/realtime/appendAudio` JSON-RPC request (v2). + pub async fn send_thread_realtime_append_audio_request( + &mut self, + params: ThreadRealtimeAppendAudioParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/realtime/appendAudio", params) + .await + } + + /// Send a `thread/realtime/appendText` JSON-RPC request (v2). + pub async fn send_thread_realtime_append_text_request( + &mut self, + params: ThreadRealtimeAppendTextParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/realtime/appendText", params) + .await + } + + /// Send a `thread/realtime/stop` JSON-RPC request (v2). + pub async fn send_thread_realtime_stop_request( + &mut self, + params: ThreadRealtimeStopParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/realtime/stop", params).await + } + + pub async fn send_thread_realtime_list_voices_request( + &mut self, + params: ThreadRealtimeListVoicesParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/realtime/listVoices", params) + .await + } + + /// Deterministically clean up an intentionally in-flight turn. + /// + /// Some tests assert behavior while a turn is still running. Returning from those tests + /// without an explicit interrupt + terminal turn notification wait can leave in-flight work + /// racing teardown and intermittently show up as `LEAK` in nextest. + /// + /// In rare races, the turn can also fail or complete on its own after we send + /// `turn/interrupt` but before the server emits the interrupt response. The helper treats a + /// buffered matching `turn/completed` notification as sufficient terminal cleanup in that + /// case so teardown does not flap on timing. + pub async fn interrupt_turn_and_wait_for_aborted( + &mut self, + thread_id: String, + turn_id: String, + read_timeout: std::time::Duration, + ) -> anyhow::Result<()> { + let interrupt_request_id = self + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + }) + .await?; + match tokio::time::timeout( + read_timeout, + self.read_stream_until_response_message(RequestId::Integer(interrupt_request_id)), + ) + .await + { + Ok(result) => { + result.with_context(|| "failed while waiting for turn interrupt response")?; + } + Err(err) => { + if self.pending_turn_completed_notification(&thread_id, &turn_id) { + return Ok(()); + } + return Err(err).with_context(|| "timed out waiting for turn interrupt response"); + } + } + match tokio::time::timeout( + read_timeout, + self.read_stream_until_notification_message("turn/completed"), + ) + .await + { + Ok(result) => { + result.with_context(|| "failed while waiting for terminal turn notification")?; + } + Err(err) => { + if self.pending_turn_completed_notification(&thread_id, &turn_id) { + return Ok(()); + } + return Err(err) + .with_context(|| "timed out waiting for terminal turn notification"); + } + } + Ok(()) + } + + /// Send a `turn/steer` JSON-RPC request (v2). + pub async fn send_turn_steer_request( + &mut self, + params: TurnSteerParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("turn/steer", params).await + } + + /// Send a `review/start` JSON-RPC request (v2). + pub async fn send_review_start_request( + &mut self, + params: ReviewStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("review/start", params).await + } + + pub async fn send_windows_sandbox_setup_start_request( + &mut self, + params: WindowsSandboxSetupStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("windowsSandbox/setupStart", params).await + } + + pub async fn send_config_read_request( + &mut self, + params: ConfigReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("config/read", params).await + } + + pub async fn send_config_value_write_request( + &mut self, + params: ConfigValueWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("config/value/write", params).await + } + + pub async fn send_config_batch_write_request( + &mut self, + params: ConfigBatchWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("config/batchWrite", params).await + } + + pub async fn send_fs_read_file_request( + &mut self, + params: FsReadFileParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/readFile", params).await + } + + pub async fn send_fs_write_file_request( + &mut self, + params: FsWriteFileParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/writeFile", params).await + } + + pub async fn send_fs_create_directory_request( + &mut self, + params: FsCreateDirectoryParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/createDirectory", params).await + } + + pub async fn send_fs_get_metadata_request( + &mut self, + params: FsGetMetadataParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/getMetadata", params).await + } + + pub async fn send_fs_read_directory_request( + &mut self, + params: FsReadDirectoryParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/readDirectory", params).await + } + + pub async fn send_fs_remove_request(&mut self, params: FsRemoveParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/remove", params).await + } + + pub async fn send_fs_copy_request(&mut self, params: FsCopyParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/copy", params).await + } + + pub async fn send_fs_watch_request(&mut self, params: FsWatchParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/watch", params).await + } + + pub async fn send_fs_unwatch_request( + &mut self, + params: FsUnwatchParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/unwatch", params).await + } + + /// Send an `account/logout` JSON-RPC request. + pub async fn send_logout_account_request(&mut self) -> anyhow::Result { + self.send_request("account/logout", /*params*/ None).await + } + + /// Send an `account/login/start` JSON-RPC request for API key login. + pub async fn send_login_account_api_key_request( + &mut self, + api_key: &str, + ) -> anyhow::Result { + let params = serde_json::json!({ + "type": "apiKey", + "apiKey": api_key, + }); + self.send_request("account/login/start", Some(params)).await + } + + /// Send an `account/login/start` JSON-RPC request for ChatGPT login. + pub async fn send_login_account_chatgpt_request(&mut self) -> anyhow::Result { + let params = serde_json::json!({ + "type": "chatgpt" + }); + self.send_request("account/login/start", Some(params)).await + } + + /// Send an `account/login/start` JSON-RPC request for ChatGPT device code login. + pub async fn send_login_account_chatgpt_device_code_request(&mut self) -> anyhow::Result { + let params = serde_json::json!({ + "type": "chatgptDeviceCode" + }); + self.send_request("account/login/start", Some(params)).await + } + + /// Send an `account/login/cancel` JSON-RPC request. + pub async fn send_cancel_login_account_request( + &mut self, + params: CancelLoginAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/login/cancel", params).await + } + + /// Send a `fuzzyFileSearch` JSON-RPC request. + pub async fn send_fuzzy_file_search_request( + &mut self, + query: &str, + roots: Vec, + cancellation_token: Option, + ) -> anyhow::Result { + 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 + } + + pub async fn send_fuzzy_file_search_session_start_request( + &mut self, + session_id: &str, + roots: Vec, + ) -> anyhow::Result { + let params = serde_json::json!({ + "sessionId": session_id, + "roots": roots, + }); + self.send_request("fuzzyFileSearch/sessionStart", Some(params)) + .await + } + + pub async fn start_fuzzy_file_search_session( + &mut self, + session_id: &str, + roots: Vec, + ) -> anyhow::Result { + let request_id = self + .send_fuzzy_file_search_session_start_request(session_id, roots) + .await?; + self.read_stream_until_response_message(RequestId::Integer(request_id)) + .await + } + + pub async fn send_fuzzy_file_search_session_update_request( + &mut self, + session_id: &str, + query: &str, + ) -> anyhow::Result { + let params = serde_json::json!({ + "sessionId": session_id, + "query": query, + }); + self.send_request("fuzzyFileSearch/sessionUpdate", Some(params)) + .await + } + + pub async fn update_fuzzy_file_search_session( + &mut self, + session_id: &str, + query: &str, + ) -> anyhow::Result { + let request_id = self + .send_fuzzy_file_search_session_update_request(session_id, query) + .await?; + self.read_stream_until_response_message(RequestId::Integer(request_id)) + .await + } + + pub async fn send_fuzzy_file_search_session_stop_request( + &mut self, + session_id: &str, + ) -> anyhow::Result { + let params = serde_json::json!({ + "sessionId": session_id, + }); + self.send_request("fuzzyFileSearch/sessionStop", Some(params)) + .await + } + + pub async fn stop_fuzzy_file_search_session( + &mut self, + session_id: &str, + ) -> anyhow::Result { + let request_id = self + .send_fuzzy_file_search_session_stop_request(session_id) + .await?; + self.read_stream_until_response_message(RequestId::Integer(request_id)) + .await + } + + async fn send_request( + &mut self, + method: &str, + params: Option, + ) -> anyhow::Result { + 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, + trace: None, + }); + 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_error( + &mut self, + id: RequestId, + error: JSONRPCErrorError, + ) -> anyhow::Result<()> { + self.send_jsonrpc_message(JSONRPCMessage::Error(JSONRPCError { id, error })) + .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 Some(stdin) = self.stdin.as_mut() else { + anyhow::bail!("mcp stdin closed"); + }; + let payload = serde_json::to_string(&message)?; + stdin.write_all(payload.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await?; + Ok(()) + } + + async fn read_jsonrpc_message(&mut self) -> anyhow::Result { + let mut line = String::new(); + self.stdout.read_line(&mut line).await?; + let message = serde_json::from_str::(&line)?; + eprintln!("read message from stdout: {message:?}"); + Ok(message) + } + + pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result { + eprintln!("in read_stream_until_request_message()"); + + let message = self + .read_stream_until_message(|message| matches!(message, JSONRPCMessage::Request(_))) + .await?; + + let JSONRPCMessage::Request(jsonrpc_request) = message else { + unreachable!("expected JSONRPCMessage::Request, got {message:?}"); + }; + jsonrpc_request + .try_into() + .with_context(|| "failed to deserialize ServerRequest from JSONRPCRequest") + } + + pub async fn read_stream_until_response_message( + &mut self, + request_id: RequestId, + ) -> anyhow::Result { + eprintln!("in read_stream_until_response_message({request_id:?})"); + + let message = self + .read_stream_until_message(|message| { + Self::message_request_id(message) == Some(&request_id) + }) + .await?; + + let JSONRPCMessage::Response(response) = message else { + unreachable!("expected JSONRPCMessage::Response, got {message:?}"); + }; + Ok(response) + } + + pub async fn read_stream_until_error_message( + &mut self, + request_id: RequestId, + ) -> anyhow::Result { + let message = self + .read_stream_until_message(|message| { + Self::message_request_id(message) == Some(&request_id) + }) + .await?; + + let JSONRPCMessage::Error(err) = message else { + unreachable!("expected JSONRPCMessage::Error, got {message:?}"); + }; + Ok(err) + } + + pub async fn read_stream_until_notification_message( + &mut self, + method: &str, + ) -> anyhow::Result { + eprintln!("in read_stream_until_notification_message({method})"); + + let message = self + .read_stream_until_message(|message| { + matches!( + message, + JSONRPCMessage::Notification(notification) if notification.method == method + ) + }) + .await?; + + let JSONRPCMessage::Notification(notification) = message else { + unreachable!("expected JSONRPCMessage::Notification, got {message:?}"); + }; + Ok(notification) + } + + pub async fn read_stream_until_matching_notification( + &mut self, + description: &str, + predicate: F, + ) -> anyhow::Result + where + F: Fn(&JSONRPCNotification) -> bool, + { + eprintln!("in read_stream_until_matching_notification({description})"); + + let message = self + .read_stream_until_message(|message| { + matches!( + message, + JSONRPCMessage::Notification(notification) if predicate(notification) + ) + }) + .await?; + + let JSONRPCMessage::Notification(notification) = message else { + unreachable!("expected JSONRPCMessage::Notification, got {message:?}"); + }; + Ok(notification) + } + + pub async fn read_next_message(&mut self) -> anyhow::Result { + self.read_stream_until_message(|_| true).await + } + + /// Clears any buffered messages so future reads only consider new stream items. + /// + /// We call this when e.g. we want to validate against the next turn and no longer care about + /// messages buffered from the prior turn. + pub fn clear_message_buffer(&mut self) { + self.pending_messages.clear(); + } + + pub fn pending_notification_methods(&self) -> Vec { + self.pending_messages + .iter() + .filter_map(|message| match message { + JSONRPCMessage::Notification(notification) => Some(notification.method.clone()), + _ => None, + }) + .collect() + } + + /// Reads the stream until a message matches `predicate`, buffering any non-matching messages + /// for later reads. + async fn read_stream_until_message(&mut self, predicate: F) -> anyhow::Result + where + F: Fn(&JSONRPCMessage) -> bool, + { + if let Some(message) = self.take_pending_message(&predicate) { + return Ok(message); + } + + loop { + let message = self.read_jsonrpc_message().await?; + if predicate(&message) { + return Ok(message); + } + self.pending_messages.push_back(message); + } + } + + fn take_pending_message(&mut self, predicate: &F) -> Option + where + F: Fn(&JSONRPCMessage) -> bool, + { + if let Some(pos) = self.pending_messages.iter().position(predicate) { + return self.pending_messages.remove(pos); + } + None + } + + fn pending_turn_completed_notification(&self, thread_id: &str, turn_id: &str) -> bool { + self.pending_messages.iter().any(|message| { + let JSONRPCMessage::Notification(notification) = message else { + return false; + }; + if notification.method != "turn/completed" { + return false; + } + let Some(params) = notification.params.as_ref() else { + return false; + }; + let Ok(payload) = serde_json::from_value::(params.clone()) + else { + return false; + }; + payload.thread_id == thread_id && payload.turn.id == turn_id + }) + } + + fn message_request_id(message: &JSONRPCMessage) -> Option<&RequestId> { + match message { + JSONRPCMessage::Request(request) => Some(&request.id), + JSONRPCMessage::Response(response) => Some(&response.id), + JSONRPCMessage::Error(err) => Some(&err.id), + JSONRPCMessage::Notification(_) => None, + } + } +} + +impl Drop for McpProcess { + fn drop(&mut self) { + // These tests spawn a `codex-app-server` child process. + // + // We keep that child alive for the test and rely on Tokio's `kill_on_drop(true)` when this + // helper is dropped. Tokio documents kill-on-drop as best-effort: dropping requests + // termination, but it does not guarantee the child has fully exited and been reaped before + // teardown continues. + // + // That makes cleanup timing nondeterministic. Leak detection can occasionally observe the + // child still alive at teardown and report `LEAK`, which makes the test flaky. + // + // Drop can't be async, so we do a bounded synchronous cleanup: + // + // 1. Close stdin to request a graceful shutdown via EOF. + // 2. Poll briefly for graceful exit. + // 3. If still alive, request termination with `start_kill()`. + // 4. Poll `try_wait()` until the OS reports the child exited, with a short timeout. + drop(self.stdin.take()); + + let graceful_start = std::time::Instant::now(); + let graceful_timeout = std::time::Duration::from_millis(200); + while graceful_start.elapsed() < graceful_timeout { + match self.process.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(5)), + Err(_) => return, + } + } + + let _ = self.process.start_kill(); + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(5); + while start.elapsed() < timeout { + match self.process.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)), + Err(_) => return, + } + } + } +} diff --git a/code-rs/app-server/tests/common/mock_model_server.rs b/code-rs/app-server/tests/common/mock_model_server.rs new file mode 100644 index 00000000000..24edcba93c1 --- /dev/null +++ b/code-rs/app-server/tests/common/mock_model_server.rs @@ -0,0 +1,81 @@ +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use core_test_support::responses; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path_regex; + +/// Create a mock server that will provide the responses, in order, for +/// requests to the `/v1/responses` endpoint. +pub async fn create_mock_responses_server_sequence(responses: Vec) -> MockServer { + let server = responses::start_mock_server().await; + + let num_calls = responses.len(); + let seq_responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(seq_responder) + .expect(num_calls as u64) + .mount(&server) + .await; + + server +} + +/// Same as `create_mock_responses_server_sequence` but does not enforce an +/// expectation on the number of calls. +pub async fn create_mock_responses_server_sequence_unchecked(responses: Vec) -> MockServer { + let server = responses::start_mock_server().await; + + let seq_responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(seq_responder) + .mount(&server) + .await; + + server +} + +struct SeqResponder { + num_calls: AtomicUsize, + responses: Vec, +} + +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) => responses::sse_response(response.clone()), + None => panic!("no response for {call_num}"), + } + } +} + +/// Create a mock responses API server that returns the same assistant message for every request. +pub async fn create_mock_responses_server_repeating_assistant(message: &str) -> MockServer { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", message), + responses::ev_completed("resp-1"), + ]); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(responses::sse_response(body)) + .mount(&server) + .await; + server +} diff --git a/code-rs/app-server/tests/common/models_cache.rs b/code-rs/app-server/tests/common/models_cache.rs new file mode 100644 index 00000000000..be7d5d047f9 --- /dev/null +++ b/code-rs/app-server/tests/common/models_cache.rs @@ -0,0 +1,99 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_core::test_support::all_model_presets; +use codex_models_manager::client_version_to_whole; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::openai_models::default_input_modalities; +use serde_json::json; +use std::path::Path; + +/// Convert a ModelPreset to ModelInfo for cache storage. +fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { + ModelInfo { + slug: preset.id.clone(), + display_name: preset.display_name.clone(), + description: Some(preset.description.clone()), + default_reasoning_level: Some(preset.default_reasoning_effort), + supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), + shell_type: ConfigShellToolType::ShellCommand, + visibility: if preset.show_in_picker { + ModelVisibility::List + } else { + ModelVisibility::Hide + }, + supported_in_api: preset.supported_in_api, + priority, + additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset.service_tiers.clone(), + upgrade: preset.upgrade.as_ref().map(Into::into), + base_instructions: "base instructions".to_string(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: ReasoningSummary::Auto, + support_verbosity: false, + default_verbosity: None, + availability_nux: preset.availability_nux.clone(), + apply_patch_tool_type: None, + web_search_tool_type: Default::default(), + truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), + supports_parallel_tool_calls: false, + supports_image_detail_original: false, + context_window: Some(272_000), + max_context_window: None, + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + input_modalities: default_input_modalities(), + used_fallback_model_metadata: false, + supports_search_tool: false, + } +} + +/// Write a models_cache.json file to the codex home directory. +/// This prevents ModelsManager from making network requests to refresh models. +/// The cache will be treated as fresh (within TTL) and used instead of fetching from the network. +/// Uses bundled-catalog-derived presets, converted to ModelInfo format. +pub fn write_models_cache(codex_home: &Path) -> std::io::Result<()> { + // Get a stable bundled-catalog-derived preset list and filter for picker-visible entries. + let presets: Vec<&ModelPreset> = all_model_presets() + .iter() + .filter(|preset| preset.show_in_picker) + .collect(); + // Convert presets to ModelInfo, assigning priorities (lower = earlier in list). + // Priority is used for sorting, so the first model gets the lowest priority. + let models: Vec = presets + .iter() + .enumerate() + .map(|(idx, preset)| { + // Lower priority = earlier in list. + let priority = idx as i32; + preset_to_info(preset, priority) + }) + .collect(); + + write_models_cache_with_models(codex_home, models) +} + +/// Write a models_cache.json file with specific models. +/// Useful when tests need specific models to be available. +pub fn write_models_cache_with_models( + codex_home: &Path, + models: Vec, +) -> std::io::Result<()> { + let cache_path = codex_home.join("models_cache.json"); + // DateTime serializes to RFC3339 format by default with serde + let fetched_at: DateTime = Utc::now(); + let client_version = client_version_to_whole(); + let cache = json!({ + "fetched_at": fetched_at, + "etag": null, + "client_version": client_version, + "models": models + }); + std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?) +} diff --git a/code-rs/app-server/tests/common/responses.rs b/code-rs/app-server/tests/common/responses.rs new file mode 100644 index 00000000000..586d1446c07 --- /dev/null +++ b/code-rs/app-server/tests/common/responses.rs @@ -0,0 +1,105 @@ +use core_test_support::responses; +use serde_json::json; +use std::path::Path; + +pub fn create_shell_command_sse_response( + command: Vec, + workdir: Option<&Path>, + timeout_ms: Option, + call_id: &str, +) -> anyhow::Result { + // The `arguments` for the `shell_command` tool is a serialized JSON object. + let command_str = shlex::try_join(command.iter().map(String::as_str))?; + let tool_call_arguments = serde_json::to_string(&json!({ + "command": command_str, + "workdir": workdir.map(|w| w.to_string_lossy()), + "timeout_ms": timeout_ms + }))?; + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "shell_command", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} + +pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result { + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", message), + responses::ev_completed("resp-1"), + ])) +} + +pub fn create_apply_patch_sse_response( + patch_content: &str, + call_id: &str, +) -> anyhow::Result { + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_apply_patch_shell_command_call_via_heredoc(call_id, patch_content), + responses::ev_completed("resp-1"), + ])) +} + +pub fn create_exec_command_sse_response(call_id: &str) -> anyhow::Result { + let (cmd, args) = if cfg!(windows) { + ("cmd.exe", vec!["/d", "/c", "echo hi"]) + } else { + ("/bin/sh", vec!["-c", "echo hi"]) + }; + let command = std::iter::once(cmd.to_string()) + .chain(args.into_iter().map(str::to_string)) + .collect::>(); + let tool_call_arguments = serde_json::to_string(&json!({ + "cmd": command.join(" "), + "yield_time_ms": 500 + }))?; + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "exec_command", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} + +pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result { + let tool_call_arguments = serde_json::to_string(&json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }))?; + + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "request_user_input", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} + +pub fn create_request_permissions_sse_response(call_id: &str) -> anyhow::Result { + let tool_call_arguments = serde_json::to_string(&json!({ + "reason": "Select a workspace root", + "permissions": { + "file_system": { + "write": [ + ".", + "../shared" + ] + } + } + }))?; + + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "request_permissions", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} diff --git a/code-rs/app-server/tests/common/rollout.rs b/code-rs/app-server/tests/common/rollout.rs new file mode 100644 index 00000000000..6b2a9a0abe9 --- /dev/null +++ b/code-rs/app-server/tests/common/rollout.rs @@ -0,0 +1,271 @@ +use anyhow::Result; +use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use serde_json::json; +use std::fs; +use std::fs::FileTimes; +use std::path::Path; +use std::path::PathBuf; +use uuid::Uuid; + +pub fn rollout_path(codex_home: &Path, filename_ts: &str, thread_id: &str) -> PathBuf { + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{thread_id}.jsonl")) +} + +/// Create a minimal rollout file under `CODEX_HOME/sessions/YYYY/MM/DD/`. +/// +/// - `filename_ts` is the filename timestamp component in `YYYY-MM-DDThh-mm-ss` format. +/// - `meta_rfc3339` is the envelope timestamp used in JSON lines. +/// - `preview` is the user message preview text. +/// - `model_provider` optionally sets the provider in the session meta payload. +/// +/// Returns the generated conversation/session UUID as a string. +pub fn create_fake_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, + git_info: Option, +) -> Result { + create_fake_rollout_with_source( + codex_home, + filename_ts, + meta_rfc3339, + preview, + model_provider, + git_info, + SessionSource::Cli, + ) +} + +/// Creates a minimal rollout whose history includes a persisted token usage event. +/// +/// Resume and fork tests use this fixture to verify lifecycle replay of restored +/// usage without starting a model turn. The exact token values are intentionally +/// non-zero and asymmetric so assertions catch swapped total/last fields and +/// dropped cached or reasoning counters. +pub fn create_fake_rollout_with_token_usage( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, +) -> Result { + let thread_id = create_fake_rollout( + codex_home, + filename_ts, + meta_rfc3339, + preview, + model_provider, + /*git_info*/ None, + )?; + let payload = serde_json::to_value(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 120, + cached_input_tokens: 20, + output_tokens: 30, + reasoning_output_tokens: 10, + total_tokens: 150, + }, + last_token_usage: TokenUsage { + input_tokens: 70, + cached_input_tokens: 10, + output_tokens: 20, + reasoning_output_tokens: 5, + total_tokens: 90, + }, + model_context_window: Some(200_000), + }), + rate_limits: None, + }))?; + let file_path = rollout_path(codex_home, filename_ts, &thread_id); + let line = json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": payload + }) + .to_string(); + fs::write( + &file_path, + format!("{}{}\n", fs::read_to_string(&file_path)?, line), + )?; + Ok(thread_id) +} + +/// Create a minimal rollout file with an explicit session source. +pub fn create_fake_rollout_with_source( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, + git_info: Option, + source: SessionSource, +) -> Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let conversation_id = ThreadId::from_string(&uuid_str)?; + + let file_path = rollout_path(codex_home, filename_ts, &uuid_str); + let dir = file_path + .parent() + .ok_or_else(|| anyhow::anyhow!("missing rollout parent directory"))?; + fs::create_dir_all(dir)?; + + // Build JSONL lines + let meta = SessionMeta { + id: conversation_id, + forked_from_id: None, + timestamp: meta_rfc3339.to_string(), + cwd: PathBuf::from("/"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source, + thread_source: None, + agent_path: None, + agent_nickname: None, + agent_role: None, + model_provider: model_provider.map(str::to_string), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }; + let payload = serde_json::to_value(SessionMetaLine { + meta, + git: git_info, + })?; + + let lines = [ + json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": payload + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"response_item", + "payload": { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text": preview}] + } + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"event_msg", + "payload": { + "type":"user_message", + "message": preview, + "kind": "plain" + } + }) + .to_string(), + ]; + + fs::write(&file_path, lines.join("\n") + "\n")?; + let parsed = chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(&file_path)? + .set_times(times)?; + Ok(uuid_str) +} + +pub fn create_fake_rollout_with_text_elements( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + text_elements: Vec, + model_provider: Option<&str>, + git_info: Option, +) -> Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let conversation_id = ThreadId::from_string(&uuid_str)?; + + // 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 = codex_home.join("sessions").join(year).join(month).join(day); + fs::create_dir_all(&dir)?; + + let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); + + // Build JSONL lines + let meta = SessionMeta { + id: conversation_id, + forked_from_id: None, + timestamp: meta_rfc3339.to_string(), + cwd: PathBuf::from("/"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + thread_source: None, + agent_path: None, + agent_nickname: None, + agent_role: None, + model_provider: model_provider.map(str::to_string), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }; + let payload = serde_json::to_value(SessionMetaLine { + meta, + git: git_info, + })?; + + let lines = [ + json!( { + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": payload + }) + .to_string(), + json!( { + "timestamp": meta_rfc3339, + "type":"response_item", + "payload": { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text": preview}] + } + }) + .to_string(), + json!( { + "timestamp": meta_rfc3339, + "type":"event_msg", + "payload": { + "type":"user_message", + "message": preview, + "text_elements": text_elements, + "local_images": [] + } + }) + .to_string(), + ]; + + fs::write(file_path, lines.join("\n") + "\n")?; + Ok(uuid_str) +} diff --git a/code-rs/app-server/tests/suite/auth.rs b/code-rs/app-server/tests/suite/auth.rs new file mode 100644 index 00000000000..1e608710126 --- /dev/null +++ b/code-rs/app-server/tests/suite/auth.rs @@ -0,0 +1,528 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use chrono::Duration; +use chrono::Utc; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +// Bazel CI can spend tens of seconds starting app-server subprocesses or +// processing auth RPCs under load. +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +fn create_config_toml_custom_provider( + codex_home: &Path, + requires_openai_auth: bool, +) -> std::io::Result<()> { + let config_toml = codex_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" + +[features] +shell_snapshot = false + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:0/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +{requires_line} +"# + ); + std::fs::write(config_toml, contents) +} + +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" + +[features] +shell_snapshot = false +"#, + ) +} + +fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +forced_login_method = "{forced_method}" + +[features] +shell_snapshot = false +"# + ); + std::fs::write(config_toml, contents) +} + +async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) -> Result<()> { + let request_id = mcp.send_login_account_api_key_request(api_key).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(resp)?; + assert_eq!(response, LoginAccountResponse::ApiKey {}); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_no_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!(status.auth_method, None, "expected no auth method"); + assert_eq!(status.auth_token, None, "expected no token"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_api_key() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); + assert_eq!(status.auth_token, Some("sk-test-key".to_string())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml_custom_provider(codex_home.path(), /*requires_openai_auth*/ false)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + 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", + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_api_key_no_include_token() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; + + // 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?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); + assert!(status.auth_token.is_none(), "token must be omitted"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::ApiKey), + auth_token: Some("sk-test-key".to_string()), + requires_openai_auth: Some(true), + } + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + let second_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let second_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)), + ) + .await??; + let second_status: GetAuthStatusResponse = to_response(second_resp)?; + assert_eq!(second_status, status); + + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now() - Duration::days(9))), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(2) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now() - Duration::days(9))), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(2) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let failed_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let failed_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(failed_request_id)), + ) + .await??; + let failed_status: GetAuthStatusResponse = to_response(failed_resp)?; + assert_eq!( + failed_status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("recovered-access-token") + .refresh_token("recovered-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now())), + AuthCredentialsStoreMode::File, + )?; + + let recovered_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let recovered_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(recovered_request_id)), + ) + .await??; + let recovered_status: GetAuthStatusResponse = to_response(recovered_resp)?; + assert_eq!( + recovered_status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: Some("recovered-access-token".to_string()), + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml_forced_login(codex_home.path(), "chatgpt")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "API key login is disabled. Use ChatGPT login instead." + ); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/conversation_summary.rs b/code-rs/app-server/tests/suite/conversation_summary.rs new file mode 100644 index 00000000000..754d1f94670 --- /dev/null +++ b/code-rs/app-server/tests/suite/conversation_summary.rs @@ -0,0 +1,266 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::rollout_path; +use app_test_support::to_response; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConversationSummary; +use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::GetConversationSummaryResponse; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_thread_store::CreateThreadParams; +use codex_thread_store::InMemoryThreadStore; +use codex_thread_store::ThreadEventPersistenceMode; +use codex_thread_store::ThreadPersistenceMetadata; +use codex_thread_store::ThreadStore; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const FILENAME_TS: &str = "2025-01-02T12-00-00"; +const META_RFC3339: &str = "2025-01-02T12:00:00Z"; +const CREATED_AT_RFC3339: &str = "2025-01-02T12:00:00.000Z"; +const UPDATED_AT_RFC3339: &str = "2025-01-02T12:00:00.000Z"; +const PREVIEW: &str = "Summarize this conversation"; +const MODEL_PROVIDER: &str = "openai"; + +fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSummary { + ConversationSummary { + conversation_id, + path, + preview: PREVIEW.to_string(), + timestamp: Some(CREATED_AT_RFC3339.to_string()), + updated_at: Some(UPDATED_AT_RFC3339.to_string()), + model_provider: MODEL_PROVIDER.to_string(), + cwd: PathBuf::from("/"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + git_info: None, + } +} + +fn normalized_canonical_path(path: impl AsRef) -> Result { + Ok(AbsolutePathBuf::from_absolute_path(path.as_ref().canonicalize()?)?.into_path_buf()) +} + +fn normalized_summary_path(mut summary: ConversationSummary) -> Result { + if !summary.path.as_os_str().is_empty() { + summary.path = normalized_canonical_path(summary.path)?; + } + Ok(summary) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { + let codex_home = TempDir::new()?; + let conversation_id = create_fake_rollout( + codex_home.path(), + FILENAME_TS, + META_RFC3339, + PREVIEW, + Some(MODEL_PROVIDER), + /*git_info*/ None, + )?; + let thread_id = ThreadId::from_string(&conversation_id)?; + let expected = expected_summary( + thread_id, + normalized_canonical_path(rollout_path( + codex_home.path(), + FILENAME_TS, + &conversation_id, + ))?, + ); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_conversation_summary_request(GetConversationSummaryParams::ThreadId { + conversation_id: thread_id, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetConversationSummaryResponse = to_response(response)?; + + assert_eq!(normalized_summary_path(received.summary)?, expected); + Ok(()) +} + +#[tokio::test] +async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> Result<()> { + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_in_memory_thread_store(codex_home.path(), &store_id)?; + let store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000125")?; + store + .create_thread(CreateThreadParams { + thread_id, + forked_from_id: None, + source: SessionSource::Cli, + thread_source: None, + base_instructions: BaseInstructions::default(), + dynamic_tools: Vec::new(), + metadata: ThreadPersistenceMetadata { + cwd: None, + model_provider: "test-provider".to_string(), + memory_mode: ThreadMemoryMode::Disabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::default(), + }) + .await?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let result = client + .request(ClientRequest::GetConversationSummary { + request_id: RequestId::Integer(1), + params: GetConversationSummaryParams::ThreadId { + conversation_id: thread_id, + }, + }) + .await? + .expect("getConversationSummary should succeed"); + let GetConversationSummaryResponse { summary } = serde_json::from_value(result)?; + + assert_eq!(summary.conversation_id, thread_id); + assert_eq!(summary.path, PathBuf::new()); + assert_eq!(summary.cwd, PathBuf::new()); + assert_eq!(summary.model_provider, "test"); + + client.shutdown().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_home() -> Result<()> +{ + let codex_home = TempDir::new()?; + let conversation_id = create_fake_rollout( + codex_home.path(), + FILENAME_TS, + META_RFC3339, + PREVIEW, + Some(MODEL_PROVIDER), + /*git_info*/ None, + )?; + let thread_id = ThreadId::from_string(&conversation_id)?; + let rollout_path = rollout_path(codex_home.path(), FILENAME_TS, &conversation_id); + let relative_path = rollout_path.strip_prefix(codex_home.path())?.to_path_buf(); + let expected = expected_summary(thread_id, normalized_canonical_path(rollout_path)?); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_conversation_summary_request(GetConversationSummaryParams::RolloutPath { + rollout_path: relative_path, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetConversationSummaryResponse = to_response(response)?; + + assert_eq!(normalized_summary_path(received.summary)?, expected); + Ok(()) +} + +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +fn create_config_toml_with_in_memory_thread_store( + codex_home: &Path, + store_id: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:1/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/fuzzy_file_search.rs b/code-rs/app-server/tests/suite/fuzzy_file_search.rs new file mode 100644 index 00000000000..f508e0c9872 --- /dev/null +++ b/code-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -0,0 +1,611 @@ +use anyhow::Result; +use anyhow::anyhow; +use app_test_support::McpProcess; +use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification; +use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +// macOS arm64 and Windows Bazel CI can spend tens of seconds in app-server +// startup before the initialize response or fuzzy-search notifications arrive. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const SHORT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); +const STOP_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(250); +const SESSION_UPDATED_METHOD: &str = "fuzzyFileSearch/sessionUpdated"; +const SESSION_COMPLETED_METHOD: &str = "fuzzyFileSearch/sessionCompleted"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileExpectation { + Any, + Empty, + NonEmpty, +} + +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" + +[features] +shell_snapshot = false +"#, + ) +} + +async fn initialized_mcp(codex_home: &TempDir) -> Result { + create_config_toml(codex_home.path())?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok(mcp) +} + +async fn wait_for_session_updated( + mcp: &mut McpProcess, + session_id: &str, + query: &str, + file_expectation: FileExpectation, +) -> Result { + let description = format!("session update for sessionId={session_id}, query={query}"); + let notification = match timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification(&description, |notification| { + if notification.method != SESSION_UPDATED_METHOD { + return false; + } + let Some(params) = notification.params.as_ref() else { + return false; + }; + let Ok(payload) = + serde_json::from_value::(params.clone()) + else { + return false; + }; + let files_match = match file_expectation { + FileExpectation::Any => true, + FileExpectation::Empty => payload.files.is_empty(), + FileExpectation::NonEmpty => !payload.files.is_empty(), + }; + payload.session_id == session_id && payload.query == query && files_match + }), + ) + .await + { + Ok(result) => result?, + Err(_) => { + anyhow::bail!( + "timed out waiting for {description}; buffered notifications={:?}", + mcp.pending_notification_methods() + ) + } + }; + let params = notification + .params + .ok_or_else(|| anyhow!("missing notification params"))?; + Ok(serde_json::from_value::< + FuzzyFileSearchSessionUpdatedNotification, + >(params)?) +} + +async fn wait_for_session_completed( + mcp: &mut McpProcess, + session_id: &str, +) -> Result { + let description = format!("session completion for sessionId={session_id}"); + let notification = match timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification(&description, |notification| { + if notification.method != SESSION_COMPLETED_METHOD { + return false; + } + let Some(params) = notification.params.as_ref() else { + return false; + }; + let Ok(payload) = serde_json::from_value::( + params.clone(), + ) else { + return false; + }; + payload.session_id == session_id + }), + ) + .await + { + Ok(result) => result?, + Err(_) => { + anyhow::bail!( + "timed out waiting for {description}; buffered notifications={:?}", + mcp.pending_notification_methods() + ) + } + }; + + let params = notification + .params + .ok_or_else(|| anyhow!("missing notification params"))?; + Ok(serde_json::from_value::< + FuzzyFileSearchSessionCompletedNotification, + >(params)?) +} + +async fn assert_update_request_fails_for_missing_session( + mcp: &mut McpProcess, + session_id: &str, + query: &str, +) -> Result<()> { + let request_id = mcp + .send_fuzzy_file_search_session_update_request(session_id, query) + .await?; + let err = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(err.error.code, -32600); + assert_eq!( + err.error.message, + format!("fuzzy file search session not found: {session_id}") + ); + Ok(()) +} + +async fn assert_no_session_updates_for( + mcp: &mut McpProcess, + session_id: &str, + grace_period: std::time::Duration, + duration: std::time::Duration, +) -> Result<()> { + let grace_deadline = tokio::time::Instant::now() + grace_period; + loop { + let now = tokio::time::Instant::now(); + if now >= grace_deadline { + break; + } + let remaining = grace_deadline - now; + match timeout( + remaining, + mcp.read_stream_until_notification_message(SESSION_UPDATED_METHOD), + ) + .await + { + Err(_) => break, + Ok(Err(err)) => return Err(err), + Ok(Ok(_)) => {} + } + } + + let deadline = tokio::time::Instant::now() + duration; + loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + return Ok(()); + } + let remaining = deadline - now; + match timeout( + remaining, + mcp.read_stream_until_notification_message(SESSION_UPDATED_METHOD), + ) + .await + { + Err(_) => return Ok(()), + Ok(Err(err)) => return Err(err), + Ok(Ok(notification)) => { + let params = notification + .params + .ok_or_else(|| anyhow!("missing notification params"))?; + let payload = + serde_json::from_value::(params)?; + if payload.session_id == session_id { + anyhow::bail!("received unexpected session update after stop: {payload:?}"); + } + } + } + } +} + +#[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 codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + let root = TempDir::new()?; + + // Create files designed to have deterministic ordering for query "abe". + std::fs::write(root.path().join("abc"), "x")?; + std::fs::write(root.path().join("abcde"), "x")?; + std::fs::write(root.path().join("abexy"), "x")?; + std::fs::write(root.path().join("zzz.txt"), "x")?; + let sub_dir = root.path().join("sub"); + std::fs::create_dir_all(&sub_dir)?; + let sub_abce_path = sub_dir.join("abce"); + std::fs::write(&sub_abce_path, "x")?; + let sub_abce_rel = sub_abce_path + .strip_prefix(root.path())? + .to_string_lossy() + .to_string(); + + // Start MCP server and initialize. + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + 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()], + /*cancellation_token*/ None, + ) + .await?; + + // 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??; + + let value = resp.result; + let expected_score = 72; + + assert_eq!( + value, + json!({ + "files": [ + { + "root": root_path.clone(), + "path": "abexy", + "match_type": "file", + "file_name": "abexy", + "score": 84, + "indices": [0, 1, 2], + }, + { + "root": root_path.clone(), + "path": sub_abce_rel, + "match_type": "file", + "file_name": "abce", + "score": expected_score, + "indices": [4, 5, 7], + }, + { + "root": root_path.clone(), + "path": "abcde", + "match_type": "file", + "file_name": "abcde", + "score": 71, + "indices": [0, 1, 4], + }, + ] + }) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + let root = TempDir::new()?; + + std::fs::write(root.path().join("alpha.txt"), "contents")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let root_path = root.path().to_string_lossy().to_string(); + let request_id = mcp + .send_fuzzy_file_search_request( + "alp", + vec![root_path.clone()], + /*cancellation_token*/ None, + ) + .await?; + + let request_id_2 = mcp + .send_fuzzy_file_search_request( + "alp", + vec![root_path.clone()], + Some(request_id.to_string()), + ) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id_2)), + ) + .await??; + + let files = resp + .result + .get("files") + .ok_or_else(|| anyhow!("files key missing"))? + .as_array() + .ok_or_else(|| anyhow!("files not array"))? + .clone(); + + assert_eq!(files.len(), 1); + assert_eq!(files[0]["root"], root_path); + assert_eq!(files[0]["path"], "alpha.txt"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_streams_updates() -> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-1"; + + mcp.start_fuzzy_file_search_session(session_id, vec![root_path.clone()]) + .await?; + mcp.update_fuzzy_file_search_session(session_id, "alp") + .await?; + + let payload = + wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?; + assert_eq!(payload.files.len(), 1); + assert_eq!(payload.files[0].root, root_path); + assert_eq!(payload.files[0].path, "alpha.txt"); + let completed = wait_for_session_completed(&mut mcp, session_id).await?; + assert_eq!(completed.session_id, session_id); + + mcp.stop_fuzzy_file_search_session(session_id).await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_update_is_case_insensitive() -> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-case-insensitive"; + + mcp.start_fuzzy_file_search_session(session_id, vec![root_path.clone()]) + .await?; + mcp.update_fuzzy_file_search_session(session_id, "ALP") + .await?; + + let payload = + wait_for_session_updated(&mut mcp, session_id, "ALP", FileExpectation::NonEmpty).await?; + assert_eq!(payload.files.len(), 1); + assert_eq!(payload.files[0].root, root_path); + assert_eq!(payload.files[0].path, "alpha.txt"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_no_updates_after_complete_until_query_edited() -> Result<()> +{ + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-complete-invariant"; + mcp.start_fuzzy_file_search_session(session_id, vec![root_path]) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alp") + .await?; + wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?; + wait_for_session_completed(&mut mcp, session_id).await?; + assert_no_session_updates_for(&mut mcp, session_id, STOP_GRACE_PERIOD, SHORT_READ_TIMEOUT) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alpha") + .await?; + wait_for_session_updated(&mut mcp, session_id, "alpha", FileExpectation::NonEmpty).await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_update_before_start_errors() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = initialized_mcp(&codex_home).await?; + assert_update_request_fails_for_missing_session(&mut mcp, "missing", "alp").await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_update_works_without_waiting_for_start_response() +-> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-no-wait"; + + let start_request_id = mcp + .send_fuzzy_file_search_session_start_request(session_id, vec![root_path.clone()]) + .await?; + let update_request_id = mcp + .send_fuzzy_file_search_session_update_request(session_id, "alp") + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_request_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + + let payload = + wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?; + assert_eq!(payload.files.len(), 1); + assert_eq!(payload.files[0].root, root_path); + assert_eq!(payload.files[0].path, "alpha.txt"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_multiple_query_updates_work() -> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + std::fs::write(root.path().join("alphabet.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-multi-update"; + mcp.start_fuzzy_file_search_session(session_id, vec![root_path.clone()]) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alp") + .await?; + let alp_payload = + wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?; + assert_eq!( + alp_payload.files.iter().all(|file| file.root == root_path), + true + ); + wait_for_session_completed(&mut mcp, session_id).await?; + + mcp.update_fuzzy_file_search_session(session_id, "zzzz") + .await?; + let zzzz_payload = + wait_for_session_updated(&mut mcp, session_id, "zzzz", FileExpectation::Any).await?; + assert_eq!(zzzz_payload.query, "zzzz"); + assert_eq!(zzzz_payload.files.is_empty(), true); + wait_for_session_completed(&mut mcp, session_id).await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_update_after_stop_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let session_id = "session-stop-fail"; + let root_path = root.path().to_string_lossy().to_string(); + mcp.start_fuzzy_file_search_session(session_id, vec![root_path]) + .await?; + mcp.stop_fuzzy_file_search_session(session_id).await?; + + assert_update_request_fails_for_missing_session(&mut mcp, session_id, "alp").await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_stops_sending_updates_after_stop() -> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + for i in 0..512 { + let file_path = root.path().join(format!("file-{i:04}.txt")); + std::fs::write(file_path, "contents")?; + } + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-stop-no-updates"; + mcp.start_fuzzy_file_search_session(session_id, vec![root_path]) + .await?; + mcp.update_fuzzy_file_search_session(session_id, "file-") + .await?; + wait_for_session_updated(&mut mcp, session_id, "file-", FileExpectation::NonEmpty).await?; + + mcp.stop_fuzzy_file_search_session(session_id).await?; + + assert_no_session_updates_for(&mut mcp, session_id, STOP_GRACE_PERIOD, SHORT_READ_TIMEOUT) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_two_sessions_are_independent() -> Result<()> { + let codex_home = TempDir::new()?; + let root_a = TempDir::new()?; + let root_b = TempDir::new()?; + std::fs::write(root_a.path().join("alpha.txt"), "contents")?; + std::fs::write(root_b.path().join("beta.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_a_path = root_a.path().to_string_lossy().to_string(); + let root_b_path = root_b.path().to_string_lossy().to_string(); + let session_a = "session-a"; + let session_b = "session-b"; + + mcp.start_fuzzy_file_search_session(session_a, vec![root_a_path.clone()]) + .await?; + mcp.start_fuzzy_file_search_session(session_b, vec![root_b_path.clone()]) + .await?; + + mcp.update_fuzzy_file_search_session(session_a, "alp") + .await?; + + let session_a_update = + wait_for_session_updated(&mut mcp, session_a, "alp", FileExpectation::NonEmpty).await?; + assert_eq!(session_a_update.files.len(), 1); + assert_eq!(session_a_update.files[0].root, root_a_path); + assert_eq!(session_a_update.files[0].path, "alpha.txt"); + + mcp.update_fuzzy_file_search_session(session_b, "bet") + .await?; + let session_b_update = + wait_for_session_updated(&mut mcp, session_b, "bet", FileExpectation::NonEmpty).await?; + assert_eq!(session_b_update.files.len(), 1); + assert_eq!(session_b_update.files[0].root, root_b_path); + assert_eq!(session_b_update.files[0].path, "beta.txt"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_query_cleared_sends_blank_snapshot() -> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-clear-query"; + mcp.start_fuzzy_file_search_session(session_id, vec![root_path]) + .await?; + + mcp.update_fuzzy_file_search_session(session_id, "alp") + .await?; + wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?; + + mcp.update_fuzzy_file_search_session(session_id, "").await?; + let payload = + wait_for_session_updated(&mut mcp, session_id, "", FileExpectation::Empty).await?; + assert_eq!(payload.files.is_empty(), true); + + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/mod.rs b/code-rs/app-server/tests/suite/mod.rs new file mode 100644 index 00000000000..c60f4a32a11 --- /dev/null +++ b/code-rs/app-server/tests/suite/mod.rs @@ -0,0 +1,4 @@ +mod auth; +mod conversation_summary; +mod fuzzy_file_search; +mod v2; diff --git a/code-rs/app-server/tests/suite/v2/account.rs b/code-rs/app-server/tests/suite/v2/account.rs new file mode 100644 index 00000000000..50c365d633b --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/account.rs @@ -0,0 +1,1775 @@ +use anyhow::Result; +use anyhow::bail; +use app_test_support::McpProcess; +use app_test_support::to_response; + +use app_test_support::ChatGptAuthFixture; +use app_test_support::ChatGptIdTokenClaims; +use app_test_support::encode_id_token; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; +use chrono::Duration as ChronoDuration; +use chrono::Utc; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginAccountStatus; +use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; +use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStatus; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_login::login_with_api_key; +use codex_protocol::account::PlanType as AccountPlanType; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; +use serial_test::serial; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; + +// Helper to create a minimal config.toml for the app server +#[derive(Default)] +struct CreateConfigTomlParams { + forced_method: Option, + forced_workspace_id: Option, + requires_openai_auth: Option, + base_url: Option, + model_provider_id: Option, + extra_provider_config: Option, +} + +fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let base_url = params + .base_url + .unwrap_or_else(|| "http://127.0.0.1:0/v1".to_string()); + let forced_line = if let Some(method) = params.forced_method { + format!("forced_login_method = \"{method}\"\n") + } else { + String::new() + }; + let forced_workspace_line = if let Some(ws) = params.forced_workspace_id { + format!("forced_chatgpt_workspace_id = \"{ws}\"\n") + } else { + String::new() + }; + let requires_line = match params.requires_openai_auth { + Some(true) => "requires_openai_auth = true\n".to_string(), + Some(false) => String::new(), + None => String::new(), + }; + let model_provider_id = params + .model_provider_id + .unwrap_or_else(|| "mock_provider".to_string()); + let provider_section = if model_provider_id == "mock_provider" { + format!( + r#"[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{base_url}" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +{requires_line} +"# + ) + } else { + params.extra_provider_config.unwrap_or_default() + }; + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +{forced_line} +{forced_workspace_line} + +model_provider = "{model_provider_id}" + +[features] +shell_snapshot = false + +{provider_section} +"# + ); + std::fs::write(config_toml, contents) +} + +async fn mock_device_code_usercode(server: &MockServer, interval_seconds: u64) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_auth_id": "device-auth-123", + "user_code": "CODE-12345", + "interval": interval_seconds.to_string(), + }))) + .mount(server) + .await; +} + +async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; +} + +async fn mock_device_code_token_success(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_code": "poll-code-321", + "code_challenge": "code-challenge-321", + "code_verifier": "code-verifier-321", + }))) + .mount(server) + .await; +} + +async fn mock_device_code_token_failure(server: &MockServer, status: u16) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/token")) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; +} + +async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) { + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id_token": id_token, + "access_token": "access-token-123", + "refresh_token": "refresh-token-123", + }))) + .mount(server) + .await; +} + +#[tokio::test] +async fn logout_account_removes_auth_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + )?; + assert!(codex_home.path().join("auth.json").exists()); + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let id = mcp.send_logout_account_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(id)), + ) + .await??; + let _ok: LogoutAccountResponse = to_response(resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert!( + payload.auth_mode.is_none(), + "auth_method should be None after logout" + ); + assert_eq!(payload.plan_type, None); + + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be deleted" + ); + + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!(account.account, None); + Ok(()) +} + +#[tokio::test] +async fn set_auth_token_updates_account_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id("org-embedded"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + access_token, + "org-embedded".to_string(), + Some("pro".to_string()), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens)); + assert_eq!(payload.plan_type, Some(AccountPlanType::Pro)); + + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!( + account, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: "embedded@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id("org-embedded"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + access_token, + "org-embedded".to_string(), + Some("pro".to_string()), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: true, + }) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!( + account, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: "embedded@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + + let refresh_request = timeout( + Duration::from_millis(250), + mcp.read_stream_until_request_message(), + ) + .await; + assert!( + refresh_request.is_err(), + "external mode should not emit account/chatgptAuthTokens/refresh for refreshToken=true" + ); + + Ok(()) +} + +async fn respond_to_refresh_request( + mcp: &mut McpProcess, + access_token: &str, + chatgpt_account_id: &str, + chatgpt_plan_type: Option<&str>, +) -> Result<()> { + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + assert_eq!(params.reason, ChatgptAuthTokensRefreshReason::Unauthorized); + let response = ChatgptAuthTokensRefreshResponse { + access_token: access_token.to_string(), + chatgpt_account_id: chatgpt_account_id.to_string(), + chatgpt_plan_type: chatgpt_plan_type.map(str::to_string), + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + Ok(()) +} + +#[tokio::test] +// 401 response triggers account/chatgptAuthTokens/refresh and retries with new tokens. +async fn external_auth_refreshes_on_unauthorized() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let success_sse = responses::sse(vec![ + responses::ev_response_created("resp-turn"), + responses::ev_assistant_message("msg-turn", "turn ok"), + responses::ev_completed("resp-turn"), + ]); + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let responses_mock = responses::mount_response_sequence( + &mock_server, + vec![unauthorized, responses::sse_response(success_sse)], + ) + .await; + + let initial_access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-initial"), + )?; + let refreshed_access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("refreshed@example.com") + .plan_type("pro") + .chatgpt_account_id("org-refreshed"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + initial_access_token.clone(), + "org-initial".to_string(), + Some("pro".to_string()), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id, + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + respond_to_refresh_request( + &mut mcp, + &refreshed_access_token, + "org-refreshed", + Some("pro"), + ) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn_completed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = responses_mock.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].header("authorization"), + Some(format!("Bearer {initial_access_token}")) + ); + assert_eq!( + requests[1].header("authorization"), + Some(format!("Bearer {refreshed_access_token}")) + ); + + Ok(()) +} + +#[tokio::test] +// Client returns JSON-RPC error to refresh; turn fails. +async fn external_auth_refresh_error_fails_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let _responses_mock = + responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + + let initial_access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-initial"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + initial_access_token, + "org-initial".to_string(), + Some("pro".to_string()), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id.clone(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + + mcp.send_error( + request_id, + JSONRPCErrorError { + code: -32_000, + message: "refresh failed".to_string(), + data: None, + }, + ) + .await?; + + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + Ok(()) +} + +#[tokio::test] +// Refresh returns tokens for the wrong workspace; turn fails. +async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_id: Some("org-expected".to_string()), + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let _responses_mock = + responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + + let initial_access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-expected"), + )?; + let refreshed_access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("refreshed@example.com") + .plan_type("pro") + .chatgpt_account_id("org-other"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + initial_access_token, + "org-expected".to_string(), + Some("pro".to_string()), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id.clone(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + + mcp.send_response( + request_id, + serde_json::to_value(ChatgptAuthTokensRefreshResponse { + access_token: refreshed_access_token, + chatgpt_account_id: "org-other".to_string(), + chatgpt_plan_type: Some("pro".to_string()), + })?, + ) + .await?; + + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + Ok(()) +} + +#[tokio::test] +// Refresh returns a malformed access token; turn fails. +async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + let unauthorized = ResponseTemplate::new(401).set_body_json(json!({ + "error": { "message": "unauthorized" } + })); + let _responses_mock = + responses::mount_response_sequence(&mock_server, vec![unauthorized]).await; + + let initial_access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .plan_type("pro") + .chatgpt_account_id("org-initial"), + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + initial_access_token, + "org-initial".to_string(), + Some("pro".to_string()), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(codex_app_server_protocol::ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(codex_app_server_protocol::TurnStartParams { + thread_id: thread.thread.id.clone(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + + let refresh_req: ServerRequest = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else { + bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}"); + }; + + mcp.send_response( + request_id, + serde_json::to_value(ChatgptAuthTokensRefreshResponse { + access_token: "not-a-jwt".to_string(), + chatgpt_account_id: "org-initial".to_string(), + chatgpt_plan_type: Some("pro".to_string()), + })?, + ) + .await?; + + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + assert_eq!(login, LoginAccountResponse::ApiKey {}); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.login_id, None); + pretty_assertions::assert_eq!(payload.success, true); + pretty_assertions::assert_eq!(payload.error, None); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.auth_mode, Some(AuthMode::ApiKey)); + pretty_assertions::assert_eq!(payload.plan_type, None); + + assert!(codex_home.path().join("auth.json").exists()); + Ok(()) +} + +#[tokio::test] +async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("chatgpt".to_string()), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "API key login is disabled. Use ChatGPT login instead." + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("api".to_string()), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "ChatGPT login is disabled. Use API key login instead." + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert!( + err.error + .message + .contains("device code login is not enabled"), + "unexpected error: {:?}", + err.error.message + ); + + let maybe_completed = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await; + assert!( + maybe_completed.is_err(), + "account/login/completed should not be emitted when device code start fails" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code start fails" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await; + mock_device_code_token_success(&mock_server).await; + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("device@example.com") + .plan_type("pro") + .chatgpt_account_id("org-device"), + )?; + mock_device_code_oauth_token(&mock_server, &id_token).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + } = login + else { + bail!("unexpected login response: {login:?}"); + }; + assert_eq!(verification_url, format!("{issuer}/codex/device")); + assert_eq!(user_code, "CODE-12345"); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, true); + assert_eq!(payload.error, None); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.auth_mode, Some(AuthMode::Chatgpt)); + assert_eq!(payload.plan_type, Some(AccountPlanType::Pro)); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should be created when device code login succeeds" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_failure_notifies_without_account_update() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await; + mock_device_code_token_failure(&mock_server, /*status*/ 500).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, false); + assert!( + payload + .error + .as_deref() + .is_some_and(|error| error.contains("device auth failed with status")), + "unexpected error: {:?}", + payload.error + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when device code login fails" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code login fails" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, /*interval_seconds*/ 1).await; + mock_device_code_token_failure(&mock_server, /*status*/ 404).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?; + assert_eq!(cancel.status, CancelLoginAccountStatus::Canceled); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, false); + assert!( + payload.error.is_some(), + "expected a non-empty error on device code cancel" + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when device code login is cancelled" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code login is cancelled" + ); + Ok(()) +} + +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { login_id, auth_url } = login else { + bail!("unexpected login response: {login:?}"); + }; + assert!( + auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost"), + "auth_url should contain a redirect_uri to localhost" + ); + + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let _ok: CancelLoginAccountResponse = to_response(cancel_resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.login_id, Some(login_id)); + pretty_assertions::assert_eq!(payload.success, false); + assert!( + payload.error.is_some(), + "expected a non-empty error on cancel" + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when login is cancelled" + ); + Ok(()) +} + +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Initiate the ChatGPT login flow + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let access_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("embedded@example.com") + .plan_type("pro") + .chatgpt_account_id("org-embedded"), + )?; + // Set an external auth token instead of completing the ChatGPT login flow. + // This should cancel the active login attempt. + let set_id = mcp + .send_chatgpt_auth_tokens_login_request( + access_token, + "org-embedded".to_string(), + Some("pro".to_string()), + ) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let response: LoginAccountResponse = to_response(set_resp)?; + assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {}); + let _updated = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + + // Verify that the active login attempt was cancelled. + // We check this by trying to cancel it and expecting a not found error. + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?; + assert_eq!(cancel.status, CancelLoginAccountStatus::NotFound); + + Ok(()) +} + +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_id: Some("ws-forced".to_string()), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { auth_url, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + assert!( + auth_url.contains("allowed_workspace_id=ws-forced"), + "auth URL should include forced workspace" + ); + Ok(()) +} + +#[tokio::test] +async fn get_account_no_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let account: GetAccountResponse = to_response(resp)?; + + assert_eq!(account.account, None, "expected no account"); + assert_eq!(account.requires_openai_auth, true); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_api_key() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let _login_ok = to_response::(resp)?; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::ApiKey {}), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_when_auth_not_required() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(false), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: None, + requires_openai_auth: false, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_aws_provider() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + model_provider_id: Some("amazon-bedrock".to_string()), + extra_provider_config: Some( + r#"[model_providers.amazon-bedrock.aws] +profile = "codex-bedrock" +region = "us-west-2" +"# + .to_string(), + ), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::AmazonBedrock {}), + requires_openai_auth: false, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt") + .email("user@example.com") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::Chatgpt { + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now() - ChronoDuration::days(9))), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1..=2) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let auth_status_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + let auth_status_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(auth_status_request_id)), + ) + .await??; + let _: GetAuthStatusResponse = to_response(auth_status_resp)?; + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + assert_eq!( + received, + GetAccountResponse { + account: None, + requires_openai_auth: true, + } + ); + server.verify().await; + Ok(()) +} + +#[tokio::test] +async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").email("user@example.com"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::Chatgpt { + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Unknown, + }), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/analytics.rs b/code-rs/app-server/tests/suite/v2/analytics.rs new file mode 100644 index 00000000000..c6f95af95df --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/analytics.rs @@ -0,0 +1,207 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::write_chatgpt_auth; +use codex_config::types::AuthCredentialsStoreMode; +use codex_config::types::OtelExporterKind; +use codex_config::types::OtelHttpProtocol; +use codex_core::config::ConfigBuilder; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const SERVICE_VERSION: &str = "0.0.0-test"; + +fn set_metrics_exporter(config: &mut codex_core::config::Config) { + config.otel.metrics_exporter = OtelExporterKind::OtlpHttp { + endpoint: "http://localhost:4318".to_string(), + headers: HashMap::new(), + protocol: OtelHttpProtocol::Json, + tls: None, + }; +} + +#[tokio::test] +async fn app_server_default_analytics_disabled_without_flag() -> Result<()> { + let codex_home = TempDir::new()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + set_metrics_exporter(&mut config); + config.analytics_enabled = None; + + let provider = codex_core::otel_init::build_provider( + &config, + SERVICE_VERSION, + Some("codex-app-server"), + /*default_analytics_enabled*/ false, + ) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + // With analytics unset in the config and the default flag is false, metrics are disabled. + // A provider may still exist for non-metrics telemetry, so check metrics specifically. + let has_metrics = provider.as_ref().and_then(|otel| otel.metrics()).is_some(); + assert_eq!(has_metrics, false); + Ok(()) +} + +#[tokio::test] +async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { + let codex_home = TempDir::new()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + set_metrics_exporter(&mut config); + config.analytics_enabled = None; + + let provider = codex_core::otel_init::build_provider( + &config, + SERVICE_VERSION, + Some("codex-app-server"), + /*default_analytics_enabled*/ true, + ) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + + // With analytics unset in the config and the default flag is true, metrics are enabled. + let has_metrics = provider.as_ref().and_then(|otel| otel.metrics()).is_some(); + assert_eq!(has_metrics, true); + Ok(()) +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + Mock::given(method("POST")) + .and(path("/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(server) + .await; + + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Ok(()) +} + +pub(crate) async fn wait_for_analytics_payload( + server: &MockServer, + read_timeout: Duration, +) -> Result { + let body = timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + if let Some(request) = requests.iter().find(|request| { + request.method == "POST" && request.url.path() == "/codex/analytics-events/events" + }) { + break request.body.clone(); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await?; + serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) +} + +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + +pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { + let events = payload["events"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("analytics payload missing events array"))?; + events + .iter() + .find(|event| event["event_type"] == "codex_thread_initialized") + .ok_or_else(|| anyhow::anyhow!("codex_thread_initialized event should be present")) +} + +pub(crate) fn assert_basic_thread_initialized_event( + event: &Value, + thread_id: &str, + expected_model: &str, + initialization_mode: &str, + expected_thread_source: &str, +) { + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!( + event["event_params"]["app_server_client"]["client_name"], + DEFAULT_CLIENT_NAME + ); + assert_eq!( + event["event_params"]["app_server_client"]["rpc_transport"], + "stdio" + ); + assert_eq!(event["event_params"]["model"], expected_model); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!( + event["event_params"]["thread_source"], + expected_thread_source + ); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["initialization_mode"], + initialization_mode + ); + assert!(event["event_params"]["created_at"].as_u64().is_some()); +} diff --git a/code-rs/app-server/tests/suite/v2/app_list.rs b/code-rs/app-server/tests/suite/v2/app_list.rs new file mode 100644 index 00000000000..395dff56683 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/app_list.rs @@ -0,0 +1,1661 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::http::header::AUTHORIZATION; +use axum::routing::get; +use codex_app_server_protocol::AppBranding; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::AppMetadata; +use codex_app_server_protocol::AppReview; +use codex_app_server_protocol::AppScreenshot; +use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::save_auth; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +// Bazel CI can spend tens of seconds starting app-server subprocesses or +// processing app-list RPCs under load. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio::test] +async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { data, next_cursor } = to_response(response)?; + + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + Ok(()) +} + +#[tokio::test] +async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("test-api-key".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }, + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { data, next_cursor } = to_response(response)?; + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server_with_workspace_plugins_enabled( + connectors, tools, /*workspace_plugins_enabled*/ false, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { data, next_cursor } = to_response(response)?; + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let start_request = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(start_response)?; + + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{server_url}" +mcp_oauth_credentials_store = "file" + +[features] +connectors = false +"# + ), + )?; + + let global_request = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + let global_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(global_request)), + ) + .await??; + let AppsListResponse { + data: global_data, + next_cursor: global_next_cursor, + } = to_response(global_response)?; + assert!(global_data.is_empty()); + assert!(global_next_cursor.is_none()); + + let thread_request = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: Some(thread.id), + force_refetch: false, + }) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request)), + ) + .await??; + let AppsListResponse { + data: thread_data, + next_cursor: thread_next_cursor, + } = to_response(thread_response)?; + assert!(thread_data.iter().any(|app| app.id == "beta")); + assert!(thread_next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn list_apps_reports_is_enabled_from_config() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{server_url}" + +[features] +connectors = true + +[apps.beta] +enabled = false +"# + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { + data: response_data, + next_cursor, + } = to_response(response)?; + assert!(next_cursor.is_none()); + assert_eq!(response_data.len(), 1); + assert_eq!(response_data[0].id, "beta"); + assert!(!response_data[0].is_enabled); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> { + let alpha_branding = Some(AppBranding { + category: Some("PRODUCTIVITY".to_string()), + developer: Some("Acme".to_string()), + website: Some("https://acme.example".to_string()), + privacy_policy: Some("https://acme.example/privacy".to_string()), + terms_of_service: Some("https://acme.example/terms".to_string()), + is_discoverable_app: true, + }); + let alpha_app_metadata = Some(AppMetadata { + review: Some(AppReview { + status: "APPROVED".to_string(), + }), + categories: Some(vec!["PRODUCTIVITY".to_string()]), + sub_categories: Some(vec!["WRITING".to_string()]), + seo_description: Some("Alpha connector".to_string()), + screenshots: Some(vec![AppScreenshot { + url: Some("https://example.com/alpha-screenshot.png".to_string()), + file_id: Some("file_123".to_string()), + user_prompt: "Summarize this draft".to_string(), + }]), + developer: Some("Acme".to_string()), + version: Some("1.2.3".to_string()), + version_id: Some("version_123".to_string()), + version_notes: Some("Fixes and improvements".to_string()), + first_party_type: Some("internal".to_string()), + first_party_requires_install: Some(true), + show_in_composer_when_unlinked: Some(true), + }); + let alpha_labels = Some(HashMap::from([ + ("feature".to_string(), "beta".to_string()), + ("source".to_string(), "directory".to_string()), + ])); + + let connectors = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, + branding: alpha_branding.clone(), + app_metadata: alpha_app_metadata.clone(), + labels: alpha_labels.clone(), + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server_with_delays( + connectors.clone(), + tools, + Duration::from_millis(300), + Duration::ZERO, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let expected_accessible = vec![AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + + let first_update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!(first_update.data, expected_accessible); + + let expected_merged = vec![ + AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, + branding: alpha_branding, + app_metadata: alpha_app_metadata, + labels: alpha_labels, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + + let second_update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!(second_update.data, expected_merged); + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { + data: response_data, + next_cursor, + } = to_response(response)?; + assert_eq!(response_data, expected_merged); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() -> Result<()> { + let connectors = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server_with_delays( + connectors.clone(), + tools, + Duration::ZERO, + Duration::from_millis(300), + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-directory-first") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let expected = vec![ + AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + + loop { + let update = read_app_list_updated_notification(&mut mcp).await?; + if update.data == expected { + break; + } + + assert!( + !update.data.is_empty() && update.data.iter().all(|connector| connector.is_accessible), + "unexpected directory-only app/list update before accessible apps loaded" + ); + } + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { data, next_cursor } = to_response(response)?; + assert_eq!(data, expected); + assert!(next_cursor.is_none()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> { + let connectors = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let (server_url, server_handle) = start_apps_server_with_delays( + connectors.clone(), + Vec::new(), + Duration::from_millis(300), + Duration::ZERO, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-empty-interim") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let maybe_update = timeout( + Duration::from_millis(150), + read_app_list_updated_notification(&mut mcp), + ) + .await; + assert!( + maybe_update.is_err(), + "unexpected empty interim app/list update" + ); + + let expected = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + + let update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!(update.data, expected); + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { data, next_cursor } = to_response(response)?; + assert_eq!(data, expected); + assert!(next_cursor.is_none()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn list_apps_paginates_results() -> Result<()> { + let connectors = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server_with_delays( + connectors.clone(), + tools, + Duration::ZERO, + Duration::from_millis(300), + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let first_request = mcp + .send_apps_list_request(AppsListParams { + limit: Some(1), + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + let first_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_request)), + ) + .await??; + let AppsListResponse { + data: first_page, + next_cursor: first_cursor, + } = to_response(first_response)?; + + let expected_first = vec![AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + + assert_eq!(first_page, expected_first); + let next_cursor = first_cursor.ok_or_else(|| anyhow::anyhow!("missing cursor"))?; + + loop { + let update = read_app_list_updated_notification(&mut mcp).await?; + if update.data.len() == 2 && update.data.iter().any(|connector| connector.is_accessible) { + break; + } + } + + let second_request = mcp + .send_apps_list_request(AppsListParams { + limit: Some(1), + cursor: Some(next_cursor), + thread_id: None, + force_refetch: false, + }) + .await?; + let second_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request)), + ) + .await??; + let AppsListResponse { + data: second_page, + next_cursor: second_cursor, + } = to_response(second_response)?; + + let expected_second = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + + assert_eq!(second_page, expected_second); + assert!(second_cursor.is_none()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let initial_request = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + let initial_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(initial_request)), + ) + .await??; + let AppsListResponse { + data: initial_data, + next_cursor: initial_next_cursor, + } = to_response(initial_response)?; + assert!(initial_next_cursor.is_none()); + assert_eq!(initial_data.len(), 1); + assert!(initial_data.iter().all(|app| app.is_accessible)); + + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token-invalid") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let refetch_request = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + let refetch_error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(refetch_request)), + ) + .await??; + assert!(refetch_error.error.message.contains("failed to")); + + let cached_request = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + let cached_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cached_request)), + ) + .await??; + let AppsListResponse { + data: cached_data, + next_cursor: cached_next_cursor, + } = to_response(cached_response)?; + + assert_eq!(cached_data, initial_data); + assert!(cached_next_cursor.is_none()); + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Result<()> { + let initial_connectors = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: Some("Beta v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + let initial_tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( + initial_connectors, + initial_tools, + Duration::from_millis(300), + Duration::ZERO, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let warm_request = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + let warm_first_update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!( + warm_first_update.data, + vec![AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); + + let warm_second_update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!( + warm_second_update.data, + vec![ + AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: Some("Beta v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ] + ); + + let warm_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(warm_request)), + ) + .await??; + let AppsListResponse { + data: warm_data, + next_cursor: warm_next_cursor, + } = to_response(warm_response)?; + assert_eq!(warm_data, warm_second_update.data); + assert!(warm_next_cursor.is_none()); + + server_control.set_connectors(vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]); + server_control.set_tools(Vec::new()); + + let refetch_request = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + + let first_update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!( + first_update.data, + vec![ + AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: Some("Beta v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ] + ); + + let maybe_second_update = timeout( + Duration::from_millis(150), + read_app_list_updated_notification(&mut mcp), + ) + .await; + assert!( + maybe_second_update.is_err(), + "unexpected inaccessible-only app/list update during force refetch" + ); + + let expected_final = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let second_update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!(second_update.data, expected_final); + + let refetch_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(refetch_request)), + ) + .await??; + let AppsListResponse { + data: refetch_data, + next_cursor: refetch_next_cursor, + } = to_response(refetch_response)?; + assert_eq!(refetch_data, expected_final); + assert!(refetch_next_cursor.is_none()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> { + let initial_connectors = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( + initial_connectors, + Vec::new(), + Duration::ZERO, + Duration::ZERO, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-enable-refresh") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let disable_request = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("apps".to_string(), false)]), + }) + .await?; + let _disable_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(disable_request)), + ) + .await??; + + server_control.set_connectors(vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]); + server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]); + + let enable_request = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("apps".to_string(), true)]), + }) + .await?; + let _enable_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(enable_request)), + ) + .await??; + + let update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!( + update.data, + vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); + + server_handle.abort(); + Ok(()) +} + +async fn read_app_list_updated_notification( + mcp: &mut McpProcess, +) -> Result { + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("app/list/updated"), + ) + .await??; + let parsed: ServerNotification = notification.try_into()?; + let ServerNotification::AppListUpdated(payload) = parsed else { + bail!("unexpected notification variant"); + }; + Ok(payload) +} + +#[derive(Clone)] +struct AppsServerState { + expected_bearer: String, + expected_account_id: String, + response: Arc>, + directory_delay: Duration, + workspace_plugins_enabled: bool, +} + +#[derive(Clone)] +struct AppListMcpServer { + tools: Arc>>, + tools_delay: Duration, +} + +impl AppListMcpServer { + fn new(tools: Arc>>, tools_delay: Duration) -> Self { + Self { tools, tools_delay } + } +} + +#[derive(Clone)] +struct AppsServerControl { + response: Arc>, + tools: Arc>>, +} + +impl AppsServerControl { + fn set_connectors(&self, connectors: Vec) { + let mut response_guard = self + .response + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *response_guard = json!({ "apps": connectors, "next_token": null }); + } + + fn set_tools(&self, tools: Vec) { + let mut tools_guard = self + .tools + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *tools_guard = tools; + } +} + +impl ServerHandler for AppListMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + let tools = self.tools.clone(); + let tools_delay = self.tools_delay; + async move { + if tools_delay > Duration::ZERO { + tokio::time::sleep(tools_delay).await; + } + let tools = tools + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(ListToolsResult { + tools, + next_cursor: None, + meta: None, + }) + } + } +} + +async fn start_apps_server_with_delays( + connectors: Vec, + tools: Vec, + directory_delay: Duration, + tools_delay: Duration, +) -> Result<(String, JoinHandle<()>)> { + let (server_url, server_handle, _server_control) = + start_apps_server_with_delays_and_control(connectors, tools, directory_delay, tools_delay) + .await?; + Ok((server_url, server_handle)) +} + +async fn start_apps_server_with_workspace_plugins_enabled( + connectors: Vec, + tools: Vec, + workspace_plugins_enabled: bool, +) -> Result<(String, JoinHandle<()>)> { + let (server_url, server_handle, _server_control) = + start_apps_server_with_delays_and_control_inner( + connectors, + tools, + Duration::ZERO, + Duration::ZERO, + workspace_plugins_enabled, + ) + .await?; + Ok((server_url, server_handle)) +} + +async fn start_apps_server_with_delays_and_control( + connectors: Vec, + tools: Vec, + directory_delay: Duration, + tools_delay: Duration, +) -> Result<(String, JoinHandle<()>, AppsServerControl)> { + start_apps_server_with_delays_and_control_inner( + connectors, + tools, + directory_delay, + tools_delay, + /*workspace_plugins_enabled*/ true, + ) + .await +} + +async fn start_apps_server_with_delays_and_control_inner( + connectors: Vec, + tools: Vec, + directory_delay: Duration, + tools_delay: Duration, + workspace_plugins_enabled: bool, +) -> Result<(String, JoinHandle<()>, AppsServerControl)> { + let response = Arc::new(StdMutex::new( + json!({ "apps": connectors, "next_token": null }), + )); + let tools = Arc::new(StdMutex::new(tools)); + let state = AppsServerState { + expected_bearer: "Bearer chatgpt-token".to_string(), + expected_account_id: "account-123".to_string(), + response: response.clone(), + directory_delay, + workspace_plugins_enabled, + }; + let state = Arc::new(state); + let server_control = AppsServerControl { + response, + tools: tools.clone(), + }; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let mcp_service = StreamableHttpService::new( + { + let tools = tools.clone(); + move || Ok(AppListMcpServer::new(tools.clone(), tools_delay)) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let router = Router::new() + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) + .route( + "/accounts/account-123/settings", + get(workspace_settings_response), + ) + .with_state(state) + .nest_service("/api/codex/apps", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle, server_control)) +} + +async fn workspace_settings_response( + State(state): State>, + headers: HeaderMap, +) -> Result { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_bearer); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_account_id); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else { + Ok(Json(json!({ + "beta_settings": { + "enable_plugins": state.workspace_plugins_enabled + } + }))) + } +} + +async fn list_directory_connectors( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> Result { + if state.directory_delay > Duration::ZERO { + tokio::time::sleep(state.directory_delay).await; + } + + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_bearer); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_account_id); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { + let response = state + .response + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(Json(response)) + } +} + +fn connector_tool(connector_id: &str, connector_name: &str) -> Result { + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + }))?; + let mut tool = Tool::new( + Cow::Owned(format!("connector_{connector_id}")), + Cow::Borrowed("Connector test tool"), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + let mut meta = Meta::new(); + meta.0 + .insert("connector_id".to_string(), json!(connector_id)); + meta.0 + .insert("connector_name".to_string(), json!(connector_name)); + tool.meta = Some(meta); + Ok(tool) +} + +fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +chatgpt_base_url = "{base_url}" +mcp_oauth_credentials_store = "file" + +[features] +connectors = true +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/client_metadata.rs b/code-rs/app-server/tests/suite/v2/client_metadata.rs new file mode 100644 index 00000000000..8d68888e7e2 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/client_metadata.rs @@ -0,0 +1,368 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +// Bazel CI can spend tens of seconds starting app-server subprocesses or +// processing turn RPCs under load. +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +#[tokio::test] +async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let client_metadata = HashMap::from([ + ("fiber_run_id".to_string(), "fiber-start-123".to_string()), + ("origin".to_string(), "gaas".to_string()), + ]); + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: Some(client_metadata.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let metadata = request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); + assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); + assert_eq!(metadata["origin"].as_str(), Some("gaas")); + assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); + assert!(metadata.get("session_id").is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let codex_home = TempDir::new()?; + + let server = responses::start_mock_server().await; + let first_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Working"), + responses::ev_completed("resp-1"), + ])) + .set_delay(std::time::Duration::from_secs(2)); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])); + let request_log = + responses::mount_response_sequence(&server, vec![first_response, second_response]).await; + + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let start_metadata = + HashMap::from([("fiber_run_id".to_string(), "fiber-start-123".to_string())]); + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Run sleep".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: Some(start_metadata.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + wait_for_request_count(&request_log, /*expected*/ 1).await?; + + let steer_metadata = HashMap::from([ + ("fiber_run_id".to_string(), "fiber-steer-456".to_string()), + ("origin".to_string(), "gaas".to_string()), + ]); + let steer_req = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Focus on the failure".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: Some(steer_metadata.clone()), + expected_turn_id: turn_id.clone(), + }) + .await?; + let steer_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(steer_req)), + ) + .await??; + let _turn: TurnSteerResponse = to_response::(steer_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2); + let first_metadata = requests[0] + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing first x-codex-turn-metadata header")); + assert_eq!( + first_metadata["fiber_run_id"].as_str(), + Some("fiber-start-123") + ); + assert_eq!(first_metadata["turn_id"].as_str(), Some(turn_id.as_str())); + + let second_metadata = requests[1] + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing second x-codex-turn-metadata header")); + assert_eq!( + second_metadata["fiber_run_id"].as_str(), + Some("fiber-steer-456") + ); + assert_eq!(second_metadata["origin"].as_str(), Some("gaas")); + assert_eq!(second_metadata["turn_id"].as_str(), Some(turn_id.as_str())); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body_v2() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let websocket_server = responses::start_websocket_server(vec![vec![ + vec![ + responses::ev_response_created("warm-1"), + responses::ev_completed("warm-1"), + ], + vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ], + ]]) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &websocket_server.uri().replacen("ws://", "http://", 1), + /*supports_websockets*/ true, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let client_metadata = HashMap::from([ + ("fiber_run_id".to_string(), "fiber-start-123".to_string()), + ("origin".to_string(), "gaas".to_string()), + ]); + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: Some(client_metadata), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let warmup = websocket_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await + .body_json(); + let request = websocket_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 1) + .await + .body_json(); + + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); + assert_eq!(request["type"].as_str(), Some("response.create")); + assert_eq!(request["previous_response_id"].as_str(), Some("warm-1")); + + let metadata = request["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing websocket x-codex-turn-metadata client metadata")); + assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); + assert_eq!(metadata["origin"].as_str(), Some("gaas")); + assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); + assert!(metadata.get("session_id").is_some()); + + websocket_server.shutdown().await; + Ok(()) +} + +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + supports_websockets: bool, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +supports_websockets = {supports_websockets} +"# + ), + ) +} + +fn parse_json_header(value: &str) -> serde_json::Value { + match serde_json::from_str(value) { + Ok(value) => value, + Err(err) => panic!("metadata header should be valid json: {err}"), + } +} + +async fn wait_for_request_count( + request_log: &core_test_support::responses::ResponseMock, + expected: usize, +) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + if request_log.requests().len() >= expected { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await?; + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/code-rs/app-server/tests/suite/v2/collaboration_mode_list.rs new file mode 100644 index 00000000000..f5914f0449b --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -0,0 +1,59 @@ +//! Validates that the collaboration mode list endpoint returns the expected default presets. +//! +//! The test drives the app server through the MCP harness and asserts that the list response +//! includes the plan and default modes, which keeps the API contract visible in one place. + +#![allow(clippy::unwrap_used)] + +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; +use codex_app_server_protocol::CollaborationModeMask; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_core::test_support::builtin_collaboration_mode_presets; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +// Bazel CI can spend tens of seconds starting app-server subprocesses or +// processing list RPCs under load. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Confirms the server returns the default collaboration mode presets in a stable order. +#[tokio::test] +async fn list_collaboration_modes_returns_presets() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_collaboration_modes_request(CollaborationModeListParams::default()) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let CollaborationModeListResponse { data: items } = + to_response::(response)?; + + let expected: Vec = builtin_collaboration_mode_presets() + .into_iter() + .map(|preset| CollaborationModeMask { + name: preset.name, + mode: preset.mode, + model: preset.model, + reasoning_effort: preset.reasoning_effort, + }) + .collect(); + assert_eq!(expected, items); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/command_exec.rs b/code-rs/app-server/tests/suite/v2/command_exec.rs new file mode 100644 index 00000000000..211cec93550 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/command_exec.rs @@ -0,0 +1,1114 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::CommandExecOutputDeltaNotification; +use codex_app_server_protocol::CommandExecOutputStream; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::CommandExecTerminalSize; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; + +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::assert_no_message; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::create_config_toml; +use super::connection_handling_websocket::read_jsonrpc_message; +use super::connection_handling_websocket::send_initialize_request; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; + +#[tokio::test] +async fn command_exec_without_streams_can_be_terminated() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "sleep-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + let terminate_request_id = mcp + .send_command_exec_terminate_request(CommandExecTerminateParams { process_id }) + .await?; + + let terminate_response = mcp + .read_stream_until_response_message(RequestId::Integer(terminate_request_id)) + .await?; + assert_eq!(terminate_response.result, serde_json::json!({})); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_ne!( + response.exit_code, 0, + "terminated command should not succeed" + ); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'legacy-out'; printf 'legacy-err' >&2".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "legacy-out".to_string(), + stderr: "legacy-err".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_env_overrides_merge_with_server_environment_and_support_unset() -> Result<()> +{ + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[("COMMAND_EXEC_BASELINE", Some("server"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "/bin/sh".to_string(), + "-lc".to_string(), + "printf '%s|%s|%s|%s' \"$COMMAND_EXEC_BASELINE\" \"$COMMAND_EXEC_EXTRA\" \"${RUST_LOG-unset}\" \"$CODEX_HOME\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ( + "COMMAND_EXEC_BASELINE".to_string(), + Some("request".to_string()), + ), + ("COMMAND_EXEC_EXTRA".to_string(), Some("added".to_string())), + ("RUST_LOG".to_string(), None), + ])), + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: format!("request|added|unset|{}", codex_home.path().display()), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_accepts_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'profile'".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "profile".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let command_dir = codex_home.path().join("command-cwd"); + std::fs::create_dir(&command_dir)?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let mut permission_profile = root_read_only_permission_profile(); + let PermissionProfile::Managed { file_system, .. } = &mut permission_profile else { + panic!("root read-only helper should use managed permissions"); + }; + let PermissionProfileFileSystemPermissions::Restricted { entries, .. } = file_system else { + panic!("root read-only helper should use restricted filesystem permissions"); + }; + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath: None }, + }, + access: FileSystemAccessMode::Write, + }); + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf child > child.txt && ! printf parent > ../parent.txt".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: Some("command-cwd".into()), + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(permission_profile), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response.exit_code, 0, + "parent cwd write should fail under command project-root profile: {response:?}" + ); + assert_eq!( + std::fs::read_to_string(command_dir.join("child.txt"))?, + "child" + ); + assert!( + !codex_home.path().join("parent.txt").exists(), + "permissionProfile :project_roots write should not grant the server cwd when command cwd differs" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "`permissionProfile` cannot be combined with `sandboxPolicy`" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("invalid-timeout-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: Some(1_000), + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec cannot set both timeoutMs and disableTimeout" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("invalid-cap-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(1024), + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec cannot set both outputBytesCap and disableOutputCap" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("negative-timeout-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(-1), + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec timeoutMs must be non-negative, got -1" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "cat".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec tty or streaming requires a client-supplied processId" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'abcdef'; printf 'uvwxyz' >&2".to_string(), + ], + process_id: Some("cap-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(5), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "abcde".to_string(), + stderr: "uvwxy".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "stream-cap-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'abcdefghij'; sleep 30".to_string(), + ], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: Some(5), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let output = collect_command_exec_output_until( + CommandExecDeltaReader::Mcp(&mut mcp), + process_id.as_str(), + "capped stdout", + |_output, delta| delta.stream == CommandExecOutputStream::Stdout && delta.cap_reached, + ) + .await?; + assert_eq!(output.stdout, "abcde"); + let terminate_request_id = mcp + .send_command_exec_terminate_request(CommandExecTerminateParams { + process_id: process_id.clone(), + }) + .await?; + let terminate_response = mcp + .read_stream_until_response_message(RequestId::Integer(terminate_request_id)) + .await?; + assert_eq!(terminate_response.result, serde_json::json!({})); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_ne!( + response.exit_code, 0, + "terminated command should not succeed" + ); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "pipe-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'out-start\\n'; printf 'err-start\\n' >&2; IFS= read line; printf 'out:%s\\n' \"$line\"; printf 'err:%s\\n' \"$line\" >&2".to_string(), + ], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: true, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + wait_for_command_exec_outputs_contains( + &mut mcp, + process_id.as_str(), + "out-start\n", + "err-start\n", + ) + .await?; + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("hello\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + wait_for_command_exec_outputs_contains( + &mut mcp, + process_id.as_str(), + "out:hello\n", + "err:hello\n", + ) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "tty-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "stty -echo; if [ -t 0 ]; then printf 'tty\\n'; else printf 'notty\\n'; fi; IFS= read line; printf 'echo:%s\\n' \"$line\"".to_string(), + ], + process_id: Some(process_id.clone()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + wait_for_command_exec_output_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "tty\n", + ) + .await?; + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("world\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + wait_for_command_exec_output_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "echo:world\n", + ) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "tty-size-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"".to_string(), + ], + process_id: Some(process_id.clone()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 31, + cols: 101, + }), + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + wait_for_command_exec_output_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "start:31 101\n", + ) + .await?; + + let resize_request_id = mcp + .send_command_exec_resize_request(CommandExecResizeParams { + process_id: process_id.clone(), + size: CommandExecTerminalSize { + rows: 45, + cols: 132, + }, + }) + .await?; + let resize_response = mcp + .read_stream_until_response_message(RequestId::Integer(resize_request_id)) + .await?; + assert_eq!(resize_response.result, serde_json::json!({})); + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("go\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + wait_for_command_exec_output_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "after:45 132\n", + ) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminates_process() +-> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let marker = format!( + "codex-command-exec-marker-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + ); + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + + send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; + read_initialize_response(&mut ws1, /*request_id*/ 1).await?; + send_initialize_request(&mut ws2, /*id*/ 2, "ws_client_two").await?; + read_initialize_response(&mut ws2, /*request_id*/ 2).await?; + + send_request( + &mut ws1, + "command/exec", + /*id*/ 101, + Some(serde_json::json!({ + "command": [ + "python3", + "-c", + "import time; print('ready', flush=True); time.sleep(30)", + marker, + ], + "processId": "shared-process", + "streamStdoutStderr": true, + })), + ) + .await?; + + collect_command_exec_output_until( + CommandExecDeltaReader::Websocket(&mut ws1), + "shared-process", + "websocket ready output", + |output, _delta| output.stdout.contains("ready\n"), + ) + .await?; + wait_for_process_marker(&marker, /*should_exist*/ true).await?; + + send_request( + &mut ws2, + "command/exec/terminate", + /*id*/ 102, + Some(serde_json::json!({ + "processId": "shared-process", + })), + ) + .await?; + + let terminate_error = loop { + let message = read_jsonrpc_message(&mut ws2).await?; + if let JSONRPCMessage::Error(error) = message + && error.id == RequestId::Integer(102) + { + break error; + } + }; + assert_eq!( + terminate_error.error.message, + "no active command/exec for process id \"shared-process\"" + ); + wait_for_process_marker(&marker, /*should_exist*/ true).await?; + + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + ws1.close(None).await?; + + wait_for_process_marker(&marker, /*should_exist*/ false).await?; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +async fn read_command_exec_delta( + mcp: &mut McpProcess, +) -> Result { + let notification = mcp + .read_stream_until_notification_message("command/exec/outputDelta") + .await?; + decode_delta_notification(notification) +} + +async fn wait_for_command_exec_output_contains( + mcp: &mut McpProcess, + process_id: &str, + stream: CommandExecOutputStream, + expected: &str, +) -> Result<()> { + let stream_name = match stream { + CommandExecOutputStream::Stdout => "stdout", + CommandExecOutputStream::Stderr => "stderr", + }; + collect_command_exec_output_until( + CommandExecDeltaReader::Mcp(mcp), + process_id, + format!("{stream_name} containing {expected:?}"), + |output, _delta| match stream { + CommandExecOutputStream::Stdout => output.stdout.contains(expected), + CommandExecOutputStream::Stderr => output.stderr.contains(expected), + }, + ) + .await?; + Ok(()) +} + +async fn wait_for_command_exec_outputs_contains( + mcp: &mut McpProcess, + process_id: &str, + stdout_expected: &str, + stderr_expected: &str, +) -> Result<()> { + collect_command_exec_output_until( + CommandExecDeltaReader::Mcp(mcp), + process_id, + format!("stdout containing {stdout_expected:?} and stderr containing {stderr_expected:?}"), + |output, _delta| { + output.stdout.contains(stdout_expected) && output.stderr.contains(stderr_expected) + }, + ) + .await?; + Ok(()) +} + +enum CommandExecDeltaReader<'a> { + Mcp(&'a mut McpProcess), + Websocket(&'a mut super::connection_handling_websocket::WsClient), +} + +#[derive(Default)] +struct CollectedCommandExecOutput { + stdout: String, + stderr: String, +} + +async fn collect_command_exec_output_until( + mut reader: CommandExecDeltaReader<'_>, + process_id: &str, + waiting_for: impl Into, + mut should_stop: impl FnMut( + &CollectedCommandExecOutput, + &CommandExecOutputDeltaNotification, + ) -> bool, +) -> Result { + let waiting_for = waiting_for.into(); + let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; + let mut output = CollectedCommandExecOutput::default(); + + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let delta = timeout(remaining, async { + match &mut reader { + CommandExecDeltaReader::Mcp(mcp) => read_command_exec_delta(mcp).await, + CommandExecDeltaReader::Websocket(stream) => { + read_command_exec_delta_ws(stream).await + } + } + }) + .await + .with_context(|| { + format!( + "timed out waiting for {waiting_for} in command/exec output for {process_id}; collected stdout={:?}, stderr={:?}", + output.stdout, output.stderr + ) + })??; + assert_eq!(delta.process_id, process_id); + + let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?; + let delta_text = delta_text.replace('\r', ""); + match delta.stream { + CommandExecOutputStream::Stdout => output.stdout.push_str(&delta_text), + CommandExecOutputStream::Stderr => output.stderr.push_str(&delta_text), + } + if should_stop(&output, &delta) { + return Ok(output); + } + } +} + +async fn read_command_exec_delta_ws( + stream: &mut super::connection_handling_websocket::WsClient, +) -> Result { + loop { + let message = read_jsonrpc_message(stream).await?; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + if notification.method == "command/exec/outputDelta" { + return decode_delta_notification(notification); + } + } +} + +fn decode_delta_notification( + notification: JSONRPCNotification, +) -> Result { + let params = notification + .params + .context("command/exec/outputDelta notification should include params")?; + serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") +} + +fn root_read_only_permission_profile() -> PermissionProfile { + PermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }], + glob_scan_max_depth: None, + }, + } +} + +async fn read_initialize_response( + stream: &mut super::connection_handling_websocket::WsClient, + request_id: i64, +) -> Result<()> { + loop { + let message = read_jsonrpc_message(stream).await?; + if let JSONRPCMessage::Response(response) = message + && response.id == RequestId::Integer(request_id) + { + return Ok(()); + } + } +} + +async fn wait_for_process_marker(marker: &str, should_exist: bool) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if process_with_marker_exists(marker)? == should_exist { + return Ok(()); + } + if Instant::now() >= deadline { + let expectation = if should_exist { "appear" } else { "exit" }; + anyhow::bail!("process marker {marker:?} did not {expectation} before timeout"); + } + sleep(Duration::from_millis(50)).await; + } +} + +fn process_with_marker_exists(marker: &str) -> Result { + let output = std::process::Command::new("ps") + .args(["-axo", "command"]) + .output() + .context("spawn ps -axo command")?; + let stdout = String::from_utf8(output.stdout).context("decode ps output")?; + Ok(stdout.lines().any(|line| line.contains(marker))) +} diff --git a/code-rs/app-server/tests/suite/v2/compaction.rs b/code-rs/app-server/tests/suite/v2/compaction.rs new file mode 100644 index 00000000000..6db031b278d --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/compaction.rs @@ -0,0 +1,409 @@ +//! End-to-end compaction flow tests. +//! +//! Phases: +//! 1) Arrange: mock responses/compact endpoints + config. +//! 2) Act: start a thread and submit multiple turns to trigger auto-compaction. +//! 3) Assert: verify item/started + item/completed notifications for context compaction. + +#![expect(clippy::expect_used)] + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadCompactStartParams; +use codex_app_server_protocol::ThreadCompactStartResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::TempDir; +use tokio::time::timeout; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const AUTO_COMPACT_LIMIT: i64 = 1_000; +const COMPACT_PROMPT: &str = "Summarize the conversation."; +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let sse1 = responses::sse(vec![ + responses::ev_assistant_message("m1", "FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", /*total_tokens*/ 70_000), + ]); + let sse2 = responses::sse(vec![ + responses::ev_assistant_message("m2", "SECOND_REPLY"), + responses::ev_completed_with_tokens("r2", /*total_tokens*/ 330_000), + ]); + let sse3 = responses::sse(vec![ + responses::ev_assistant_message("m3", "LOCAL_SUMMARY"), + responses::ev_completed_with_tokens("r3", /*total_tokens*/ 200), + ]); + let sse4 = responses::sse(vec![ + responses::ev_assistant_message("m4", "FINAL_REPLY"), + responses::ev_completed_with_tokens("r4", /*total_tokens*/ 120), + ]); + responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::default(), + AUTO_COMPACT_LIMIT, + /*requires_openai_auth*/ None, + "mock_provider", + COMPACT_PROMPT, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + for message in ["first", "second", "third"] { + send_turn_and_wait(&mut mcp, &thread_id, message).await?; + } + + let started = wait_for_context_compaction_started(&mut mcp).await?; + let completed = wait_for_context_compaction_completed(&mut mcp).await?; + + let ThreadItem::ContextCompaction { id: started_id } = started.item else { + unreachable!("started item should be context compaction"); + }; + let ThreadItem::ContextCompaction { id: completed_id } = completed.item else { + unreachable!("completed item should be context compaction"); + }; + + assert_eq!(started.thread_id, thread_id); + assert_eq!(completed.thread_id, thread_id); + assert_eq!(started_id, completed_id); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()> { + skip_if_no_network!(Ok(())); + const REMOTE_AUTO_COMPACT_LIMIT: i64 = 200_000; + + let server = responses::start_mock_server().await; + let sse1 = responses::sse(vec![ + responses::ev_assistant_message("m1", "FIRST_REPLY"), + responses::ev_completed_with_tokens("r1", /*total_tokens*/ 70_000), + ]); + let sse2 = responses::sse(vec![ + responses::ev_assistant_message("m2", "SECOND_REPLY"), + responses::ev_completed_with_tokens("r2", /*total_tokens*/ 330_000), + ]); + let sse3 = responses::sse(vec![ + responses::ev_assistant_message("m3", "FINAL_REPLY"), + responses::ev_completed_with_tokens("r3", /*total_tokens*/ 120), + ]); + let responses_log = responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3]).await; + + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "REMOTE_COMPACT_SUMMARY".to_string(), + }], + phase: None, + }, + ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }, + ]; + let compact_mock = responses::mount_compact_json_once( + &server, + serde_json::json!({ "output": compacted_history }), + ) + .await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::default(), + REMOTE_AUTO_COMPACT_LIMIT, + Some(true), + "mock_provider", + COMPACT_PROMPT, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + for message in ["first", "second", "third"] { + send_turn_and_wait(&mut mcp, &thread_id, message).await?; + } + + let started = wait_for_context_compaction_started(&mut mcp).await?; + let completed = wait_for_context_compaction_completed(&mut mcp).await?; + + let ThreadItem::ContextCompaction { id: started_id } = started.item else { + unreachable!("started item should be context compaction"); + }; + let ThreadItem::ContextCompaction { id: completed_id } = completed.item else { + unreachable!("completed item should be context compaction"); + }; + + assert_eq!(started.thread_id, thread_id); + assert_eq!(completed.thread_id, thread_id); + assert_eq!(started_id, completed_id); + + let compact_requests = compact_mock.requests(); + assert_eq!(compact_requests.len(), 1); + assert_eq!(compact_requests[0].path(), "/v1/responses/compact"); + + let response_requests = responses_log.requests(); + assert_eq!(response_requests.len(), 3); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let sse = responses::sse(vec![ + responses::ev_assistant_message("m1", "MANUAL_COMPACT_SUMMARY"), + responses::ev_completed_with_tokens("r1", /*total_tokens*/ 200), + ]); + responses::mount_sse_sequence(&server, vec![sse]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::default(), + AUTO_COMPACT_LIMIT, + /*requires_openai_auth*/ None, + "mock_provider", + COMPACT_PROMPT, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + let compact_id = mcp + .send_thread_compact_start_request(ThreadCompactStartParams { + thread_id: thread_id.clone(), + }) + .await?; + let compact_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(compact_id)), + ) + .await??; + let _compact: ThreadCompactStartResponse = + to_response::(compact_resp)?; + + let started = wait_for_context_compaction_started(&mut mcp).await?; + let completed = wait_for_context_compaction_completed(&mut mcp).await?; + + let ThreadItem::ContextCompaction { id: started_id } = started.item else { + unreachable!("started item should be context compaction"); + }; + let ThreadItem::ContextCompaction { id: completed_id } = completed.item else { + unreachable!("completed item should be context compaction"); + }; + + assert_eq!(started.thread_id, thread_id); + assert_eq!(completed.thread_id, thread_id); + assert_eq!(started_id, completed_id); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn thread_compact_start_rejects_invalid_thread_id() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::default(), + AUTO_COMPACT_LIMIT, + /*requires_openai_auth*/ None, + "mock_provider", + COMPACT_PROMPT, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_compact_start_request(ThreadCompactStartParams { + thread_id: "not-a-thread-id".to_string(), + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert!(error.error.message.contains("invalid thread id")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::default(), + AUTO_COMPACT_LIMIT, + /*requires_openai_auth*/ None, + "mock_provider", + COMPACT_PROMPT, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_compact_start_request(ThreadCompactStartParams { + thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert!(error.error.message.contains("thread not found")); + + Ok(()) +} + +async fn start_thread(mcp: &mut McpProcess) -> Result { + let thread_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + Ok(thread.id) +} + +async fn send_turn_and_wait(mcp: &mut McpProcess, thread_id: &str, text: &str) -> Result { + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![V2UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + wait_for_turn_completed(mcp, &turn.id).await?; + Ok(turn.id) +} + +async fn wait_for_turn_completed(mcp: &mut McpProcess, turn_id: &str) -> Result<()> { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = + serde_json::from_value(notification.params.clone().expect("turn/completed params"))?; + if completed.turn.id == turn_id { + return Ok(()); + } + } +} + +async fn wait_for_context_compaction_started( + mcp: &mut McpProcess, +) -> Result { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/started"), + ) + .await??; + let started: ItemStartedNotification = + serde_json::from_value(notification.params.clone().expect("item/started params"))?; + if let ThreadItem::ContextCompaction { .. } = started.item { + return Ok(started); + } + } +} + +async fn wait_for_context_compaction_completed( + mcp: &mut McpProcess, +) -> Result { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/completed"), + ) + .await??; + let completed: ItemCompletedNotification = + serde_json::from_value(notification.params.clone().expect("item/completed params"))?; + if let ThreadItem::ContextCompaction { .. } = completed.item { + return Ok(completed); + } + } +} diff --git a/code-rs/app-server/tests/suite/v2/config_rpc.rs b/code-rs/app-server/tests/suite/v2/config_rpc.rs new file mode 100644 index 00000000000..b5f795740cc --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/config_rpc.rs @@ -0,0 +1,777 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::test_path_buf_with_windows; +use app_test_support::test_tmp_path_buf; +use app_test_support::to_response; +use codex_app_server_protocol::AppConfig; +use codex_app_server_protocol::AppToolApproval; +use codex_app_server_protocol::AppsConfig; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ToolsV2; +use codex_app_server_protocol::WriteStatus; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WebSearchContextSize; +use codex_protocol::config_types::WebSearchLocation; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; + +// Bazel CI can spend tens of seconds starting app-server subprocesses or +// processing config RPCs under load. +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { + Ok(std::fs::write( + codex_home.path().join("config.toml"), + contents, + )?) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_returns_effective_and_layers() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" +sandbox_mode = "workspace-write" +"#, + )?; + let codex_home_path = codex_home.path().canonicalize()?; + let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + assert_eq!(config.model.as_deref(), Some("gpt-user")); + assert_eq!( + origins.get("model").expect("origin").name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + let layers = layers.expect("layers present"); + assert_layers_user_then_optional_system(&layers, user_file)?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_tools() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" + +[tools.web_search] +context_size = "low" +allowed_domains = ["example.com"] + +[tools] +view_image = false +"#, + )?; + let codex_home_path = codex_home.path().canonicalize()?; + let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + let tools = config.tools.expect("tools present"); + assert_eq!( + tools, + ToolsV2 { + web_search: Some(WebSearchToolConfig { + context_size: Some(WebSearchContextSize::Low), + allowed_domains: Some(vec!["example.com".to_string()]), + location: None, + }), + view_image: Some(false), + } + ); + assert_eq!( + origins + .get("tools.web_search.context_size") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("tools.web_search.allowed_domains.0") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins.get("tools.view_image").expect("origin").name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + + let layers = layers.expect("layers present"); + assert_layers_user_then_optional_system(&layers, user_file)?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_nested_web_search_tool_config() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +web_search = "live" + +[tools.web_search] +context_size = "high" +allowed_domains = ["example.com"] +location = { country = "US", city = "New York", timezone = "America/New_York" } +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + assert_eq!( + config.tools.expect("tools present").web_search, + Some(WebSearchToolConfig { + context_size: Some(WebSearchContextSize::High), + allowed_domains: Some(vec!["example.com".to_string()]), + location: Some(WebSearchLocation { + country: Some("US".to_string()), + region: None, + city: Some("New York".to_string()), + timezone: Some("America/New_York".to_string()), + }), + }), + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_ignores_bool_web_search_tool_config() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +[tools] +web_search = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + assert_eq!(config.tools.expect("tools present").web_search, None,); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_apps() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +[apps.app1] +enabled = false +destructive_enabled = false +default_tools_approval_mode = "prompt" +"#, + )?; + let codex_home_path = codex_home.path().canonicalize()?; + let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + assert_eq!( + config.apps, + Some(AppsConfig { + default: None, + apps: std::collections::HashMap::from([( + "app1".to_string(), + AppConfig { + enabled: false, + destructive_enabled: Some(false), + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, + }, + )]), + }) + ); + assert_eq!( + origins.get("apps.app1.enabled").expect("origin").name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("apps.app1.destructive_enabled") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("apps.app1.default_tools_approval_mode") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + + let layers = layers.expect("layers present"); + assert_layers_user_then_optional_system(&layers, user_file)?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_project_layers_for_cwd() -> Result<()> { + let codex_home = TempDir::new()?; + write_config(&codex_home, r#"model = "gpt-user""#)?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + let project_config = AbsolutePathBuf::try_from(project_config_dir)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, origins, .. + } = to_response(resp)?; + + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + origins.get("model_reasoning_effort").expect("origin").name, + ConfigLayerSource::Project { + dot_codex_folder: project_config + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_system_layer_and_overrides() -> Result<()> { + let codex_home = TempDir::new()?; + let user_dir = test_path_buf_with_windows("/user", Some(r"C:\Users\user")); + let system_dir = test_path_buf_with_windows("/system", Some(r"C:\System")); + write_config( + &codex_home, + &format!( + r#" +model = "gpt-user" +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [{}] +network_access = true +"#, + serde_json::json!(user_dir) + ), + )?; + let codex_home_path = codex_home.path().canonicalize()?; + let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; + + let managed_path = codex_home.path().join("managed_config.toml"); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?; + std::fs::write( + &managed_path, + format!( + r#" +model = "gpt-system" +approval_policy = "never" + +[sandbox_workspace_write] +writable_roots = [{}] +"#, + serde_json::json!(system_dir.clone()) + ), + )?; + + let managed_path_str = managed_path.display().to_string(); + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + Some(&managed_path_str), + )], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + assert_eq!(config.model.as_deref(), Some("gpt-system")); + assert_eq!( + origins.get("model").expect("origin").name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone(), + } + ); + + assert_eq!(config.approval_policy, Some(AskForApproval::Never)); + assert_eq!( + origins.get("approval_policy").expect("origin").name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone(), + } + ); + + assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); + assert_eq!( + origins.get("sandbox_mode").expect("origin").name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + + let sandbox = config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![system_dir]); + assert_eq!( + origins + .get("sandbox_workspace_write.writable_roots.0") + .expect("origin") + .name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_file.clone(), + } + ); + + assert!(sandbox.network_access); + assert_eq!( + origins + .get("sandbox_workspace_write.network_access") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + + let layers = layers.expect("layers present"); + assert_layers_managed_user_then_optional_system(&layers, managed_file, user_file)?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_value_write_replaces_value() -> Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().canonicalize()?; + write_config( + &temp_dir, + r#" +model = "gpt-old" +"#, + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let expected_version = read.origins.get("model").map(|m| m.version.clone()); + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version, + }) + .await?; + let write_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let write: ConfigWriteResponse = to_response(write_resp)?; + let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home); + + assert_eq!(write.status, WriteStatus::Ok); + assert_eq!(write.file_path, expected_file_path); + assert!(write.overridden_metadata.is_none()); + + let verify_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let verify_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(verify_id)), + ) + .await??; + let verify: ConfigReadResponse = to_response(verify_resp)?; + assert_eq!(verify.config.model.as_deref(), Some("gpt-new")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().canonicalize()?; + write_config( + &temp_dir, + r#" +model = "gpt-old" +"#, + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await?; + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + + let write_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let write: ConfigWriteResponse = to_response(write_resp)?; + assert_eq!(write.status, WriteStatus::Ok); + + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + assert_eq!(read.config.model.as_deref(), Some("gpt-new")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_value_write_rejects_version_conflict() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-old" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: Some(codex_home.path().join("config.toml").display().to_string()), + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: Some("sha256:stale".to_string()), + }) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(write_id)), + ) + .await??; + let code = err + .error + .data + .as_ref() + .and_then(|d| d.get("config_write_error_code")) + .and_then(|v| v.as_str()); + assert_eq!(code, Some("configVersionConflict")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_batch_write_applies_multiple_edits() -> Result<()> { + let tmp_dir = TempDir::new()?; + let codex_home = tmp_dir.path().canonicalize()?; + write_config(&tmp_dir, "")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let writable_root = test_tmp_path_buf(); + let batch_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: Some(codex_home.join("config.toml").display().to_string()), + edits: vec![ + ConfigEdit { + key_path: "sandbox_mode".to_string(), + value: json!("workspace-write"), + merge_strategy: MergeStrategy::Replace, + }, + ConfigEdit { + key_path: "sandbox_workspace_write".to_string(), + value: json!({ + "writable_roots": [writable_root.clone()], + "network_access": false + }), + merge_strategy: MergeStrategy::Replace, + }, + ], + expected_version: None, + reload_user_config: false, + }) + .await?; + let batch_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + ) + .await??; + let batch_write: ConfigWriteResponse = to_response(batch_resp)?; + assert_eq!(batch_write.status, WriteStatus::Ok); + let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home); + assert_eq!(batch_write.file_path, expected_file_path); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); + let sandbox = read + .config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![writable_root]); + assert!(!sandbox.network_access); + + Ok(()) +} + +fn assert_layers_user_then_optional_system( + layers: &[codex_app_server_protocol::ConfigLayer], + user_file: AbsolutePathBuf, +) -> Result<()> { + let mut first_index = 0; + if matches!( + layers.first().map(|layer| &layer.name), + Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) + ) { + first_index = 1; + } + assert_eq!(layers.len(), first_index + 2); + assert_eq!( + layers[first_index].name, + ConfigLayerSource::User { file: user_file } + ); + assert!(matches!( + layers[first_index + 1].name, + ConfigLayerSource::System { .. } + )); + Ok(()) +} + +fn assert_layers_managed_user_then_optional_system( + layers: &[codex_app_server_protocol::ConfigLayer], + managed_file: AbsolutePathBuf, + user_file: AbsolutePathBuf, +) -> Result<()> { + let mut first_index = 0; + if matches!( + layers.first().map(|layer| &layer.name), + Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm) + ) { + first_index = 1; + } + assert_eq!(layers.len(), first_index + 3); + assert_eq!( + layers[first_index].name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } + ); + assert_eq!( + layers[first_index + 1].name, + ConfigLayerSource::User { file: user_file } + ); + assert!(matches!( + layers[first_index + 2].name, + ConfigLayerSource::System { .. } + )); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/code-rs/app-server/tests/suite/v2/connection_handling_websocket.rs new file mode 100644 index 00000000000..6581c1467a7 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -0,0 +1,874 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use app_test_support::DISABLE_PLUGIN_STARTUP_TASKS_ARG; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use futures::SinkExt; +use futures::StreamExt; +use hmac::Hmac; +use hmac::Mac; +use reqwest::StatusCode; +use serde_json::json; +use sha2::Sha256; +use std::net::SocketAddr; +use std::path::Path; +use std::process::Stdio; +use tempfile::TempDir; +use time::OffsetDateTime; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::process::Child; +use tokio::process::Command; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Error as WsError; +use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; +use tokio_tungstenite::tungstenite::http::header::ORIGIN; + +// macOS and Windows CI can spend tens of seconds starting the app-server test +// binary under Bazel before it accepts JSON-RPC or reports its websocket bind +// address. +#[cfg(any(target_os = "macos", windows))] +pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +pub(super) type WsClient = WebSocketStream>; +type HmacSha256 = Hmac; + +#[tokio::test] +async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + + send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; + let first_init = read_response_for_id(&mut ws1, /*id*/ 1).await?; + assert_eq!(first_init.id, RequestId::Integer(1)); + + // Initialize responses are request-scoped and must not leak to other + // connections. + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + + send_config_read_request(&mut ws2, /*id*/ 2).await?; + let not_initialized = read_error_for_id(&mut ws2, /*id*/ 2).await?; + assert_eq!(not_initialized.error.message, "Not initialized"); + + send_initialize_request(&mut ws2, /*id*/ 3, "ws_client_two").await?; + let second_init = read_response_for_id(&mut ws2, /*id*/ 3).await?; + assert_eq!(second_init.id, RequestId::Integer(3)); + + // Same request-id on different connections must route independently. + send_config_read_request(&mut ws1, /*id*/ 77).await?; + send_config_read_request(&mut ws2, /*id*/ 77).await?; + let ws1_config = read_response_for_id(&mut ws1, /*id*/ 77).await?; + let ws2_config = read_response_for_id(&mut ws2, /*id*/ 77).await?; + + assert_eq!(ws1_config.id, RequestId::Integer(77)); + assert_eq!(ws2_config.id, RequestId::Integer(77)); + assert!(ws1_config.result.get("config").is_some()); + assert!(ws2_config.result.get("config").is_some()); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let client = reqwest::Client::new(); + + let readyz = http_get(&client, bind_addr, "/readyz").await?; + assert_eq!(readyz.status(), StatusCode::OK); + + let healthz = http_get(&client, bind_addr, "/healthz").await?; + assert_eq!(healthz.status(), StatusCode::OK); + + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_health_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_browser_origin_without_auth() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_loopback_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + drop(ws); + + assert_websocket_connect_rejected_with_headers( + bind_addr, + /*bearer_token*/ None, + Some("https://evil.example"), + StatusCode::FORBIDDEN, + ) + .await?; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let token_file = codex_home.path().join("app-server-token"); + std::fs::write(&token_file, "super-secret-token\n")?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let auth_args = vec![ + "--ws-auth".to_string(), + "capability-token".to_string(), + "--ws-token-file".to_string(), + token_file.display().to_string(), + ]; + + let (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; + + assert_websocket_connect_rejected(bind_addr, /*bearer_token*/ None).await?; + assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?; + + let mut ws = connect_websocket_with_bearer(bind_addr, Some("super-secret-token")).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_auth_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_verifies_signed_short_lived_bearer_tokens() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let shared_secret_file = codex_home.path().join("app-server-signing-secret"); + let shared_secret = "0123456789abcdef0123456789abcdef"; + std::fs::write(&shared_secret_file, format!("{shared_secret}\n"))?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let auth_args = vec![ + "--ws-auth".to_string(), + "signed-bearer-token".to_string(), + "--ws-shared-secret-file".to_string(), + shared_secret_file.display().to_string(), + "--ws-issuer".to_string(), + "codex-enroller".to_string(), + "--ws-audience".to_string(), + "codex-app-server".to_string(), + "--ws-max-clock-skew-seconds".to_string(), + "1".to_string(), + ]; + + let (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; + let expired_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() - 30, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(expired_token.as_str())).await?; + + let malformed_token = "not-a-jwt"; + assert_websocket_connect_rejected(bind_addr, Some(malformed_token)).await?; + + let not_yet_valid_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "nbf": OffsetDateTime::now_utc().unix_timestamp() + 30, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(not_yet_valid_token.as_str())).await?; + + let wrong_issuer_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "someone-else", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_issuer_token.as_str())).await?; + + let wrong_audience_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "wrong-audience", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_audience_token.as_str())).await?; + + let wrong_signature_token = signed_bearer_token( + b"fedcba9876543210fedcba9876543210", + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_signature_token.as_str())).await?; + + let valid_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + let mut ws = connect_websocket_with_bearer(bind_addr, Some(valid_token.as_str())).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_signed_auth_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_short_signed_bearer_secret_configuration() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let shared_secret_file = codex_home.path().join("app-server-signing-secret"); + std::fs::write(&shared_secret_file, "too-short\n")?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let output = run_websocket_server_to_completion_with_args( + codex_home.path(), + "ws://127.0.0.1:0", + &[ + "--ws-auth".to_string(), + "signed-bearer-token".to_string(), + "--ws-shared-secret-file".to_string(), + shared_secret_file.display().to_string(), + ], + ) + .await?; + assert!( + !output.status.success(), + "short shared secret should fail websocket server startup" + ); + let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?; + assert!( + stderr.contains("must be at least 32 bytes"), + "unexpected stderr: {stderr}" + ); + + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_default() -> Result<()> +{ + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &[]).await?; + + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_non_loopback_default_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + + Ok(()) +} + +#[tokio::test] +async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout() -> Result<()> +{ + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut ws1 = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws1, /*id*/ 1, "ws_thread_owner").await?; + read_response_for_id(&mut ws1, /*id*/ 1).await?; + + let thread_id = start_thread(&mut ws1, /*id*/ 2).await?; + assert_loaded_threads(&mut ws1, /*id*/ 3, &[thread_id.as_str()]).await?; + + ws1.close(None).await.context("failed to close websocket")?; + drop(ws1); + + let mut ws2 = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws2, /*id*/ 4, "ws_reconnect_client").await?; + read_response_for_id(&mut ws2, /*id*/ 4).await?; + + wait_for_loaded_threads(&mut ws2, /*first_id*/ 5, &[thread_id.as_str()]).await?; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, SocketAddr)> { + spawn_websocket_server_with_args(codex_home, "ws://127.0.0.1:0", &[]).await +} + +pub(super) async fn spawn_websocket_server_with_args( + codex_home: &Path, + listen_url: &str, + extra_args: &[String], +) -> Result<(Child, SocketAddr)> { + let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") + .context("should find app-server binary")?; + let mut cmd = Command::new(program); + cmd.arg("--listen") + .arg(listen_url) + .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) + .args(extra_args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .env("CODEX_HOME", codex_home) + .env("RUST_LOG", "warn"); + let mut process = cmd + .kill_on_drop(true) + .spawn() + .context("failed to spawn websocket app-server process")?; + + let stderr = process + .stderr + .take() + .context("failed to capture websocket app-server stderr")?; + let mut stderr_reader = BufReader::new(stderr).lines(); + let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; + let bind_addr = loop { + let line = timeout( + deadline.saturating_duration_since(Instant::now()), + stderr_reader.next_line(), + ) + .await + .context("timed out waiting for websocket app-server to report bound websocket address")? + .context("failed to read websocket app-server stderr")? + .context("websocket app-server exited before reporting bound websocket address")?; + eprintln!("[websocket app-server stderr] {line}"); + + let stripped_line = { + let mut stripped = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' && matches!(chars.peek(), Some(&'[')) { + chars.next(); + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + continue; + } + stripped.push(ch); + } + stripped + }; + + if let Some(bind_addr) = stripped_line + .split_whitespace() + .find_map(|token| token.strip_prefix("ws://")) + .and_then(|addr| addr.parse::().ok()) + { + break bind_addr; + } + }; + + tokio::spawn(async move { + while let Ok(Some(line)) = stderr_reader.next_line().await { + eprintln!("[websocket app-server stderr] {line}"); + } + }); + + Ok((process, bind_addr)) +} + +pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result { + connect_websocket_with_bearer(bind_addr, /*bearer_token*/ None).await +} + +pub(super) async fn connect_websocket_with_bearer( + bind_addr: SocketAddr, + bearer_token: Option<&str>, +) -> Result { + let url = format!("ws://{}", connectable_bind_addr(bind_addr)); + let request = websocket_request(url.as_str(), bearer_token, /*origin*/ None)?; + let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; + loop { + match connect_async(request.clone()).await { + Ok((stream, _response)) => return Ok(stream), + Err(err) => { + if Instant::now() >= deadline { + bail!("failed to connect websocket to {url}: {err}"); + } + sleep(Duration::from_millis(50)).await; + } + } + } +} + +async fn assert_websocket_connect_rejected( + bind_addr: SocketAddr, + bearer_token: Option<&str>, +) -> Result<()> { + assert_websocket_connect_rejected_with_headers( + bind_addr, + bearer_token, + /*origin*/ None, + StatusCode::UNAUTHORIZED, + ) + .await +} + +async fn assert_websocket_connect_rejected_with_headers( + bind_addr: SocketAddr, + bearer_token: Option<&str>, + origin: Option<&str>, + expected_status: StatusCode, +) -> Result<()> { + let url = format!("ws://{}", connectable_bind_addr(bind_addr)); + let request = websocket_request(url.as_str(), bearer_token, origin)?; + + match connect_async(request).await { + Ok((_stream, response)) => { + bail!( + "expected websocket handshake rejection, got {}", + response.status() + ) + } + Err(WsError::Http(response)) => { + assert_eq!(response.status(), expected_status); + Ok(()) + } + Err(err) => bail!("expected http rejection during websocket handshake: {err}"), + } +} + +async fn run_websocket_server_to_completion_with_args( + codex_home: &Path, + listen_url: &str, + extra_args: &[String], +) -> Result { + let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") + .context("should find app-server binary")?; + let mut cmd = Command::new(program); + cmd.arg("--listen") + .arg(listen_url) + .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) + .args(extra_args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .env("CODEX_HOME", codex_home) + .env("RUST_LOG", "warn"); + timeout(DEFAULT_READ_TIMEOUT, cmd.output()) + .await + .context("timed out waiting for websocket app-server to exit")? + .context("failed to run websocket app-server") +} + +async fn http_get( + client: &reqwest::Client, + bind_addr: SocketAddr, + path: &str, +) -> Result { + let connectable_bind_addr = connectable_bind_addr(bind_addr); + let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; + loop { + match client + .get(format!("http://{connectable_bind_addr}{path}")) + .send() + .await + .with_context(|| format!("failed to GET http://{connectable_bind_addr}{path}")) + { + Ok(response) => return Ok(response), + Err(err) => { + if Instant::now() >= deadline { + bail!("failed to GET http://{connectable_bind_addr}{path}: {err}"); + } + sleep(Duration::from_millis(50)).await; + } + } + } +} + +fn websocket_request( + url: &str, + bearer_token: Option<&str>, + origin: Option<&str>, +) -> Result> { + let mut request = url + .into_client_request() + .context("failed to create websocket request")?; + if let Some(bearer_token) = bearer_token { + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {bearer_token}")) + .context("invalid bearer token header")?, + ); + } + if let Some(origin) = origin { + request.headers_mut().insert( + ORIGIN, + HeaderValue::from_str(origin).context("invalid origin header")?, + ); + } + Ok(request) +} + +pub(super) async fn send_initialize_request( + stream: &mut WsClient, + id: i64, + client_name: &str, +) -> Result<()> { + let params = InitializeParams { + client_info: ClientInfo { + name: client_name.to_string(), + title: Some("WebSocket Test Client".to_string()), + version: "0.1.0".to_string(), + }, + capabilities: None, + }; + send_request( + stream, + "initialize", + id, + Some(serde_json::to_value(params)?), + ) + .await +} + +async fn start_thread(stream: &mut WsClient, id: i64) -> Result { + send_request( + stream, + "thread/start", + id, + Some(serde_json::to_value(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let response = read_response_for_id(stream, id).await?; + let ThreadStartResponse { thread, .. } = to_response::(response)?; + Ok(thread.id) +} + +async fn assert_loaded_threads(stream: &mut WsClient, id: i64, expected: &[&str]) -> Result<()> { + let response = request_loaded_threads(stream, id).await?; + let mut actual = response.data; + actual.sort(); + let mut expected = expected + .iter() + .map(|thread_id| (*thread_id).to_string()) + .collect::>(); + expected.sort(); + assert_eq!(actual, expected); + assert_eq!(response.next_cursor, None); + Ok(()) +} + +async fn wait_for_loaded_threads( + stream: &mut WsClient, + first_id: i64, + expected: &[&str], +) -> Result<()> { + let mut next_id = first_id; + let expected = expected + .iter() + .map(|thread_id| (*thread_id).to_string()) + .collect::>(); + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let response = request_loaded_threads(stream, next_id).await?; + next_id += 1; + let mut actual = response.data; + actual.sort(); + if actual == expected { + return Ok::<(), anyhow::Error>(()); + } + sleep(Duration::from_millis(50)).await; + } + }) + .await + .context("timed out waiting for loaded thread list")??; + Ok(()) +} + +async fn request_loaded_threads( + stream: &mut WsClient, + id: i64, +) -> Result { + send_request( + stream, + "thread/loaded/list", + id, + Some(serde_json::to_value(ThreadLoadedListParams::default())?), + ) + .await?; + let response = read_response_for_id(stream, id).await?; + to_response::(response) +} + +async fn send_config_read_request(stream: &mut WsClient, id: i64) -> Result<()> { + send_request( + stream, + "config/read", + id, + Some(json!({ "includeLayers": false })), + ) + .await +} + +pub(super) async fn send_request( + stream: &mut WsClient, + method: &str, + id: i64, + params: Option, +) -> Result<()> { + let message = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(id), + method: method.to_string(), + params, + trace: None, + }); + send_jsonrpc(stream, message).await +} + +async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> { + let payload = serde_json::to_string(&message)?; + stream + .send(WebSocketMessage::Text(payload.into())) + .await + .context("failed to send websocket frame") +} + +pub(super) async fn read_response_for_id( + stream: &mut WsClient, + id: i64, +) -> Result { + let target_id = RequestId::Integer(id); + loop { + let message = read_jsonrpc_message(stream).await?; + if let JSONRPCMessage::Response(response) = message + && response.id == target_id + { + return Ok(response); + } + } +} + +pub(super) async fn read_notification_for_method( + stream: &mut WsClient, + method: &str, +) -> Result { + loop { + let message = read_jsonrpc_message(stream).await?; + if let JSONRPCMessage::Notification(notification) = message + && notification.method == method + { + return Ok(notification); + } + } +} + +pub(super) async fn read_response_and_notification_for_method( + stream: &mut WsClient, + id: i64, + method: &str, +) -> Result<(JSONRPCResponse, JSONRPCNotification)> { + let target_id = RequestId::Integer(id); + let mut response = None; + let mut notification = None; + + while response.is_none() || notification.is_none() { + let message = read_jsonrpc_message(stream).await?; + match message { + JSONRPCMessage::Response(candidate) if candidate.id == target_id => { + response = Some(candidate); + } + JSONRPCMessage::Notification(candidate) if candidate.method == method => { + if notification.replace(candidate).is_some() { + bail!( + "received duplicate notification for method `{method}` before completing paired read" + ); + } + } + _ => {} + } + } + + let Some(response) = response else { + bail!("response must be set before returning"); + }; + let Some(notification) = notification else { + bail!("notification must be set before returning"); + }; + + Ok((response, notification)) +} + +pub(super) async fn read_error_for_id(stream: &mut WsClient, id: i64) -> Result { + let target_id = RequestId::Integer(id); + loop { + let message = read_jsonrpc_message(stream).await?; + if let JSONRPCMessage::Error(err) = message + && err.id == target_id + { + return Ok(err); + } + } +} + +pub(super) async fn read_jsonrpc_message(stream: &mut WsClient) -> Result { + loop { + let frame = timeout(DEFAULT_READ_TIMEOUT, stream.next()) + .await + .context("timed out waiting for websocket frame")? + .context("websocket stream ended unexpectedly")? + .context("failed to read websocket frame")?; + + match frame { + WebSocketMessage::Text(text) => return Ok(serde_json::from_str(text.as_ref())?), + WebSocketMessage::Ping(payload) => { + stream.send(WebSocketMessage::Pong(payload)).await?; + } + WebSocketMessage::Pong(_) => {} + WebSocketMessage::Close(frame) => { + bail!("websocket closed unexpectedly: {frame:?}") + } + WebSocketMessage::Binary(_) => bail!("unexpected binary websocket frame"), + WebSocketMessage::Frame(_) => {} + } + } +} + +pub(super) async fn assert_no_message(stream: &mut WsClient, wait_for: Duration) -> Result<()> { + match timeout(wait_for, stream.next()).await { + Ok(Some(Ok(frame))) => bail!("unexpected frame while waiting for silence: {frame:?}"), + Ok(Some(Err(err))) => bail!("unexpected websocket read error: {err}"), + Ok(None) => bail!("websocket closed unexpectedly while waiting for silence"), + Err(_) => Ok(()), + } +} + +pub(super) fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn connectable_bind_addr(bind_addr: SocketAddr) -> SocketAddr { + match bind_addr { + SocketAddr::V4(addr) if addr.ip().is_unspecified() => { + SocketAddr::from(([127, 0, 0, 1], addr.port())) + } + SocketAddr::V6(addr) if addr.ip().is_unspecified() => { + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], addr.port())) + } + _ => bind_addr, + } +} + +fn signed_bearer_token(shared_secret: &[u8], claims: serde_json::Value) -> Result { + let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let payload = format!("{header_segment}.{claims_segment}"); + let mut mac = HmacSha256::new_from_slice(shared_secret).context("failed to create hmac")?; + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + Ok(format!("{payload}.{signature}")) +} diff --git a/code-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs b/code-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs new file mode 100644 index 00000000000..591b70af094 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -0,0 +1,294 @@ +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::WsClient; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::create_config_toml; +use super::connection_handling_websocket::read_response_for_id; +use super::connection_handling_websocket::send_initialize_request; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use futures::SinkExt; +use futures::StreamExt; +use std::process::Command as StdCommand; +use tempfile::TempDir; +use tokio::process::Child; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use wiremock::Mock; +use wiremock::matchers::method; +use wiremock::matchers::path_regex; + +#[tokio::test] +async fn websocket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> { + let GracefulCtrlCFixture { + _codex_home, + _server, + mut process, + mut ws, + } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; + + send_sigint(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + let status = wait_for_process_exit_within( + &mut process, + Duration::from_secs(10), + "timed out waiting for graceful Ctrl-C restart shutdown", + ) + .await?; + assert!(status.success(), "expected graceful exit, got {status}"); + + expect_websocket_disconnect(&mut ws).await?; + + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> { + let GracefulCtrlCFixture { + _codex_home, + _server, + mut process, + mut ws, + } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; + + send_sigint(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + send_sigint(&process)?; + let status = wait_for_process_exit_within( + &mut process, + Duration::from_secs(2), + "timed out waiting for forced Ctrl-C restart shutdown", + ) + .await?; + assert!(status.success(), "expected graceful exit, got {status}"); + + expect_websocket_disconnect(&mut ws).await?; + + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> { + let GracefulCtrlCFixture { + _codex_home, + _server, + mut process, + mut ws, + } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; + + send_sigterm(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + let status = wait_for_process_exit_within( + &mut process, + Duration::from_secs(10), + "timed out waiting for graceful SIGTERM restart shutdown", + ) + .await?; + assert!(status.success(), "expected graceful exit, got {status}"); + + expect_websocket_disconnect(&mut ws).await?; + + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> { + let GracefulCtrlCFixture { + _codex_home, + _server, + mut process, + mut ws, + } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; + + send_sigterm(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + send_sigterm(&process)?; + let status = wait_for_process_exit_within( + &mut process, + Duration::from_secs(2), + "timed out waiting for forced SIGTERM restart shutdown", + ) + .await?; + assert!(status.success(), "expected graceful exit, got {status}"); + + expect_websocket_disconnect(&mut ws).await?; + + Ok(()) +} + +struct GracefulCtrlCFixture { + _codex_home: TempDir, + _server: wiremock::MockServer, + process: Child, + ws: WsClient, +} + +async fn start_ctrl_c_restart_fixture(turn_delay: Duration) -> Result { + let server = responses::start_mock_server().await; + let delayed_turn_response = create_final_assistant_message_sse_response("Done")?; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(responses::sse_response(delayed_turn_response).set_delay(turn_delay)) + .up_to_n_times(1) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let mut ws = connect_websocket(bind_addr).await?; + + send_initialize_request(&mut ws, /*id*/ 1, "ws_graceful_shutdown").await?; + let init_response = read_response_for_id(&mut ws, /*id*/ 1).await?; + assert_eq!(init_response.id, RequestId::Integer(1)); + + send_thread_start_request(&mut ws, /*id*/ 2).await?; + let thread_start_response = read_response_for_id(&mut ws, /*id*/ 2).await?; + let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?; + + send_turn_start_request(&mut ws, /*id*/ 3, &thread.id).await?; + let turn_start_response = read_response_for_id(&mut ws, /*id*/ 3).await?; + assert_eq!(turn_start_response.id, RequestId::Integer(3)); + + wait_for_responses_post(&server, Duration::from_secs(5)).await?; + + Ok(GracefulCtrlCFixture { + _codex_home: codex_home, + _server: server, + process, + ws, + }) +} + +async fn send_thread_start_request(stream: &mut WsClient, id: i64) -> Result<()> { + send_request( + stream, + "thread/start", + id, + Some(serde_json::to_value(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await +} + +async fn send_turn_start_request(stream: &mut WsClient, id: i64, thread_id: &str) -> Result<()> { + send_request( + stream, + "turn/start", + id, + Some(serde_json::to_value(TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + })?), + ) + .await +} + +async fn wait_for_responses_post(server: &wiremock::MockServer, wait_for: Duration) -> Result<()> { + let deadline = Instant::now() + wait_for; + loop { + let requests = server + .received_requests() + .await + .context("failed to read mock server requests")?; + if requests + .iter() + .any(|request| request.method == "POST" && request.url.path().ends_with("/responses")) + { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("timed out waiting for /responses request"); + } + sleep(Duration::from_millis(10)).await; + } +} + +fn send_sigint(process: &Child) -> Result<()> { + send_signal(process, "-INT") +} + +fn send_sigterm(process: &Child) -> Result<()> { + send_signal(process, "-TERM") +} + +fn send_signal(process: &Child, signal: &str) -> Result<()> { + let pid = process + .id() + .context("websocket app-server process has no pid")?; + let status = StdCommand::new("kill") + .arg(signal) + .arg(pid.to_string()) + .status() + .with_context(|| format!("failed to invoke kill {signal}"))?; + if !status.success() { + bail!("kill {signal} exited with {status}"); + } + Ok(()) +} + +async fn assert_process_does_not_exit_within(process: &mut Child, window: Duration) -> Result<()> { + match timeout(window, process.wait()).await { + Err(_) => Ok(()), + Ok(Ok(status)) => bail!("process exited too early during graceful drain: {status}"), + Ok(Err(err)) => Err(err).context("failed waiting for process"), + } +} + +async fn wait_for_process_exit_within( + process: &mut Child, + window: Duration, + timeout_context: &'static str, +) -> Result { + timeout(window, process.wait()) + .await + .context(timeout_context)? + .context("failed waiting for websocket app-server process exit") +} + +async fn expect_websocket_disconnect(stream: &mut WsClient) -> Result<()> { + loop { + let frame = timeout(DEFAULT_READ_TIMEOUT, stream.next()) + .await + .context("timed out waiting for websocket disconnect")?; + match frame { + None => return Ok(()), + Some(Ok(WebSocketMessage::Close(_))) => return Ok(()), + Some(Ok(WebSocketMessage::Ping(payload))) => { + stream + .send(WebSocketMessage::Pong(payload)) + .await + .context("failed to reply to ping while waiting for disconnect")?; + } + Some(Ok(WebSocketMessage::Pong(_))) => {} + Some(Ok(WebSocketMessage::Frame(_))) => {} + Some(Ok(WebSocketMessage::Text(_))) => {} + Some(Ok(WebSocketMessage::Binary(_))) => {} + Some(Err(_)) => return Ok(()), + } + } +} diff --git a/code-rs/app-server/tests/suite/v2/dynamic_tools.rs b/code-rs/app-server/tests/suite/v2/dynamic_tools.rs new file mode 100644 index 00000000000..b357e139db5 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -0,0 +1,767 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallParams; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_app_server_protocol::DynamicToolCallStatus; +use codex_app_server_protocol::DynamicToolSpec; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::MockServer; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// Ensures dynamic tool specs are serialized into the model request payload. +#[tokio::test] +async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Use a minimal JSON schema so we can assert the tool payload round-trips. + let input_schema = json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false, + }); + let dynamic_tool = DynamicToolSpec { + namespace: None, + name: "demo_tool".to_string(), + description: "Demo dynamic tool".to_string(), + input_schema: input_schema.clone(), + defer_loading: false, + }; + + // Thread start injects dynamic tools into the thread's tool registry. + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool.clone()]), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + // Start a turn so a model request is issued. + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + // Inspect the captured model request to assert the tool spec made it through. + let bodies = responses_bodies(&server).await?; + let body = bodies + .first() + .context("expected at least one responses request")?; + let tool = find_tool(body, &dynamic_tool.name) + .context("expected dynamic tool to be injected into request")?; + + assert_eq!( + tool.get("description"), + Some(&Value::String(dynamic_tool.description.clone())) + ); + assert_eq!(tool.get("parameters"), Some(&input_schema)); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "hidden_tool".to_string(), + description: "Hidden dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false, + }), + defer_loading: true, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool.clone()]), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let bodies = responses_bodies(&server).await?; + assert!( + bodies + .iter() + .all(|body| find_tool(body, &dynamic_tool.name).is_none()), + "hidden dynamic tool should not be sent to the model" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result<()> { + let server = MockServer::start().await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: None, + name: "hidden_tool".to_string(), + description: "Hidden dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: true, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(thread_req)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert!(error.error.message.contains("hidden_tool")); + assert!(error.error.message.contains("namespace")); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> { + let server = MockServer::start().await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup.ticket".to_string(), + description: "Invalid dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(thread_req)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert!(error.error.message.contains("Responses API")); + assert!(error.error.message.contains("lookup.ticket")); + + Ok(()) +} + +/// Exercises the full dynamic tool call path (server request, client response, model output). +#[tokio::test] +async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> { + let call_id = "dyn-call-1"; + let tool_namespace = "codex_app"; + let tool_name = "demo_tool"; + let tool_args = json!({ "city": "Paris" }); + let tool_call_arguments = serde_json::to_string(&tool_args)?; + + // First response triggers a dynamic tool call, second closes the turn. + let responses = vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": call_id, + "namespace": tool_namespace, + "name": tool_name, + "arguments": tool_call_arguments, + } + }), + responses::ev_completed("resp-1"), + ]), + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some(tool_namespace.to_string()), + name: tool_name.to_string(), + description: "Demo dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id.clone(); + + // Start a turn so the tool call is emitted. + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![V2UserInput::Text { + text: "Run the tool".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); + + let started = wait_for_dynamic_tool_started(&mut mcp, call_id).await?; + assert_eq!(started.thread_id, thread_id); + assert_eq!(started.turn_id, turn_id.clone()); + let ThreadItem::DynamicToolCall { + id, + namespace, + tool, + arguments, + status, + content_items, + success, + duration_ms, + } = started.item + else { + panic!("expected dynamic tool call item"); + }; + assert_eq!(id, call_id); + assert_eq!(namespace.as_deref(), Some(tool_namespace)); + assert_eq!(tool, tool_name); + assert_eq!(arguments, tool_args); + assert_eq!(status, DynamicToolCallStatus::InProgress); + assert_eq!(content_items, None); + assert_eq!(success, None); + assert_eq!(duration_ms, None); + + // Read the tool call request from the app server. + let request = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let (request_id, params) = match request { + ServerRequest::DynamicToolCall { request_id, params } => (request_id, params), + other => panic!("expected DynamicToolCall request, got {other:?}"), + }; + + let expected = DynamicToolCallParams { + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + call_id: call_id.to_string(), + namespace: Some(tool_namespace.to_string()), + tool: tool_name.to_string(), + arguments: tool_args.clone(), + }; + assert_eq!(params, expected); + + // Respond to the tool call so the model receives a function_call_output. + let response = DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }], + success: true, + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + + let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; + assert_eq!(completed.thread_id, thread_id); + assert_eq!(completed.turn_id, turn_id); + let ThreadItem::DynamicToolCall { + id, + namespace, + tool, + arguments, + status, + content_items, + success, + duration_ms, + } = completed.item + else { + panic!("expected dynamic tool call item"); + }; + assert_eq!(id, call_id); + assert_eq!(namespace.as_deref(), Some(tool_namespace)); + assert_eq!(tool, tool_name); + assert_eq!(arguments, tool_args); + assert_eq!(status, DynamicToolCallStatus::Completed); + assert_eq!( + content_items, + Some(vec![DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }]) + ); + assert_eq!(success, Some(true)); + assert!(duration_ms.is_some()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let bodies = responses_bodies(&server).await?; + let payload = bodies + .iter() + .find_map(|body| function_call_output_payload(body, call_id)) + .context("expected function_call_output in follow-up request")?; + let expected_payload = FunctionCallOutputPayload::from_text("dynamic-ok".to_string()); + assert_eq!(payload, expected_payload); + + Ok(()) +} + +/// Ensures dynamic tool call responses can include structured content items. +#[tokio::test] +async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> { + let call_id = "dyn-call-items-1"; + let tool_name = "demo_tool"; + let tool_args = json!({ "city": "Paris" }); + let tool_call_arguments = serde_json::to_string(&tool_args)?; + + let responses = vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, tool_name, &tool_call_arguments), + responses::ev_completed("resp-1"), + ]), + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: None, + name: tool_name.to_string(), + description: "Demo dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id.clone(); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![V2UserInput::Text { + text: "Run the tool".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); + + let started = wait_for_dynamic_tool_started(&mut mcp, call_id).await?; + assert_eq!(started.thread_id, thread_id.clone()); + assert_eq!(started.turn_id, turn_id.clone()); + + let request = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let (request_id, params) = match request { + ServerRequest::DynamicToolCall { request_id, params } => (request_id, params), + other => panic!("expected DynamicToolCall request, got {other:?}"), + }; + + let expected = DynamicToolCallParams { + thread_id, + turn_id: turn_id.clone(), + call_id: call_id.to_string(), + namespace: None, + tool: tool_name.to_string(), + arguments: tool_args, + }; + assert_eq!(params, expected); + + let response_content_items = vec![ + DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + }, + ]; + let content_items = response_content_items + .clone() + .into_iter() + .map(|item| match item { + DynamicToolCallOutputContentItem::InputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + DynamicToolCallOutputContentItem::InputImage { image_url } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + } + } + }) + .collect::>(); + let response = DynamicToolCallResponse { + content_items: response_content_items, + success: true, + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + + let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; + assert_eq!(completed.thread_id, expected.thread_id.clone()); + assert_eq!(completed.turn_id, turn_id); + let ThreadItem::DynamicToolCall { + status, + content_items: completed_content_items, + success, + .. + } = completed.item + else { + panic!("expected dynamic tool call item"); + }; + assert_eq!(status, DynamicToolCallStatus::Completed); + assert_eq!( + completed_content_items, + Some(vec![ + DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + }, + ]) + ); + assert_eq!(success, Some(true)); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let bodies = responses_bodies(&server).await?; + let output_value = bodies + .iter() + .find_map(|body| function_call_output_raw_output(body, call_id)) + .context("expected function_call_output output in follow-up request")?; + assert_eq!( + output_value, + json!([ + { + "type": "input_text", + "text": "dynamic-ok" + }, + { + "type": "input_image", + "image_url": "data:image/png;base64,AAA", + "detail": "high" + } + ]) + ); + + let payload = bodies + .iter() + .find_map(|body| function_call_output_payload(body, call_id)) + .context("expected function_call_output in follow-up request")?; + assert_eq!( + payload.body, + FunctionCallOutputBody::ContentItems(content_items.clone()) + ); + assert_eq!(payload.success, None); + assert_eq!( + serde_json::to_string(&payload)?, + serde_json::to_string(&content_items)? + ); + + Ok(()) +} + +async fn responses_bodies(server: &MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + + requests + .into_iter() + .filter(|req| req.url.path().ends_with("/responses")) + .map(|req| { + req.body_json::() + .context("request body should be JSON") + }) + .collect() +} + +fn find_tool<'a>(body: &'a Value, name: &str) -> Option<&'a Value> { + body.get("tools") + .and_then(Value::as_array) + .and_then(|tools| { + tools + .iter() + .find(|tool| tool.get("name").and_then(Value::as_str) == Some(name)) + }) +} + +fn function_call_output_payload(body: &Value, call_id: &str) -> Option { + function_call_output_raw_output(body, call_id) + .and_then(|output| serde_json::from_value(output).ok()) +} + +fn function_call_output_raw_output(body: &Value, call_id: &str) -> Option { + body.get("input") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) + }) + .and_then(|item| item.get("output")) + .cloned() +} + +async fn wait_for_dynamic_tool_started( + mcp: &mut McpProcess, + call_id: &str, +) -> Result { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/started"), + ) + .await??; + let Some(params) = notification.params else { + continue; + }; + let started: ItemStartedNotification = serde_json::from_value(params)?; + if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) { + return Ok(started); + } + } +} + +async fn wait_for_dynamic_tool_completed( + mcp: &mut McpProcess, + call_id: &str, +) -> Result { + loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/completed"), + ) + .await??; + let Some(params) = notification.params else { + continue; + }; + let completed: ItemCompletedNotification = serde_json::from_value(params)?; + if matches!(&completed.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) { + return Ok(completed); + } + } +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/experimental_api.rs b/code-rs/app-server/tests/suite/v2/experimental_api.rs new file mode 100644 index 00000000000..9ac0dc3e21f --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/experimental_api.rs @@ -0,0 +1,321 @@ +use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MockExperimentalMethodParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadMemoryMode; +use codex_app_server_protocol::ThreadMemoryModeSetParams; +use codex_app_server_protocol::ThreadRealtimeStartParams; +use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_protocol::protocol::RealtimeOutputModality; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn mock_experimental_method_requires_experimental_api_capability() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_mock_experimental_method_request(MockExperimentalMethodParams::default()) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "mock/experimentalMethod"); + Ok(()) +} + +#[tokio::test] +async fn realtime_conversation_start_requires_experimental_api_capability() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: "thr_123".to_string(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("hello".to_string())), + realtime_session_id: None, + transport: None, + voice: None, + }) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "thread/realtime/start"); + Ok(()) +} + +#[tokio::test] +async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_memory_mode_set_request(ThreadMemoryModeSetParams { + thread_id: "thr_123".to_string(), + mode: ThreadMemoryMode::Disabled, + }) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "thread/memoryMode/set"); + Ok(()) +} + +#[tokio::test] +async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: "thr_123".to_string(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("hello".to_string())), + realtime_session_id: None, + transport: Some(ThreadRealtimeStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + voice: None, + }) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "thread/realtime/start"); + Ok(()) +} + +#[tokio::test] +async fn thread_start_mock_field_requires_experimental_api_capability() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + mock_experimental_field: Some("mock".to_string()), + ..Default::default() + }) + .await?; + + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "thread/start.mockExperimentalField"); + Ok(()) +} + +#[tokio::test] +async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capability() +-> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadStartResponse = to_response(response)?; + Ok(()) +} + +#[tokio::test] +async fn thread_start_granular_approval_policy_requires_experimental_api_capability() -> Result<()> +{ + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + ..Default::default() + }) + .await?; + + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "askForApproval.granular"); + Ok(()) +} + +fn default_client_info() -> ClientInfo { + ClientInfo { + name: DEFAULT_CLIENT_NAME.to_string(), + title: None, + version: "0.1.0".to_string(), + } +} + +fn assert_experimental_capability_error(error: JSONRPCError, reason: &str) { + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + format!("{reason} requires experimentalApi capability") + ); + assert_eq!(error.error.data, None); +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/code-rs/app-server/tests/suite/v2/experimental_feature_list.rs new file mode 100644 index 00000000000..a186485df6a --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -0,0 +1,423 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; +use codex_app_server_protocol::ExperimentalFeatureListParams; +use codex_app_server_protocol::ExperimentalFeatureListResponse; +use codex_app_server_protocol::ExperimentalFeatureStage; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::ConfigBuilder; +use codex_features::FEATURES; +use codex_features::Stage; +use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::collections::BTreeMap; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Result<()> { + let codex_home = TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(LoaderOverrides::with_managed_config_path_for_tests( + codex_home.path().join("managed_config.toml"), + )) + .build() + .await?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) + .await?; + + let actual = read_response::(&mut mcp, request_id).await?; + let expected_data = FEATURES + .iter() + .map(|spec| { + let (stage, display_name, description, announcement) = match spec.stage { + Stage::Experimental { + name, + menu_description, + announcement, + } => ( + ExperimentalFeatureStage::Beta, + Some(name.to_string()), + Some(menu_description.to_string()), + Some(announcement.to_string()), + ), + Stage::UnderDevelopment => { + (ExperimentalFeatureStage::UnderDevelopment, None, None, None) + } + Stage::Stable => (ExperimentalFeatureStage::Stable, None, None, None), + Stage::Deprecated => (ExperimentalFeatureStage::Deprecated, None, None, None), + Stage::Removed => (ExperimentalFeatureStage::Removed, None, None, None), + }; + + ExperimentalFeature { + name: spec.key.to_string(), + stage, + display_name, + description, + announcement, + enabled: config.features.enabled(spec.id), + default_enabled: spec.default_enabled, + } + }) + .collect::>(); + let expected = ExperimentalFeatureListResponse { + data: expected_data, + next_cursor: None, + }; + + assert_eq!(actual, expected); + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_policy() +-> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) + .await?; + + let actual = read_response::(&mut mcp, request_id).await?; + let apps = actual + .data + .iter() + .find(|feature| feature.name == "apps") + .expect("apps feature should be present"); + let plugins = actual + .data + .iter() + .find(|feature| feature.name == "plugins") + .expect("plugins feature should be present"); + assert!(!apps.enabled); + assert!(!plugins.enabled); + assert!(apps.default_enabled); + assert!(plugins.default_enabled); + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() +-> Result<()> { + let codex_home = TempDir::new()?; + let project_cwd = codex_home.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + for cwd in [None, Some(project_cwd.display().to_string())] { + let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + } + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nmemories = false\n", + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("memories".to_string(), true)]), + ) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("memories".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("memories")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([ + ("memories".to_string(), true), + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), + ) + .await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([ + ("memories".to_string(), true), + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("memories")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("plugins")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_search")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_suggest")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_call_mcp_elicitation")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let remote_control_enabled = false; + let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]); + + let actual = set_experimental_feature_enablement(&mut mcp, enablement.clone()).await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { enablement } + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::new(), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("personality".to_string(), true)]), + }) + .await?; + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.code, -32600); + assert!( + error + .message + .contains("unsupported feature enablement `personality`"), + "{}", + error.message + ); + assert!( + error.message.contains( + "apps, memories, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" + ), + "{}", + error.message + ); + + Ok(()) +} + +async fn set_experimental_feature_enablement( + mcp: &mut McpProcess, + enablement: BTreeMap, +) -> Result { + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_config(mcp: &mut McpProcess, cwd: Option) -> Result { + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_response(mcp: &mut McpProcess, request_id: i64) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} diff --git a/code-rs/app-server/tests/suite/v2/external_agent_config.rs b/code-rs/app-server/tests/suite/v2/external_agent_config.rs new file mode 100644 index 00000000000..f5f74c0231b --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -0,0 +1,1016 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::ExternalAgentConfigDetectResponse; +use codex_app_server_protocol::ExternalAgentConfigImportResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::TempDir; +#[cfg(unix)] +use tokio::io::AsyncWriteExt; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio::test] +async fn external_agent_config_import_sends_completion_notification_for_sync_only_import() +-> Result<()> { + let codex_home = TempDir::new()?; + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "CONFIG", + "description": "Import config", + "cwd": null + }] + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_sends_completion_notification_for_local_plugins() -> Result<()> +{ + let codex_home = TempDir::new()?; + let marketplace_root = codex_home.path().join("marketplace"); + let plugin_root = marketplace_root.join("plugins").join("sample"); + std::fs::create_dir_all(marketplace_root.join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + marketplace_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample","version":"0.1.0"}"#, + )?; + std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let settings = serde_json::json!({ + "enabledPlugins": { + "sample@debug": true + }, + "extraKnownMarketplaces": { + "debug": { + "source": "local", + "path": marketplace_root, + } + } + }); + std::fs::write( + codex_home.path().join(".claude").join("settings.json"), + serde_json::to_string_pretty(&settings)?, + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "PLUGINS", + "description": "Import plugins", + "cwd": null, + "details": { + "plugins": [{ + "marketplaceName": "debug", + "pluginNames": ["sample"] + }] + } + }] + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + + assert_eq!(response, ExternalAgentConfigImportResponse {}); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + let plugin = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "debug") + .and_then(|marketplace| { + marketplace + .plugins + .iter() + .find(|plugin| plugin.name == "sample") + }) + .expect("expected imported plugin to be listed"); + assert!(plugin.installed); + assert!(plugin.enabled); + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_sends_completion_notification_after_pending_plugins_finish() +-> Result<()> { + let codex_home = TempDir::new()?; + std::fs::create_dir_all(codex_home.path().join(".claude"))?; + // This test only needs a pending non-local plugin import. Use an invalid + // source so the background completion path cannot make a real network clone. + std::fs::write( + codex_home.path().join(".claude").join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "not a valid marketplace source" + } + } +}"#, + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "PLUGINS", + "description": "Import plugins", + "cwd": null, + "details": { + "plugins": [{ + "marketplaceName": "acme-tools", + "pluginNames": ["formatter"] + }] + } + }] + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("follow-up answer").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + [ + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + serde_json::json!({ + "type": "assistant", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first answer" }, + }) + .to_string(), + serde_json::json!({ + "type": "custom-title", + "customTitle": "source session title", + }) + .to_string(), + ] + .join("\n"), + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/detect", + Some(serde_json::json!({ + "includeHome": true, + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + assert_eq!(detected.items.len(), 1); + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected.items })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + let thread = response + .data + .first() + .expect("expected imported thread") + .clone(); + assert_eq!(thread.preview, "first request"); + assert_eq!(thread.name.as_deref(), Some("source session title")); + + let request_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadReadResponse = to_response(response)?; + assert_eq!(response.thread.turns.len(), 1); + let items = &response.thread.turns[0].items; + assert_eq!(items.len(), 3); + assert_eq!( + items.last(), + Some(&ThreadItem::AgentMessage { + id: "item-3".into(), + text: "".into(), + phase: None, + memory_citation: None, + }) + ); + + let request_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadResumeResponse = to_response(response)?; + + let request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadReadResponse = to_response(response)?; + assert_eq!(response.thread.turns.len(), 2); + match &response.thread.turns[1].items[1] { + ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "follow-up answer"), + other => panic!("expected agent message item, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_agent_config_import_accepts_detected_session_payload_after_restart() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": session_path, + "cwd": project_root, + "title": "first request" + }] + } + }] + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data.len(), 1); + + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_skips_already_imported_session_versions() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/detect", + Some(serde_json::json!({ "includeHome": true })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + + for _ in 0..2 { + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected.items.clone() })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ExternalAgentConfigImportResponse = to_response(response)?; + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + } + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data.len(), 1); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_agent_config_import_returns_before_background_session_import_finishes() +-> Result<()> { + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + )?; + + let project_config_dir = project_root.join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + let project_config = project_config_dir.join("config.toml"); + let status = std::process::Command::new("mkfifo") + .arg(&project_config) + .status()?; + assert!(status.success()); + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/detect", + Some(serde_json::json!({ "includeHome": true })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + assert_eq!(detected.items.len(), 1); + let detected_items = detected.items; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected_items.clone() })), + ) + .await?; + let response: JSONRPCResponse = timeout( + Duration::from_secs(5), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + + assert!( + timeout( + Duration::from_millis(200), + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed") + ) + .await + .is_err(), + "session import completed before the blocked background import was unblocked" + ); + + let duplicate_request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected_items })), + ) + .await?; + let response: JSONRPCResponse = timeout( + Duration::from_secs(5), + mcp.read_stream_until_response_message(RequestId::Integer(duplicate_request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + + let writer = tokio::spawn(async move { + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .open(&project_config) + .await?; + file.write_all(b"\n").await + }); + timeout(DEFAULT_TIMEOUT, writer).await???; + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data.len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_agent_config_import_rejects_undetected_session_paths() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let detected_session_path = session_dir.join("detected.jsonl"); + let undetected_session_path = codex_home.path().join("outside.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + for path in [&detected_session_path, &undetected_session_path] { + std::fs::write( + path, + format!( + r#"{{"type":"user","cwd":"{}","timestamp":"{}","message":{{"content":"first request"}}}}"#, + project_root.display(), + recent_timestamp + ), + )?; + } + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": undetected_session_path, + "cwd": project_root, + "title": "first request" + }] + } + }] + })), + ) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); + assert!( + err.error + .message + .contains("external agent session was not detected for import") + ); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data, Vec::new()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_agent_config_import_compacts_huge_session_before_first_follow_up() -> Result<()> { + let server = responses::start_mock_server().await; + let response_log = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_assistant_message("m1", "LOCAL_SUMMARY"), + responses::ev_completed_with_tokens("r1", /*total_tokens*/ 120), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m2", "follow-up answer"), + responses::ev_completed_with_tokens("r2", /*total_tokens*/ 80), + ]), + ], + ) + .await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::default(), + /*auto_compact_limit*/ 200, + /*requires_openai_auth*/ None, + "mock_provider", + "Summarize the conversation.", + )?; + + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + let huge_user = "u".repeat(20_000); + let huge_assistant = "a".repeat(20_000); + std::fs::write( + &session_path, + [ + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": &huge_user }, + }) + .to_string(), + serde_json::json!({ + "type": "assistant", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": &huge_assistant }, + }) + .to_string(), + ] + .join("\n"), + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/detect", + Some(serde_json::json!({ + "includeHome": true, + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + assert_eq!(detected.items.len(), 1); + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected.items })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ExternalAgentConfigImportResponse = to_response(response)?; + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + let thread = response + .data + .first() + .expect("expected imported thread") + .clone(); + + let request_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadResumeResponse = to_response(response)?; + + let request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_log.requests(); + assert_eq!(requests.len(), 2); + let first = requests[0].body_json().to_string(); + let second = requests[1].body_json().to_string(); + assert!(first.contains("Summarize the conversation.")); + assert!(!first.contains("follow up")); + assert!(second.contains("follow up")); + assert!(second.contains("LOCAL_SUMMARY")); + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/fs.rs b/code-rs/app-server/tests/suite/v2/fs.rs new file mode 100644 index 00000000000..a780a51e0b8 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/fs.rs @@ -0,0 +1,859 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::FsChangedNotification; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadDirectoryEntry; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +#[cfg(unix)] +use std::os::unix::fs::symlink; +#[cfg(unix)] +use std::process::Command; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const OPTIONAL_FS_CHANGE_TIMEOUT: Duration = Duration::from_secs(2); + +async fn initialized_mcp(codex_home: &TempDir) -> Result { + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok(mcp) +} + +async fn expect_error_message( + mcp: &mut McpProcess, + request_id: i64, + expected_message: &str, +) -> Result<()> { + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.message, expected_message); + Ok(()) +} + +#[allow(clippy::expect_used)] +fn absolute_path(path: PathBuf) -> AbsolutePathBuf { + assert!( + path.is_absolute(), + "path must be absolute: {}", + path.display() + ); + AbsolutePathBuf::try_from(path).expect("path should be absolute") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { + let codex_home = TempDir::new()?; + let file_path = codex_home.path().join("note.txt"); + std::fs::write(&file_path, "hello")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_get_metadata_request(codex_app_server_protocol::FsGetMetadataParams { + path: absolute_path(file_path.clone()), + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let result = response + .result + .as_object() + .context("fs/getMetadata result should be an object")?; + let mut keys = result.keys().cloned().collect::>(); + keys.sort(); + assert_eq!( + keys, + vec![ + "createdAtMs".to_string(), + "isDirectory".to_string(), + "isFile".to_string(), + "isSymlink".to_string(), + "modifiedAtMs".to_string(), + ] + ); + + let stat: FsGetMetadataResponse = to_response(response)?; + assert_eq!( + stat, + FsGetMetadataResponse { + is_directory: false, + is_file: true, + is_symlink: false, + created_at_ms: stat.created_at_ms, + modified_at_ms: stat.modified_at_ms, + } + ); + assert!( + stat.modified_at_ms > 0, + "modifiedAtMs should be populated for existing files" + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_get_metadata_reports_symlink() -> Result<()> { + let codex_home = TempDir::new()?; + let file_path = codex_home.path().join("note.txt"); + let symlink_path = codex_home.path().join("note-link.txt"); + std::fs::write(&file_path, "hello")?; + symlink(&file_path, &symlink_path)?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_get_metadata_request(codex_app_server_protocol::FsGetMetadataParams { + path: absolute_path(symlink_path), + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let stat: FsGetMetadataResponse = to_response(response)?; + assert_eq!(stat.is_directory, false); + assert_eq!(stat.is_file, true); + assert_eq!(stat.is_symlink, true); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { + let codex_home = TempDir::new()?; + let source_dir = codex_home.path().join("source"); + let nested_dir = source_dir.join("nested"); + let source_file = source_dir.join("root.txt"); + let copied_dir = codex_home.path().join("copied"); + let copy_file_path = codex_home.path().join("copy.txt"); + let nested_file = nested_dir.join("note.txt"); + + let mut mcp = initialized_mcp(&codex_home).await?; + + let create_directory_request_id = mcp + .send_fs_create_directory_request(codex_app_server_protocol::FsCreateDirectoryParams { + path: absolute_path(nested_dir.clone()), + recursive: None, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(create_directory_request_id)), + ) + .await??; + + let write_request_id = mcp + .send_fs_write_file_request(FsWriteFileParams { + path: absolute_path(nested_file.clone()), + data_base64: STANDARD.encode("hello from app-server"), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_request_id)), + ) + .await??; + + let root_write_request_id = mcp + .send_fs_write_file_request(FsWriteFileParams { + path: absolute_path(source_file.clone()), + data_base64: STANDARD.encode("hello from source root"), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(root_write_request_id)), + ) + .await??; + + let read_request_id = mcp + .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { + path: absolute_path(nested_file.clone()), + }) + .await?; + let read_response: FsReadFileResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)), + ) + .await??, + )?; + assert_eq!( + read_response, + FsReadFileResponse { + data_base64: STANDARD.encode("hello from app-server"), + } + ); + + let copy_file_request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(nested_file.clone()), + destination_path: absolute_path(copy_file_path.clone()), + recursive: false, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(copy_file_request_id)), + ) + .await??; + assert_eq!( + std::fs::read_to_string(©_file_path)?, + "hello from app-server" + ); + + let copy_dir_request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(source_dir.clone()), + destination_path: absolute_path(copied_dir.clone()), + recursive: true, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(copy_dir_request_id)), + ) + .await??; + assert_eq!( + std::fs::read_to_string(copied_dir.join("nested").join("note.txt"))?, + "hello from app-server" + ); + + let read_directory_request_id = mcp + .send_fs_read_directory_request(codex_app_server_protocol::FsReadDirectoryParams { + path: absolute_path(source_dir.clone()), + }) + .await?; + let readdir_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_directory_request_id)), + ) + .await??; + let mut entries = + to_response::(readdir_response)? + .entries; + entries.sort_by(|left, right| left.file_name.cmp(&right.file_name)); + assert_eq!( + entries, + vec![ + FsReadDirectoryEntry { + file_name: "nested".to_string(), + is_directory: true, + is_file: false, + }, + FsReadDirectoryEntry { + file_name: "root.txt".to_string(), + is_directory: false, + is_file: true, + }, + ] + ); + + let remove_request_id = mcp + .send_fs_remove_request(codex_app_server_protocol::FsRemoveParams { + path: absolute_path(copied_dir.clone()), + recursive: None, + force: None, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(remove_request_id)), + ) + .await??; + assert!( + !copied_dir.exists(), + "fs/remove should default to recursive+force for directory trees" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_write_file_accepts_base64_bytes() -> Result<()> { + let codex_home = TempDir::new()?; + let file_path = codex_home.path().join("blob.bin"); + let bytes = [0_u8, 1, 2, 255]; + + let mut mcp = initialized_mcp(&codex_home).await?; + let write_request_id = mcp + .send_fs_write_file_request(FsWriteFileParams { + path: absolute_path(file_path.clone()), + data_base64: STANDARD.encode(bytes), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_request_id)), + ) + .await??; + assert_eq!(std::fs::read(&file_path)?, bytes); + + let read_request_id = mcp + .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { + path: absolute_path(file_path), + }) + .await?; + let read_response: FsReadFileResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)), + ) + .await??, + )?; + assert_eq!( + read_response, + FsReadFileResponse { + data_base64: STANDARD.encode(bytes), + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_write_file_rejects_invalid_base64() -> Result<()> { + let codex_home = TempDir::new()?; + let file_path = codex_home.path().join("blob.bin"); + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_write_file_request(FsWriteFileParams { + path: absolute_path(file_path), + data_base64: "%%%".to_string(), + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert!( + error + .error + .message + .starts_with("fs/writeFile requires valid base64 dataBase64:"), + "unexpected error message: {}", + error.error.message + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_methods_reject_relative_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let absolute_file = codex_home.path().join("absolute.txt"); + std::fs::write(&absolute_file, "hello")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + + let read_id = mcp + .send_raw_request("fs/readFile", Some(json!({ "path": "relative.txt" }))) + .await?; + expect_error_message( + &mut mcp, + read_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + let write_id = mcp + .send_raw_request( + "fs/writeFile", + Some(json!({ + "path": "relative.txt", + "dataBase64": STANDARD.encode("hello"), + })), + ) + .await?; + expect_error_message( + &mut mcp, + write_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + let create_directory_id = mcp + .send_raw_request( + "fs/createDirectory", + Some(json!({ + "path": "relative-dir", + "recursive": null, + })), + ) + .await?; + expect_error_message( + &mut mcp, + create_directory_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + let get_metadata_id = mcp + .send_raw_request("fs/getMetadata", Some(json!({ "path": "relative.txt" }))) + .await?; + expect_error_message( + &mut mcp, + get_metadata_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + let read_directory_id = mcp + .send_raw_request("fs/readDirectory", Some(json!({ "path": "relative-dir" }))) + .await?; + expect_error_message( + &mut mcp, + read_directory_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + let remove_id = mcp + .send_raw_request( + "fs/remove", + Some(json!({ + "path": "relative.txt", + "recursive": null, + "force": null, + })), + ) + .await?; + expect_error_message( + &mut mcp, + remove_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + let copy_source_id = mcp + .send_raw_request( + "fs/copy", + Some(json!({ + "sourcePath": "relative.txt", + "destinationPath": absolute_file.clone(), + "recursive": false, + })), + ) + .await?; + expect_error_message( + &mut mcp, + copy_source_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + let copy_destination_id = mcp + .send_raw_request( + "fs/copy", + Some(json!({ + "sourcePath": absolute_file, + "destinationPath": "relative-copy.txt", + "recursive": false, + })), + ) + .await?; + expect_error_message( + &mut mcp, + copy_destination_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_copy_rejects_directory_without_recursive() -> Result<()> { + let codex_home = TempDir::new()?; + let source_dir = codex_home.path().join("source"); + std::fs::create_dir_all(&source_dir)?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(source_dir), + destination_path: absolute_path(codex_home.path().join("dest")), + recursive: false, + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + error.error.message, + "fs/copy requires recursive: true when sourcePath is a directory" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_copy_rejects_copying_directory_into_descendant() -> Result<()> { + let codex_home = TempDir::new()?; + let source_dir = codex_home.path().join("source"); + std::fs::create_dir_all(source_dir.join("nested"))?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(source_dir.clone()), + destination_path: absolute_path(source_dir.join("nested").join("copy")), + recursive: true, + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + error.error.message, + "fs/copy cannot copy a directory to itself or one of its descendants" + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_copy_preserves_symlinks_in_recursive_copy() -> Result<()> { + let codex_home = TempDir::new()?; + let source_dir = codex_home.path().join("source"); + let nested_dir = source_dir.join("nested"); + let copied_dir = codex_home.path().join("copied"); + std::fs::create_dir_all(&nested_dir)?; + symlink("nested", source_dir.join("nested-link"))?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(source_dir), + destination_path: absolute_path(copied_dir.clone()), + recursive: true, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let copied_link = copied_dir.join("nested-link"); + let metadata = std::fs::symlink_metadata(&copied_link)?; + assert!(metadata.file_type().is_symlink()); + assert_eq!(std::fs::read_link(copied_link)?, PathBuf::from("nested")); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_copy_ignores_unknown_special_files_in_recursive_copy() -> Result<()> { + let codex_home = TempDir::new()?; + let source_dir = codex_home.path().join("source"); + let copied_dir = codex_home.path().join("copied"); + std::fs::create_dir_all(&source_dir)?; + std::fs::write(source_dir.join("note.txt"), "hello")?; + let fifo_path = source_dir.join("named-pipe"); + let output = Command::new("mkfifo").arg(&fifo_path).output()?; + if !output.status.success() { + anyhow::bail!( + "mkfifo failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(source_dir), + destination_path: absolute_path(copied_dir.clone()), + recursive: true, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + std::fs::read_to_string(copied_dir.join("note.txt"))?, + "hello" + ); + assert!(!copied_dir.join("named-pipe").exists()); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> { + let codex_home = TempDir::new()?; + let fifo_path = codex_home.path().join("named-pipe"); + let output = Command::new("mkfifo").arg(&fifo_path).output()?; + if !output.status.success() { + anyhow::bail!( + "mkfifo failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let mut mcp = initialized_mcp(&codex_home).await?; + let request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(fifo_path), + destination_path: absolute_path(codex_home.path().join("copied")), + recursive: false, + }) + .await?; + expect_error_message( + &mut mcp, + request_id, + "fs/copy only supports regular files, directories, and symlinks", + ) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications() +-> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let fetch_head = git_dir.join("FETCH_HEAD"); + std::fs::create_dir_all(&git_dir)?; + std::fs::write(&fetch_head, "old\n")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_id = "watch-git-dir".to_string(); + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + watch_id: watch_id.clone(), + path: absolute_path(git_dir.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(git_dir.clone())); + + std::fs::write(&fetch_head, "updated\n")?; + + // Kernel file watching is not reliable in every sandboxed test environment. + // Keep validating notification shape when the backend does emit, but do not + // fail the whole suite if no OS event arrives. + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!(changed.watch_id, watch_id.clone()); + assert_eq!( + changed.changed_paths, + vec![absolute_path(fetch_head.clone())] + ); + } + while timeout( + Duration::from_millis(200), + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await + .is_ok() + {} + + let unwatch_request_id = mcp + .send_fs_unwatch_request(FsUnwatchParams { watch_id }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unwatch_request_id)), + ) + .await??; + + std::fs::write(git_dir.join("packed-refs"), "refs\n")?; + let maybe_notification = timeout( + Duration::from_millis(1500), + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await; + assert!( + maybe_notification.is_err(), + "fs/unwatch should stop future change notifications" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let head_path = git_dir.join("HEAD"); + std::fs::create_dir_all(&git_dir)?; + std::fs::write(&head_path, "ref: refs/heads/main\n")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_id = "watch-head".to_string(); + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + watch_id: watch_id.clone(), + path: absolute_path(head_path.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(head_path.clone())); + + replace_file_atomically(&head_path, "ref: refs/heads/feature\n")?; + + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!( + changed, + FsChangedNotification { + watch_id, + changed_paths: vec![absolute_path(head_path.clone())], + } + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_allows_missing_file_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let fetch_head = git_dir.join("FETCH_HEAD"); + std::fs::create_dir_all(&git_dir)?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_id = "watch-fetch-head".to_string(); + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + watch_id: watch_id.clone(), + path: absolute_path(fetch_head.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(fetch_head.clone())); + + replace_file_atomically(&fetch_head, "origin/main\n")?; + + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!( + changed, + FsChangedNotification { + watch_id, + changed_paths: vec![absolute_path(fetch_head.clone())], + } + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_rejects_relative_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let watch_id = mcp + .send_raw_request( + "fs/watch", + Some(json!({ "watchId": "watch-relative", "path": "relative-path" })), + ) + .await?; + expect_error_message( + &mut mcp, + watch_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + Ok(()) +} + +fn fs_changed_notification(notification: JSONRPCNotification) -> Result { + let params = notification + .params + .context("fs/changed notification should include params")?; + Ok(serde_json::from_value::(params)?) +} + +async fn maybe_fs_changed_notification( + mcp: &mut McpProcess, +) -> Result> { + match timeout( + OPTIONAL_FS_CHANGE_TIMEOUT, + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await + { + Ok(notification) => Ok(Some(fs_changed_notification(notification?)?)), + Err(_) => Ok(None), + } +} + +fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> { + let temp_path = path.with_extension("lock"); + std::fs::write(&temp_path, contents)?; + + #[cfg(windows)] + match std::fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + std::fs::rename(temp_path, path)?; + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/hooks_list.rs b/code-rs/app-server/tests/suite/v2/hooks_list.rs new file mode 100644 index 00000000000..623896626c8 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/hooks_list.rs @@ -0,0 +1,906 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookHandlerType; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HookTrustStatus; +use codex_app_server_protocol::HooksListEntry; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::skip_if_windows; +use pretty_assertions::assert_eq; +use serde::Serialize; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Serialize)] +struct NormalizedHookIdentity { + event_name: &'static str, + #[serde(flatten)] + group: codex_config::MatcherGroup, +} + +fn command_hook_hash( + event_name: &'static str, + matcher: Option<&str>, + command: &str, + timeout_sec: u64, + status_message: Option<&str>, +) -> String { + let identity = NormalizedHookIdentity { + event_name, + group: codex_config::MatcherGroup { + matcher: matcher.map(ToOwned::to_owned), + hooks: vec![codex_config::HookHandlerConfig::Command { + command: command.to_string(), + timeout_sec: Some(timeout_sec), + r#async: false, + status_message: status_message.map(ToOwned::to_owned), + }], + }, + }; + let Ok(value) = codex_config::TomlValue::try_from(identity) else { + unreachable!("normalized hook identity should serialize to TOML"); + }; + codex_config::version_for_toml(&value) +} + +fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + r#"[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/listed-hook.py" +timeout = 5 +statusMessage = "running listed hook" +"#, + )?; + Ok(()) +} + +fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> Result<()> { + let plugin_root = codex_home.join("plugins/cache/test/demo/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("hooks"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"demo"}"#, + )?; + std::fs::write(plugin_root.join("hooks/hooks.json"), hooks_json)?; + std::fs::write( + codex_home.join("config.toml"), + r#"[features] +plugins = true +plugin_hooks = true +hooks = true + +[plugins."demo@test"] +enabled = true +"#, + )?; + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_discovered_hook() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_user_hook_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let config_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( + codex_home.path().join("config.toml"), + )?)?; + assert_eq!( + data, + vec![HooksListEntry { + cwd: cwd.path().to_path_buf(), + hooks: vec![HookMetadata { + key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()), + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("python3 /tmp/listed-hook.py".to_string()), + timeout_sec: 5, + status_message: Some("running listed hook".to_string()), + source_path: config_path, + source: HookSource::User, + plugin_id: None, + display_order: 0, + enabled: true, + is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "python3 /tmp/listed-hook.py", + /*timeout_sec*/ 5, + Some("running listed hook"), + ), + trust_status: HookTrustStatus::Untrusted, + }], + warnings: Vec::new(), + errors: Vec::new(), + }] + ); + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config( + codex_home.path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo plugin hook", + "timeout": 7, + "statusMessage": "running plugin hook" + } + ] + } + ] + } +}"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let plugin_hooks_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( + codex_home + .path() + .join("plugins/cache/test/demo/local/hooks/hooks.json"), + )?)?; + assert_eq!( + data, + vec![HooksListEntry { + cwd: cwd.path().to_path_buf(), + hooks: vec![HookMetadata { + key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("echo plugin hook".to_string()), + timeout_sec: 7, + status_message: Some("running plugin hook".to_string()), + source_path: plugin_hooks_path, + source: HookSource::Plugin, + plugin_id: Some("demo@test".to_string()), + display_order: 0, + enabled: true, + is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "echo plugin hook", + /*timeout_sec*/ 7, + Some("running plugin hook"), + ), + trust_status: HookTrustStatus::Untrusted, + }], + warnings: Vec::new(), + errors: Vec::new(), + }] + ); + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config(codex_home.path(), "{ not-json")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].hooks, Vec::new()); + assert_eq!(data[0].warnings.len(), 1); + assert!( + data[0].warnings[0].contains("failed to parse plugin hooks config"), + "unexpected warnings: {:?}", + data[0].warnings + ); + Ok(()) +} + +#[tokio::test] +async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +hooks = false +"#, + )?; + std::fs::create_dir_all(workspace.path().join(".git"))?; + std::fs::create_dir_all(workspace.path().join(".codex"))?; + std::fs::write( + workspace.path().join(".codex/config.toml"), + r#"[features] +hooks = true + +[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "echo project hook" +timeout = 5 +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![ + codex_home.path().to_path_buf(), + workspace.path().to_path_buf(), + ], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let project_config_path = + AbsolutePathBuf::try_from(workspace.path().join(".codex/config.toml"))?; + assert_eq!( + data, + vec![ + HooksListEntry { + cwd: codex_home.path().to_path_buf(), + hooks: Vec::new(), + warnings: Vec::new(), + errors: Vec::new(), + }, + HooksListEntry { + cwd: workspace.path().to_path_buf(), + hooks: vec![HookMetadata { + key: format!( + "{}:pre_tool_use:0:0", + project_config_path.as_path().display() + ), + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("echo project hook".to_string()), + timeout_sec: 5, + status_message: None, + source_path: project_config_path, + source: HookSource::Project, + plugin_id: None, + display_order: 0, + enabled: true, + is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "echo project hook", + /*timeout_sec*/ 5, + /*status_message*/ None, + ), + trust_status: HookTrustStatus::Untrusted, + }], + warnings: Vec::new(), + errors: Vec::new(), + }, + ] + ); + Ok(()) +} + +#[tokio::test] +async fn config_batch_write_toggles_user_hook() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_user_hook_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let hook = &data[0].hooks[0]; + assert_eq!(hook.enabled, true); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "enabled": false + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data[0].hooks.len(), 1); + assert_eq!(data[0].hooks[0].key, hook.key); + assert_eq!(data[0].hooks[0].enabled, false); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "enabled": true + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data[0].hooks[0].enabled, true); + Ok(()) +} + +#[tokio::test] +async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> { + skip_if_windows!(Ok(())); + + let responses = vec![ + create_final_assistant_message_sse_response("Warmup")?, + create_final_assistant_message_sse_response("Untrusted turn")?, + create_final_assistant_message_sse_response("Trusted turn")?, + create_final_assistant_message_sse_response("Modified turn")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py"); + let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl"); + std::fs::write( + &hook_script_path, + format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + hook_log_path = hook_log_path.display(), + ), + )?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[hooks] + +[[hooks.UserPromptSubmit]] + +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "python3 {hook_script_path}" +"#, + server_uri = server.uri(), + hook_script_path = hook_script_path.display(), + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let hook = data[0].hooks[0].clone(); + assert_eq!(hook.trust_status, HookTrustStatus::Untrusted); + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(response)?; + + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert!(!std::fs::exists(&hook_log_path)?); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "trusted_hash": hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let trusted_hook = &data[0].hooks[0]; + assert_eq!(trusted_hook.key, hook.key); + assert_eq!(trusted_hook.current_hash, hook.current_hash); + assert_eq!(trusted_hook.trust_status, HookTrustStatus::Trusted); + + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.UserPromptSubmit".to_string(), + value: serde_json::json!([{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", hook_script_path.display()), + "statusMessage": "modified hook", + }], + }]), + merge_strategy: MergeStrategy::Replace, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let modified_hook = &data[0].hooks[0]; + assert_eq!(modified_hook.key, hook.key); + assert_ne!(modified_hook.current_hash, hook.current_hash); + assert_eq!(modified_hook.trust_status, HookTrustStatus::Modified); + + let third_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "third turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(third_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + Ok(()) +} + +#[tokio::test] +async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> { + skip_if_windows!(Ok(())); + + let responses = vec![ + create_final_assistant_message_sse_response("Warmup")?, + create_final_assistant_message_sse_response("First turn")?, + create_final_assistant_message_sse_response("Second turn")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py"); + let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl"); + std::fs::write( + &hook_script_path, + format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + hook_log_path = hook_log_path.display(), + ), + )?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[hooks] + +[[hooks.UserPromptSubmit]] + +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "python3 {hook_script_path}" +"#, + server_uri = server.uri(), + hook_script_path = hook_script_path.display(), + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let hook = &data[0].hooks[0]; + assert_eq!(hook.enabled, true); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "trusted_hash": hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(response)?; + + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "enabled": false + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/initialize.rs b/code-rs/app-server/tests/suite/v2/initialize.rs new file mode 100644 index 00000000000..165160468f7 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/initialize.rs @@ -0,0 +1,326 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cargo_bin::cargo_bin; +use core_test_support::fs_wait; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn initialize_uses_client_info_name_as_originator() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "codex_vscode".to_string(), + title: Some("Codex VS Code Extension".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Response(response) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + let InitializeResponse { + user_agent, + codex_home: response_codex_home, + platform_family, + platform_os, + } = to_response::(response)?; + + assert!(user_agent.starts_with("codex_vscode/")); + assert_eq!(response_codex_home, expected_codex_home); + assert_eq!(platform_family, std::env::consts::FAMILY); + assert_eq!(platform_os, std::env::consts::OS); + Ok(()) +} + +#[tokio::test] +async fn initialize_respects_originator_override_env_var() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[( + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", + Some("codex_originator_via_env_var"), + )], + ) + .await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "codex_vscode".to_string(), + title: Some("Codex VS Code Extension".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Response(response) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + let InitializeResponse { + user_agent, + codex_home: response_codex_home, + platform_family, + platform_os, + } = to_response::(response)?; + + assert!(user_agent.starts_with("codex_originator_via_env_var/")); + assert_eq!(response_codex_home, expected_codex_home); + assert_eq!(platform_family, std::env::consts::FAMILY); + assert_eq!(platform_os, std::env::consts::OS); + Ok(()) +} + +#[tokio::test] +async fn initialize_rejects_invalid_client_name() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)], + ) + .await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "bad\rname".to_string(), + title: Some("Bad Client".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Error(error) = message else { + anyhow::bail!("expected initialize error, got {message:?}"); + }; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "Invalid clientInfo.name: 'bad\rname'. Must be a valid HTTP header value." + ); + assert_eq!(error.error.data, None); + Ok(()) +} + +#[tokio::test] +async fn initialize_opt_out_notification_methods_filters_notifications() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex_vscode".to_string(), + title: Some("Codex VS Code Extension".to_string()), + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + opt_out_notification_methods: Some(vec!["thread/started".to_string()]), + }), + ), + ) + .await??; + let JSONRPCMessage::Response(_) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let response = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let message = mcp.read_next_message().await?; + match message { + JSONRPCMessage::Response(response) + if response.id == RequestId::Integer(request_id) => + { + return Ok(response); + } + JSONRPCMessage::Notification(notification) + if notification.method == "thread/started" => + { + anyhow::bail!("thread/started should be filtered by optOutNotificationMethods"); + } + _ => {} + } + } + }) + .await??; + let _: ThreadStartResponse = to_response(response)?; + + let thread_started = timeout( + std::time::Duration::from_millis(500), + mcp.read_stream_until_notification_message("thread/started"), + ) + .await; + assert!( + thread_started.is_err(), + "thread/started should be filtered by optOutNotificationMethods" + ); + Ok(()) +} + +#[tokio::test] +async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let notify_file = codex_home.path().join("notify.json"); + let notify_capture = cargo_bin("codex-app-server-test-notify-capture")?; + let notify_capture = notify_capture + .to_str() + .expect("notify capture path should be valid UTF-8"); + let notify_file_str = notify_file + .to_str() + .expect("notify file path should be valid UTF-8"); + create_config_toml_with_extra( + codex_home.path(), + &server.uri(), + "never", + &format!( + "notify = [{}, {}]", + toml_basic_string(notify_capture), + toml_basic_string(notify_file_str) + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "xcode".to_string(), + title: Some("Xcode".to_string()), + version: "1.0.0".to_string(), + }), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + let payload_raw = tokio::fs::read_to_string(¬ify_file).await?; + let payload: Value = serde_json::from_str(&payload_raw)?; + assert_eq!(payload["client"], "xcode"); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { + create_config_toml_with_extra(codex_home, server_uri, approval_policy, "") +} + +fn create_config_toml_with_extra( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, + extra: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +{extra} + +[features] +shell_snapshot = false + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn toml_basic_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} diff --git a/code-rs/app-server/tests/suite/v2/marketplace_add.rs b/code-rs/app-server/tests/suite/v2/marketplace_add.rs new file mode 100644 index 00000000000..5f470f617db --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/marketplace_add.rs @@ -0,0 +1,62 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::MarketplaceAddResponse; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn marketplace_add_local_directory_source() -> Result<()> { + let codex_home = TempDir::new()?; + let source = codex_home.path().join("marketplace"); + std::fs::create_dir_all(source.join(".agents/plugins"))?; + std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; + std::fs::write( + source.join(".agents/plugins/marketplace.json"), + r#"{"name":"debug","plugins":[]}"#, + )?; + std::fs::write( + source.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write(source.join("plugins/sample/marker.txt"), "local ref")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_marketplace_add_request(MarketplaceAddParams { + source: "./marketplace".to_string(), + ref_name: None, + sparse_paths: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let MarketplaceAddResponse { + marketplace_name, + installed_root, + already_added, + } = to_response(response)?; + let expected_root = AbsolutePathBuf::from_absolute_path(source.canonicalize()?)?; + + assert_eq!(marketplace_name, "debug"); + assert_eq!(installed_root, expected_root); + assert!(!already_added); + assert_eq!( + std::fs::read_to_string(installed_root.as_path().join("plugins/sample/marker.txt"))?, + "local ref" + ); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/marketplace_remove.rs b/code-rs/app-server/tests/suite/v2/marketplace_remove.rs new file mode 100644 index 00000000000..dc438499f37 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/marketplace_remove.rs @@ -0,0 +1,115 @@ +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MarketplaceRemoveParams; +use codex_app_server_protocol::MarketplaceRemoveResponse; +use codex_app_server_protocol::RequestId; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +fn configured_marketplace_update() -> MarketplaceConfigUpdate<'static> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + } +} + +fn write_installed_marketplace(codex_home: &std::path::Path, marketplace_name: &str) -> Result<()> { + let root = marketplace_install_root(codex_home).join(marketplace_name); + std::fs::create_dir_all(root.join(".agents/plugins"))?; + std::fs::write(root.join(".agents/plugins/marketplace.json"), "{}")?; + Ok(()) +} + +fn canonicalize_path_with_existing_parent(path: &std::path::Path) -> Result { + let parent = path + .parent() + .with_context(|| format!("path {} should have a parent", path.display()))?; + let file_name = path + .file_name() + .with_context(|| format!("path {} should have a file name", path.display()))?; + + Ok(parent.canonicalize()?.join(file_name)) +} + +#[tokio::test] +async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_marketplace_remove_request(MarketplaceRemoveParams { + marketplace_name: "debug".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: MarketplaceRemoveResponse = to_response(response)?; + assert_eq!(response.marketplace_name, "debug"); + let removed_installed_root = response + .installed_root + .context("marketplace/remove should return removed installed root")?; + assert_eq!( + canonicalize_path_with_existing_parent(removed_installed_root.as_path())?, + canonicalize_path_with_existing_parent(&installed_root)?, + ); + + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains("[marketplaces.debug]")); + assert!( + !marketplace_install_root(codex_home.path()) + .join("debug") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_marketplace_remove_request(MarketplaceRemoveParams { + marketplace_name: "debug".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert_eq!( + err.error.message, + "marketplace `debug` is not configured or installed", + ); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/marketplace_upgrade.rs b/code-rs/app-server/tests/suite/v2/marketplace_upgrade.rs new file mode 100644 index 00000000000..8660497da50 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/marketplace_upgrade.rs @@ -0,0 +1,318 @@ +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MarketplaceUpgradeParams; +use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::RequestId; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +#[cfg(windows)] +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces"; + +fn run_git(cwd: &Path, args: &[&str]) -> Result { + let output = Command::new("git").current_dir(cwd).args(args).output()?; + if !output.status.success() { + anyhow::bail!( + "git {} failed in {}: {}", + args.join(" "), + cwd.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn write_marketplace_files(root: &Path, marketplace_name: &str, marker: &str) -> Result<()> { + std::fs::create_dir_all(root.join(".agents/plugins"))?; + std::fs::write( + root.join(".agents/plugins/marketplace.json"), + format!(r#"{{"name":"{marketplace_name}","plugins":[]}}"#), + )?; + std::fs::write(root.join("marker.txt"), marker)?; + Ok(()) +} + +fn init_marketplace_repo(root: &Path, marketplace_name: &str, marker: &str) -> Result { + run_git(root, &["init"])?; + run_git(root, &["config", "user.email", "codex@example.com"])?; + run_git(root, &["config", "user.name", "Codex Tests"])?; + write_marketplace_files(root, marketplace_name, marker)?; + run_git(root, &["add", "."])?; + run_git(root, &["commit", "-m", "initial marketplace"])?; + run_git(root, &["rev-parse", "HEAD"]) +} + +fn commit_marketplace_marker(root: &Path, marker: &str) -> Result { + std::fs::write(root.join("marker.txt"), marker)?; + run_git(root, &["add", "marker.txt"])?; + run_git(root, &["commit", "-m", "update marker"])?; + run_git(root, &["rev-parse", "HEAD"]) +} + +fn configured_git_marketplace_update<'a>( + source: &'a str, + last_revision: Option<&'a str>, + ref_name: Option<&'a str>, +) -> MarketplaceConfigUpdate<'a> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision, + source_type: "git", + source, + ref_name, + sparse_paths: &[], + } +} + +fn configured_local_marketplace_update(source: &str) -> MarketplaceConfigUpdate<'_> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "local", + source, + ref_name: None, + sparse_paths: &[], + } +} + +fn record_git_marketplace( + codex_home: &Path, + marketplace_name: &str, + source: &Path, + last_revision: &str, + ref_name: Option<&str>, +) -> Result<()> { + let source = source.display().to_string(); + record_user_marketplace( + codex_home, + marketplace_name, + &configured_git_marketplace_update(&source, Some(last_revision), ref_name), + )?; + Ok(()) +} + +fn disable_plugin_startup_tasks(codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[features]\nplugins = false\n"), + )?; + Ok(()) +} + +fn marketplace_install_root(codex_home: &Path) -> std::path::PathBuf { + codex_home.join(INSTALLED_MARKETPLACES_DIR) +} + +fn expected_installed_root(codex_home: &Path, marketplace_name: &str) -> Result { + AbsolutePathBuf::try_from( + marketplace_install_root(&codex_home.canonicalize()?).join(marketplace_name), + ) + .context("expected installed root should be absolute") +} + +async fn send_marketplace_upgrade( + mcp: &mut McpProcess, + marketplace_name: Option<&str>, +) -> Result { + let request_id = mcp + .send_marketplace_upgrade_request(MarketplaceUpgradeParams { + marketplace_name: marketplace_name.map(str::to_string), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +#[tokio::test] +async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> { + let codex_home = TempDir::new()?; + let debug_source = TempDir::new()?; + let tools_source = TempDir::new()?; + let debug_old_revision = init_marketplace_repo(debug_source.path(), "debug", "debug old")?; + let tools_old_revision = init_marketplace_repo(tools_source.path(), "tools", "tools old")?; + let debug_new_revision = commit_marketplace_marker(debug_source.path(), "debug new")?; + let tools_new_revision = commit_marketplace_marker(tools_source.path(), "tools new")?; + record_git_marketplace( + codex_home.path(), + "debug", + debug_source.path(), + &debug_old_revision, + Some(&debug_new_revision), + )?; + record_git_marketplace( + codex_home.path(), + "tools", + tools_source.path(), + &tools_old_revision, + Some(&tools_new_revision), + )?; + disable_plugin_startup_tasks(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let debug_root = expected_installed_root(codex_home.path(), "debug")?; + let tools_root = expected_installed_root(codex_home.path(), "tools")?; + let response = send_marketplace_upgrade(&mut mcp, /*marketplace_name*/ None).await?; + + assert_eq!( + response, + MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string(), "tools".to_string()], + upgraded_roots: vec![debug_root.clone(), tools_root.clone()], + errors: Vec::new(), + } + ); + assert_eq!( + std::fs::read_to_string(debug_root.as_path().join("marker.txt"))?, + "debug new" + ); + assert_eq!( + std::fs::read_to_string(tools_root.as_path().join("marker.txt"))?, + "tools new" + ); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains(&debug_new_revision)); + assert!(config.contains(&tools_new_revision)); + Ok(()) +} + +#[tokio::test] +async fn marketplace_upgrade_named_marketplace_only() -> Result<()> { + let codex_home = TempDir::new()?; + let debug_source = TempDir::new()?; + let tools_source = TempDir::new()?; + let debug_old_revision = init_marketplace_repo(debug_source.path(), "debug", "debug old")?; + let tools_old_revision = init_marketplace_repo(tools_source.path(), "tools", "tools old")?; + commit_marketplace_marker(debug_source.path(), "debug new")?; + commit_marketplace_marker(tools_source.path(), "tools new")?; + record_git_marketplace( + codex_home.path(), + "debug", + debug_source.path(), + &debug_old_revision, + /*ref_name*/ None, + )?; + record_git_marketplace( + codex_home.path(), + "tools", + tools_source.path(), + &tools_old_revision, + /*ref_name*/ None, + )?; + disable_plugin_startup_tasks(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let tools_root = expected_installed_root(codex_home.path(), "tools")?; + let response = send_marketplace_upgrade(&mut mcp, Some("tools")).await?; + + assert_eq!( + response, + MarketplaceUpgradeResponse { + selected_marketplaces: vec!["tools".to_string()], + upgraded_roots: vec![tools_root.clone()], + errors: Vec::new(), + } + ); + assert_eq!( + std::fs::read_to_string(tools_root.as_path().join("marker.txt"))?, + "tools new" + ); + assert!( + !marketplace_install_root(codex_home.path()) + .join("debug") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + let old_revision = init_marketplace_repo(source.path(), "debug", "debug old")?; + commit_marketplace_marker(source.path(), "debug new")?; + record_git_marketplace( + codex_home.path(), + "debug", + source.path(), + &old_revision, + /*ref_name*/ None, + )?; + disable_plugin_startup_tasks(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let first_response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?; + assert!(first_response.errors.is_empty()); + + let response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?; + + assert_eq!( + response, + MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string()], + upgraded_roots: Vec::new(), + errors: Vec::new(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_upgrade_rejects_unknown_or_non_git_marketplace() -> Result<()> { + let codex_home = TempDir::new()?; + let local_source = TempDir::new()?; + record_user_marketplace( + codex_home.path(), + "local-only", + &configured_local_marketplace_update(&local_source.path().display().to_string()), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + for marketplace_name in ["missing", "local-only"] { + let request_id = mcp + .send_marketplace_upgrade_request(MarketplaceUpgradeParams { + marketplace_name: Some(marketplace_name.to_string()), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert_eq!( + err.error.message, + format!("marketplace `{marketplace_name}` is not configured as a Git marketplace"), + ); + } + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/mcp_resource.rs b/code-rs/app-server/tests/suite/v2/mcp_resource.rs new file mode 100644 index 00000000000..a51f4bbd4e0 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -0,0 +1,327 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Router; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpResourceContent; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::ProtocolVersion; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use rmcp::model::ResourceContents; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const TEST_RESOURCE_URI: &str = "test://codex/resource"; +const TEST_BLOB_RESOURCE_URI: &str = "test://codex/resource.bin"; +const TEST_RESOURCE_BLOB: &str = "YmluYXJ5LXJlc291cmNl"; +const TEST_RESOURCE_TEXT: &str = "Resource body from the MCP server."; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_resource_read_returns_resource_contents() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?; + + let codex_home = TempDir::new()?; + let responses_server_uri = responses_server.uri(); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +chatgpt_base_url = "{apps_server_url}" +mcp_oauth_credentials_store = "file" + +[features] +apps = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{responses_server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let read_request_id = mcp + .send_mcp_resource_read_request(McpResourceReadParams { + thread_id: Some(thread.id), + server: "codex_apps".to_string(), + uri: TEST_RESOURCE_URI.to_string(), + }) + .await?; + let read_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)), + ) + .await??; + + assert_eq!( + to_response::(read_response)?, + expected_resource_read_response() + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_resource_read_returns_resource_contents_without_thread() -> Result<()> { + let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?; + + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{apps_server_url}" +mcp_oauth_credentials_store = "file" + +[features] +apps = true +"# + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_request_id = mcp + .send_mcp_resource_read_request(McpResourceReadParams { + thread_id: None, + server: "codex_apps".to_string(), + uri: TEST_RESOURCE_URI.to_string(), + }) + .await?; + let read_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)), + ) + .await??; + + assert_eq!( + to_response::(read_response)?, + expected_resource_read_response() + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { + let codex_home = TempDir::new()?; + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + // This negative-path test does not need the stdio subprocess; keeping it + // in-process avoids child-process teardown timing in nextest leak detection. + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let response = client + .request(ClientRequest::McpResourceRead { + request_id: RequestId::Integer(1), + params: McpResourceReadParams { + thread_id: Some("00000000-0000-4000-8000-000000000000".to_string()), + server: "codex_apps".to_string(), + uri: TEST_RESOURCE_URI.to_string(), + }, + }) + .await; + client.shutdown().await?; + + let error = match response? { + Ok(result) => anyhow::bail!("expected thread-not-found error, got response: {result:?}"), + Err(error) => error, + }; + assert!( + error.message.contains("thread not found"), + "expected thread-not-found error, got: {error:?}" + ); + + Ok(()) +} + +async fn start_resource_apps_mcp_server() -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let apps_server_url = format!("http://{addr}"); + + let mcp_service = StreamableHttpService::new( + move || Ok(ResourceAppsMcpServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/api/codex/apps", mcp_service); + let apps_server_handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((apps_server_url, apps_server_handle)) +} + +fn expected_resource_read_response() -> McpResourceReadResponse { + McpResourceReadResponse { + contents: vec![ + McpResourceContent::Text { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + McpResourceContent::Blob { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ], + } +} + +#[derive(Clone, Default)] +struct ResourceAppsMcpServer; + +impl ServerHandler for ResourceAppsMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2025_06_18, + capabilities: ServerCapabilities::builder().enable_resources().build(), + ..ServerInfo::default() + } + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let uri = request.uri; + if uri != TEST_RESOURCE_URI { + return Err(rmcp::ErrorData::resource_not_found( + format!("resource not found: {uri}"), + None, + )); + } + + Ok(ReadResourceResult { + contents: vec![ + ResourceContents::TextResourceContents { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + ResourceContents::BlobResourceContents { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ], + }) + } +} diff --git a/code-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/code-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs new file mode 100644 index 00000000000..13ebe0b99c8 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -0,0 +1,490 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::http::header::AUTHORIZATION; +use axum::routing::get; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpElicitationSchema; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::assert_regex_match; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::BooleanSchema; +use rmcp::model::CallToolRequestParams; +use rmcp::model::CallToolResult; +use rmcp::model::Content; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::ElicitationSchema; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::PrimitiveSchema; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const CONNECTOR_ID: &str = "calendar"; +const CONNECTOR_NAME: &str = "Calendar"; +const TOOL_NAMESPACE: &str = "mcp__codex_apps__calendar"; +const CALLABLE_TOOL_NAME: &str = "_confirm_action"; +const TOOL_NAME: &str = "calendar_confirm_action"; +const TOOL_CALL_ID: &str = "call-calendar-confirm"; +const ELICITATION_MESSAGE: &str = "Allow this request?"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_server_elicitation_round_trip() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let tool_call_arguments = serde_json::to_string(&json!({}))?; + let response_mock = responses::mount_sse_sequence( + &responses_server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-0"), + responses::ev_assistant_message("msg-0", "Warmup"), + responses::ev_completed("resp-0"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + TOOL_CALL_ID, + TOOL_NAMESPACE, + CALLABLE_TOOL_NAME, + &tool_call_arguments, + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let (apps_server_url, apps_server_handle) = start_apps_server().await?; + + let codex_home = TempDir::new()?; + write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let warmup_turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Warm up connectors.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let warmup_turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; + + let warmup_completed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let warmup_completed: TurnCompletedNotification = serde_json::from_value( + warmup_completed + .params + .clone() + .expect("warmup turn/completed params"), + )?; + assert_eq!(warmup_completed.thread_id, thread.id); + assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( + ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(anyhow::Error::msg)?, + )?)?; + + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id.clone(), + turn_id: Some(turn.id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }, + } + ); + + let resolved_request_id = request_id.clone(); + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + meta: None, + })?, + ) + .await?; + + let mut saw_resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!( + resolved, + ServerRequestResolvedNotification { + thread_id: thread.id.clone(), + request_id: resolved_request_id.clone(), + } + ); + saw_resolved = true; + } + "turn/completed" => { + let completed: TurnCompletedNotification = serde_json::from_value( + notification.params.clone().expect("turn/completed params"), + )?; + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + break; + } + _ => {} + } + } + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); + assert_eq!( + function_call_output.get("type"), + Some(&Value::String("function_call_output".to_string())) + ); + assert_eq!( + function_call_output.get("call_id"), + Some(&Value::String(TOOL_CALL_ID.to_string())) + ); + let output = function_call_output + .get("output") + .and_then(Value::as_str) + .expect("function_call_output output should be a JSON string"); + let payload = assert_regex_match( + r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#, + output, + ) + .get(1) + .expect("wall-time wrapped output should include payload") + .as_str(); + assert_eq!( + serde_json::from_str::(payload)?, + json!([{ + "type": "text", + "text": "accepted" + }]) + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +#[derive(Clone)] +struct AppsServerState { + expected_bearer: String, + expected_account_id: String, +} + +#[derive(Clone, Default)] +struct ElicitationAppsMcpServer; + +impl ServerHandler for ElicitationAppsMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: rmcp::model::ProtocolVersion::V_2025_06_18, + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Borrowed(TOOL_NAME), + Cow::Borrowed("Confirm a calendar action."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + let mut meta = Meta::new(); + meta.0 + .insert("connector_id".to_string(), json!(CONNECTOR_ID)); + meta.0 + .insert("connector_name".to_string(), json!(CONNECTOR_NAME)); + tool.meta = Some(meta); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let requested_schema = ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!( + result.content, + Some(json!({ + "confirmed": true, + })) + ); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + + Ok(CallToolResult::success(vec![Content::text(output)])) + } +} + +async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { + let state = Arc::new(AppsServerState { + expected_bearer: "Bearer chatgpt-token".to_string(), + expected_account_id: "account-123".to_string(), + }); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let mcp_service = StreamableHttpService::new( + move || Ok(ElicitationAppsMcpServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let router = Router::new() + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) + .with_state(state) + .nest_service("/api/codex/apps", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn list_directory_connectors( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> Result, StatusCode> { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_bearer); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_account_id); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { + Ok(Json(json!({ + "apps": [{ + "id": CONNECTOR_ID, + "name": CONNECTOR_NAME, + "description": "Calendar connector", + "logo_url": null, + "logo_url_dark": null, + "distribution_channel": null, + "branding": null, + "app_metadata": null, + "labels": null, + "install_url": null, + "is_accessible": false, + "is_enabled": true + }], + "next_token": null + }))) + } +} + +fn write_config_toml( + codex_home: &std::path::Path, + responses_server_uri: &str, + apps_server_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +chatgpt_base_url = "{apps_server_url}" +mcp_oauth_credentials_store = "file" + +[features] +apps = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{responses_server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/mcp_server_status.rs b/code-rs/app-server/tests/suite/v2/mcp_server_status.rs new file mode 100644 index 00000000000..44efec4ed3f --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -0,0 +1,390 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use axum::Router; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; +use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParams; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::service::RequestContext; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn mcp_server_status_list_returns_raw_server_and_tool_names() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server("look-up.raw").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.some-server] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: None, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + + assert_eq!(response.next_cursor, None); + assert_eq!(response.data.len(), 1); + let status = &response.data[0]; + assert_eq!(status.name, "some-server"); + assert_eq!( + status.tools.keys().cloned().collect::>(), + BTreeSet::from(["look-up.raw".to_string()]) + ); + assert_eq!( + status + .tools + .get("look-up.raw") + .map(|tool| tool.name.as_str()), + Some("look-up.raw") + ); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[derive(Clone)] +struct McpStatusServer { + tool_name: Arc, +} + +impl ServerHandler for McpStatusServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Owned(self.tool_name.as_ref().clone()), + Cow::Borrowed("Look up test data."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } +} + +#[derive(Clone)] +struct SlowInventoryServer { + tool_name: Arc, +} + +impl ServerHandler for SlowInventoryServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Owned(self.tool_name.as_ref().clone()), + Cow::Borrowed("Look up test data."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(ListResourcesResult { + resources: Vec::new(), + next_cursor: None, + meta: None, + }) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(ListResourceTemplatesResult { + resource_templates: Vec::new(), + next_cursor: None, + meta: None, + }) + } +} + +#[tokio::test] +async fn mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (mcp_server_url, mcp_server_handle) = start_slow_inventory_mcp_server("lookup").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.some-server] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + }) + .await?; + let response = timeout( + Duration::from_millis(500), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + + assert_eq!(response.next_cursor, None); + assert_eq!(response.data.len(), 1); + let status = &response.data[0]; + assert_eq!(status.name, "some-server"); + assert_eq!( + status.tools.keys().cloned().collect::>(), + BTreeSet::from(["lookup".to_string()]) + ); + assert_eq!(status.resources, Vec::new()); + assert_eq!(status.resource_templates, Vec::new()); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[tokio::test] +async fn mcp_server_status_list_keeps_tools_for_sanitized_name_collisions() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (dash_server_url, dash_server_handle) = start_mcp_server("dash_lookup").await?; + let (underscore_server_url, underscore_server_handle) = + start_mcp_server("underscore_lookup").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.some-server] +url = "{dash_server_url}/mcp" + +[mcp_servers.some_server] +url = "{underscore_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: None, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + + assert_eq!(response.next_cursor, None); + assert_eq!(response.data.len(), 2); + let status_tools = response + .data + .iter() + .map(|status| { + ( + status.name.as_str(), + status.tools.keys().cloned().collect::>(), + ) + }) + .collect::>(); + assert_eq!( + status_tools, + BTreeMap::from([ + ("some-server", BTreeSet::from(["dash_lookup".to_string()])), + ( + "some_server", + BTreeSet::from(["underscore_lookup".to_string()]) + ) + ]) + ); + + dash_server_handle.abort(); + let _ = dash_server_handle.await; + underscore_server_handle.abort(); + let _ = underscore_server_handle.await; + + Ok(()) +} + +async fn start_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let tool_name = Arc::new(tool_name.to_string()); + let mcp_service = StreamableHttpService::new( + move || { + Ok(McpStatusServer { + tool_name: Arc::clone(&tool_name), + }) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/mcp", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn start_slow_inventory_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let tool_name = Arc::new(tool_name.to_string()); + let mcp_service = StreamableHttpService::new( + move || { + Ok(SlowInventoryServer { + tool_name: Arc::clone(&tool_name), + }) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/mcp", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} diff --git a/code-rs/app-server/tests/suite/v2/mcp_tool.rs b/code-rs/app-server/tests/suite/v2/mcp_tool.rs new file mode 100644 index 00000000000..b805f75ba78 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -0,0 +1,704 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use axum::Router; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpElicitationSchema; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::McpServerToolCallResponse; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::BooleanSchema; +use rmcp::model::CallToolRequestParams; +use rmcp::model::CallToolResult; +use rmcp::model::Content; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::ElicitationSchema; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::PrimitiveSchema; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const TEST_SERVER_NAME: &str = "tool_server"; +const TEST_TOOL_NAME: &str = "echo_tool"; +const LARGE_RESPONSE_MESSAGE: &str = "large"; +const ELICITATION_TRIGGER_MESSAGE: &str = "confirm"; +const ELICITATION_MESSAGE: &str = "Allow this request?"; +const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; +const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; +const URL_ELICITATION_URL: &str = "https://github.example/login/device"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + let thread_id = thread.id.clone(); + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread_id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": "hello from app", + })), + meta: Some(json!({ + "source": "mcp-app", + })), + }) + .await?; + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!( + response.content[0].get("text"), + Some(&json!("echo: hello from app")) + ); + assert_eq!( + response.structured_content, + Some(json!({ + "echoed": "hello from app", + "threadId": thread_id, + })) + ); + assert_eq!(response.is_error, Some(false)); + assert_eq!( + response.meta, + Some(json!({ + "calledBy": "mcp-app", + })) + ); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[tokio::test] +async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: "00000000-0000-4000-8000-000000000000".to_string(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({})), + meta: None, + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert!( + error.error.message.contains("thread not found"), + "expected thread-not-found error, got: {error:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_round_trips_elicitation() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": ELICITATION_TRIGGER_MESSAGE, + })), + meta: None, + }) + .await?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( + ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(anyhow::Error::msg)?, + )?)?; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id, + turn_id: None, + server_name: TEST_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }, + } + ); + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + meta: None, + })?, + ) + .await?; + + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": URL_ELICITATION_TRIGGER_MESSAGE, + })), + meta: None, + }) + .await?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id, + turn_id: None, + server_name: TEST_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }, + } + ); + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: None, + meta: None, + })?, + ) + .await?; + + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> { + let call_id = "call-large-mcp"; + let namespace = format!("mcp__{TEST_SERVER_NAME}__"); + let responses = vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + &namespace, + TEST_TOOL_NAME, + &serde_json::to_string(&json!({ + "message": LARGE_RESPONSE_MESSAGE, + }))?, + ), + responses::ev_completed("resp-1"), + ]), + create_final_assistant_message_sse_response("done")?, + ]; + let responses_server = create_mock_responses_server_sequence(responses).await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1_000_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Call the large MCP tool".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?; + assert_eq!(completed.turn_id, turn.id); + + let ThreadItem::McpToolCall { + id, + server, + tool, + status, + result: Some(result), + error, + .. + } = completed.item + else { + panic!("expected completed MCP tool call item"); + }; + assert_eq!(id, call_id); + assert_eq!(server, TEST_SERVER_NAME); + assert_eq!(tool, TEST_TOOL_NAME); + assert_eq!(status, McpToolCallStatus::Completed); + assert_eq!(error, None); + assert_eq!(result.structured_content, None); + assert_eq!(result.meta, None); + assert_eq!(result.content.len(), 1); + + let text = result.content[0] + .get("text") + .and_then(serde_json::Value::as_str) + .expect("truncated MCP event result should be represented as text content"); + assert!(text.contains("truncated")); + assert!(text.len() < DEFAULT_OUTPUT_BYTES_CAP + 1024); + + let serialized_item = serde_json::to_string(&ThreadItem::McpToolCall { + id, + server, + tool, + status, + arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }), + mcp_app_resource_uri: None, + result: Some(result), + error: None, + duration_ms: None, + })?; + assert!(serialized_item.len() < DEFAULT_OUTPUT_BYTES_CAP * 2 + 2048); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[derive(Clone, Default)] +struct ToolAppsMcpServer; + +impl ServerHandler for ToolAppsMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Borrowed(TEST_TOOL_NAME), + Cow::Borrowed("Echo a message."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + assert_eq!(request.name.as_ref(), TEST_TOOL_NAME); + let message = request + .arguments + .as_ref() + .and_then(|arguments| arguments.get("message")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let thread_id = context + .meta + .0 + .get("threadId") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + + let mut meta = Meta::new(); + meta.0.insert("calledBy".to_string(), json!("mcp-app")); + + if message == LARGE_RESPONSE_MESSAGE { + let large_text = "large-mcp-content-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8); + let mut result = CallToolResult::structured(json!({ + "large": "structured-value-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8), + })); + result.content = vec![Content::text(large_text)]; + result.meta = Some(meta); + return Ok(result); + } + + if message == ELICITATION_TRIGGER_MESSAGE { + let requested_schema = ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!( + result.content, + Some(json!({ + "confirmed": true, + })) + ); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + + if message == URL_ELICITATION_TRIGGER_MESSAGE { + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!(result.content, Some(json!({}))); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + + let mut result = CallToolResult::structured(json!({ + "echoed": message, + "threadId": thread_id, + })); + result.content = vec![Content::text(format!("echo: {message}"))]; + result.meta = Some(meta); + Ok(result) + } +} + +async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let mcp_service = StreamableHttpService::new( + || Ok(ToolAppsMcpServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/mcp", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn wait_for_mcp_tool_call_completed( + mcp: &mut McpProcess, + call_id: &str, +) -> Result { + loop { + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/completed"), + ) + .await??; + let Some(params) = notification.params else { + continue; + }; + let completed: ItemCompletedNotification = serde_json::from_value(params)?; + if matches!(&completed.item, ThreadItem::McpToolCall { id, .. } if id == call_id) { + return Ok(completed); + } + } +} diff --git a/code-rs/app-server/tests/suite/v2/memory_reset.rs b/code-rs/app-server/tests/suite/v2/memory_reset.rs new file mode 100644 index 00000000000..3c7ae38671d --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/memory_reset.rs @@ -0,0 +1,145 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use chrono::Utc; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MemoryResetResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use codex_state::Stage1JobClaimOutcome; +use codex_state::StateRuntime; +use codex_state::ThreadMetadataBuilder; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::sync::Arc; +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] +async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + let state_db = init_state_db(codex_home.path()).await?; + + let memory_root = codex_home.path().join("memories"); + tokio::fs::create_dir_all(memory_root.join("rollout_summaries")).await?; + tokio::fs::write(memory_root.join("MEMORY.md"), "stale memory\n").await?; + tokio::fs::write( + memory_root.join("rollout_summaries").join("stale.md"), + "stale rollout summary\n", + ) + .await?; + + let thread_id = seed_stage1_output(&state_db, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request("memory/reset", /*params*/ None) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: MemoryResetResponse = to_response::(response)?; + + let stage1_outputs = state_db.list_stage1_outputs_for_global(/*n*/ 10).await?; + assert_eq!(stage1_outputs, Vec::new()); + assert_eq!( + state_db.get_thread_memory_mode(thread_id).await?.as_deref(), + Some("enabled") + ); + + let mut remaining_entries = tokio::fs::read_dir(&memory_root).await?; + assert!( + remaining_entries.next_entry().await?.is_none(), + "memory root should be empty after reset" + ); + + Ok(()) +} + +async fn seed_stage1_output(state_db: &Arc, codex_home: &Path) -> Result { + let now = Utc::now(); + let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; + let worker_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; + let mut builder = ThreadMetadataBuilder::new( + thread_id, + codex_home.join("sessions").join("test.jsonl"), + now, + SessionSource::Cli, + ); + builder.updated_at = Some(now); + builder.cwd = codex_home.to_path_buf(); + let metadata = builder.build("mock_provider"); + state_db.upsert_thread(&metadata).await?; + + let claim = state_db + .try_claim_stage1_job( + thread_id, + worker_id, + now.timestamp(), + /*lease_seconds*/ 3600, + /*max_running_jobs*/ 64, + ) + .await?; + let Stage1JobClaimOutcome::Claimed { ownership_token } = claim else { + anyhow::bail!("unexpected stage1 claim outcome: {claim:?}"); + }; + assert!( + state_db + .mark_stage1_job_succeeded( + thread_id, + ownership_token.as_str(), + now.timestamp(), + "raw memory", + "rollout summary", + /*rollout_slug*/ None, + ) + .await?, + "stage1 success should be recorded" + ); + state_db + .enqueue_global_consolidation(now.timestamp()) + .await?; + + Ok(thread_id) +} + +async fn init_state_db(codex_home: &Path) -> Result> { + let state_db = StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into()).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + Ok(state_db) +} + +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "mock_provider" +suppress_unstable_features_warning = true + +[features] +sqlite = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:9/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"#, + ) +} diff --git a/code-rs/app-server/tests/suite/v2/mod.rs b/code-rs/app-server/tests/suite/v2/mod.rs new file mode 100644 index 00000000000..8e13df7825f --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/mod.rs @@ -0,0 +1,67 @@ +mod account; +mod analytics; +mod app_list; +mod client_metadata; +mod collaboration_mode_list; +#[cfg(unix)] +mod command_exec; +mod compaction; +mod config_rpc; +mod connection_handling_websocket; +#[cfg(unix)] +mod connection_handling_websocket_unix; +mod dynamic_tools; +mod experimental_api; +mod experimental_feature_list; +mod external_agent_config; +mod fs; +mod hooks_list; +mod initialize; +mod marketplace_add; +mod marketplace_remove; +mod marketplace_upgrade; +mod mcp_resource; +mod mcp_server_elicitation; +mod mcp_server_status; +mod mcp_tool; +mod memory_reset; +mod model_list; +mod model_provider_capabilities_read; +mod output_schema; +mod plan_item; +mod plugin_install; +mod plugin_list; +mod plugin_read; +mod plugin_share; +mod plugin_uninstall; +mod process_exec; +mod rate_limits; +mod realtime_conversation; +#[cfg(debug_assertions)] +mod remote_thread_store; +mod request_permissions; +mod request_user_input; +mod review; +mod safety_check_downgrade; +mod skills_list; +mod thread_archive; +mod thread_fork; +mod thread_inject_items; +mod thread_list; +mod thread_loaded_list; +mod thread_memory_mode_set; +mod thread_metadata_update; +mod thread_name_websocket; +mod thread_read; +mod thread_resume; +mod thread_rollback; +mod thread_shell_command; +mod thread_start; +mod thread_status; +mod thread_unarchive; +mod thread_unsubscribe; +mod turn_interrupt; +mod turn_start; +mod turn_start_zsh_fork; +mod turn_steer; +mod windows_sandbox_setup; diff --git a/code-rs/app-server/tests/suite/v2/model_list.rs b/code-rs/app-server/tests/suite/v2/model_list.rs new file mode 100644 index 00000000000..e2039d333ae --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/model_list.rs @@ -0,0 +1,226 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_models_cache; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::Model; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::ModelServiceTier; +use codex_app_server_protocol::ModelUpgradeInfo; +use codex_app_server_protocol::ReasoningEffortOption; +use codex_app_server_protocol::RequestId; +use codex_protocol::openai_models::ModelPreset; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +fn model_from_preset(preset: &ModelPreset) -> Model { + Model { + id: preset.id.clone(), + model: preset.model.clone(), + upgrade: preset.upgrade.as_ref().map(|upgrade| upgrade.id.clone()), + upgrade_info: preset.upgrade.as_ref().map(|upgrade| ModelUpgradeInfo { + model: upgrade.id.clone(), + upgrade_copy: upgrade.upgrade_copy.clone(), + model_link: upgrade.model_link.clone(), + migration_markdown: upgrade.migration_markdown.clone(), + }), + availability_nux: preset.availability_nux.clone().map(Into::into), + display_name: preset.display_name.clone(), + description: preset.description.clone(), + hidden: !preset.show_in_picker, + supported_reasoning_efforts: preset + .supported_reasoning_efforts + .iter() + .map(|preset| ReasoningEffortOption { + reasoning_effort: preset.effort, + description: preset.description.clone(), + }) + .collect(), + default_reasoning_effort: preset.default_reasoning_effort, + input_modalities: preset.input_modalities.clone(), + // `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not + // preserve personality placeholders in base instructions, so app-server list results from + // cache report `supports_personality = false`. + // todo(sayan): fix, maybe make roundtrip use ModelInfo only + supports_personality: false, + additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset + .service_tiers + .iter() + .map(|service_tier| ModelServiceTier { + id: service_tier.id.clone(), + name: service_tier.name.clone(), + description: service_tier.description.clone(), + }) + .collect(), + is_default: preset.is_default, + } +} + +fn expected_visible_models() -> Vec { + // Filter by supported_in_api to support testing with both ChatGPT and non-ChatGPT auth modes. + let mut presets = ModelPreset::filter_by_auth( + codex_core::test_support::all_model_presets().clone(), + /*chatgpt_mode*/ false, + ); + + // Mirror `ModelsManager::build_available_models()` default selection after auth filtering. + ModelPreset::mark_default_by_picker_visibility(&mut presets); + + presets + .iter() + .filter(|preset| preset.show_in_picker) + .map(model_from_preset) + .collect() +} + +#[tokio::test] +async fn list_models_returns_all_models_with_large_limit() -> Result<()> { + let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: Some(100), + cursor: None, + include_hidden: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let ModelListResponse { + data: items, + next_cursor, + } = to_response::(response)?; + + let expected_models = expected_visible_models(); + + assert_eq!(items, expected_models); + assert!(next_cursor.is_none()); + Ok(()) +} + +#[tokio::test] +async fn list_models_includes_hidden_models() -> Result<()> { + let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: Some(100), + cursor: None, + include_hidden: Some(true), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let ModelListResponse { + data: items, + next_cursor, + } = to_response::(response)?; + + assert!(items.iter().any(|item| item.hidden)); + assert!(next_cursor.is_none()); + Ok(()) +} + +#[tokio::test] +async fn list_models_pagination_works() -> Result<()> { + let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let expected_models = expected_visible_models(); + let mut cursor = None; + let mut items = Vec::new(); + + for _ in 0..expected_models.len() { + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: Some(1), + cursor: cursor.clone(), + include_hidden: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let ModelListResponse { + data: page_items, + next_cursor, + } = to_response::(response)?; + + assert_eq!(page_items.len(), 1); + items.extend(page_items); + + if let Some(next_cursor) = next_cursor { + cursor = Some(next_cursor); + } else { + assert_eq!(items, expected_models); + return Ok(()); + } + } + + panic!( + "model pagination did not terminate after {} pages", + expected_models.len() + ); +} + +#[tokio::test] +async fn list_models_rejects_invalid_cursor() -> Result<()> { + let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: None, + cursor: Some("invalid".to_string()), + include_hidden: None, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(error.error.message, "invalid cursor: invalid"); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs b/code-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs new file mode 100644 index 00000000000..6dcb9ac4ec6 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs @@ -0,0 +1,69 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; +use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn read_default_provider_capabilities() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_model_provider_capabilities_read_request(ModelProviderCapabilitiesReadParams {}) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: ModelProviderCapabilitiesReadResponse = to_response(response)?; + + let expected = ModelProviderCapabilitiesReadResponse { + namespace_tools: true, + image_generation: true, + web_search: true, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"model_provider = "amazon-bedrock" +"#, + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_model_provider_capabilities_read_request(ModelProviderCapabilitiesReadParams {}) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: ModelProviderCapabilitiesReadResponse = to_response(response)?; + + let expected = ModelProviderCapabilitiesReadResponse { + namespace_tools: false, + image_generation: false, + web_search: false, + }; + assert_eq!(received, expected); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/output_schema.rs b/code-rs/app-server/tests/suite/v2/output_schema.rs new file mode 100644 index 00000000000..149e098b686 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/output_schema.rs @@ -0,0 +1,234 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn turn_start_accepts_output_schema_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let output_schema = serde_json::json!({ + "type": "object", + "properties": { + "answer": { "type": "string" } + }, + "required": ["answer"], + "additionalProperties": false + }); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + output_schema: Some(output_schema.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let payload = request.body_json(); + let text = payload.get("text").expect("request missing text field"); + let format = text + .get("format") + .expect("request missing text.format field"); + assert_eq!( + format, + &serde_json::json!({ + "name": "codex_output_schema", + "type": "json_schema", + "strict": true, + "schema": output_schema, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body1 = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock1 = responses::mount_sse_once(&server, body1).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let output_schema = serde_json::json!({ + "type": "object", + "properties": { + "answer": { "type": "string" } + }, + "required": ["answer"], + "additionalProperties": false + }); + + let turn_req_1 = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + output_schema: Some(output_schema.clone()), + ..Default::default() + }) + .await?; + let turn_resp_1: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req_1)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp_1)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload1 = response_mock1.single_request().body_json(); + assert_eq!( + payload1.pointer("/text/format"), + Some(&serde_json::json!({ + "name": "codex_output_schema", + "type": "json_schema", + "strict": true, + "schema": output_schema, + })) + ); + + let body2 = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ]); + let response_mock2 = responses::mount_sse_once(&server, body2).await; + + let turn_req_2 = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello again".to_string(), + text_elements: Vec::new(), + }], + output_schema: None, + ..Default::default() + }) + .await?; + let turn_resp_2: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req_2)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp_2)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload2 = response_mock2.single_request().body_json(); + assert_eq!(payload2.pointer("/text/format"), None); + + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/plan_item.rs b/code-rs/app-server/tests/suite/v2/plan_item.rs new file mode 100644 index 00000000000..97e67fa090c --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/plan_item.rs @@ -0,0 +1,289 @@ +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PlanDeltaNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_features::FEATURES; +use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::sleep; +use tokio::time::timeout; +use wiremock::MockServer; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let plan_block = "\n# Final plan\n- first\n- second\n\n"; + let full_message = format!("Preface\n{plan_block}Postscript"); + let responses = vec![responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_message_item_added("msg-1", ""), + responses::ev_output_text_delta(&full_message), + responses::ev_assistant_message("msg-1", &full_message), + responses::ev_completed("resp-1"), + ])]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let turn = start_plan_mode_turn(&mut mcp).await?; + let (_, completed_items, plan_deltas, turn_completed) = + collect_turn_notifications(&mut mcp).await?; + wait_for_responses_request_count(&server, /*expected_count*/ 1).await?; + + assert_eq!(turn_completed.turn.id, turn.id); + assert_eq!(turn_completed.turn.status, TurnStatus::Completed); + + let expected_plan = ThreadItem::Plan { + id: format!("{}-plan", turn.id), + text: "# Final plan\n- first\n- second\n".to_string(), + }; + let expected_plan_id = format!("{}-plan", turn.id); + let streamed_plan = plan_deltas + .iter() + .map(|delta| delta.delta.as_str()) + .collect::(); + assert_eq!(streamed_plan, "# Final plan\n- first\n- second\n"); + assert!( + plan_deltas + .iter() + .all(|delta| delta.item_id == expected_plan_id) + ); + let plan_items = completed_items + .iter() + .filter_map(|item| match item { + ThreadItem::Plan { .. } => Some(item.clone()), + _ => None, + }) + .collect::>(); + assert_eq!(plan_items, vec![expected_plan]); + assert!( + completed_items + .iter() + .any(|item| matches!(item, ThreadItem::AgentMessage { .. })), + "agent message items should still be emitted alongside the plan item" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses = vec![responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ])]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let _turn = start_plan_mode_turn(&mut mcp).await?; + let (_, completed_items, plan_deltas, _) = collect_turn_notifications(&mut mcp).await?; + wait_for_responses_request_count(&server, /*expected_count*/ 1).await?; + + let has_plan_item = completed_items + .iter() + .any(|item| matches!(item, ThreadItem::Plan { .. })); + assert!(!has_plan_item); + assert!(plan_deltas.is_empty()); + + Ok(()) +} + +async fn start_plan_mode_turn(mcp: &mut McpProcess) -> Result { + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let thread = to_response::(thread_resp)?.thread; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Plan this".to_string(), + text_elements: Vec::new(), + }], + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + Ok(to_response::(turn_resp)?.turn) +} + +async fn collect_turn_notifications( + mcp: &mut McpProcess, +) -> Result<( + Vec, + Vec, + Vec, + TurnCompletedNotification, +)> { + let mut started_items = Vec::new(); + let mut completed_items = Vec::new(); + let mut plan_deltas = Vec::new(); + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "item/started" => { + let params = notification + .params + .ok_or_else(|| anyhow!("item/started notifications must include params"))?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + started_items.push(payload.item); + } + "item/completed" => { + let params = notification + .params + .ok_or_else(|| anyhow!("item/completed notifications must include params"))?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + completed_items.push(payload.item); + } + "item/plan/delta" => { + let params = notification + .params + .ok_or_else(|| anyhow!("item/plan/delta notifications must include params"))?; + let payload: PlanDeltaNotification = serde_json::from_value(params)?; + plan_deltas.push(payload); + } + "turn/completed" => { + let params = notification + .params + .ok_or_else(|| anyhow!("turn/completed notifications must include params"))?; + let payload: TurnCompletedNotification = serde_json::from_value(params)?; + return Ok((started_items, completed_items, plan_deltas, payload)); + } + _ => {} + } + } +} + +async fn wait_for_responses_request_count( + server: &MockServer, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + let responses_request_count = requests + .iter() + .filter(|request| { + request.method == "POST" && request.url.path().ends_with("/responses") + }) + .count(); + if responses_request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if responses_request_count > expected_count { + bail!( + "expected exactly {expected_count} /responses requests, got {responses_request_count}" + ); + } + sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let features = BTreeMap::from([(Feature::CollaborationModes, true)]); + let feature_entries = features + .into_iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +{feature_entries} + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/plugin_install.rs b/code-rs/app-server/tests/suite/v2/plugin_install.rs new file mode 100644 index 00000000000..6adcd921954 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/plugin_install.rs @@ -0,0 +1,1601 @@ +use std::borrow::Cow; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use app_test_support::ChatGptAuthFixture; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::McpProcess; +use app_test_support::start_analytics_events_server; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::http::header::AUTHORIZATION; +use axum::routing::get; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use codex_utils_absolute_path::AbsolutePathBuf; +use flate2::Compression; +use flate2::write::GzEncoder; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; +use wiremock::Match; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +// Plugin install tests wait on connector discovery after the install response path +// starts, which is noticeably slower on Windows CI. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000"; +const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; + +#[tokio::test] +async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/install", + Some(serde_json::json!({ + "marketplacePath": "relative-marketplace.json", + "pluginName": "missing-plugin", + })), + ) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("Invalid request")); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_missing_install_source() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + codex_home.path().join("marketplace.json"), + )?), + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = false +"#, + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin install is not enabled") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let installed_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.2.3"); + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install_after_cache_write( + &server, + REMOTE_PLUGIN_ID, + installed_path.join(".codex-plugin/plugin.json"), + ) + .await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + + assert_eq!( + response, + PluginInstallResponse { + auth_policy: PluginAuthPolicy::OnUse, + apps_needing_auth: Vec::new(), + } + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 1, + ) + .await?; + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 1, + ) + .await?; + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); + assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); + assert!( + !codex_home + .path() + .join(format!( + "plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}/1.2.3" + )) + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + /*bundle_download_url*/ None, + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!( + err.error + .message + .contains("backend did not return a download URL") + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = format!("{}/bundles/linear.tar.gz", server.uri()); + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!( + err.error + .message + .contains("unsupported download URL scheme") + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail( + &server, + REMOTE_PLUGIN_ID, + "../1.2.3", + Some("https://127.0.0.1:1/bundles/linear.tar.gz"), + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!(err.error.message.contains("invalid release version")); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { + let codex_home = TempDir::new()?; + write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "linear/../../oops".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail_with_status( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + Some(&bundle_url), + PluginAvailability::DisabledByAdmin, + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("disabled by admin")); + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 0, + ) + .await?; + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("Codex plugins are disabled for this workspace") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + codex_home.path().join("missing-marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "missing-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("marketplace file")); + assert!(err.error.message.contains("does not exist")); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + Some("NOT_AVAILABLE"), + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("not available for install")); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + }, + "policy": { + "products": ["CHATGPT"] + } + } + ] +}"#, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = + McpProcess::new_with_args(codex_home.path(), &["--session-source", "atlas"]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("not available for install")); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + assert_eq!(response.apps_needing_auth, Vec::::new()); + + let payload = wait_for_plugin_analytics_payload(&analytics_server).await?; + assert_eq!( + payload, + json!({ + "events": [{ + "event_type": "codex_plugin_installed", + "event_params": { + "plugin_id": "sample-plugin@debug", + "plugin_name": "sample-plugin", + "marketplace_name": "debug", + "has_skills": false, + "mcp_server_count": 0, + "connector_ids": [], + "product_client_id": DEFAULT_CLIENT_NAME, + } + }] + }) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + mount_backend_analytics_events(&server).await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + assert_eq!(response.apps_needing_auth, Vec::::new()); + + let payload = wait_for_plugin_analytics_payload(&server).await?; + assert_eq!( + payload, + json!({ + "events": [{ + "event_type": "codex_plugin_installed", + "event_params": { + "plugin_id": REMOTE_PLUGIN_ID, + "plugin_name": "linear", + "marketplace_name": "chatgpt-global", + "has_skills": true, + "mcp_server_count": 0, + "connector_ids": [], + "product_client_id": DEFAULT_CLIENT_NAME, + } + }] + }) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 503, + b"bundle temporarily unavailable".to_vec(), + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!(err.error.message.contains("failed with status 503")); + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 1, + ) + .await?; + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_returns_apps_needing_auth() -> Result<()> { + let connectors = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: Some("featured".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server(connectors, tools).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + + assert_eq!( + response, + PluginInstallResponse { + auth_policy: PluginAuthPolicy::OnInstall, + apps_needing_auth: vec![AppSummary { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + needs_auth: true, + }], + } + ); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { + let connectors = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: Some("featured".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let (server_url, server_handle) = start_apps_server(connectors, Vec::new()).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + Some("ON_USE"), + )?; + write_plugin_source( + repo_root.path(), + "sample-plugin", + &["alpha", "asdk_app_6938a94a61d881918ef32cb999ff937c"], + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + + assert_eq!( + response, + PluginInstallResponse { + auth_policy: PluginAuthPolicy::OnUse, + apps_needing_auth: vec![AppSummary { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + needs_auth: true, + }], + } + ); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nplugins = true\n", + )?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + std::fs::write( + repo_root.path().join("sample-plugin/.mcp.json"), + r#"{ + "mcpServers": { + "sample-mcp": { + "command": "echo" + } + } +}"#, + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + assert_eq!(response.apps_needing_auth, Vec::::new()); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains("[mcp_servers.sample-mcp]")); + assert!(!config.contains("command = \"echo\"")); + + let request_id = mcp + .send_raw_request( + "mcpServer/oauth/login", + Some(json!({ + "name": "sample-mcp", + })), + ) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert_eq!( + err.error.message, + "OAuth login is only supported for streamable HTTP servers." + ); + Ok(()) +} + +#[derive(Clone)] +struct AppsServerState { + response: Arc>, +} + +#[derive(Clone)] +struct PluginInstallMcpServer { + tools: Arc>>, +} + +impl ServerHandler for PluginInstallMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + let tools = self.tools.clone(); + async move { + let tools = tools + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(ListToolsResult { + tools, + next_cursor: None, + meta: None, + }) + } + } +} + +async fn start_apps_server( + connectors: Vec, + tools: Vec, +) -> Result<(String, JoinHandle<()>)> { + let state = Arc::new(AppsServerState { + response: Arc::new(StdMutex::new( + json!({ "apps": connectors, "next_token": null }), + )), + }); + let tools = Arc::new(StdMutex::new(tools)); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let mcp_service = StreamableHttpService::new( + { + let tools = tools.clone(); + move || { + Ok(PluginInstallMcpServer { + tools: tools.clone(), + }) + } + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new() + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) + .with_state(state) + .nest_service("/api/codex/apps", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn list_directory_connectors( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> Result { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "Bearer chatgpt-token"); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "account-123"); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { + let response = state + .response + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(Json(response)) + } +} + +fn connector_tool(connector_id: &str, connector_name: &str) -> Result { + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + }))?; + let mut tool = Tool::new( + Cow::Owned(format!("connector_{connector_id}")), + Cow::Borrowed("Connector test tool"), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + let mut meta = Meta::new(); + meta.0 + .insert("connector_id".to_string(), json!(connector_id)); + meta.0 + .insert("connector_name".to_string(), json!(connector_name)); + tool.meta = Some(meta); + Ok(tool) +} + +fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" +mcp_oauth_credentials_store = "file" + +[features] +connectors = true +"# + ), + ) +} + +fn write_plugins_enabled_config_with_base_url( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{base_url}" + +[features] +plugins = true +"#, + ), + ) +} + +fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!("chatgpt_base_url = \"{base_url}\"\n"), + ) +} + +async fn mount_backend_analytics_events(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/backend-api/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#)) + .mount(server) + .await; +} + +async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + if let Some(request) = requests.iter().find(|request| { + request.method == "POST" + && request + .url + .path() + .ends_with("/codex/analytics-events/events") + }) { + return serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +fn configure_remote_plugin_test(codex_home: &std::path::Path, server: &MockServer) -> Result<()> { + write_remote_plugin_catalog_config(codex_home, &format!("{}/backend-api/", server.uri()))?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + ) +} + +async fn mount_remote_plugin_bundle( + server: &MockServer, + status_code: u16, + body: Vec, +) -> String { + Mock::given(method("GET")) + .and(path("/bundles/linear.tar.gz")) + .respond_with( + ResponseTemplate::new(status_code) + .insert_header("content-type", "application/gzip") + .set_body_bytes(body), + ) + .mount(server) + .await; + format!("{}/bundles/linear.tar.gz", server.uri()) +} + +async fn mount_remote_plugin_detail( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, +) { + mount_remote_plugin_detail_with_status( + server, + remote_plugin_id, + release_version, + bundle_download_url, + PluginAvailability::Available, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_status( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, +) { + let status = match status { + PluginAvailability::Available => "ENABLED", + PluginAvailability::DisabledByAdmin => "DISABLED_BY_ADMIN", + }; + let bundle_download_url_field = bundle_download_url + .map(|url| format!(r#" "bundle_download_url": "{url}","#)) + .unwrap_or_default(); + let detail_body = format!( + r#"{{ + "id": "{remote_plugin_id}", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "{status}", + "release": {{ + "version": "{release_version}", +{bundle_download_url_field} + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {{ + "short_description": "Plan and track work" + }}, + "skills": [] + }} +}}"# + ); + + Mock::given(method("GET")) + .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) + .and(query_param("includeDownloadUrls", "true")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(server) + .await; +} + +async fn mount_empty_remote_installed_plugins(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#, + )) + .mount(server) + .await; +} + +async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) { + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/ps/plugins/{remote_plugin_id}/install" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)), + ) + .mount(server) + .await; +} + +#[derive(Debug, Clone)] +struct CacheManifestExists { + manifest_path: std::path::PathBuf, +} + +impl Match for CacheManifestExists { + fn matches(&self, _request: &Request) -> bool { + self.manifest_path.is_file() + } +} + +async fn mount_remote_plugin_install_after_cache_write( + server: &MockServer, + remote_plugin_id: &str, + manifest_path: std::path::PathBuf, +) { + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/ps/plugins/{remote_plugin_id}/install" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(CacheManifestExists { manifest_path }) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)), + ) + .mount(server) + .await; +} + +async fn send_remote_plugin_install_request( + mcp: &mut McpProcess, + remote_plugin_id: &str, +) -> Result { + mcp.send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()), + plugin_name: remote_plugin_id.to_string(), + }) + .await +} + +async fn wait_for_remote_plugin_request_count( + server: &MockServer, + method_name: &str, + path_suffix: &str, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + let request_count = requests + .iter() + .filter(|request| { + request.method == method_name && request.url.path().ends_with(path_suffix) + }) + .count(); + if request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if request_count > expected_count { + bail!( + "expected exactly {expected_count} {method_name} {path_suffix} requests, got {request_count}" + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +fn write_plugin_marketplace( + repo_root: &std::path::Path, + marketplace_name: &str, + plugin_name: &str, + source_path: &str, + install_policy: Option<&str>, + auth_policy: Option<&str>, +) -> std::io::Result<()> { + let policy = if install_policy.is_some() || auth_policy.is_some() { + let installation = install_policy + .map(|installation| format!("\n \"installation\": \"{installation}\"")) + .unwrap_or_default(); + let separator = if install_policy.is_some() && auth_policy.is_some() { + "," + } else { + "" + }; + let authentication = auth_policy + .map(|authentication| { + format!("{separator}\n \"authentication\": \"{authentication}\"") + }) + .unwrap_or_default(); + format!(",\n \"policy\": {{{installation}{authentication}\n }}") + } else { + String::new() + }; + std::fs::create_dir_all(repo_root.join(".git"))?; + std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; + std::fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "{marketplace_name}", + "plugins": [ + {{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "{source_path}" + }}{policy} + }} + ] +}}"# + ), + ) +} + +fn write_plugin_source( + repo_root: &std::path::Path, + plugin_name: &str, + app_ids: &[&str], +) -> Result<()> { + let plugin_root = repo_root.join(plugin_name); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + + let apps = app_ids + .iter() + .map(|app_id| ((*app_id).to_string(), json!({ "id": app_id }))) + .collect::>(); + std::fs::write( + plugin_root.join(".app.json"), + serde_json::to_vec_pretty(&json!({ "apps": apps }))?, + )?; + Ok(()) +} + +fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { + let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + let skill = "# Plan Work\n\nTrack work in Linear.\n"; + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in [ + ( + ".codex-plugin/plugin.json", + manifest.as_bytes(), + /*mode*/ 0o644, + ), + ( + "skills/plan-work/SKILL.md", + skill.as_bytes(), + /*mode*/ 0o644, + ), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + tar.append_data(&mut header, path, contents)?; + } + Ok(tar.into_inner()?.finish()?) +} diff --git a/code-rs/app-server/tests/suite/v2/plugin_list.rs b/code-rs/app-server/tests/suite/v2/plugin_list.rs new file mode 100644 index 00000000000..ea8294671bd --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/plugin_list.rs @@ -0,0 +1,2533 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginListMarketplaceKind; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_utils_absolute_path::AbsolutePathBuf; +use flate2::Compression; +use flate2::write::GzEncoder; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; +const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; +const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; +const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; +const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; + +fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + r#"[features] +plugins = true +"#, + ) +} + +fn write_plugins_enabled_config_with_base_url( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{base_url}" + +[features] +plugins = true +"#, + ), + ) +} + +#[tokio::test] +async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + write_plugins_enabled_config(codex_home.path())?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + std::fs::write(marketplace_path.as_path(), "{not json")?; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| { marketplace.path.as_ref() != Some(&marketplace_path) }), + "invalid marketplace should be skipped" + ); + assert_eq!(response.marketplace_load_errors.len(), 1); + assert_eq!( + response.marketplace_load_errors[0].marketplace_path, + marketplace_path + ); + assert!( + response.marketplace_load_errors[0] + .message + .contains("invalid marketplace file"), + "unexpected error: {:?}", + response.marketplace_load_errors + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_rejects_relative_cwds() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/list", + Some(serde_json::json!({ + "cwds": ["relative-root"], + })), + ) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("Invalid request")); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load() -> Result<()> +{ + let codex_home = TempDir::new()?; + let valid_repo_root = TempDir::new()?; + let invalid_repo_root = TempDir::new()?; + std::fs::create_dir_all(valid_repo_root.path().join(".git"))?; + std::fs::create_dir_all(valid_repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all( + valid_repo_root + .path() + .join("plugins/valid-plugin/.codex-plugin"), + )?; + std::fs::create_dir_all(invalid_repo_root.path().join(".git"))?; + std::fs::create_dir_all(invalid_repo_root.path().join(".agents/plugins"))?; + write_plugins_enabled_config(codex_home.path())?; + + let valid_marketplace_path = AbsolutePathBuf::try_from( + valid_repo_root + .path() + .join(".agents/plugins/marketplace.json"), + )?; + let invalid_marketplace_path = AbsolutePathBuf::try_from( + invalid_repo_root + .path() + .join(".agents/plugins/marketplace.json"), + )?; + let valid_plugin_path = + AbsolutePathBuf::try_from(valid_repo_root.path().join("plugins/valid-plugin"))?; + + std::fs::write( + valid_marketplace_path.as_path(), + r#"{ + "name": "valid-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./plugins/valid-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + valid_repo_root + .path() + .join("plugins/valid-plugin/.codex-plugin/plugin.json"), + r#"{"name":"valid-plugin","keywords":["api-key","developer tools"]}"#, + )?; + std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![ + AbsolutePathBuf::try_from(valid_repo_root.path())?, + AbsolutePathBuf::try_from(invalid_repo_root.path())?, + ]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response.marketplaces, + vec![PluginMarketplaceEntry { + name: "valid-marketplace".to_string(), + path: Some(valid_marketplace_path), + interface: None, + plugins: vec![PluginSummary { + id: "valid-plugin@valid-marketplace".to_string(), + name: "valid-plugin".to_string(), + share_context: None, + source: PluginSource::Local { + path: valid_plugin_path, + }, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: None, + keywords: vec!["api-key".to_string(), "developer tools".to_string()], + }], + }] + ); + assert_eq!(response.marketplace_load_errors.len(), 1); + assert_eq!( + response.marketplace_load_errors[0].marketplace_path, + invalid_marketplace_path + ); + assert!( + response.marketplace_load_errors[0] + .message + .contains("invalid marketplace file"), + "unexpected error: {:?}", + response.marketplace_load_errors + ); + assert!(response.featured_plugin_ids.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./demo-plugin" + } + } + ] +}"#, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), + ) + .mount(&server) + .await; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_without_managed_config_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "local-marketplace", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin"}"#, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":true}}"#), + ) + .mount(&server) + .await; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_without_managed_config_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + for _ in 0..2 { + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "local-marketplace"); + } + + wait_for_workspace_settings_request_count(&server, /*expected_count*/ 1).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins() +-> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let valid_plugin_root = repo_root.path().join("plugins/valid-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all( + repo_root + .path() + .join(ALTERNATE_MARKETPLACE_RELATIVE_PATH) + .parent() + .unwrap(), + )?; + std::fs::create_dir_all( + valid_plugin_root + .join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH) + .parent() + .unwrap(), + )?; + write_plugins_enabled_config(codex_home.path())?; + + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(ALTERNATE_MARKETPLACE_RELATIVE_PATH))?; + let valid_plugin_path = AbsolutePathBuf::try_from(valid_plugin_root.clone())?; + + std::fs::write( + marketplace_path.as_path(), + r#"{ + "name": "alternate-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": "./plugins/valid-plugin" + }, + { + "name": "missing-plugin", + "source": "./plugins/missing-plugin" + } + ] +}"#, + )?; + std::fs::write( + valid_plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH), + r#"{ + "name": "valid-plugin", + "interface": { + "displayName": "Valid Plugin" + } +}"#, + )?; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response.marketplaces, + vec![PluginMarketplaceEntry { + name: "alternate-marketplace".to_string(), + path: Some(marketplace_path), + interface: None, + plugins: vec![ + PluginSummary { + id: "valid-plugin@alternate-marketplace".to_string(), + name: "valid-plugin".to_string(), + share_context: None, + source: PluginSource::Local { + path: valid_plugin_path, + }, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: Some(codex_app_server_protocol::PluginInterface { + display_name: Some("Valid Plugin".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + }), + keywords: Vec::new(), + }, + PluginSummary { + id: "missing-plugin@alternate-marketplace".to_string(), + name: "missing-plugin".to_string(), + share_context: None, + source: PluginSource::Local { + path: AbsolutePathBuf::try_from( + repo_root.path().join("plugins/missing-plugin"), + )?, + }, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + }, + ], + }] + ); + assert!(response.marketplace_load_errors.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_accepts_omitted_cwds() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::write( + codex_home.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "home-plugin", + "source": { + "source": "local", + "path": "./home-plugin" + } + } + ] +}"#, + )?; + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: PluginListResponse = to_response(response)?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"demo-plugin"}"#, + )?; + write_plugin_share_local_path_mapping( + codex_home.path(), + "plugins_123", + &AbsolutePathBuf::try_from(plugin_root)?, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "demo-plugin") + .expect("expected demo-plugin entry"); + let share_context = plugin + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.share_url, None); + assert_eq!(share_context.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + assert_eq!(share_context.share_targets, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + write_installed_plugin(&codex_home, "codex-curated", "enabled-plugin")?; + write_installed_plugin(&codex_home, "codex-curated", "disabled-plugin")?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "interface": { + "displayName": "ChatGPT Official" + }, + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + }, + { + "name": "disabled-plugin", + "source": { + "source": "local", + "path": "./disabled-plugin" + } + }, + { + "name": "uninstalled-plugin", + "source": { + "source": "local", + "path": "./uninstalled-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."enabled-plugin@codex-curated"] +enabled = true + +[plugins."disabled-plugin@codex-curated"] +enabled = false +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path.as_ref() + == Some( + &AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + ) + .expect("absolute marketplace path"), + ) + }) + .expect("expected repo marketplace entry"); + + assert_eq!(marketplace.name, "codex-curated"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("ChatGPT Official") + ); + assert_eq!(marketplace.plugins.len(), 3); + assert_eq!(marketplace.plugins[0].id, "enabled-plugin@codex-curated"); + assert_eq!(marketplace.plugins[0].name, "enabled-plugin"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, true); + assert_eq!( + marketplace.plugins[0].install_policy, + PluginInstallPolicy::Available + ); + assert_eq!( + marketplace.plugins[0].auth_policy, + PluginAuthPolicy::OnInstall + ); + assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated"); + assert_eq!(marketplace.plugins[1].name, "disabled-plugin"); + assert_eq!(marketplace.plugins[1].installed, true); + assert_eq!(marketplace.plugins[1].enabled, false); + assert_eq!( + marketplace.plugins[1].install_policy, + PluginInstallPolicy::Available + ); + assert_eq!( + marketplace.plugins[1].auth_policy, + PluginAuthPolicy::OnInstall + ); + assert_eq!( + marketplace.plugins[2].id, + "uninstalled-plugin@codex-curated" + ); + assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin"); + assert_eq!(marketplace.plugins[2].installed, false); + assert_eq!(marketplace.plugins[2].enabled, false); + assert_eq!( + marketplace.plugins[2].install_policy, + PluginInstallPolicy::Available + ); + assert_eq!( + marketplace.plugins[2].auth_policy, + PluginAuthPolicy::OnInstall + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?; + write_installed_plugin(&codex_home, "codex-curated", "shared-plugin")?; + std::fs::write( + codex_home.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./shared-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."shared-plugin@codex-curated"] +enabled = true +"#, + )?; + + let workspace_enabled = TempDir::new()?; + std::fs::create_dir_all(workspace_enabled.path().join(".git"))?; + std::fs::create_dir_all(workspace_enabled.path().join(".agents/plugins"))?; + std::fs::write( + workspace_enabled + .path() + .join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./shared-plugin" + } + } + ] +}"#, + )?; + std::fs::create_dir_all(workspace_enabled.path().join(".codex"))?; + std::fs::write( + workspace_enabled.path().join(".codex/config.toml"), + r#"[plugins."shared-plugin@codex-curated"] +enabled = false +"#, + )?; + set_project_trust_level( + codex_home.path(), + workspace_enabled.path(), + TrustLevel::Trusted, + )?; + + let workspace_default = TempDir::new()?; + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![ + AbsolutePathBuf::try_from(workspace_enabled.path())?, + AbsolutePathBuf::try_from(workspace_default.path())?, + ]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let shared_plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "shared-plugin") + .expect("expected shared-plugin entry"); + assert_eq!(shared_plugin.id, "shared-plugin@codex-curated"); + assert_eq!(shared_plugin.installed, true); + assert_eq!(shared_plugin.enabled, true); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Design" + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "interface": { + "displayName": "Plugin Display Name", + "shortDescription": "Short description for subtitle", + "longDescription": "Long description for details page", + "developerName": "OpenAI", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "websiteURL": "https://openai.com/", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Starter prompt for trying a plugin", + "Find my next action" + ], + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/screenshot1.png", "./assets/screenshot2.png"] + } +}"##, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "demo-plugin") + .expect("expected demo-plugin entry"); + + assert_eq!(plugin.id, "demo-plugin@codex-curated"); + assert_eq!(plugin.installed, false); + assert_eq!(plugin.enabled, false); + assert_eq!(plugin.install_policy, PluginInstallPolicy::Available); + assert_eq!(plugin.auth_policy, PluginAuthPolicy::OnInstall); + let interface = plugin + .interface + .as_ref() + .expect("expected plugin interface"); + assert_eq!( + interface.display_name.as_deref(), + Some("Plugin Display Name") + ); + assert_eq!(interface.category.as_deref(), Some("Design")); + assert_eq!( + interface.website_url.as_deref(), + Some("https://openai.com/") + ); + assert_eq!( + interface.privacy_policy_url.as_deref(), + Some("https://openai.com/policies/row-privacy-policy/") + ); + assert_eq!( + interface.terms_of_service_url.as_deref(), + Some("https://openai.com/policies/row-terms-of-use/") + ); + assert_eq!( + interface.default_prompt, + Some(vec![ + "Starter prompt for trying a plugin".to_string(), + "Find my next action".to_string() + ]) + ); + assert_eq!( + interface.composer_icon, + Some(AbsolutePathBuf::try_from( + plugin_root.join("assets/icon.png") + )?) + ); + assert_eq!( + interface.logo, + Some(AbsolutePathBuf::try_from( + plugin_root.join("assets/logo.png") + )?) + ); + assert_eq!( + interface.screenshots, + vec![ + AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot1.png"))?, + AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot2.png"))?, + ] + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "interface": { + "defaultPrompt": "Starter prompt for trying a plugin" + } +}"##, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "demo-plugin") + .expect("expected demo-plugin entry"); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec!["Starter prompt for trying a plugin".to_string()]) + ); + Ok(()) +} + +#[tokio::test] +async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + write_openai_curated_marketplace(codex_home.path(), &["linear"])?; + + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) + .mount(&server) + .await; + + let marker_path = codex_home + .path() + .join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); + + { + let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + wait_for_path_exists(&marker_path).await?; + wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) + .await?; + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + let curated_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("expected openai-curated marketplace entry"); + assert_eq!( + curated_marketplace + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) + .collect::>(), + vec![("linear@openai-curated".to_string(), true, true)] + ); + wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) + .await?; + } + + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + + { + let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + } + + tokio::time::sleep(Duration::from_millis(250)).await; + wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?; + Ok(()) +} + +#[tokio::test] +async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let bundle_url = mount_remote_plugin_bundle( + &server, + "linear", + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + let global_installed_body = + remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let installed_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.2.3"); + let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?; + assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains("linear@chatgpt-global")); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + write_installed_plugin_with_version(&codex_home, "chatgpt-global", "linear", "1.0.0")?; + write_installed_plugin_with_version(&codex_home, "chatgpt-global", "stale", "1.0.0")?; + + let bundle_url = mount_remote_plugin_bundle( + &server, + "linear", + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + let global_installed_body = + remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await; + mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let old_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.0.0"); + let new_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.2.3"); + let stale_path = codex_home.path().join("plugins/cache/chatgpt-global/stale"); + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + let remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "chatgpt-global") + .expect("expected chatgpt-global marketplace entry"); + assert_eq!( + remote_marketplace + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) + .collect::>(), + vec![( + "plugins~Plugin_00000000000000000000000000000000".to_string(), + true, + true + )] + ); + + wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?; + wait_for_path_missing(&old_path).await?; + wait_for_path_missing(&stale_path).await?; + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains("linear@chatgpt-global")); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let global_directory_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "keywords": ["issue-tracking", "project management"], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let global_installed_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [] + }, + "enabled": true, + "disabled_skill_names": [] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "WORKSPACE")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "chatgpt-global") + .expect("expected ChatGPT remote marketplace"); + assert_eq!(remote_marketplace.path, None); + assert_eq!( + remote_marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("ChatGPT Plugins") + ); + assert_eq!(remote_marketplace.plugins.len(), 1); + assert_eq!( + remote_marketplace.plugins[0].id, + "plugins~Plugin_00000000000000000000000000000000" + ); + assert_eq!(remote_marketplace.plugins[0].name, "linear"); + assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); + assert_eq!(remote_marketplace.plugins[0].installed, true); + assert_eq!(remote_marketplace.plugins[0].enabled, true); + assert_eq!( + remote_marketplace.plugins[0].availability, + codex_app_server_protocol::PluginAvailability::Available + ); + assert_eq!( + remote_marketplace.plugins[0] + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Linear") + ); + assert_eq!( + remote_marketplace.plugins[0].keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); + assert_eq!(response.featured_plugin_ids, Vec::::new()); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_explicit() +-> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "chatgpt-global") + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let workspace_plugin_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + /*enabled*/ None, + ); + let workspace_installed_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + /*enabled*/ Some(false), + ); + mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "workspace-directory"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Workspace Directory") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins[0].name, "workspace-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, false); + assert!( + !server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .any(|request| request + .url + .query() + .is_some_and(|query| query.contains("scope=GLOBAL"))) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let shared_plugin_body = workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + /*enabled*/ None, + ); + let workspace_installed_body = workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + /*enabled*/ Some(true), + ); + mount_shared_workspace_plugins(&server, &shared_plugin_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "shared-with-me"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins[0].name, "shared-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, true); + let share_context = marketplace.plugins[0] + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_22222222222222222222222222222222" + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-gavin__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); + assert_eq!( + share_context.share_url.as_deref(), + Some("https://chatgpt.example/plugins/share/share-key-1") + ); + assert_eq!( + share_context.share_targets, + Some(vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-ada__account-123".to_string(), + name: "Ada".to_string(), + }]) + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let global_directory_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "DISABLED_BY_ADMIN", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let global_installed_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "DISABLED_BY_ADMIN", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + }, + "enabled": true, + "disabled_skill_names": [] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + for (scope, body) in [ + ("GLOBAL", global_directory_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", scope)) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + for (scope, body) in [ + ("GLOBAL", global_installed_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + let remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "chatgpt-global") + .expect("expected ChatGPT remote marketplace"); + let plugin = remote_marketplace + .plugins + .first() + .expect("expected remote plugin"); + assert_eq!(plugin.installed, true); + assert_eq!(plugin.enabled, true); + assert_eq!( + plugin.availability, + codex_app_server_protocol::PluginAvailability::DisabledByAdmin + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let local_plugin_root = codex_home + .path() + .join(".agents/plugins/plugins/local-linear/.codex-plugin"); + std::fs::create_dir_all(&local_plugin_root)?; + std::fs::write( + codex_home.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "chatgpt-global", + "plugins": [ + { + "name": "local-linear", + "source": { + "source": "local", + "path": "./plugins/local-linear" + } + } + ] +}"#, + )?; + std::fs::write( + local_plugin_root.join("plugin.json"), + r#"{"name":"local-linear"}"#, + )?; + + let global_directory_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + for (scope, body) in [ + ("GLOBAL", global_directory_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", scope)) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + for scope in ["GLOBAL", "WORKSPACE"] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) + .mount(&server) + .await; + } + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + let matching_marketplaces = response + .marketplaces + .iter() + .filter(|marketplace| marketplace.name == "chatgpt-global") + .collect::>(); + + assert_eq!(matching_marketplaces.len(), 1); + assert_eq!(matching_marketplaces[0].path, None); + assert_eq!(matching_marketplaces[0].plugins.len(), 1); + assert_eq!( + matching_marketplaces[0].plugins[0].source, + PluginSource::Remote + ); + assert_eq!(matching_marketplaces[0].plugins[0].name, "linear"); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = false +remote_plugin = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!(response.marketplaces.is_empty()); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?; + write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail"])?; + + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response.featured_plugin_ids, + vec!["linear@openai-curated".to_string()] + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?; + write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail"])?; + + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response.featured_plugin_ids, + vec!["linear@openai-curated".to_string()] + ); + Ok(()) +} + +async fn wait_for_featured_plugin_request_count( + server: &MockServer, + expected_count: usize, +) -> Result<()> { + wait_for_remote_plugin_request_count(server, "/plugins/featured", expected_count).await +} + +async fn wait_for_workspace_settings_request_count( + server: &MockServer, + expected_count: usize, +) -> Result<()> { + wait_for_remote_plugin_request_count(server, "/accounts/account-123/settings", expected_count) + .await +} + +async fn wait_for_remote_plugin_request_count( + server: &MockServer, + path_suffix: &str, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + let request_count = requests + .iter() + .filter(|request| { + request.method == "GET" && request.url.path().ends_with(path_suffix) + }) + .count(); + if request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if request_count > expected_count { + bail!( + "expected exactly {expected_count} {path_suffix} requests, got {request_count}" + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + if path.exists() { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +async fn wait_for_path_missing(path: &std::path::Path) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + if !path.exists() { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", scope)) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} + +async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/shared")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} + +async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} + +fn empty_remote_installed_plugins_body() -> &'static str { + r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"# +} + +fn workspace_remote_plugin_page_body( + remote_plugin_id: &str, + plugin_name: &str, + display_name: &str, + enabled: Option, +) -> String { + let enabled_field = enabled + .map(|enabled| format!(r#", "enabled": {enabled}, "disabled_skill_names": []"#)) + .unwrap_or_default(); + format!( + r#"{{ + "plugins": [ + {{ + "id": "{remote_plugin_id}", + "name": "{plugin_name}", + "scope": "WORKSPACE", + "creator_account_user_id": "user-gavin__account-123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "creator_name": "Gavin", + "share_principals": [ + {{ + "principal_type": "user", + "principal_id": "user-gavin__account-123", + "role": "owner", + "name": "Gavin" + }}, + {{ + "principal_type": "user", + "principal_id": "user-ada__account-123", + "role": "reader", + "name": "Ada" + }} + ], + "release": {{ + "display_name": "{display_name}", + "description": "Track work", + "app_ids": [], + "interface": {{}}, + "skills": [] + }}{enabled_field} + }} + ], + "pagination": {{ + "limit": 50, + "next_page_token": null + }} +}}"# + ) +} + +fn remote_installed_plugin_body( + bundle_download_url: &str, + release_version: &str, + enabled: bool, +) -> String { + format!( + r#"{{ + "plugins": [ + {{ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": {{ + "version": "{release_version}", + "display_name": "Linear", + "description": "Track work in Linear", + "bundle_download_url": "{bundle_download_url}", + "app_ids": [], + "interface": {{}}, + "skills": [] + }}, + "enabled": {enabled}, + "disabled_skill_names": [] + }} + ], + "pagination": {{ + "limit": 50, + "next_page_token": null + }} +}}"# + ) +} + +async fn mount_remote_plugin_bundle( + server: &MockServer, + plugin_name: &str, + body: Vec, +) -> String { + let bundle_path = format!("/bundles/{plugin_name}.tar.gz"); + Mock::given(method("GET")) + .and(path(bundle_path.as_str())) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/gzip") + .set_body_bytes(body), + ) + .mount(server) + .await; + format!("{}{bundle_path}", server.uri()) +} + +fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { + let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n"; + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in [ + ( + ".codex-plugin/plugin.json", + manifest.as_bytes(), + /*mode*/ 0o644, + ), + ( + "skills/plan-work/SKILL.md", + skill.as_bytes(), + /*mode*/ 0o644, + ), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + tar.append_data(&mut header, path, contents)?; + } + Ok(tar.into_inner()?.finish()?) +} + +fn write_installed_plugin( + codex_home: &TempDir, + marketplace_name: &str, + plugin_name: &str, +) -> Result<()> { + write_installed_plugin_with_version(codex_home, marketplace_name, plugin_name, "local") +} + +fn write_installed_plugin_with_version( + codex_home: &TempDir, + marketplace_name: &str, + plugin_name: &str, + plugin_version: &str, +) -> Result<()> { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join(plugin_version) + .join(".codex-plugin"); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + Ok(()) +} + +fn write_plugin_sync_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false + +[plugins."gmail@openai-curated"] +enabled = false + +[plugins."calendar@openai-curated"] +enabled = true +"# + ), + ) +} + +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +fn write_openai_curated_marketplace( + codex_home: &std::path::Path, + plugin_names: &[&str], +) -> std::io::Result<()> { + let curated_root = codex_home.join(".tmp/plugins"); + std::fs::create_dir_all(curated_root.join(".git"))?; + std::fs::create_dir_all(curated_root.join(".agents/plugins"))?; + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + std::fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "openai-curated", + "plugins": [ +{plugins} + ] +}}"# + ), + )?; + + for plugin_name in plugin_names { + let plugin_root = curated_root.join(format!("plugins/{plugin_name}/.codex-plugin")); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + } + std::fs::create_dir_all(codex_home.join(".tmp"))?; + std::fs::write( + codex_home.join(".tmp/plugins.sha"), + format!("{TEST_CURATED_PLUGIN_SHA}\n"), + )?; + Ok(()) +} + +fn write_plugin_share_local_path_mapping( + codex_home: &std::path::Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) -> std::io::Result<()> { + let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new(); + local_plugin_paths_by_remote_plugin_id.insert( + remote_plugin_id.to_string(), + serde_json::to_value(plugin_path).map_err(std::io::Error::other)?, + ); + let contents = serde_json::to_string_pretty(&serde_json::json!({ + "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id, + })) + .map_err(std::io::Error::other)?; + std::fs::create_dir_all(codex_home.join(".tmp"))?; + std::fs::write( + codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + format!("{contents}\n"), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/plugin_read.rs b/code-rs/app-server/tests/suite/v2/plugin_read.rs new file mode 100644 index 00000000000..16924b02181 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/plugin_read.rs @@ -0,0 +1,1673 @@ +use std::borrow::Cow; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::http::header::AUTHORIZATION; +use axum::routing::get; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; +use codex_app_server_protocol::PluginSkillReadParams; +use codex_app_server_protocol::PluginSkillReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn plugin_read_rejects_missing_read_source() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + codex_home.path().join("marketplace.json"), + )?), + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "keywords": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": [] + }, + "skills": [] + } +}"#; + let installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!( + response.plugin.summary.id, + "plugins~Plugin_00000000000000000000000000000000" + ); + assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!(response.plugin.summary.share_context, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_11111111111111111111111111111111", + "name": "shared-linear", + "scope": "WORKSPACE", + "creator_account_user_id": "user-gavin__account-123", + "creator_name": "Gavin", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": [ + { + "principal_type": "user", + "principal_id": "user-gavin__account-123", + "role": "owner", + "name": "Gavin" + }, + { + "principal_type": "user", + "principal_id": "user-ada__account-123", + "role": "reader", + "name": "Ada" + } + ], + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Shared Linear", + "description": "Track shared work", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } +}"#; + let installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_11111111111111111111111111111111", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("shared-with-me".to_string()), + plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_11111111111111111111111111111111" + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-gavin__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); + assert_eq!( + share_context.share_url.as_deref(), + Some("https://chatgpt.example/plugins/share/share-key-1") + ); + assert_eq!( + share_context.share_targets, + Some(vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-ada__account-123".to_string(), + name: "Ada".to_string(), + }]) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "keywords": ["issue-tracking", "project management"], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [ + { + "name": "plan-work", + "description": "Plan work from Linear issues", + "plugin_release_skill_id": "skill-1", + "interface": { + "display_name": "Plan Work", + "short_description": "Create a plan from issues" + } + } + ] + } +}"#; + let installed_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [ + { + "name": "plan-work", + "description": "Plan work from Linear issues", + "plugin_release_skill_id": "skill-1", + "interface": { + "display_name": "Plan Work", + "short_description": "Create a plan from issues" + } + } + ] + }, + "enabled": false, + "disabled_skill_names": ["plan-work"] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!(response.plugin.marketplace_path, None); + assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!( + response.plugin.summary.id, + "plugins~Plugin_00000000000000000000000000000000" + ); + assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.installed, true); + assert_eq!(response.plugin.summary.enabled, false); + assert_eq!( + response.plugin.description.as_deref(), + Some("Track work in Linear") + ); + assert_eq!( + response.plugin.summary.keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); + assert_eq!(response.plugin.skills.len(), 1); + assert_eq!(response.plugin.skills[0].name, "plan-work"); + assert_eq!(response.plugin.skills[0].path, None); + assert_eq!(response.plugin.skills[0].enabled, false); + assert_eq!(response.plugin.apps.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let skill_body = r##"{ + "plugin_id": "plugins~Plugin_00000000000000000000000000000000", + "status": "ENABLED", + "plugin_release_id": "release-1", + "name": "plan-work", + "description": "Plan work from Linear issues", + "plugin_release_skill_id": "skill-1", + "skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan." +}"##; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(skill_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_skill_read_request(PluginSkillReadParams { + remote_marketplace_name: "chatgpt-global".to_string(), + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + skill_name: "plan-work".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginSkillReadResponse = to_response(response)?; + + assert_eq!( + response, + PluginSkillReadResponse { + contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins~Plugin_missing")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"detail":"not found"}"#)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_missing".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("read remote plugin details: remote plugin catalog request") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = false +remote_plugin = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "linear".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin read is not enabled") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { + let codex_home = TempDir::new()?; + write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "linear/../../oops".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + assert!( + err.error + .message + .contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_canonical_openai_curated_marketplace_name() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "openai-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "description": "OpenAI curated plugin" +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."demo-plugin@openai-curated"] +enabled = true +"#, + )?; + write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.marketplace_name, "openai-curated"); + assert_eq!(response.plugin.marketplace_path, Some(marketplace_path)); + assert_eq!(response.plugin.summary.id, "demo-plugin@openai-curated"); + assert_eq!(response.plugin.summary.name, "demo-plugin"); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin"}"#, + )?; + write_plugins_enabled_config(&codex_home)?; + let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.share_url, None); + assert_eq!(share_context.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + assert_eq!(share_context.share_targets, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("hooks"))?; + std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?; + std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Design" + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "description": "Longer manifest description", + "keywords": ["api-key", "developer tools"], + "interface": { + "displayName": "Plugin Display Name", + "shortDescription": "Short description for subtitle", + "longDescription": "Long description for details page", + "developerName": "OpenAI", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "websiteURL": "https://openai.com/", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": [ + "Draft the reply", + "Find my next action" + ], + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/screenshot1.png"] + } +}"##, + )?; + std::fs::write( + plugin_root.join("skills/thread-summarizer/SKILL.md"), + r#"--- +name: thread-summarizer +description: Summarize email threads +--- + +# Thread Summarizer +"#, + )?; + std::fs::write( + plugin_root.join("skills/chatgpt-only/SKILL.md"), + r#"--- +name: chatgpt-only +description: Visible only for ChatGPT +--- + +# ChatGPT Only +"#, + )?; + std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer/agents"))?; + std::fs::write( + plugin_root.join("skills/thread-summarizer/agents/openai.yaml"), + r#"policy: + products: + - CODEX +"#, + )?; + std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only/agents"))?; + std::fs::write( + plugin_root.join("skills/chatgpt-only/agents/openai.yaml"), + r#"policy: + products: + - CHATGPT +"#, + )?; + std::fs::write( + plugin_root.join(".app.json"), + r#"{ + "apps": { + "gmail": { + "id": "gmail" + } + } +}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "demo": { + "command": "demo-server" + } + } +}"#, + )?; + std::fs::write( + plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ] + } + ] + } +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true +plugin_hooks = true + +[[skills.config]] +name = "demo-plugin:thread-summarizer" +enabled = false + +[plugins."demo-plugin@codex-curated"] +enabled = true + +[hooks.state."demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false +"#, + )?; + write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.marketplace_name, "codex-curated"); + assert_eq!(response.plugin.marketplace_path, Some(marketplace_path)); + assert_eq!(response.plugin.summary.id, "demo-plugin@codex-curated"); + assert_eq!(response.plugin.summary.name, "demo-plugin"); + assert_eq!( + response.plugin.description.as_deref(), + Some("Longer manifest description") + ); + assert_eq!(response.plugin.summary.installed, true); + assert_eq!(response.plugin.summary.enabled, true); + assert_eq!( + response.plugin.summary.install_policy, + PluginInstallPolicy::Available + ); + assert_eq!( + response.plugin.summary.auth_policy, + PluginAuthPolicy::OnInstall + ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Plugin Display Name") + ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.category.as_deref()), + Some("Design") + ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec![ + "Draft the reply".to_string(), + "Find my next action".to_string() + ]) + ); + assert_eq!( + response.plugin.summary.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); + assert_eq!(response.plugin.skills.len(), 1); + assert_eq!( + response.plugin.skills[0].name, + "demo-plugin:thread-summarizer" + ); + assert_eq!( + response.plugin.skills[0].description, + "Summarize email threads" + ); + assert!(!response.plugin.skills[0].enabled); + assert_eq!( + response.plugin.hooks, + vec![ + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + }, + ] + ); + assert_eq!(response.plugin.apps.len(), 1); + assert_eq!(response.plugin.apps[0].id, "gmail"); + assert_eq!(response.plugin.apps[0].name, "gmail"); + assert_eq!( + response.plugin.apps[0].install_url.as_deref(), + Some("https://chatgpt.com/apps/gmail/gmail") + ); + assert_eq!(response.plugin.apps[0].needs_auth, true); + assert_eq!(response.plugin.mcp_servers.len(), 1); + assert_eq!(response.plugin.mcp_servers[0], "demo"); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_app_needs_auth() -> Result<()> { + let connectors = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: Some("featured".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server(connectors, tools).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!( + response + .plugin + .apps + .iter() + .map(|app| (app.id.as_str(), app.needs_auth)) + .collect::>(), + vec![("alpha", true), ("beta", false)] + ); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "interface": { + "defaultPrompt": "Starter prompt for trying a plugin" + } +}"##, + )?; + write_plugins_enabled_config(&codex_home)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec!["Starter prompt for trying a plugin".to_string()]) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }} + }} + ] +}}"# + ), + )?; + write_plugins_enabled_config(&codex_home)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "toolkit".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + let expected_description = format!( + "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`." + ); + assert_eq!( + response.plugin.description.as_deref(), + Some(expected_description.as_str()) + ); + assert!(!response.plugin.summary.installed); + assert!(response.plugin.skills.is_empty()); + assert!(response.plugin.apps.is_empty()); + assert!(response.plugin.mcp_servers.is_empty()); + assert!( + !codex_home + .path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + write_plugins_enabled_config(&codex_home)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "missing-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("plugin `missing-plugin` was not found") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + write_plugins_enabled_config(&codex_home)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("missing or invalid plugin.json")); + Ok(()) +} + +fn write_installed_plugin( + codex_home: &TempDir, + marketplace_name: &str, + plugin_name: &str, +) -> Result<()> { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local/.codex-plugin"); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + Ok(()) +} + +fn write_plugins_enabled_config(codex_home: &TempDir) -> Result<()> { + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true +"#, + )?; + Ok(()) +} + +#[derive(Clone)] +struct AppsServerState { + response: Arc>, +} + +#[derive(Clone)] +struct PluginReadMcpServer { + tools: Arc>>, +} + +impl ServerHandler for PluginReadMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + let tools = self.tools.clone(); + async move { + let tools = tools + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(ListToolsResult { + tools, + next_cursor: None, + meta: None, + }) + } + } +} + +async fn start_apps_server( + connectors: Vec, + tools: Vec, +) -> Result<(String, JoinHandle<()>)> { + let state = Arc::new(AppsServerState { + response: Arc::new(StdMutex::new( + json!({ "apps": connectors, "next_token": null }), + )), + }); + let tools = Arc::new(StdMutex::new(tools)); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let mcp_service = StreamableHttpService::new( + { + let tools = tools.clone(); + move || { + Ok(PluginReadMcpServer { + tools: tools.clone(), + }) + } + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new() + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) + .with_state(state) + .nest_service("/api/codex/apps", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn list_directory_connectors( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> Result { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "Bearer chatgpt-token"); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "account-123"); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { + let response = state + .response + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(Json(response)) + } +} + +fn connector_tool(connector_id: &str, connector_name: &str) -> Result { + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + }))?; + let mut tool = Tool::new( + Cow::Owned(format!("connector_{connector_id}")), + Cow::Borrowed("Connector test tool"), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + let mut meta = Meta::new(); + meta.0 + .insert("connector_id".to_string(), json!(connector_id)); + meta.0 + .insert("connector_name".to_string(), json!(connector_name)); + tool.meta = Some(meta); + Ok(tool) +} + +fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" +mcp_oauth_credentials_store = "file" + +[features] +plugins = true +connectors = true +"# + ), + ) +} + +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +fn write_plugin_marketplace( + repo_root: &std::path::Path, + marketplace_name: &str, + plugin_name: &str, + source_path: &str, +) -> std::io::Result<()> { + std::fs::create_dir_all(repo_root.join(".git"))?; + std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; + std::fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "{marketplace_name}", + "plugins": [ + {{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "{source_path}" + }} + }} + ] +}}"# + ), + ) +} + +fn write_plugin_source( + repo_root: &std::path::Path, + plugin_name: &str, + app_ids: &[&str], +) -> Result<()> { + let plugin_root = repo_root.join(plugin_name); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + + let apps = app_ids + .iter() + .map(|app_id| ((*app_id).to_string(), json!({ "id": app_id }))) + .collect::>(); + std::fs::write( + plugin_root.join(".app.json"), + serde_json::to_vec_pretty(&json!({ "apps": apps }))?, + )?; + Ok(()) +} + +fn write_plugin_share_local_path_mapping( + codex_home: &std::path::Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) -> std::io::Result<()> { + let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new(); + local_plugin_paths_by_remote_plugin_id.insert( + remote_plugin_id.to_string(), + serde_json::to_value(plugin_path).map_err(std::io::Error::other)?, + ); + let contents = serde_json::to_string_pretty(&json!({ + "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id, + })) + .map_err(std::io::Error::other)?; + std::fs::create_dir_all(codex_home.join(".tmp"))?; + std::fs::write( + codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + format!("{contents}\n"), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/plugin_share.rs b/code-rs/app-server/tests/suite/v2/plugin_share.rs new file mode 100644 index 00000000000..dc1f56d487e --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/plugin_share.rs @@ -0,0 +1,852 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareListItem; +use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; +use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn plugin_share_save_uploads_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + write_corrupt_plugin_share_local_path_mapping(codex_home.path())?; + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .and(header("x-ms-blob-type", "BlockBlob")) + .and(header("content-type", "application/gzip")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": expected_plugin_path.clone(), + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareSaveResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareSaveResponse { + remote_plugin_id: "plugins_123".to_string(), + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + } + ); + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let request_id = mcp + .send_raw_request("plugin/share/list", Some(json!({}))) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareListResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + keywords: Vec::new(), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: Some(expected_plugin_path), + }], + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_forwards_access_policy() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + "discoverability": "UNLISTED", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "account-123", + }, + ], + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": expected_plugin_path, + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareSaveResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareSaveResponse { + remote_plugin_id: "plugins_123".to_string(), + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "discoverability": "LISTED", + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "discoverability LISTED is not supported for plugin/share/save; use UNLISTED or PRIVATE" + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "workspace", + "principalId": "account-123", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access" + ); + + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "workspace", + "principalId": "account-123", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access" + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "remotePluginId": "plugins_123", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share settings" + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request("plugin/share/list", Some(json!({}))) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareListResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + keywords: Vec::new(), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, + }], + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("PUT")) + .and(path("/backend-api/ps/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "discoverability": "UNLISTED", + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "account-123", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "owner-1", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + { + "principal_type": "workspace", + "principal_id": "account-123", + "name": "Workspace", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareUpdateTargetsResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + discoverability: codex_app_server_protocol::PluginShareDiscoverability::Unlisted, + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?; + + Mock::given(method("DELETE")) + .and(path("/backend-api/public/plugins/workspace/plugins_123")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/delete", + Some(json!({ + "remotePluginId": "plugins_123", + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareDeleteResponse = to_response(response)?; + + assert_eq!(response, PluginShareDeleteResponse {}); + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let request_id = mcp + .send_raw_request("plugin/share/list", Some(json!({}))) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareListResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + keywords: Vec::new(), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, + }], + } + ); + Ok(()) +} + +fn write_remote_plugin_config(codex_home: &Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { + json!({ + "id": plugin_id, + "name": "demo-plugin", + "scope": "WORKSPACE", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Demo Plugin", + "description": "Demo plugin description", + "interface": { + "short_description": "A demo plugin", + "capabilities": ["Read", "Write"] + }, + "skills": [] + } + }) +} + +fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value { + let mut plugin = remote_plugin_json(plugin_id); + let serde_json::Value::Object(fields) = &mut plugin else { + unreachable!("plugin json should be an object"); + }; + fields.insert("enabled".to_string(), json!(true)); + fields.insert("disabled_skill_names".to_string(), json!([])); + plugin +} + +fn empty_pagination_json() -> serde_json::Value { + json!({ + "next_page_token": null + }) +} + +fn expected_plugin_interface() -> PluginInterface { + PluginInterface { + display_name: Some("Demo Plugin".to_string()), + short_description: Some("A demo plugin".to_string()), + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Read".to_string(), "Write".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + } +} + +fn expected_share_context(plugin_id: &str) -> PluginShareContext { + PluginShareContext { + remote_plugin_id: plugin_id.to_string(), + share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), + creator_account_user_id: None, + creator_name: None, + share_targets: None, + } +} + +fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result { + let plugin_path = root.join(plugin_name); + write_file( + &plugin_path.join(".codex-plugin/plugin.json"), + &format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + write_file( + &plugin_path.join("skills/example/SKILL.md"), + "# Example\n\nA test skill.\n", + )?; + Ok(plugin_path) +} + +fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> { + write_file( + &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + "not-json", + ) +} + +fn write_plugin_share_local_path_mapping( + codex_home: &Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) -> std::io::Result<()> { + let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new(); + local_plugin_paths_by_remote_plugin_id.insert( + remote_plugin_id.to_string(), + serde_json::to_value(plugin_path).map_err(std::io::Error::other)?, + ); + let contents = serde_json::to_string_pretty(&json!({ + "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id, + })) + .map_err(std::io::Error::other)?; + write_file( + &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + &format!("{contents}\n"), + ) +} + +fn write_file(path: &Path, contents: &str) -> std::io::Result<()> { + let Some(parent) = path.parent() else { + return Err(std::io::Error::other(format!( + "file path `{}` should have a parent", + path.display() + ))); + }; + std::fs::create_dir_all(parent)?; + std::fs::write(path, contents) +} diff --git a/code-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/code-rs/app-server/tests/suite/v2/plugin_uninstall.rs new file mode 100644 index 00000000000..5679234d2bc --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -0,0 +1,682 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::bail; +use app_test_support::ChatGptAuthFixture; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::McpProcess; +use app_test_support::start_analytics_events_server; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear"; +const WORKSPACE_REMOTE_PLUGIN_ID: &str = "plugins_69f27c3e67848191a45cbaa5f2adb39d"; + +#[tokio::test] +async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> { + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "debug", "sample-plugin")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let params = PluginUninstallParams { + plugin_id: "sample-plugin@debug".to_string(), + }; + + let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + assert!( + !codex_home + .path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); + + let request_id = mcp.send_plugin_uninstall_request(params).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "debug", "sample-plugin")?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + "chatgpt_base_url = \"{}\"\n\n[features]\nplugins = true\n\n[plugins.\"sample-plugin@debug\"]\nenabled = true\n", + analytics_server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "sample-plugin@debug".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + let payload = timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = analytics_server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + if let Some(request) = requests.iter().find(|request| { + request.method == "POST" && request.url.path() == "/codex/analytics-events/events" + }) { + break request.body.clone(); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await?; + let payload: serde_json::Value = serde_json::from_slice(&payload).expect("analytics payload"); + assert_eq!( + payload, + json!({ + "events": [{ + "event_type": "codex_plugin_uninstalled", + "event_params": { + "plugin_id": "sample-plugin@debug", + "plugin_name": "sample-plugin", + "marketplace_name": "debug", + "has_skills": false, + "mcp_server_count": 0, + "connector_ids": [], + "product_client_id": DEFAULT_CLIENT_NAME, + } + }] + }) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_remote_plugin_when_plugins_are_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = false +"#, + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "plugins~Plugin_sample".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin uninstall is not enabled") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "GLOBAL").await; + + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), + ) + .mount(&server) + .await; + + let remote_plugin_cache_root = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear"); + std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; + std::fs::write( + remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), + r#"{"name":"linear","version":"1.0.0"}"#, + )?; + let legacy_remote_plugin_cache_root = codex_home + .path() + .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + + assert_eq!(response, PluginUninstallResponse {}); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!(!remote_plugin_cache_root.exists()); + assert!(!legacy_remote_plugin_cache_root.exists()); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "WORKSPACE").await; + + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), + ) + .mount(&server) + .await; + + let workspace_cache_root = codex_home + .path() + .join("plugins/cache/workspace-directory/linear"); + std::fs::create_dir_all(workspace_cache_root.join("1.0.0/.codex-plugin"))?; + std::fs::write( + workspace_cache_root.join("1.0.0/.codex-plugin/plugin.json"), + r#"{"name":"linear","version":"1.0.0"}"#, + )?; + let global_cache_root = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear"); + std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + + assert_eq!(response, PluginUninstallResponse {}); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!(!workspace_cache_root.exists()); + assert!(global_cache_root.exists()); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + mount_remote_plugin_detail_with_name( + &server, + WORKSPACE_REMOTE_PLUGIN_ID, + "skill-improver", + "1.0.0", + "WORKSPACE", + ) + .await; + + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + r#"{{"id":"{WORKSPACE_REMOTE_PLUGIN_ID}","enabled":false}}"# + ))) + .mount(&server) + .await; + + let remote_plugin_cache_root = codex_home + .path() + .join("plugins/cache/workspace-directory/skill-improver"); + std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; + std::fs::write( + remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), + r#"{"name":"skill-improver","version":"1.0.0"}"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: WORKSPACE_REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + + assert_eq!(response, PluginUninstallResponse {}); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!(!remote_plugin_cache_root.exists()); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let legacy_remote_plugin_cache_root = codex_home + .path() + .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("remote plugin catalog request")); + wait_for_remote_plugin_request_count( + &server, + "GET", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}"), + /*expected_count*/ 1, + ) + .await?; + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 0, + ) + .await?; + assert!(legacy_remote_plugin_cache_root.exists()); + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_remote_plugin_id_with_spaces_before_network_call() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "sample plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + wait_for_remote_plugin_request_count( + &server, + "POST", + "/plugins/sample plugin/uninstall", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "linear/../../oops".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + wait_for_remote_plugin_request_count( + &server, + "POST", + "/plugins/linear/../../oops/uninstall", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: String::new(), + }) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + + Ok(()) +} + +fn write_installed_plugin( + codex_home: &TempDir, + marketplace_name: &str, + plugin_name: &str, +) -> Result<()> { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local/.codex-plugin"); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + Ok(()) +} + +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +async fn mount_remote_plugin_detail( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + scope: &str, +) { + mount_remote_plugin_detail_with_name( + server, + remote_plugin_id, + "linear", + release_version, + scope, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_name( + server: &MockServer, + remote_plugin_id: &str, + plugin_name: &str, + release_version: &str, + scope: &str, +) { + let detail_body = format!( + r#"{{ + "id": "{remote_plugin_id}", + "name": "{plugin_name}", + "scope": "{scope}", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": {{ + "version": "{release_version}", + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {{ + "short_description": "Plan and track work" + }}, + "skills": [] + }} +}}"# + ); + + Mock::given(method("GET")) + .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(server) + .await; +} + +async fn wait_for_remote_plugin_request_count( + server: &MockServer, + method_name: &str, + path_suffix: &str, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + if expected_count == 0 { + return Ok::<(), anyhow::Error>(()); + } + bail!("wiremock did not record requests"); + }; + let request_count = requests + .iter() + .filter(|request| { + request.method == method_name && request.url.path().ends_with(path_suffix) + }) + .count(); + if request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if request_count > expected_count { + bail!( + "expected exactly {expected_count} {method_name} {path_suffix} requests, got {request_count}" + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/process_exec.rs b/code-rs/app-server/tests/suite/v2/process_exec.rs new file mode 100644 index 00000000000..5dd3e84b4c7 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/process_exec.rs @@ -0,0 +1,250 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use codex_app_server_protocol::ProcessExitedNotification; +use codex_app_server_protocol::ProcessKillParams; +use codex_app_server_protocol::ProcessSpawnParams; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::sleep; +use tokio::time::timeout; +use wiremock::MockServer; + +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::create_config_toml; + +#[tokio::test] +async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Result<()> { + let codex_home = TempDir::new()?; + let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; + + let process_handle = "one-shot-1".to_string(); + let probe_file = codex_home.path().join("process-created"); + let release_file = codex_home.path().join("process-release"); + // Use a probe/release handshake instead of asserting on wall-clock timing: + // the child proves it started by writing the probe file, then waits for the + // test to create the release file before it can emit output and exit. + let command = if cfg!(windows) { + vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + concat!( + "[IO.File]::WriteAllText($env:CODEX_PROCESS_EXEC_PROBE_FILE, 'process'); ", + "while (!(Test-Path -LiteralPath $env:CODEX_PROCESS_EXEC_RELEASE_FILE)) { ", + "Start-Sleep -Milliseconds 20 ", + "}; ", + "[Console]::Out.Write('process-out'); ", + "[Console]::Error.Write('process-err')", + ) + .to_string(), + ] + } else { + vec![ + "sh".to_string(), + "-c".to_string(), + concat!( + "printf process > \"$CODEX_PROCESS_EXEC_PROBE_FILE\"; ", + "while [ ! -e \"$CODEX_PROCESS_EXEC_RELEASE_FILE\" ]; do sleep 0.05; done; ", + "printf process-out; ", + "printf process-err >&2", + ) + .to_string(), + ] + }; + let env = HashMap::from([ + ( + "CODEX_PROCESS_EXEC_PROBE_FILE".to_string(), + Some(probe_file.display().to_string()), + ), + ( + "CODEX_PROCESS_EXEC_RELEASE_FILE".to_string(), + Some(release_file.display().to_string()), + ), + ]); + let spawn_request_id = mcp + .send_process_spawn_request(ProcessSpawnParams { + env: Some(env), + output_bytes_cap: Some(None), + timeout_ms: Some(None), + ..process_spawn_params(process_handle.clone(), codex_home.path(), command)? + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(spawn_request_id)) + .await?; + assert_eq!(response.result, serde_json::json!({})); + + wait_for_file(&probe_file).await?; + assert_eq!(std::fs::read_to_string(&probe_file)?, "process"); + std::fs::write(&release_file, "release")?; + + let exited = read_process_exited(&mut mcp).await?; + assert_eq!( + exited, + ProcessExitedNotification { + process_handle, + exit_code: 0, + stdout: "process-out".to_string(), + stdout_cap_reached: false, + stderr: "process-err".to_string(), + stderr_cap_reached: false, + } + ); + Ok(()) +} + +#[tokio::test] +async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { + let codex_home = TempDir::new()?; + let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; + + let process_handle = "capped-one-shot-1".to_string(); + let command = if cfg!(windows) { + vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "[Console]::Out.Write('abcde'); [Console]::Error.Write('12345')".to_string(), + ] + } else { + vec![ + "sh".to_string(), + "-lc".to_string(), + "printf abcde; printf 12345 >&2".to_string(), + ] + }; + let spawn_request_id = mcp + .send_process_spawn_request(ProcessSpawnParams { + output_bytes_cap: Some(Some(3)), + ..process_spawn_params(process_handle.clone(), codex_home.path(), command)? + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(spawn_request_id)) + .await?; + assert_eq!(response.result, serde_json::json!({})); + + let exited = read_process_exited(&mut mcp).await?; + assert_eq!( + exited, + ProcessExitedNotification { + process_handle, + exit_code: 0, + stdout: "abc".to_string(), + stdout_cap_reached: true, + stderr: "123".to_string(), + stderr_cap_reached: true, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn process_kill_terminates_running_process() -> Result<()> { + let codex_home = TempDir::new()?; + let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; + + let process_handle = "sleep-process-1".to_string(); + let command = if cfg!(windows) { + vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 30".to_string(), + ] + } else { + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()] + }; + let spawn_request_id = mcp + .send_process_spawn_request(process_spawn_params( + process_handle.clone(), + codex_home.path(), + command, + )?) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(spawn_request_id)) + .await?; + assert_eq!(response.result, serde_json::json!({})); + + let kill_request_id = mcp + .send_process_kill_request(ProcessKillParams { + process_handle: process_handle.clone(), + }) + .await?; + let kill_response = mcp + .read_stream_until_response_message(RequestId::Integer(kill_request_id)) + .await?; + assert_eq!(kill_response.result, serde_json::json!({})); + + let exited = read_process_exited(&mut mcp).await?; + assert_eq!(exited.process_handle, process_handle); + assert_ne!(exited.exit_code, 0); + assert_eq!(exited.stdout, ""); + assert!(!exited.stdout_cap_reached); + assert_eq!(exited.stderr, ""); + assert!(!exited.stderr_cap_reached); + + Ok(()) +} + +async fn initialized_mcp(codex_home: &Path) -> Result<(MockServer, McpProcess)> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + create_config_toml(codex_home, &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok((server, mcp)) +} + +fn process_spawn_params( + process_handle: String, + cwd: &Path, + command: Vec, +) -> Result { + Ok(ProcessSpawnParams { + command, + process_handle, + cwd: AbsolutePathBuf::try_from(cwd)?, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }) +} + +async fn read_process_exited(mcp: &mut McpProcess) -> Result { + let notification = mcp + .read_stream_until_notification_message("process/exited") + .await?; + let params = notification + .params + .context("process/exited notification should include params")?; + serde_json::from_value(params).context("deserialize process/exited notification") +} + +async fn wait_for_file(path: &Path) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + while !path.exists() { + sleep(Duration::from_millis(20)).await; + } + }) + .await + .context("timed out waiting for process probe file") +} diff --git a/code-rs/app-server/tests/suite/v2/rate_limits.rs b/code-rs/app-server/tests/suite/v2/rate_limits.rs new file mode 100644 index 00000000000..b15960c1e91 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/rate_limits.rs @@ -0,0 +1,456 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RateLimitReachedType; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; +use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::account::PlanType as AccountPlanType; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const INTERNAL_ERROR_CODE: i64 = -32603; + +#[tokio::test] +async fn get_account_rate_limits_requires_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_get_account_rate_limits_request().await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "codex account authentication required to read rate limits" + ); + + Ok(()) +} + +#[tokio::test] +async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let request_id = mcp.send_get_account_rate_limits_request().await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "chatgpt authentication required to read rate limits" + ); + + Ok(()) +} + +#[tokio::test] +async fn get_account_rate_limits_returns_snapshot() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + let primary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:02:00Z") + .expect("parse primary reset timestamp") + .timestamp(); + let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z") + .expect("parse secondary reset timestamp") + .timestamp(); + let response_body = json!({ + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 42, + "limit_window_seconds": 3600, + "reset_after_seconds": 120, + "reset_at": primary_reset_timestamp, + }, + "secondary_window": { + "used_percent": 5, + "limit_window_seconds": 86400, + "reset_after_seconds": 43200, + "reset_at": secondary_reset_timestamp, + } + }, + "rate_limit_reached_type": { + "type": "workspace_member_usage_limit_reached", + }, + "additional_rate_limits": [ + { + "limit_name": "codex_other", + "metered_feature": "codex_other", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 88, + "limit_window_seconds": 1800, + "reset_after_seconds": 600, + "reset_at": 1735693200 + } + } + } + ] + }); + + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(response_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_get_account_rate_limits_request().await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let received: GetAccountRateLimitsResponse = to_response(response)?; + + let expected = GetAccountRateLimitsResponse { + rate_limits: RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 42, + window_duration_mins: Some(60), + resets_at: Some(primary_reset_timestamp), + }), + secondary: Some(RateLimitWindow { + used_percent: 5, + window_duration_mins: Some(1440), + resets_at: Some(secondary_reset_timestamp), + }), + credits: None, + plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + }, + rate_limits_by_limit_id: Some( + [ + ( + "codex".to_string(), + RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 42, + window_duration_mins: Some(60), + resets_at: Some(primary_reset_timestamp), + }), + secondary: Some(RateLimitWindow { + used_percent: 5, + window_duration_mins: Some(1440), + resets_at: Some(secondary_reset_timestamp), + }), + credits: None, + plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: Some( + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + ), + }, + ), + ( + "codex_other".to_string(), + RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: Some("codex_other".to_string()), + primary: Some(RateLimitWindow { + used_percent: 88, + window_duration_mins: Some(30), + resets_at: Some(1735693200), + }), + secondary: None, + credits: None, + plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, + }, + ), + ] + .into_iter() + .collect(), + ), + }; + assert_eq!(received, expected); + + Ok(()) +} + +#[tokio::test] +async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "codex account authentication required to notify workspace owner" + ); + + Ok(()) +} + +#[tokio::test] +async fn send_add_credits_nudge_email_requires_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "chatgpt authentication required to notify workspace owner" + ); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(wiremock::matchers::body_json(json!({ + "credit_type": "usage_limit", + }))) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::Sent); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .respond_with(ResponseTemplate::new(429)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::CooldownActive); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INTERNAL_ERROR_CODE); + assert!( + error + .error + .message + .contains("failed to notify workspace owner"), + "unexpected error message: {}", + error.error.message + ); + assert_eq!(error.error.data, None); + + Ok(()) +} + +async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { + let request_id = mcp.send_login_account_api_key_request(api_key).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(response)?; + assert_eq!(login, LoginAccountResponse::ApiKey {}); + + Ok(()) +} + +fn write_chatgpt_base_url(codex_home: &Path, base_url: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, format!("chatgpt_base_url = \"{base_url}\"\n")) +} diff --git a/code-rs/app-server/tests/suite/v2/realtime_conversation.rs b/code-rs/app-server/tests/suite/v2/realtime_conversation.rs new file mode 100644 index 00000000000..975819dc7f5 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -0,0 +1,2394 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_shell_command_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; +use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; +use codex_app_server_protocol::ThreadRealtimeAppendTextParams; +use codex_app_server_protocol::ThreadRealtimeAppendTextResponse; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; +use codex_app_server_protocol::ThreadRealtimeClosedNotification; +use codex_app_server_protocol::ThreadRealtimeErrorNotification; +use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; +use codex_app_server_protocol::ThreadRealtimeListVoicesParams; +use codex_app_server_protocol::ThreadRealtimeListVoicesResponse; +use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification; +use codex_app_server_protocol::ThreadRealtimeSdpNotification; +use codex_app_server_protocol::ThreadRealtimeStartParams; +use codex_app_server_protocol::ThreadRealtimeStartResponse; +use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadRealtimeStartedNotification; +use codex_app_server_protocol::ThreadRealtimeStopParams; +use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification; +use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartedNotification; +use codex_features::FEATURES; +use codex_features::Feature; +use codex_protocol::protocol::RealtimeConversationVersion; +use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeVoice; +use codex_protocol::protocol::RealtimeVoicesList; +use core_test_support::responses; +use core_test_support::responses::WebSocketConnectionConfig; +use core_test_support::responses::WebSocketRequest; +use core_test_support::responses::WebSocketTestServer; +use core_test_support::responses::start_websocket_server; +use core_test_support::responses::start_websocket_server_with_headers; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::mpsc; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Match; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request as WiremockRequest; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::path_regex; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DELEGATED_SHELL_TOOL_TIMEOUT_MS: u64 = 30_000; +const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex."; +const V2_STEERING_ACKNOWLEDGEMENT: &str = + "This was sent to steer the previous background agent task."; +const V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT: &str = + "Background agent finished. Use the preceding [BACKEND] messages as the result."; + +#[derive(Debug, Clone, Copy)] +enum StartupContextConfig<'a> { + Generated, + Override(&'a str), +} + +#[derive(Debug, Clone)] +struct RealtimeCallRequestCapture { + requests: Arc>>, +} + +impl RealtimeCallRequestCapture { + fn new() -> Self { + Self { + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + fn single_request(&self) -> WiremockRequest { + let requests = self + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(requests.len(), 1, "expected one realtime call request"); + requests[0].clone() + } +} + +impl Match for RealtimeCallRequestCapture { + fn matches(&self, request: &WiremockRequest) -> bool { + self.requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(request.clone()); + true + } +} + +fn normalized_json_string(raw: &str) -> Result { + let value: Value = serde_json::from_str(raw).context("expected JSON fixture to parse")?; + serde_json::to_string(&value).context("expected JSON fixture to serialize") +} + +struct GatedSseResponse { + gate_rx: Mutex>>, + response: String, +} + +impl Respond for GatedSseResponse { + fn respond(&self, _: &WiremockRequest) -> ResponseTemplate { + let gate_rx = self + .gate_rx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(gate_rx) = gate_rx { + let _ = gate_rx.recv(); + } + responses::sse_response(self.response.clone()) + } +} + +#[derive(Debug, Clone, Copy)] +enum RealtimeTestVersion { + V1, + V2, +} + +impl RealtimeTestVersion { + fn config_value(self) -> &'static str { + match self { + RealtimeTestVersion::V1 => "v1", + RealtimeTestVersion::V2 => "v2", + } + } +} + +#[derive(Debug, Clone, Copy)] +enum RealtimeTestSandbox { + ReadOnly, + DangerFullAccess, +} + +impl RealtimeTestSandbox { + fn config_value(self) -> &'static str { + match self { + RealtimeTestSandbox::ReadOnly => "read-only", + RealtimeTestSandbox::DangerFullAccess => "danger-full-access", + } + } +} + +#[derive(Debug, PartialEq)] +struct StartedWebrtcRealtime { + started: ThreadRealtimeStartedNotification, + sdp: ThreadRealtimeSdpNotification, +} + +// Scripted SSE responses for the normal background agent loop. Realtime can ask for a delegated +// background agent turn; that turn talks to this mock `/responses` endpoint and may request +// ordinary tools. +struct MainLoopResponsesScript { + responses: Vec, +} + +// Scripted server events for the direct realtime sideband WebSocket. This mock is the realtime +// session app-server joins after call creation; it is not the background agent Responses stream. +struct RealtimeSidebandScript { + connections: Vec, +} + +struct RealtimeE2eHarness { + mcp: McpProcess, + _codex_home: TempDir, + main_loop_responses_server: MockServer, + realtime_server: WebSocketTestServer, + call_capture: RealtimeCallRequestCapture, + thread_id: String, +} + +impl RealtimeE2eHarness { + // Owns the full mocked app-server realtime route: MCP client, Responses mocks, WebRTC call + // creation capture, sideband WebSocket server, login, config, and a started thread. + async fn new( + realtime_version: RealtimeTestVersion, + main_loop: MainLoopResponsesScript, + realtime_sideband: RealtimeSidebandScript, + ) -> Result { + let main_loop_responses_server = + create_mock_responses_server_sequence_unchecked(main_loop.responses).await; + Self::new_with_main_loop_responses_server_and_sandbox( + realtime_version, + main_loop_responses_server, + realtime_sideband, + RealtimeTestSandbox::ReadOnly, + ) + .await + } + + async fn new_with_sandbox( + realtime_version: RealtimeTestVersion, + main_loop: MainLoopResponsesScript, + realtime_sideband: RealtimeSidebandScript, + sandbox: RealtimeTestSandbox, + ) -> Result { + let main_loop_responses_server = + create_mock_responses_server_sequence_unchecked(main_loop.responses).await; + Self::new_with_main_loop_responses_server_and_sandbox( + realtime_version, + main_loop_responses_server, + realtime_sideband, + sandbox, + ) + .await + } + + async fn new_with_main_loop_responses_server( + realtime_version: RealtimeTestVersion, + main_loop_responses_server: MockServer, + realtime_sideband: RealtimeSidebandScript, + ) -> Result { + Self::new_with_main_loop_responses_server_and_sandbox( + realtime_version, + main_loop_responses_server, + realtime_sideband, + RealtimeTestSandbox::ReadOnly, + ) + .await + } + + async fn new_with_main_loop_responses_server_and_sandbox( + realtime_version: RealtimeTestVersion, + main_loop_responses_server: MockServer, + realtime_sideband: RealtimeSidebandScript, + sandbox: RealtimeTestSandbox, + ) -> Result { + let call_capture = RealtimeCallRequestCapture::new(); + Mock::given(method("POST")) + .and(path("/v1/realtime/calls")) + .and(call_capture.clone()) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Location", "/v1/realtime/calls/rtc_e2e") + .set_body_string("v=answer\r\n"), + ) + .mount(&main_loop_responses_server) + .await; + + let realtime_server = + start_websocket_server_with_headers(realtime_sideband.connections).await; + let codex_home = TempDir::new()?; + create_config_toml_with_realtime_version( + codex_home.path(), + &main_loop_responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ true, + StartupContextConfig::Override("startup context"), + realtime_version, + sandbox, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + Ok(Self { + mcp, + _codex_home: codex_home, + main_loop_responses_server, + realtime_server, + call_capture, + thread_id: thread_start.thread.id, + }) + } + + async fn start_webrtc_realtime(&mut self, offer_sdp: &str) -> Result { + // Starts realtime through the public JSON-RPC method, then waits for the same client-visible + // notifications a desktop app needs: started first, SDP answer second. + let start_request_id = self + .mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: self.thread_id.clone(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ThreadRealtimeStartTransport::Webrtc { + sdp: offer_sdp.to_string(), + }), + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + self.mcp + .read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + let started = self + .read_notification::("thread/realtime/started") + .await?; + let sdp = self + .read_notification::("thread/realtime/sdp") + .await?; + + Ok(StartedWebrtcRealtime { started, sdp }) + } + + async fn read_notification(&mut self, method: &str) -> Result { + read_notification(&mut self.mcp, method).await + } + + /// Returns the nth JSON message app-server wrote to the fake Realtime API + /// sideband websocket. + async fn sideband_outbound_request(&self, request_index: usize) -> Value { + timeout( + DEFAULT_TIMEOUT, + self.realtime_server + .wait_for_request(/*connection_index*/ 0, request_index), + ) + .await + .unwrap_or_else(|_| { + panic!("timed out waiting for realtime sideband request {request_index}") + }) + .body_json() + } + + async fn append_audio(&mut self, thread_id: String) -> Result<()> { + let request_id = self + .mcp + .send_thread_realtime_append_audio_request(ThreadRealtimeAppendAudioParams { + thread_id, + audio: ThreadRealtimeAudioChunk { + data: "BQYH".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(480), + item_id: None, + }, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + self.mcp + .read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadRealtimeAppendAudioResponse = to_response(response)?; + Ok(()) + } + + async fn append_text(&mut self, thread_id: String, text: &str) -> Result<()> { + let request_id = self + .mcp + .send_thread_realtime_append_text_request(ThreadRealtimeAppendTextParams { + thread_id, + text: text.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + self.mcp + .read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadRealtimeAppendTextResponse = to_response(response)?; + Ok(()) + } + + async fn main_loop_responses_requests(&self) -> Result> { + responses_requests(&self.main_loop_responses_server).await + } + + async fn shutdown(self) { + self.realtime_server.shutdown().await; + } +} + +fn main_loop_responses(responses: Vec) -> MainLoopResponsesScript { + MainLoopResponsesScript { responses } +} + +fn no_main_loop_responses() -> MainLoopResponsesScript { + main_loop_responses(Vec::new()) +} + +fn realtime_sideband(connections: Vec) -> RealtimeSidebandScript { + RealtimeSidebandScript { connections } +} + +fn realtime_sideband_connection( + realtime_server_events: Vec>, +) -> WebSocketConnectionConfig { + WebSocketConnectionConfig { + requests: realtime_server_events, + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: true, + } +} + +fn open_realtime_sideband_connection( + realtime_server_events: Vec>, +) -> WebSocketConnectionConfig { + WebSocketConnectionConfig { + close_after_requests: false, + ..realtime_sideband_connection(realtime_server_events) + } +} + +fn session_updated(realtime_session_id: &str) -> Value { + json!({ + "type": "session.updated", + "session": { "id": realtime_session_id, "instructions": "backend prompt" } + }) +} + +fn v2_background_agent_tool_call(call_id: &str, prompt: &str) -> Value { + json!({ + "type": "conversation.item.done", + "item": { + "id": format!("item_{call_id}"), + "type": "function_call", + "name": "background_agent", + "call_id": call_id, + "arguments": json!({ "prompt": prompt }).to_string() + } + }) +} + +#[tokio::test] +async fn realtime_conversation_streams_v2_notifications() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses_server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("delegated")?, + ]) + .await; + let realtime_server = start_websocket_server(vec![vec![ + vec![json!({ + "type": "session.updated", + "session": { "id": "sess_backend", "instructions": "backend prompt" } + })], + vec![], + vec![ + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24_000, + "channels": 1, + "samples_per_channel": 512 + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "assistant", + "content": [{ "type": "text", "text": "hi" }] + } + }), + json!({ + "type": "conversation.item.input_audio_transcription.delta", + "delta": "delegate now" + }), + json!({ + "type": "response.output_text.delta", + "delta": "working" + }), + json!({ + "type": "response.output_text.done", + "text": "working on it" + }), + json!({ + "type": "conversation.item.done", + "item": { + "id": "item_assistant_1", + "type": "message", + "role": "assistant", + "content": [{ "type": "output_text", "text": "working on it" }] + } + }), + json!({ + "type": "conversation.item.done", + "item": { + "id": "item_2", + "type": "function_call", + "name": "background_agent", + "call_id": "handoff_1", + "arguments": "{\"input_transcript\":\"delegate now\"}" + } + }), + json!({ + "type": "error", + "message": "upstream boom" + }), + ], + vec![], + ]]) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ true, + StartupContextConfig::Generated, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + let start_request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: thread_start.thread.id.clone(), + output_modality: RealtimeOutputModality::Audio, + prompt: None, + realtime_session_id: None, + transport: None, + voice: Some(RealtimeVoice::Cedar), + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + let started = + read_notification::(&mut mcp, "thread/realtime/started") + .await?; + assert_eq!(started.thread_id, thread_start.thread.id); + assert!(started.realtime_session_id.is_some()); + assert_eq!(started.version, RealtimeConversationVersion::V2); + + let startup_context_request = realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await; + assert_eq!( + startup_context_request.body_json()["type"].as_str(), + Some("session.update") + ); + assert_eq!( + startup_context_request.body_json()["session"]["audio"]["output"]["voice"], + "cedar" + ); + assert_eq!( + startup_context_request.body_json()["session"]["output_modalities"], + json!(["audio"]) + ); + let startup_context_instructions = + startup_context_request.body_json()["session"]["instructions"] + .as_str() + .context("expected startup context instructions")? + .to_string(); + assert!(startup_context_instructions.starts_with("backend prompt")); + assert!(startup_context_instructions.contains(STARTUP_CONTEXT_HEADER)); + + let audio_append_request_id = mcp + .send_thread_realtime_append_audio_request(ThreadRealtimeAppendAudioParams { + thread_id: started.thread_id.clone(), + audio: ThreadRealtimeAudioChunk { + data: "BQYH".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(480), + item_id: None, + }, + }) + .await?; + let audio_append_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(audio_append_request_id)), + ) + .await??; + let _: ThreadRealtimeAppendAudioResponse = to_response(audio_append_response)?; + + let text_append_request_id = mcp + .send_thread_realtime_append_text_request(ThreadRealtimeAppendTextParams { + thread_id: started.thread_id.clone(), + text: "hello".to_string(), + }) + .await?; + let text_append_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(text_append_request_id)), + ) + .await??; + let _: ThreadRealtimeAppendTextResponse = to_response(text_append_response)?; + + let output_audio = read_notification::( + &mut mcp, + "thread/realtime/outputAudio/delta", + ) + .await?; + assert_eq!(output_audio.audio.data, "AQID"); + assert_eq!(output_audio.audio.sample_rate, 24_000); + assert_eq!(output_audio.audio.num_channels, 1); + assert_eq!(output_audio.audio.samples_per_channel, Some(512)); + + let item_added = read_notification::( + &mut mcp, + "thread/realtime/itemAdded", + ) + .await?; + assert_eq!(item_added.thread_id, output_audio.thread_id); + assert_eq!(item_added.item["type"], json!("message")); + + let first_transcript_delta = read_notification::( + &mut mcp, + "thread/realtime/transcript/delta", + ) + .await?; + assert_eq!(first_transcript_delta.thread_id, output_audio.thread_id); + assert_eq!(first_transcript_delta.role, "user"); + assert_eq!(first_transcript_delta.delta, "delegate now"); + + let second_transcript_delta = read_notification::( + &mut mcp, + "thread/realtime/transcript/delta", + ) + .await?; + assert_eq!(second_transcript_delta.thread_id, output_audio.thread_id); + assert_eq!(second_transcript_delta.role, "assistant"); + assert_eq!(second_transcript_delta.delta, "working"); + + let final_transcript_done = read_notification::( + &mut mcp, + "thread/realtime/transcript/done", + ) + .await?; + assert_eq!(final_transcript_done.thread_id, output_audio.thread_id); + assert_eq!(final_transcript_done.role, "assistant"); + assert_eq!(final_transcript_done.text, "working on it"); + + let handoff_item_added = read_notification::( + &mut mcp, + "thread/realtime/itemAdded", + ) + .await?; + assert_eq!(handoff_item_added.thread_id, output_audio.thread_id); + assert_eq!(handoff_item_added.item["type"], json!("handoff_request")); + assert_eq!(handoff_item_added.item["handoff_id"], json!("handoff_1")); + assert_eq!(handoff_item_added.item["item_id"], json!("item_2")); + assert_eq!( + handoff_item_added.item["input_transcript"], + json!("delegate now") + ); + assert_eq!( + handoff_item_added.item["active_transcript"], + json!([ + {"role": "user", "text": "delegate now"}, + {"role": "assistant", "text": "working on it"} + ]) + ); + + let realtime_error = + read_notification::(&mut mcp, "thread/realtime/error") + .await?; + assert_eq!(realtime_error.thread_id, output_audio.thread_id); + assert_eq!(realtime_error.message, "upstream boom"); + + let closed = + read_notification::(&mut mcp, "thread/realtime/closed") + .await?; + assert_eq!(closed.thread_id, output_audio.thread_id); + assert_eq!(closed.reason.as_deref(), Some("error")); + + let connections = realtime_server.connections(); + assert_eq!(connections.len(), 1); + let connection = &connections[0]; + assert_eq!(connection.len(), 3); + assert_eq!( + connection[0].body_json()["type"].as_str(), + Some("session.update") + ); + assert_eq!( + connection[0].body_json()["session"]["instructions"].as_str(), + Some(startup_context_instructions.as_str()), + ); + let mut request_types = [ + connection[1].body_json()["type"] + .as_str() + .context("expected websocket request type")? + .to_string(), + connection[2].body_json()["type"] + .as_str() + .context("expected websocket request type")? + .to_string(), + ]; + request_types.sort(); + assert_eq!( + request_types, + [ + "conversation.item.create".to_string(), + "input_audio_buffer.append".to_string(), + ] + ); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn realtime_text_output_modality_requests_text_output_and_final_transcript() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let realtime_server = start_websocket_server(vec![vec![vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_text", "instructions": "backend prompt" } + }), + json!({ + "type": "response.output_text.delta", + "delta": "hello " + }), + json!({ + "type": "response.output_text.delta", + "delta": "world" + }), + json!({ + "type": "response.output_audio_transcript.done", + "transcript": "hello world" + }), + json!({ + "type": "conversation.item.done", + "item": { + "id": "item_output_1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hello world"}] + } + }), + ]]]) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ true, + StartupContextConfig::Generated, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + let start_request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: thread_start.thread.id.clone(), + output_modality: RealtimeOutputModality::Text, + prompt: None, + realtime_session_id: None, + transport: None, + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + let session_update = realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await; + assert_eq!( + session_update.body_json()["session"]["output_modalities"], + json!(["text"]) + ); + + let first_delta = read_notification::( + &mut mcp, + "thread/realtime/transcript/delta", + ) + .await?; + let second_delta = read_notification::( + &mut mcp, + "thread/realtime/transcript/delta", + ) + .await?; + let done = read_notification::( + &mut mcp, + "thread/realtime/transcript/done", + ) + .await?; + assert_eq!( + vec![first_delta, second_delta], + vec![ + ThreadRealtimeTranscriptDeltaNotification { + thread_id: thread_start.thread.id.clone(), + role: "assistant".to_string(), + delta: "hello ".to_string(), + }, + ThreadRealtimeTranscriptDeltaNotification { + thread_id: thread_start.thread.id.clone(), + role: "assistant".to_string(), + delta: "world".to_string(), + }, + ] + ); + assert_eq!( + done, + ThreadRealtimeTranscriptDoneNotification { + thread_id: thread_start.thread.id, + role: "assistant".to_string(), + text: "hello world".to_string(), + } + ); + assert!( + timeout( + Duration::from_millis(200), + mcp.read_stream_until_notification_message("thread/realtime/transcript/done"), + ) + .await + .is_err(), + "should not emit duplicate transcript done from audio transcript done" + ); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn realtime_list_voices_returns_supported_names() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://127.0.0.1:1", + "ws://127.0.0.1:1", + /*realtime_enabled*/ true, + StartupContextConfig::Generated, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_realtime_list_voices_request(ThreadRealtimeListVoicesParams {}) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadRealtimeListVoicesResponse = to_response(response)?; + + assert_eq!( + response, + ThreadRealtimeListVoicesResponse { + voices: RealtimeVoicesList { + v1: vec![ + RealtimeVoice::Juniper, + RealtimeVoice::Maple, + RealtimeVoice::Spruce, + RealtimeVoice::Ember, + RealtimeVoice::Vale, + RealtimeVoice::Breeze, + RealtimeVoice::Arbor, + RealtimeVoice::Sol, + RealtimeVoice::Cove, + ], + v2: vec![ + RealtimeVoice::Alloy, + RealtimeVoice::Ash, + RealtimeVoice::Ballad, + RealtimeVoice::Coral, + RealtimeVoice::Echo, + RealtimeVoice::Sage, + RealtimeVoice::Shimmer, + RealtimeVoice::Verse, + RealtimeVoice::Marin, + RealtimeVoice::Cedar, + ], + default_v1: RealtimeVoice::Cove, + default_v2: RealtimeVoice::Marin, + }, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let realtime_server = start_websocket_server(vec![vec![ + vec![json!({ + "type": "session.updated", + "session": { "id": "sess_backend", "instructions": "backend prompt" } + })], + vec![], + ]]) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ true, + StartupContextConfig::Generated, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + let start_request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: thread_start.thread.id.clone(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: None, + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + let started = + read_notification::(&mut mcp, "thread/realtime/started") + .await?; + + let stop_request_id = mcp + .send_thread_realtime_stop_request(ThreadRealtimeStopParams { + thread_id: started.thread_id.clone(), + }) + .await?; + let stop_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(stop_request_id)), + ) + .await??; + let _: ThreadRealtimeStopResponse = to_response(stop_response)?; + + let closed = + read_notification::(&mut mcp, "thread/realtime/closed") + .await?; + assert_eq!(closed.thread_id, started.thread_id); + assert!(matches!( + closed.reason.as_deref(), + Some("requested" | "transport_closed") + )); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let call_capture = RealtimeCallRequestCapture::new(); + Mock::given(method("POST")) + .and(path("/v1/realtime/calls")) + .and(call_capture.clone()) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Location", "/v1/realtime/calls/rtc_app_test") + .set_body_string("v=answer\r\n"), + ) + .mount(&responses_server) + .await; + let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_webrtc", "instructions": "backend prompt" } + })]], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: false, + }]) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ true, + StartupContextConfig::Override("startup context"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + let thread_id = thread_start.thread.id; + let start_request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: thread_id.clone(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ThreadRealtimeStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + let started = + read_notification::(&mut mcp, "thread/realtime/started") + .await?; + assert_eq!(started.thread_id, thread_id); + assert_eq!(started.version, RealtimeConversationVersion::V2); + + let sdp_notification = + read_notification::(&mut mcp, "thread/realtime/sdp").await?; + assert_eq!( + sdp_notification, + ThreadRealtimeSdpNotification { + thread_id: thread_id.clone(), + sdp: "v=answer\r\n".to_string() + } + ); + + let session_update = realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await; + assert_eq!( + session_update.body_json()["type"].as_str(), + Some("session.update") + ); + assert!( + session_update.body_json()["session"]["instructions"] + .as_str() + .context("expected session.update instructions")? + .contains("startup context") + ); + assert_eq!( + realtime_server.single_handshake().uri(), + "/v1/realtime?call_id=rtc_app_test" + ); + + let stop_request_id = mcp + .send_thread_realtime_stop_request(ThreadRealtimeStopParams { + thread_id: thread_id.clone(), + }) + .await?; + let stop_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(stop_request_id)), + ) + .await??; + let _: ThreadRealtimeStopResponse = to_response(stop_response)?; + + let closed_notification = + read_notification::(&mut mcp, "thread/realtime/closed") + .await?; + assert_eq!(closed_notification.thread_id, thread_id); + assert!( + matches!( + closed_notification.reason.as_deref(), + Some("requested" | "transport_closed") + ), + "unexpected close reason: {closed_notification:?}" + ); + + let request = call_capture.single_request(); + assert_eq!(request.url.path(), "/v1/realtime/calls"); + assert_eq!(request.url.query(), None); + assert_eq!( + request + .headers + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("multipart/form-data; boundary=codex-realtime-call-boundary") + ); + let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; + let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"transcription":{"model":"gpt-4o-mini-transcribe"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true,"silence_duration_ms":500}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}},{"type":"function","name":"remain_silent","description":"Call this when the best response is to say nothing. Use it instead of speaking after hidden system/control messages, after background agent updates in silent modes, or whenever acknowledging aloud would be distracting. This tool has no user-visible effect.","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}"#; + let session = normalized_json_string(session)?; + assert_eq!( + body, + format!( + "--codex-realtime-call-boundary\r\n\ + Content-Disposition: form-data; name=\"sdp\"\r\n\ + Content-Type: application/sdp\r\n\ + \r\n\ + v=offer\r\n\ + \r\n\ + --codex-realtime-call-boundary\r\n\ + Content-Disposition: form-data; name=\"session\"\r\n\ + Content-Type: application/json\r\n\ + \r\n\ + {session}\r\n\ + --codex-realtime-call-boundary--\r\n" + ) + ); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v1_start_posts_offer_returns_sdp_and_joins_sideband() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: build a v1 realtime thread with a mocked call-create response and a sideband socket + // that immediately proves the joined connection can receive server events. + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + no_main_loop_responses(), + realtime_sideband(vec![open_realtime_sideband_connection(vec![vec![ + session_updated("sess_v1_webrtc"), + ]])]), + ) + .await?; + + // Phase 2: start through app-server and assert the app receives both the started notification + // and the answer SDP. + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!( + started, + StartedWebrtcRealtime { + started: ThreadRealtimeStartedNotification { + thread_id: harness.thread_id.clone(), + realtime_session_id: Some(harness.thread_id.clone()), + version: RealtimeConversationVersion::V1, + }, + sdp: ThreadRealtimeSdpNotification { + thread_id: harness.thread_id.clone(), + sdp: "v=answer\r\n".to_string(), + }, + } + ); + + // Phase 3: verify the HTTP call-create leg, the direct sideband join, and the normal v1 + // session.update; the WebRTC transport should remain alive instead of closing after SDP. + assert_call_create_multipart( + harness.call_capture.single_request(), + "v=offer\r\n", + v1_session_create_json(), + )?; + + let session_update = harness.sideband_outbound_request(/*request_index*/ 0).await; + assert_v1_session_update(&session_update)?; + assert_eq!( + harness.realtime_server.single_handshake().uri(), + "/v1/realtime?intent=quicksilver&call_id=rtc_e2e" + ); + + let closed = timeout( + Duration::from_millis(100), + harness + .mcp + .read_stream_until_notification_message("thread/realtime/closed"), + ) + .await; + assert!(closed.is_err(), "WebRTC start should not close immediately"); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: script one v1 handoff request on the sideband and one delegated Responses turn. + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "delegated from v1", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v1_handoff"), + json!({ + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "delegate from v1" + }), + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "the secret word is " + }), + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "kumquat" + }), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_v1", + "item_id": "item_v1", + "input_transcript": "delegate from v1" + }), + ], + vec![], + ])]), + ) + .await?; + + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V1); + assert_call_create_multipart( + harness.call_capture.single_request(), + "v=offer\r\n", + v1_session_create_json(), + )?; + assert_v1_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + + // Phase 2: wait for the delegated background agent turn that is launched by the handoff request. + let turn_started = harness + .read_notification::("turn/started") + .await?; + assert_eq!(turn_started.thread_id, harness.thread_id); + let turn_completed = harness + .read_notification::("turn/completed") + .await?; + assert_eq!(turn_completed.thread_id, harness.thread_id); + + // Phase 3: assert the delegated prompt went to Responses, then the v1 handoff append went back + // over the existing sideband connection. + let requests = harness.main_loop_responses_requests().await?; + assert_eq!(requests.len(), 1); + assert!( + response_request_contains_text( + &requests[0], + "\n delegate from v1\n user: delegate from v1\nassistant: the secret word is kumquat\n", + ), + "delegated Responses request should contain realtime delegation envelope: {}", + requests[0] + ); + let handoff_append = harness.sideband_outbound_request(/*request_index*/ 1).await; + assert_eq!( + handoff_append, + json!({ + "type": "conversation.handoff.append", + "handoff_id": "handoff_v1", + "output_text": "\"Agent Final Message\":\n\ndelegated from v1", + }) + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: create a v2 WebRTC conversation whose sideband sends transcript + output audio + // after the client has had a chance to append input. + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V2, + no_main_loop_responses(), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![session_updated("sess_v2_stream")], + vec![], + vec![ + json!({ + "type": "conversation.item.input_audio_transcription.delta", + "delta": "transcribed audio" + }), + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24_000, + "channels": 1, + "samples_per_channel": 512 + }), + ], + ])]), + ) + .await?; + + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + + // Phase 2: drive app-server as the client would: append audio, append text, then receive + // transcript/audio notifications that came from the sideband socket. + let thread_id = started.started.thread_id.clone(); + harness.append_audio(thread_id.clone()).await?; + harness.append_text(thread_id, "hello").await?; + + let transcript = harness + .read_notification::( + "thread/realtime/transcript/delta", + ) + .await?; + assert_eq!(transcript.delta, "transcribed audio"); + let output_audio = harness + .read_notification::( + "thread/realtime/outputAudio/delta", + ) + .await?; + assert_eq!(output_audio.audio.data, "AQID"); + + // Phase 3: prove the client inputs were translated into the v2 realtime sideband events. + let requests = [ + harness.sideband_outbound_request(/*request_index*/ 1).await, + harness.sideband_outbound_request(/*request_index*/ 2).await, + ]; + assert!( + requests + .iter() + .any(|request| request["type"] == "input_audio_buffer.append" + && request["audio"] == "BQYH"), + "sideband requests should include audio append: {requests:?}" + ); + assert!( + requests.iter().any(|request| { + request["type"] == "conversation.item.create" + && request["item"]["type"] == "message" + && request["item"]["role"] == "user" + && request["item"]["content"][0]["type"] == "input_text" + && request["item"]["content"][0]["text"] == "[USER] hello" + }), + "sideband requests should include user text item: {requests:?}" + ); + + harness.shutdown().await; + Ok(()) +} + +/// Regression coverage for Realtime V2 text input while a response is active. +/// +/// Text input is append-only, so app-server should send the user message without +/// requesting a new realtime response. +#[tokio::test] +async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: script a server-side response that becomes active after the first + // user text turn, then finishes only after a later audio input. + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V2, + no_main_loop_responses(), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![session_updated("sess_v2_response_queue")], + vec![ + json!({ + "type": "response.created", + "response": { "id": "resp_active" } + }), + json!({ + "type": "response.output_text.delta", + "delta": "active response started" + }), + ], + vec![], + vec![json!({ + "type": "response.done", + "response": { "id": "resp_active" } + })], + ])]), + ) + .await?; + + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V2); + + // From here on, `sideband_outbound_request(n)` reads outbound messages to + // the fake Realtime API sideband websocket. These are not client-facing + // notifications; they are the protocol frames app-server sends upstream. + assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + + // Phase 2: send the first text turn. Text input is append-only, so this + // sends only the user text item. + let thread_id = started.started.thread_id.clone(); + harness.append_text(thread_id.clone(), "first").await?; + assert_v2_user_text_item( + &harness.sideband_outbound_request(/*request_index*/ 1).await, + "first", + ); + let transcript = harness + .read_notification::( + "thread/realtime/transcript/delta", + ) + .await?; + assert_eq!(transcript.delta, "active response started"); + + // Phase 3: send a second text turn while `resp_active` is still open. The + // user message must reach realtime without requesting another response. + harness.append_text(thread_id.clone(), "second").await?; + assert_v2_user_text_item( + &harness.sideband_outbound_request(/*request_index*/ 2).await, + "second", + ); + + // Phase 4: audio still forwards normally after text input. + harness.append_audio(thread_id).await?; + + let audio = harness.sideband_outbound_request(/*request_index*/ 3).await; + assert_eq!(audio["type"], "input_audio_buffer.append"); + assert_eq!(audio["audio"], "BQYH"); + + harness.shutdown().await; + Ok(()) +} + +/// Regression coverage for append-only Realtime V2 text input when the active +/// response is cancelled instead of completed. +#[tokio::test] +async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: script a server-side response that becomes active after the first + // text turn, then is cancelled only after a later audio input. + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V2, + no_main_loop_responses(), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![session_updated("sess_v2_response_cancel_queue")], + vec![json!({ + "type": "response.created", + "response": { "id": "resp_cancelled" } + })], + vec![], + vec![json!({ + "type": "response.cancelled", + "response": { "id": "resp_cancelled" } + })], + ])]), + ) + .await?; + + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + + // Phase 2: send the first text turn. Text input is append-only, so this + // sends only the user text item. + let thread_id = started.started.thread_id.clone(); + harness.append_text(thread_id.clone(), "first").await?; + assert_v2_user_text_item( + &harness.sideband_outbound_request(/*request_index*/ 1).await, + "first", + ); + + // Phase 3: send a second text turn while `resp_cancelled` is still open. + // The user message must reach realtime without requesting another response. + harness.append_text(thread_id.clone(), "second").await?; + assert_v2_user_text_item( + &harness.sideband_outbound_request(/*request_index*/ 2).await, + "second", + ); + + // Phase 4: audio still forwards normally after text input. + harness.append_audio(thread_id).await?; + + let audio = harness.sideband_outbound_request(/*request_index*/ 3).await; + assert_eq!(audio["type"], "input_audio_buffer.append"); + assert_eq!(audio["audio"], "BQYH"); + + harness.shutdown().await; + Ok(()) +} + +/// Regression coverage for the Realtime V2 background-agent final-output path. +/// +/// Once the background agent finishes, app-server sends the final function-call +/// output to realtime and then requests a new `response.create` so realtime can +/// react to that final output. +#[tokio::test] +async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_output() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + // Phase 1: script a v2 background agent function call and a delegated Responses turn that + // returns final assistant text. + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V2, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "delegated from v2", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v2_tool"), + json!({ + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "Hi how are you" + }), + json!({ + "type": "response.output_audio_transcript.done", + "transcript": "Doing well, what can I help you with?" + }), + json!({ + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "The secret word is strawberry" + }), + json!({ + "type": "conversation.item.created", + "item": { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "silent_delegate" + }] + } + }), + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "Got it-strawberry. What's next on the menu?" + }), + v2_background_agent_tool_call("call_v2", "run ls"), + ], + vec![], + vec![], + vec![], + ])]), + ) + .await?; + + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V2); + + // Phase 2: wait for the delegated turn lifecycle kicked off by the v2 function-call item. + let turn_started = harness + .read_notification::("turn/started") + .await?; + assert_eq!(turn_started.thread_id, harness.thread_id); + let turn_completed = harness + .read_notification::("turn/completed") + .await?; + assert_eq!(turn_completed.thread_id, harness.thread_id); + + // Phase 3: assert the delegated prompt went to Responses and the result + // returned as exactly one v2 function-call output event on the sideband. + let requests = harness.main_loop_responses_requests().await?; + assert_eq!(requests.len(), 1); + assert!( + response_request_contains_text( + &requests[0], + "\n run ls\n user: Hi how are you\nassistant: Doing well, what can I help you with?\nuser: The secret word is strawberry\nassistant: Got it-strawberry. What's next on the menu?\nuser: run ls\n", + ), + "delegated Responses request should contain realtime delegation envelope: {}", + requests[0] + ); + assert!( + !response_request_contains_text(&requests[0], ""), + "delegated Responses request should not include realtime control injects: {}", + requests[0] + ); + + let progress = harness.sideband_outbound_request(/*request_index*/ 1).await; + assert_v2_progress_update(&progress, "delegated from v2"); + + let tool_output = harness.sideband_outbound_request(/*request_index*/ 2).await; + assert_v2_function_call_output(&tool_output, "call_v2", V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT); + assert_eq!( + function_call_output_sideband_requests(&harness.realtime_server).len(), + 1 + ); + + // Phase 4: after the final function-call output, realtime needs an explicit + // `response.create` to produce the next user-visible response. + assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 3).await); + + harness.shutdown().await; + Ok(()) +} + +/// Regression coverage for Realtime V2 steering while a background-agent task is +/// already active. +/// +/// The second background-agent tool call is treated as guidance for the active +/// task. App-server acknowledges that steering message to realtime and then +/// emits `response.create` so realtime can speak that acknowledgement. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn webrtc_v2_background_agent_steering_ack_requests_response_create() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: gate the delegated Responses turn from the first tool call so + // the background-agent handoff stays active while realtime sends a second + // tool call that should steer the active task. + let main_loop_responses_server = responses::start_mock_server().await; + let (gate_completed_tx, gate_completed_rx) = mpsc::channel(); + let gated_response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "first task finished"), + responses::ev_completed("resp-1"), + ]); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(GatedSseResponse { + gate_rx: Mutex::new(Some(gate_completed_rx)), + response: gated_response, + }) + .expect(2) + .mount(&main_loop_responses_server) + .await; + + let mut harness = RealtimeE2eHarness::new_with_main_loop_responses_server( + RealtimeTestVersion::V2, + main_loop_responses_server, + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v2_steering_ack"), + v2_background_agent_tool_call("call_active", "start a task"), + v2_background_agent_tool_call("call_steer", "steer the active task"), + ], + vec![], + vec![], + vec![], + vec![], + ])]), + ) + .await?; + + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + let turn_started = harness + .read_notification::("turn/started") + .await?; + assert_eq!(turn_started.thread_id, harness.thread_id); + + // Phase 2: the second tool call happens while `call_active` is still + // running, so app-server sends a steering acknowledgement as a function-call + // output for the second call. + assert_v2_function_call_output( + &harness.sideband_outbound_request(/*request_index*/ 1).await, + "call_steer", + V2_STEERING_ACKNOWLEDGEMENT, + ); + + // Phase 3: realtime needs a `response.create` after the steering + // acknowledgement so it can surface that acknowledgement to the user. + assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 2).await); + + // Phase 4: release the gated delegated turn. Codex should then continue + // the same run with the steering text included in the follow-up Responses + // request, proving realtime did not merely acknowledge and drop it. + let _ = gate_completed_tx.send(()); + let turn_completed = harness + .read_notification::("turn/completed") + .await?; + assert_eq!(turn_completed.thread_id, harness.thread_id); + + let requests = harness.main_loop_responses_requests().await?; + assert_eq!(requests.len(), 2); + assert!( + response_request_contains_text(&requests[1], "steer the active task"), + "follow-up Responses request should contain steering prompt: {}", + requests[1] + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v2_background_agent_progress_is_sent_before_function_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V2, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "progress before final", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v2_progress_before_final"), + v2_background_agent_tool_call("call_progress_order", "stream progress"), + ], + vec![], + vec![], + ])]), + ) + .await?; + + let started = harness.start_webrtc_realtime("v=offer\r\n").await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V2); + + let turn_completed = harness + .read_notification::("turn/completed") + .await?; + assert_eq!(turn_completed.thread_id, harness.thread_id); + + let progress = harness.sideband_outbound_request(/*request_index*/ 1).await; + assert_v2_progress_update(&progress, "progress before final"); + + let tool_output = harness.sideband_outbound_request(/*request_index*/ 2).await; + assert_v2_function_call_output( + &tool_output, + "call_progress_order", + V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT, + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: keep the two mocked OpenAI conversations explicit. The realtime sideband only + // calls the `background_agent` function; the shell command is requested by the delegated + // background agent Responses turn that app-server starts after receiving that function call. + let main_loop = main_loop_responses(vec![ + create_shell_command_sse_response( + realtime_tool_ok_command(), + /*workdir*/ None, + // Windows CI can spend several seconds starting the nested PowerShell command. This + // test verifies delegated shell-tool plumbing, not timeout enforcement. + Some(DELEGATED_SHELL_TOOL_TIMEOUT_MS), + "shell_call", + )?, + create_final_assistant_message_sse_response("shell tool finished")?, + ]); + let realtime = realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v2_shell"), + v2_background_agent_tool_call("call_shell", "run shell through delegated turn"), + ], + vec![], + vec![], + ])]); + + let mut harness = RealtimeE2eHarness::new_with_sandbox( + RealtimeTestVersion::V2, + main_loop, + realtime, + RealtimeTestSandbox::DangerFullAccess, + ) + .await?; + + let _ = harness.start_webrtc_realtime("v=offer\r\n").await?; + + // Phase 2: observe the delegated background agent turn executing the requested shell command. + let started_command = wait_for_started_command_execution(&mut harness.mcp).await?; + let ThreadItem::CommandExecution { id, status, .. } = started_command.item else { + unreachable!("helper returns command execution items"); + }; + assert_eq!( + (id.as_str(), status), + ("shell_call", CommandExecutionStatus::InProgress) + ); + + let completed_command = wait_for_completed_command_execution(&mut harness.mcp).await?; + let ThreadItem::CommandExecution { + id, + status, + aggregated_output, + .. + } = completed_command.item + else { + unreachable!("helper returns command execution items"); + }; + assert_eq!(id.as_str(), "shell_call"); + assert_eq!(status, CommandExecutionStatus::Completed); + assert_eq!(aggregated_output.as_deref(), Some("realtime-tool-ok")); + + // Phase 3: verify the shell output reached Responses and the final delegated answer returned + // to realtime as a single function-call-output item. + let turn_completed = harness + .read_notification::("turn/completed") + .await?; + assert_eq!(turn_completed.thread_id, harness.thread_id); + + let requests = harness.main_loop_responses_requests().await?; + assert_eq!(requests.len(), 2); + assert!( + response_request_contains_text(&requests[1], "realtime-tool-ok"), + "follow-up Responses request should contain shell output: {}", + requests[1] + ); + + let progress = harness.sideband_outbound_request(/*request_index*/ 1).await; + assert_v2_progress_update(&progress, "shell tool finished"); + + let tool_output = harness.sideband_outbound_request(/*request_index*/ 2).await; + assert_v2_function_call_output( + &tool_output, + "call_shell", + V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT, + ); + assert_eq!( + function_call_output_sideband_requests(&harness.realtime_server).len(), + 1 + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn webrtc_v2_tool_call_does_not_block_sideband_audio() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: gate the delegated Responses stream so the sideband can send audio while the tool + // call is still waiting on its delegated turn. + let main_loop_responses_server = responses::start_mock_server().await; + let (gate_completed_tx, gate_completed_rx) = mpsc::channel(); + let gated_response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "late delegated result"), + responses::ev_completed("resp-1"), + ]); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(GatedSseResponse { + gate_rx: Mutex::new(Some(gate_completed_rx)), + response: gated_response, + }) + .expect(1) + .mount(&main_loop_responses_server) + .await; + + let mut harness = RealtimeE2eHarness::new_with_main_loop_responses_server( + RealtimeTestVersion::V2, + main_loop_responses_server, + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v2_nonblocking"), + v2_background_agent_tool_call("call_audio", "delegate while audio continues"), + json!({ + "type": "response.output_audio.delta", + "delta": "CQoL", + "sample_rate": 24_000, + "channels": 1, + "samples_per_channel": 256 + }), + ], + vec![], + vec![], + ])]), + ) + .await?; + + let _ = harness.start_webrtc_realtime("v=offer\r\n").await?; + let _ = harness + .read_notification::("turn/started") + .await?; + + // Phase 2: require app-server to fan out sideband audio before the delegated tool call is + // allowed to finish. + let audio = harness + .read_notification::( + "thread/realtime/outputAudio/delta", + ) + .await?; + assert_eq!(audio.audio.data, "CQoL"); + + // Phase 3: release the delegated turn and assert the sideband function-call output is delivered + // after the nonblocking audio. + let _ = gate_completed_tx.send(()); + let turn_completed = harness + .read_notification::("turn/completed") + .await?; + assert_eq!(turn_completed.thread_id, harness.thread_id); + + let progress = harness.sideband_outbound_request(/*request_index*/ 1).await; + assert_v2_progress_update(&progress, "late delegated result"); + + let tool_output = harness.sideband_outbound_request(/*request_index*/ 2).await; + assert_v2_function_call_output( + &tool_output, + "call_audio", + V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT, + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + // Phase 1: make call creation fail before any sideband connection can matter. + let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + Mock::given(method("POST")) + .and(path("/v1/realtime/calls")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&responses_server) + .await; + let realtime_server = start_websocket_server(vec![vec![]]).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ true, + StartupContextConfig::Override("startup context"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + login_with_api_key(&mut mcp, "sk-test-key").await?; + + // Phase 2: start a normal app-server thread and request realtime over WebRTC. + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + let start_request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: thread_start.thread.id, + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ThreadRealtimeStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + // Phase 3: the JSON-RPC start request returns, and the realtime failure is delivered as the + // typed realtime error notification. + let error = + read_notification::(&mut mcp, "thread/realtime/error") + .await?; + assert!(error.message.contains("currently experiencing high demand")); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn realtime_conversation_requires_feature_flag() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let realtime_server = start_websocket_server(vec![vec![]]).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ false, + StartupContextConfig::Generated, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + let start_request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: thread_start.thread.id.clone(), + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: None, + voice: None, + }) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(start_request_id)), + ) + .await??; + assert_invalid_request( + error, + format!( + "thread {} does not support realtime conversation", + thread_start.thread.id + ), + ); + + realtime_server.shutdown().await; + Ok(()) +} + +async fn read_notification(mcp: &mut McpProcess, method: &str) -> Result { + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message(method), + ) + .await??; + let params = notification + .params + .context("expected notification params to be present")?; + Ok(serde_json::from_value(params)?) +} + +async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { + let request_id = mcp.send_login_account_api_key_request(api_key).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(response)?; + assert_eq!(login, LoginAccountResponse::ApiKey {}); + + Ok(()) +} + +async fn wait_for_started_command_execution( + mcp: &mut McpProcess, +) -> Result { + loop { + let started = read_notification::(mcp, "item/started").await?; + if let ThreadItem::CommandExecution { .. } = &started.item { + return Ok(started); + } + } +} + +async fn wait_for_completed_command_execution( + mcp: &mut McpProcess, +) -> Result { + loop { + let completed = + read_notification::(mcp, "item/completed").await?; + if let ThreadItem::CommandExecution { .. } = &completed.item { + return Ok(completed); + } + } +} + +async fn responses_requests(server: &MockServer) -> Result> { + server + .received_requests() + .await + .context("failed to fetch received requests")? + .into_iter() + .filter(|request| request.url.path().ends_with("/responses")) + .map(|request| { + request + .body_json::() + .context("Responses request body should be JSON") + }) + .collect() +} + +fn response_request_contains_text(request: &Value, text: &str) -> bool { + match request { + Value::String(value) => value.contains(text), + Value::Array(values) => values + .iter() + .any(|value| response_request_contains_text(value, text)), + Value::Object(map) => map + .values() + .any(|value| response_request_contains_text(value, text)), + Value::Null | Value::Bool(_) | Value::Number(_) => false, + } +} + +fn realtime_tool_ok_command() -> Vec { + #[cfg(windows)] + { + vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + "[Console]::Write('realtime-tool-ok')".to_string(), + ] + } + + #[cfg(not(windows))] + { + vec!["printf".to_string(), "realtime-tool-ok".to_string()] + } +} + +fn function_call_output_sideband_requests(server: &WebSocketTestServer) -> Vec { + server + .single_connection() + .iter() + .map(WebSocketRequest::body_json) + .filter(|request| { + request["type"] == "conversation.item.create" + && request["item"]["type"] == "function_call_output" + }) + .collect() +} + +fn assert_v2_function_call_output(request: &Value, call_id: &str, expected_output: &str) { + assert_eq!( + request, + &json!({ + "type": "conversation.item.create", + "item": { + "type": "function_call_output", + "call_id": call_id, + "output": expected_output, + } + }) + ); +} + +fn assert_v2_progress_update(request: &Value, expected_text: &str) { + assert_eq!( + request, + &json!({ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": format!("[BACKEND] {expected_text}") + }] + } + }) + ); +} + +fn assert_v2_user_text_item(request: &Value, expected_text: &str) { + assert_eq!( + request, + &json!({ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": format!("[USER] {expected_text}") + }] + } + }) + ); +} + +fn assert_v2_response_create(request: &Value) { + assert_eq!( + request, + &json!({ + "type": "response.create" + }) + ); +} + +fn assert_v1_session_update(request: &Value) -> Result<()> { + assert_eq!(request["type"].as_str(), Some("session.update")); + assert_eq!(request["session"]["type"].as_str(), Some("quicksilver")); + assert!( + request["session"]["instructions"] + .as_str() + .context("v1 session.update instructions")? + .contains("startup context") + ); + assert_eq!( + request["session"]["audio"]["output"]["voice"].as_str(), + Some("cove") + ); + assert_eq!(request["session"]["tools"], Value::Null); + Ok(()) +} + +fn assert_v2_session_update(request: &Value) -> Result<()> { + assert_eq!(request["type"].as_str(), Some("session.update")); + assert_eq!(request["session"]["type"].as_str(), Some("realtime")); + assert!( + request["session"]["instructions"] + .as_str() + .context("v2 session.update instructions")? + .contains("startup context") + ); + assert_eq!( + request["session"]["tools"][0]["name"].as_str(), + Some("background_agent") + ); + assert_eq!( + request["session"]["tools"][1]["name"].as_str(), + Some("remain_silent") + ); + assert_eq!( + request["session"]["audio"]["input"]["transcription"]["model"].as_str(), + Some("gpt-4o-mini-transcribe") + ); + Ok(()) +} + +fn assert_call_create_multipart( + request: WiremockRequest, + offer_sdp: &str, + session: &str, +) -> Result<()> { + assert_eq!(request.url.path(), "/v1/realtime/calls"); + assert_eq!(request.url.query(), None); + assert_eq!( + request + .headers + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("multipart/form-data; boundary=codex-realtime-call-boundary") + ); + let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; + let session = normalized_json_string(session)?; + assert_eq!( + body, + format!( + "--codex-realtime-call-boundary\r\n\ + Content-Disposition: form-data; name=\"sdp\"\r\n\ + Content-Type: application/sdp\r\n\ + \r\n\ + {offer_sdp}\r\n\ + --codex-realtime-call-boundary\r\n\ + Content-Disposition: form-data; name=\"session\"\r\n\ + Content-Type: application/json\r\n\ + \r\n\ + {session}\r\n\ + --codex-realtime-call-boundary--\r\n" + ) + ); + Ok(()) +} + +fn v1_session_create_json() -> &'static str { + r#"{"audio":{"input":{"format":{"type":"audio/pcm","rate":24000}},"output":{"voice":"cove"}},"type":"quicksilver","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context"}"# +} + +fn create_config_toml( + codex_home: &Path, + responses_server_uri: &str, + realtime_server_uri: &str, + realtime_enabled: bool, + startup_context: StartupContextConfig<'_>, +) -> std::io::Result<()> { + create_config_toml_with_realtime_version( + codex_home, + responses_server_uri, + realtime_server_uri, + realtime_enabled, + startup_context, + RealtimeTestVersion::V2, + RealtimeTestSandbox::ReadOnly, + ) +} + +fn create_config_toml_with_realtime_version( + codex_home: &Path, + responses_server_uri: &str, + realtime_server_uri: &str, + realtime_enabled: bool, + startup_context: StartupContextConfig<'_>, + realtime_version: RealtimeTestVersion, + sandbox: RealtimeTestSandbox, +) -> std::io::Result<()> { + let realtime_feature_key = FEATURES + .iter() + .find(|spec| spec.id == Feature::RealtimeConversation) + .map(|spec| spec.key) + .unwrap_or("realtime_conversation"); + let realtime_version = realtime_version.config_value(); + let sandbox = sandbox.config_value(); + let startup_context = match startup_context { + StartupContextConfig::Generated => String::new(), + StartupContextConfig::Override(context) => { + format!("experimental_realtime_ws_startup_context = {context:?}\n") + } + }; + + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "{sandbox}" +model_provider = "mock_provider" +experimental_realtime_ws_base_url = "{realtime_server_uri}" +experimental_realtime_ws_backend_prompt = "backend prompt" +{startup_context} + +[realtime] +version = "{realtime_version}" +type = "conversational" + +[features] +{realtime_feature_key} = {realtime_enabled} + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{responses_server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn assert_invalid_request(error: JSONRPCError, message: String) { + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, message); + assert_eq!(error.error.data, None); +} diff --git a/code-rs/app-server/tests/suite/v2/remote_thread_store.rs b/code-rs/app-server/tests/suite/v2/remote_thread_store.rs new file mode 100644 index 00000000000..e5c0b2c53fc --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -0,0 +1,286 @@ +//! Regression coverage for app-server thread operations backed by a non-local +//! `ThreadStore`. +//! +//! The app-server startup path should honor `experimental_thread_store` +//! by routing all thread persistence through the configured store. This suite uses +//! the thread-store crate's test-only in-memory store to exercise the non-local +//! config-driven selection path without touching local rollout or sqlite storage. +//! +//! The important failure mode is accidentally materializing local persistence +//! while a non-local store is configured. After `thread/start` and a simple turn, +//! the temporary `codex_home` must not contain rollout session files or sqlite +//! state files. This does not observe read-only probes that leave no artifact; it +//! is a stop-gap that prevents additional local persistence writes from slipping +//! in unnoticed. + +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use app_test_support::create_mock_responses_server_repeating_assistant; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessServerEvent; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use codex_thread_store::InMemoryThreadStore; +use pretty_assertions::assert_eq; +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] +async fn thread_start_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + // Plugin startup warmups may create `.tmp` under codex_home. Disable them + // here so this regression stays focused on thread persistence artifacts. + create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + + let thread_store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + + let mut client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let response = client + .request(ClientRequest::ThreadStart { + request_id: RequestId::Integer(1), + params: ThreadStartParams::default(), + }) + .await? + .expect("thread/start should succeed"); + let ThreadStartResponse { thread, .. } = + serde_json::from_value(response).expect("thread/start response should parse"); + assert_eq!(thread.path, None); + + client + .request(ClientRequest::TurnStart { + request_id: RequestId::Integer(2), + params: TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await? + .expect("turn/start should succeed"); + + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let Some(event) = client.next_event().await else { + anyhow::bail!("in-process app-server stopped before turn/completed"); + }; + if let InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted( + completed, + )) = event + && completed.thread_id == thread.id + { + return Ok::<(), anyhow::Error>(()); + } + } + }) + .await??; + + let response = client + .request(ClientRequest::ThreadList { + request_id: RequestId::Integer(3), + params: ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(Vec::new()), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }, + }) + .await? + .expect("thread/list should succeed"); + let ThreadListResponse { data, .. } = + serde_json::from_value(response).expect("thread/list response should parse"); + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, thread.id); + assert_eq!(data[0].path, None); + + client.shutdown().await?; + + let calls = thread_store.calls().await; + assert_eq!(calls.create_thread, 1); + assert_eq!(calls.list_threads, 1); + assert!( + calls.append_items > 0, + "turn/start should append rollout items through the injected store" + ); + assert!( + calls.flush_thread > 0, + "turn completion should flush through the injected store" + ); + + assert_no_local_persistence_artifacts(codex_home.path())?; + + Ok(()) +} + +fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> { + // These are the observable tripwires for accidental local persistence. If a + // future code path constructs a local rollout/session store or opens the + // local thread sqlite database, it should leave one of these artifacts in + // the isolated test codex_home. + assert!( + !codex_home.join("sessions").exists(), + "non-local thread persistence should not create local rollout sessions" + ); + assert!( + !codex_home.join("archived_sessions").exists(), + "non-local thread persistence should not create archived rollout sessions" + ); + assert!( + !codex_state::state_db_path(codex_home).exists(), + "non-local thread persistence should not create local thread sqlite" + ); + + let sqlite_artifacts = std::fs::read_dir(codex_home)? + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.ends_with(".sqlite") + || name.ends_with(".sqlite-shm") + || name.ends_with(".sqlite-wal") + }) + }) + .collect::>(); + + assert!( + sqlite_artifacts.is_empty(), + "non-local thread persistence should not create sqlite artifacts: {sqlite_artifacts:?}" + ); + let mut entries = codex_home_entries(codex_home)?; + // Bazel test runs may initialize shell snapshot storage under codex_home. + // That is not thread persistence; keep the assertion focused on rollout, + // session, sqlite, and other unexpected thread-store artifacts. + entries.remove("shell_snapshots"); + assert_eq!( + entries, + BTreeSet::from([ + "config.toml".to_string(), + "installation_id".to_string(), + "memories".to_string(), + "skills".to_string(), + ]), + "non-local thread persistence should not create unexpected files in codex_home" + ); + + Ok(()) +} + +fn codex_home_entries(codex_home: &Path) -> Result> { + Ok(std::fs::read_dir(codex_home)? + .filter_map(|entry| { + let entry = entry.ok()?; + Some(entry.file_name().to_string_lossy().into_owned()) + }) + .collect()) +} + +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +fn create_config_toml_with_thread_store( + codex_home: &Path, + server_uri: &str, + store_id: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[features] +plugins = false +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/request_permissions.rs b/code-rs/app-server/tests/suite/v2/request_permissions.rs new file mode 100644 index 00000000000..7acdc1b12f2 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/request_permissions.rs @@ -0,0 +1,180 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_request_permissions_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionGrantScope; +use codex_app_server_protocol::PermissionsRequestApprovalResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +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 request_permissions_round_trip() -> Result<()> { + let codex_home = tempfile::TempDir::new()?; + let responses = vec![ + create_request_permissions_sse_response("call1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "pick a directory".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::PermissionsRequestApproval { request_id, params } = server_req else { + panic!("expected PermissionsRequestApproval request, got: {server_req:?}"); + }; + + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + assert_eq!(params.item_id, "call1"); + assert!(params.cwd.as_path().is_absolute()); + assert_eq!(params.reason, Some("Select a workspace root".to_string())); + let requested_file_system = params + .permissions + .file_system + .expect("request should include file system permissions"); + let requested_writes = requested_file_system + .write + .clone() + .expect("request should include write permissions"); + assert_eq!(requested_writes.len(), 2); + assert_eq!( + requested_file_system.entries, + Some(vec![ + codex_app_server_protocol::FileSystemSandboxEntry { + path: codex_app_server_protocol::FileSystemPath::Path { + path: requested_writes[0].clone(), + }, + access: codex_app_server_protocol::FileSystemAccessMode::Write, + }, + codex_app_server_protocol::FileSystemSandboxEntry { + path: codex_app_server_protocol::FileSystemPath::Path { + path: requested_writes[1].clone(), + }, + access: codex_app_server_protocol::FileSystemAccessMode::Write, + }, + ]) + ); + let resolved_request_id = request_id.clone(); + + mcp.send_response( + request_id, + serde_json::to_value(PermissionsRequestApprovalResponse { + permissions: codex_app_server_protocol::GrantedPermissionProfile { + network: None, + file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { + read: None, + write: Some(vec![requested_writes[0].clone()]), + glob_scan_max_depth: None, + entries: None, + }), + }, + scope: PermissionGrantScope::Turn, + strict_auto_review: None, + })?, + ) + .await?; + + let mut saw_resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(resolved.thread_id, thread.id); + assert_eq!(resolved.request_id, resolved_request_id); + saw_resolved = true; + } + "turn/completed" => { + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + break; + } + _ => {} + } + } + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[features] +request_permissions_tool = true +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/request_user_input.rs b/code-rs/app-server/tests/suite/v2/request_user_input.rs new file mode 100644 index 00000000000..f77ddfb4f7d --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/request_user_input.rs @@ -0,0 +1,152 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_request_user_input_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn request_user_input_round_trip() -> Result<()> { + let codex_home = tempfile::TempDir::new()?; + let responses = vec![ + create_request_user_input_sse_response("call1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "ask something".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, params } = server_req else { + panic!("expected ToolRequestUserInput request, got: {server_req:?}"); + }; + + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + assert_eq!(params.item_id, "call1"); + assert_eq!(params.questions.len(), 1); + let resolved_request_id = request_id.clone(); + + mcp.send_response( + request_id, + serde_json::json!({ + "answers": { + "confirm_path": { "answers": ["yes"] } + } + }), + ) + .await?; + let mut saw_resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(resolved.thread_id, thread.id); + assert_eq!(resolved.request_id, resolved_request_id); + saw_resolved = true; + } + "turn/completed" => { + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + break; + } + _ => {} + } + } + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/review.rs b/code-rs/app-server/tests/suite/v2/review.rs new file mode 100644 index 00000000000..bf0271f8217 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/review.rs @@ -0,0 +1,520 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_shell_command_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewDelivery; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +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); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test] +async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()> { + let review_payload = json!({ + "findings": [ + { + "title": "Prefer Stylize helpers", + "body": "Use .dim()/.bold() chaining instead of manual Style.", + "confidence_score": 0.9, + "priority": 1, + "code_location": { + "absolute_file_path": "/tmp/file.rs", + "line_range": {"start": 10, "end": 20} + } + } + ], + "overall_correctness": "good", + "overall_explanation": "Looks solid overall with minor polish suggested.", + "overall_confidence_score": 0.75 + }) + .to_string(); + let server = create_mock_responses_server_repeating_assistant(&review_payload).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_default_thread(&mut mcp).await?; + + let review_req = mcp + .send_review_start_request(ReviewStartParams { + thread_id: thread_id.clone(), + delivery: Some(ReviewDelivery::Inline), + target: ReviewTarget::Commit { + sha: "1234567deadbeef".to_string(), + title: Some("Tidy UI colors".to_string()), + }, + }) + .await?; + let review_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(review_req)), + ) + .await??; + let ReviewStartResponse { + turn, + review_thread_id, + } = to_response::(review_resp)?; + assert_eq!(review_thread_id, thread_id.clone()); + let turn_id = turn.id.clone(); + assert_eq!(turn.status, TurnStatus::InProgress); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + content: vec![V2UserInput::Text { + text: "commit 1234567: Tidy UI colors".to_string(), + text_elements: Vec::new(), + }], + }] + ); + + // Confirm we see the EnteredReviewMode marker on the main thread. + let mut saw_entered_review_mode = false; + for _ in 0..10 { + let item_started: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/started"), + ) + .await??; + let started: ItemStartedNotification = + serde_json::from_value(item_started.params.expect("params must be present"))?; + match started.item { + ThreadItem::EnteredReviewMode { id, review } => { + assert_eq!(id, turn_id); + assert_eq!(review, "commit 1234567: Tidy UI colors"); + saw_entered_review_mode = true; + break; + } + _ => continue, + } + } + assert!( + saw_entered_review_mode, + "did not observe enteredReviewMode item" + ); + + // Confirm we see the ExitedReviewMode marker (with review text) + // on the same turn. Ignore any other items the stream surfaces. + let mut review_body: Option = None; + for _ in 0..10 { + let review_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/completed"), + ) + .await??; + let completed: ItemCompletedNotification = + serde_json::from_value(review_notif.params.expect("params must be present"))?; + match completed.item { + ThreadItem::ExitedReviewMode { id, review } => { + assert_eq!(id, turn_id); + review_body = Some(review); + break; + } + _ => continue, + } + } + + let review = review_body.expect("did not observe a code review item"); + assert!(review.contains("Prefer Stylize helpers")); + assert!(review.contains("/tmp/file.rs:10-20")); + + Ok(()) +} + +#[tokio::test] +#[ignore = "TODO(owenlin0): flaky"] +async fn review_start_exec_approval_item_id_matches_command_execution_item() -> Result<()> { + let responses = vec![ + create_shell_command_sse_response( + vec![ + "git".to_string(), + "rev-parse".to_string(), + "HEAD".to_string(), + ], + /*workdir*/ None, + Some(5000), + "review-call-1", + )?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml_with_approval_policy(codex_home.path(), &server.uri(), "untrusted")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_default_thread(&mut mcp).await?; + + let review_req = mcp + .send_review_start_request(ReviewStartParams { + thread_id, + delivery: Some(ReviewDelivery::Inline), + target: ReviewTarget::Commit { + sha: "1234567deadbeef".to_string(), + title: Some("Check review approvals".to_string()), + }, + }) + .await?; + let review_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(review_req)), + ) + .await??; + let ReviewStartResponse { turn, .. } = to_response::(review_resp)?; + let turn_id = turn.id.clone(); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + content: vec![V2UserInput::Text { + text: "commit 1234567: Check review approvals".to_string(), + text_elements: Vec::new(), + }], + }] + ); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { + panic!("expected CommandExecutionRequestApproval request"); + }; + assert_eq!(params.item_id, "review-call-1"); + assert_eq!(params.turn_id, turn_id); + + let mut command_item_id = None; + for _ in 0..10 { + let item_started: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/started"), + ) + .await??; + let started: ItemStartedNotification = + serde_json::from_value(item_started.params.expect("params must be present"))?; + if let ThreadItem::CommandExecution { id, .. } = started.item { + command_item_id = Some(id); + break; + } + } + let command_item_id = command_item_id.expect("did not observe command execution item"); + assert_eq!(command_item_id, params.item_id); + + mcp.send_response( + request_id, + serde_json::json!({ "decision": codex_protocol::protocol::ReviewDecision::Approved }), + ) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn review_start_rejects_empty_base_branch() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let thread_id = start_default_thread(&mut mcp).await?; + + let request_id = mcp + .send_review_start_request(ReviewStartParams { + thread_id, + delivery: Some(ReviewDelivery::Inline), + target: ReviewTarget::BaseBranch { + branch: " ".to_string(), + }, + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert!( + error.error.message.contains("branch must not be empty"), + "unexpected message: {}", + error.error.message + ); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "flaky on windows CI")] +#[tokio::test] +async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<()> { + let review_payload = json!({ + "findings": [], + "overall_correctness": "ok", + "overall_explanation": "detached review", + "overall_confidence_score": 0.5 + }) + .to_string(); + let server = create_mock_responses_server_repeating_assistant(&review_payload).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_default_thread(&mut mcp).await?; + materialize_thread_rollout(&mut mcp, &thread_id).await?; + + let review_req = mcp + .send_review_start_request(ReviewStartParams { + thread_id: thread_id.clone(), + delivery: Some(ReviewDelivery::Detached), + target: ReviewTarget::Custom { + instructions: "detached review".to_string(), + }, + }) + .await?; + let review_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(review_req)), + ) + .await??; + let ReviewStartResponse { + turn, + review_thread_id, + } = to_response::(review_resp)?; + + assert_eq!(turn.status, TurnStatus::InProgress); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn.id.clone(), + content: vec![V2UserInput::Text { + text: "detached review".to_string(), + text_elements: Vec::new(), + }], + }] + ); + assert_ne!( + review_thread_id, thread_id, + "detached review should run on a different thread" + ); + + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notification = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + if notification.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notification.params.expect("params must be present"))?; + if status_changed.thread_id == review_thread_id { + anyhow::bail!( + "detached review threads should be introduced without a preceding thread/status/changed" + ); + } + continue; + } + if notification.method == "thread/started" { + break notification; + } + }; + let started: ThreadStartedNotification = + serde_json::from_value(notification.params.expect("params must be present"))?; + assert_eq!(started.thread.id, review_thread_id); + assert_eq!(started.thread.session_id, review_thread_id); + + Ok(()) +} + +#[tokio::test] +async fn review_start_rejects_empty_commit_sha() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let thread_id = start_default_thread(&mut mcp).await?; + + let request_id = mcp + .send_review_start_request(ReviewStartParams { + thread_id, + delivery: Some(ReviewDelivery::Inline), + target: ReviewTarget::Commit { + sha: "\t".to_string(), + title: None, + }, + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert!( + error.error.message.contains("sha must not be empty"), + "unexpected message: {}", + error.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn review_start_rejects_empty_custom_instructions() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let thread_id = start_default_thread(&mut mcp).await?; + + let request_id = mcp + .send_review_start_request(ReviewStartParams { + thread_id, + delivery: Some(ReviewDelivery::Inline), + target: ReviewTarget::Custom { + instructions: "\n\n".to_string(), + }, + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert!( + error + .error + .message + .contains("instructions must not be empty"), + "unexpected message: {}", + error.error.message + ); + + Ok(()) +} + +async fn start_default_thread(mcp: &mut McpProcess) -> Result { + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/started"), + ) + .await??; + Ok(thread.id) +} + +async fn materialize_thread_rollout(mcp: &mut McpProcess, thread_id: &str) -> Result<()> { + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![V2UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + create_config_toml_with_approval_policy(codex_home, server_uri, "never") +} + +fn create_config_toml_with_approval_policy( + codex_home: &std::path::Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +shell_snapshot = false + +[model_providers.mock_provider] +name = "Mock provider" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/code-rs/app-server/tests/suite/v2/safety_check_downgrade.rs new file mode 100644 index 00000000000..7c1619e38a9 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -0,0 +1,474 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ModelRerouteReason; +use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelVerification; +use codex_app_server_protocol::ModelVerificationNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::ResponseTemplate; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const REQUESTED_MODEL: &str = "gpt-5.4"; +const SERVER_MODEL: &str = "gpt-5.3-codex"; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; +const CYBER_POLICY_MESSAGE: &str = + "This request has been flagged for potentially high-risk cyber activity."; + +#[tokio::test] +async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body).insert_header("OpenAI-Model", SERVER_MODEL); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger safeguard".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; + assert_eq!( + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "message": CYBER_POLICY_MESSAGE, + "type": "invalid_request", + "param": null, + "code": "cyber_policy" + } + })); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger cyber policy error".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let error = collect_cyber_policy_error_and_validate_no_reroute(&mut mcp).await?; + assert_eq!( + error, + ErrorNotification { + error: codex_app_server_protocol::TurnError { + message: CYBER_POLICY_MESSAGE.to_string(), + codex_error_info: Some(CodexErrorInfo::CyberPolicy), + additional_details: None, + }, + will_retry: false, + thread_id: thread.id, + turn_id: turn_start.turn.id, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + serde_json::json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "headers": { + "OpenAI-Model": SERVER_MODEL + } + } + }), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body).insert_header("OpenAI-Model", REQUESTED_MODEL); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger response model check".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let rerouted = collect_turn_notifications_and_validate_no_warning_item(&mut mcp).await?; + assert_eq!( + rerouted, + ModelReroutedNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + from_model: REQUESTED_MODEL.to_string(), + to_model: SERVER_MODEL.to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn model_verification_emits_typed_notification_and_warning_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_model_verification_metadata( + "resp-1", + vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION], + ), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger model verification".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let verification = + collect_model_verification_notifications_and_validate_no_warning_item(&mut mcp).await?; + assert_eq!( + verification, + ModelVerificationNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + verifications: vec![ModelVerification::TrustedAccessForCyber], + } + ); + + Ok(()) +} + +async fn collect_turn_notifications_and_validate_no_warning_item( + mcp: &mut McpProcess, +) -> Result { + let mut rerouted = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "model/rerouted" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("model/rerouted notifications must include params") + })?; + let payload: ModelReroutedNotification = serde_json::from_value(params)?; + rerouted = Some(payload); + } + "item/started" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/started notifications must include params") + })?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "item/completed" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/completed notifications must include params") + })?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "turn/completed" => { + return rerouted.ok_or_else(|| { + anyhow::anyhow!("expected model/rerouted notification before turn/completed") + }); + } + _ => {} + } + } +} + +async fn collect_model_verification_notifications_and_validate_no_warning_item( + mcp: &mut McpProcess, +) -> Result { + let mut verification = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "model/verification" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("model/verification notifications must include params") + })?; + let payload: ModelVerificationNotification = serde_json::from_value(params)?; + verification = Some(payload); + } + "warning" => { + anyhow::bail!("verification-only response must not emit warning"); + } + "model/rerouted" => { + anyhow::bail!("verification-only response must not emit model/rerouted"); + } + "item/started" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/started notifications must include params") + })?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "item/completed" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/completed notifications must include params") + })?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "turn/completed" => { + let verification = verification.ok_or_else(|| { + anyhow::anyhow!( + "expected model/verification notification before turn/completed" + ) + })?; + return Ok(verification); + } + _ => {} + } + } +} + +async fn collect_cyber_policy_error_and_validate_no_reroute( + mcp: &mut McpProcess, +) -> Result { + let mut error = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "error" => { + let params = notification + .params + .ok_or_else(|| anyhow::anyhow!("error notifications must include params"))?; + let payload: ErrorNotification = serde_json::from_value(params)?; + if payload.error.codex_error_info == Some(CodexErrorInfo::CyberPolicy) { + error = Some(payload); + } + } + "model/rerouted" => { + anyhow::bail!("cyber policy response must not emit model/rerouted"); + } + "turn/completed" => { + return error.ok_or_else(|| { + anyhow::anyhow!("expected cyber policy error before turn/completed") + }); + } + _ => {} + } + } +} + +fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { + let ThreadItem::UserMessage { content, .. } = item else { + return None; + }; + + content.iter().find_map(|input| match input { + UserInput::Text { text, .. } if text.starts_with("Warning: ") => Some(text.as_str()), + _ => None, + }) +} + +fn is_warning_user_message_item(item: &ThreadItem) -> bool { + warning_text_from_item(item).is_some() +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "{REQUESTED_MODEL}" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +remote_models = false +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/skills_list.rs b/code-rs/app-server/tests/suite/v2/skills_list.rs new file mode 100644 index 00000000000..39dae06bd0b --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/skills_list.rs @@ -0,0 +1,634 @@ +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SkillsChangedNotification; +use codex_app_server_protocol::SkillsListParams; +use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_config::types::AuthCredentialsStoreMode; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const WATCHER_TIMEOUT: Duration = Duration::from_secs(20); + +fn write_skill(root: &TempDir, name: &str) -> Result<()> { + let skill_dir = root.path().join("skills").join(name); + std::fs::create_dir_all(&skill_dir)?; + let content = format!("---\nname: {name}\ndescription: {name} description\n---\n\n# Body\n"); + std::fs::write(skill_dir.join("SKILL.md"), content)?; + Ok(()) +} + +fn write_plugins_enabled_config_with_base_url( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{base_url}" + +[features] +plugins = true +"#, + ), + ) +} + +fn write_remote_plugins_enabled_config_with_base_url( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"#, + ), + ) +} + +fn write_plugin_with_skill( + repo_root: &std::path::Path, + plugin_name: &str, + skill_name: &str, +) -> Result<()> { + std::fs::create_dir_all(repo_root.join(".git"))?; + std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; + std::fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "local-marketplace", + "plugins": [ + {{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./{plugin_name}" + }} + }} + ] +}}"# + ), + )?; + + let plugin_root = repo_root.join(plugin_name); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + + let skill_dir = plugin_root.join("skills").join(skill_name); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {skill_name}\ndescription: {skill_name} description\n---\n\n# Body\n"), + )?; + Ok(()) +} + +fn write_cached_remote_plugin_with_skill( + codex_home: &std::path::Path, +) -> Result { + let plugin_root = codex_home.join("plugins/cache/chatgpt-global/linear/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + )?; + + let skill_dir = plugin_root.join("skills/triage-issues"); + std::fs::create_dir_all(&skill_dir)?; + let skill_path = skill_dir.join("SKILL.md"); + std::fs::write( + &skill_path, + "---\nname: triage-issues\ndescription: Triage Linear issues\n---\n\n# Body\n", + )?; + Ok(skill_path) +} + +#[tokio::test] +async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let server = MockServer::start().await; + let expected_skill_path = + std::fs::canonicalize(write_cached_remote_plugin_with_skill(codex_home.path())?)?; + write_remote_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let global_directory_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let global_installed_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + }, + "enabled": true, + "disabled_skill_names": [] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + for (scope, body) in [ + ("GLOBAL", global_directory_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", scope)) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let stale_skills_list_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: true, + }) + .await?; + let stale_skills_list_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(stale_skills_list_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(stale_skills_list_response)?; + assert_eq!(data.len(), 1); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "linear:triage-issues"), + "remote installed plugin cache has not been refreshed yet" + ); + + for (scope, body) in [ + ("GLOBAL", global_installed_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + + let plugin_list_request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let plugin_list_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(plugin_list_request_id)), + ) + .await??; + let _: PluginListResponse = to_response(plugin_list_response)?; + + let SkillsListResponse { data } = timeout(DEFAULT_TIMEOUT, async { + loop { + let skills_list_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_list_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_list_request_id)), + ) + .await??; + let response: SkillsListResponse = to_response(skills_list_response)?; + if response.data.iter().any(|entry| { + entry + .skills + .iter() + .any(|skill| skill.name == "linear:triage-issues") + }) { + break Ok::(response); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await??; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + let skill = data[0] + .skills + .iter() + .find(|skill| skill.name == "linear:triage-issues") + .expect("expected skill from cached remote plugin"); + assert_eq!( + std::fs::canonicalize(skill.path.as_path())?, + expected_skill_path + ); + assert_eq!(skill.enabled, true); + Ok(()) +} + +#[tokio::test] +async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_skill(&codex_home, "home-skill")?; + write_plugin_with_skill(repo_root.path(), "demo-plugin", "plugin-skill")?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![repo_root.path().to_path_buf()], + force_reload: true, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "home-skill"), + "non-plugin skills should remain available" + ); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "demo-plugin:plugin-skill"), + "plugin skills should be hidden when workspace Codex plugins are disabled" + ); + Ok(()) +} + +#[tokio::test] +async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_skill(&codex_home, "home-skill")?; + let repo_skill_dir = cwd.path().join(".codex/skills/repo-skill"); + std::fs::create_dir_all(&repo_skill_dir)?; + std::fs::write( + repo_skill_dir.join("SKILL.md"), + "---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n", + )?; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: true, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].cwd, cwd.path().to_path_buf()); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "home-skill") + ); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "repo-skill") + ); + Ok(()) +} + +#[tokio::test] +async fn skills_list_accepts_relative_cwds() -> Result<()> { + let codex_home = TempDir::new()?; + let relative_cwd = std::path::PathBuf::from("relative-cwd"); + std::fs::create_dir_all(codex_home.path().join(&relative_cwd))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![relative_cwd.clone()], + force_reload: true, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].cwd, relative_cwd); + assert_eq!(data[0].errors, Vec::new()); + Ok(()) +} + +#[tokio::test] +async fn skills_list_preserves_requested_cwd_order() -> Result<()> { + let codex_home = TempDir::new()?; + let first_cwd = TempDir::new()?; + let second_cwd = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![ + first_cwd.path().to_path_buf(), + second_cwd.path().to_path_buf(), + ], + force_reload: true, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!( + data.iter() + .map(|entry| entry.cwd.clone()) + .collect::>(), + vec![ + first_cwd.path().to_path_buf(), + second_cwd.path().to_path_buf(), + ] + ); + Ok(()) +} + +#[tokio::test] +async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + // Seed the cwd cache before the cwd-local skill exists. + let first_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let first_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_request_id)), + ) + .await??; + let SkillsListResponse { data: first_data } = to_response(first_response)?; + assert_eq!(first_data.len(), 1); + assert!( + first_data[0] + .skills + .iter() + .all(|skill| skill.name != "late-extra-skill") + ); + + let skill_dir = cwd.path().join(".codex/skills/late-extra-skill"); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: late-extra-skill\ndescription: late skill\n---\n\n# Body\n", + )?; + + let second_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let second_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)), + ) + .await??; + let SkillsListResponse { data: second_data } = to_response(second_response)?; + assert_eq!(second_data.len(), 1); + assert!( + second_data[0] + .skills + .iter() + .all(|skill| skill.name != "late-extra-skill") + ); + + let third_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: true, + }) + .await?; + let third_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(third_request_id)), + ) + .await??; + let SkillsListResponse { data: third_data } = to_response(third_response)?; + assert_eq!(third_data.len(), 1); + assert!( + third_data[0] + .skills + .iter() + .any(|skill| skill.name == "late-extra-skill") + ); + Ok(()) +} + +#[tokio::test] +async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { + let codex_home = TempDir::new()?; + write_skill(&codex_home, "demo")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: None, + model_provider: None, + service_tier: None, + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox: None, + permissions: None, + config: None, + service_name: None, + base_instructions: None, + developer_instructions: None, + personality: None, + ephemeral: None, + session_start_source: None, + thread_source: None, + dynamic_tools: None, + environments: None, + mock_experimental_field: None, + experimental_raw_events: false, + persist_extended_history: false, + }) + .await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + + let skill_path = codex_home + .path() + .join("skills") + .join("demo") + .join("SKILL.md"); + std::fs::write( + &skill_path, + "---\nname: demo\ndescription: updated\n---\n\n# Updated\n", + )?; + + let notification = timeout( + WATCHER_TIMEOUT, + mcp.read_stream_until_notification_message("skills/changed"), + ) + .await??; + let params = notification + .params + .context("skills/changed params must be present")?; + let notification: SkillsChangedNotification = serde_json::from_value(params)?; + + assert_eq!(notification, SkillsChangedNotification {}); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_archive.rs b/code-rs/app-server/tests/suite/v2/thread_archive.rs new file mode 100644 index 00000000000..b441a23cb62 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_archive.rs @@ -0,0 +1,657 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadArchivedNotification; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_core::find_archived_thread_path_by_id_str; +use codex_core::find_thread_path_by_id_str; +use codex_protocol::ThreadId; +use codex_state::DirectionalThreadSpawnEdgeStatus; +use codex_state::StateRuntime; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_archive_requires_materialized_rollout() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread. + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + assert!(!thread.id.is_empty()); + + let rollout_path = thread.path.clone().expect("thread path"); + assert!( + !rollout_path.exists(), + "fresh thread rollout should not exist yet at {}", + rollout_path.display() + ); + assert!( + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) + .await? + .is_none(), + "thread id should not be discoverable before rollout materialization" + ); + + // Archive should fail before the rollout is materialized. + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let archive_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(archive_id)), + ) + .await??; + assert!( + archive_err + .error + .message + .contains("no rollout found for thread id"), + "unexpected archive error: {}", + archive_err.error.message + ); + + // Materialize rollout via a real user turn and confirm archive succeeds. + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_start_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_start_response)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert!( + rollout_path.exists(), + "expected rollout path {} to exist after first user message", + rollout_path.display() + ); + + let discovered_path = + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) + .await? + .expect("expected rollout path for thread id to exist after materialization"); + assert_paths_match_on_disk(&discovered_path, &rollout_path)?; + + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + let archive_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/archived"), + ) + .await??; + let archived_notification: ThreadArchivedNotification = serde_json::from_value( + archive_notification + .params + .expect("thread/archived notification params"), + )?; + assert_eq!(archived_notification.thread_id, thread.id); + + // Verify file moved. + let archived_directory = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + // The archived file keeps the original filename (rollout-...-.jsonl). + let archived_rollout_path = + archived_directory.join(rollout_path.file_name().expect("rollout file name")); + 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() + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_archive_archives_spawned_descendants() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let parent_id = create_fake_rollout( + codex_home.path(), + "2025-01-01T00-00-00", + "2025-01-01T00:00:00Z", + "parent", + Some("mock_provider"), + /*git_info*/ None, + )?; + let child_id = create_fake_rollout( + codex_home.path(), + "2025-01-01T00-01-00", + "2025-01-01T00:01:00Z", + "child", + Some("mock_provider"), + /*git_info*/ None, + )?; + let grandchild_id = create_fake_rollout( + codex_home.path(), + "2025-01-01T00-02-00", + "2025-01-01T00:02:00Z", + "grandchild", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let parent_thread_id = ThreadId::from_string(&parent_id)?; + let child_thread_id = ThreadId::from_string(&child_id)?; + let grandchild_thread_id = ThreadId::from_string(&grandchild_id)?; + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + state_db + .upsert_thread_spawn_edge( + parent_thread_id, + child_thread_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await?; + state_db + .upsert_thread_spawn_edge( + child_thread_id, + grandchild_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: parent_id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + + let mut archived_ids = Vec::new(); + for _ in 0..3 { + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/archived"), + ) + .await??; + let archived_notification: ThreadArchivedNotification = serde_json::from_value( + notification + .params + .expect("thread/archived notification params"), + )?; + archived_ids.push(archived_notification.thread_id); + } + assert_eq!(archived_ids, vec![parent_id, grandchild_id, child_id]); + + for thread_id in [parent_thread_id, child_thread_id, grandchild_thread_id] { + assert!( + find_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_none(), + "expected active rollout for {thread_id} to be archived" + ); + assert!( + find_archived_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_some(), + "expected archived rollout for {thread_id} to exist" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let parent_id = create_fake_rollout( + codex_home.path(), + "2025-01-01T00-00-00", + "2025-01-01T00:00:00Z", + "parent", + Some("mock_provider"), + /*git_info*/ None, + )?; + let child_id = create_fake_rollout( + codex_home.path(), + "2025-01-01T00-01-00", + "2025-01-01T00:01:00Z", + "child", + Some("mock_provider"), + /*git_info*/ None, + )?; + let grandchild_id = create_fake_rollout( + codex_home.path(), + "2025-01-01T00-02-00", + "2025-01-01T00:02:00Z", + "grandchild", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let parent_thread_id = ThreadId::from_string(&parent_id)?; + let child_thread_id = ThreadId::from_string(&child_id)?; + let grandchild_thread_id = ThreadId::from_string(&grandchild_id)?; + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + state_db + .upsert_thread_spawn_edge( + parent_thread_id, + child_thread_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await?; + state_db + .upsert_thread_spawn_edge( + child_thread_id, + grandchild_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await?; + + let child_rollout_path = + find_thread_path_by_id_str(codex_home.path(), &child_id, /*state_db_ctx*/ None) + .await? + .expect("child rollout path"); + let archived_child_path = codex_home + .path() + .join(ARCHIVED_SESSIONS_SUBDIR) + .join(child_rollout_path.file_name().expect("rollout file name")); + std::fs::create_dir_all(&archived_child_path)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: parent_id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + + let mut archived_ids = Vec::new(); + for _ in 0..2 { + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/archived"), + ) + .await??; + let archived_notification: ThreadArchivedNotification = serde_json::from_value( + notification + .params + .expect("thread/archived notification params"), + )?; + archived_ids.push(archived_notification.thread_id); + } + assert_eq!(archived_ids, vec![parent_id, grandchild_id]); + + assert!( + timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("thread/archived"), + ) + .await + .is_err() + ); + + assert!( + child_rollout_path.exists(), + "child should stay active after descendant archive failure" + ); + assert!( + archived_child_path.is_dir(), + "test conflict should remain in archived sessions" + ); + for thread_id in [parent_thread_id, grandchild_thread_id] { + assert!( + find_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_none(), + "expected active rollout for {thread_id} to be archived" + ); + assert!( + find_archived_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_some(), + "expected archived rollout for {thread_id} to exist" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let parent_id = create_fake_rollout( + codex_home.path(), + "2025-01-01T00-00-00", + "2025-01-01T00:00:00Z", + "parent", + Some("mock_provider"), + /*git_info*/ None, + )?; + let parent_thread_id = ThreadId::from_string(&parent_id)?; + let missing_child_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000901")?; + + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + state_db + .upsert_thread_spawn_edge( + parent_thread_id, + missing_child_thread_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: parent_id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/archived"), + ) + .await??; + let archived_notification: ThreadArchivedNotification = serde_json::from_value( + notification + .params + .expect("thread/archived notification params"), + )?; + assert_eq!(archived_notification.thread_id, parent_id); + + assert!( + find_thread_path_by_id_str(codex_home.path(), &parent_id, /*state_db_ctx*/ None) + .await? + .is_none(), + "parent should be archived even when a descendant is missing" + ); + assert!( + find_archived_thread_path_by_id_str( + codex_home.path(), + &parent_id, + /*state_db_ctx*/ None, + ) + .await? + .is_some(), + "parent should be moved into archived sessions" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_start_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_start_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_start_response)?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let archive_id = primary + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("thread/archived"), + ) + .await??; + + let unarchive_id = primary + .send_thread_unarchive_request(ThreadUnarchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let unarchive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(unarchive_id)), + ) + .await??; + let _: ThreadUnarchiveResponse = to_response::(unarchive_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("thread/unarchived"), + ) + .await??; + primary.clear_message_buffer(); + + let resume_id = secondary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let resume: ThreadResumeResponse = to_response::(resume_resp)?; + assert_eq!(resume.thread.status, ThreadStatus::Idle); + primary.clear_message_buffer(); + secondary.clear_message_buffer(); + + let resumed_turn_id = secondary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "secondary turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let resumed_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resumed_turn_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(resumed_turn_resp)?; + + assert!( + timeout( + std::time::Duration::from_millis(250), + primary.read_stream_until_notification_message("turn/started"), + ) + .await + .is_err() + ); + + timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, config_contents(server_uri)) +} + +fn config_contents(server_uri: &str) -> String { + format!( + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ) +} + +fn assert_paths_match_on_disk(actual: &Path, expected: &Path) -> std::io::Result<()> { + let actual = actual.canonicalize()?; + let expected = expected.canonicalize()?; + assert_eq!(actual, expected); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_fork.rs b/code-rs/app-server/tests/suite/v2/thread_fork.rs new file mode 100644 index 00000000000..3eb262bd2bb --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_fork.rs @@ -0,0 +1,775 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::create_fake_rollout_with_token_usage; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSource; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::analytics::assert_basic_thread_initialized_event; +use super::analytics::mount_analytics_capture; +use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_payload; + +#[cfg(windows)] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + + let original_path = codex_home + .path() + .join("sessions") + .join("2025") + .join("01") + .join("05") + .join(format!( + "rollout-2025-01-05T12-00-00-{conversation_id}.jsonl" + )); + assert!( + original_path.exists(), + "expected original rollout to exist at {}", + original_path.display() + ); + let original_contents = std::fs::read_to_string(&original_path)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let fork_result = fork_resp.result.clone(); + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = fork_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "forked threads should serialize `sessionId` on the thread object" + ); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "forked threads do not inherit a name; expected `name: null`" + ); + assert_eq!( + fork_result.get("sessionId"), + None, + "thread/fork should not serialize a top-level `sessionId`" + ); + + let after_contents = std::fs::read_to_string(&original_path)?; + assert_eq!( + after_contents, original_contents, + "fork should not mutate the original rollout file" + ); + + assert_ne!(thread.id, conversation_id); + assert_eq!(thread.session_id, thread.id); + assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); + assert_eq!(thread.preview, preview); + assert_eq!(thread.model_provider, "mock_provider"); + assert_eq!(thread.status, ThreadStatus::Idle); + let thread_path = thread.path.clone().expect("thread path"); + assert!(thread_path.as_path().is_absolute()); + assert_ne!(thread_path.as_path(), original_path); + assert!(thread.cwd.as_path().is_absolute()); + assert_eq!(thread.source, SessionSource::VsCode); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); + assert_eq!(thread.name, None); + + assert_eq!( + thread.turns.len(), + 1, + "expected forked thread to include one turn" + ); + let turn = &thread.turns[0]; + assert_eq!(turn.status, TurnStatus::Interrupted); + assert_eq!(turn.items.len(), 1, "expected user message item"); + match &turn.items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: preview.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + // A corresponding thread/started notification should arrive. + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notif = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notif) = message else { + continue; + }; + if notif.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + if status_changed.thread_id == thread.id { + anyhow::bail!( + "thread/fork should introduce the thread without a preceding thread/status/changed" + ); + } + continue; + } + if notif.method == "thread/started" { + break notif; + } + }; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json.get("name"), + Some(&Value::Null), + "thread/started must serialize `name: null` when unset" + ); + assert_eq!( + started_thread_json.get("turns"), + Some(&json!([])), + "thread/started must not emit copied fork turns" + ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied fork origin" + ); + let started: ThreadStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + let mut expected_started_thread = thread; + expected_started_thread.turns.clear(); + assert_eq!(started.thread, expected_started_thread); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_can_load_source_by_path() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + let original_path = codex_home + .path() + .join("sessions") + .join("2025") + .join("01") + .join("05") + .join(format!( + "rollout-2025-01-05T12-00-00-{conversation_id}.jsonl" + )); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(original_path), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + assert_ne!(thread.id, conversation_id); + assert_eq!(thread.forked_from_id, Some(conversation_id)); + assert_eq!(thread.preview, preview); + assert_eq!(thread.model_provider, "mock_provider"); + assert_eq!(thread.turns.len(), 1, "expected copied fork history"); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id, + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else { + panic!("expected thread/tokenUsage/updated notification"); + }; + + assert_eq!(notification.thread_id, thread.id); + assert_eq!(notification.turn_id, thread.turns[0].id); + assert_eq!(notification.token_usage.total.total_tokens, 150); + assert_eq!(notification.token_usage.total.input_tokens, 120); + assert_eq!(notification.token_usage.total.cached_input_tokens, 20); + assert_eq!(notification.token_usage.total.output_tokens, 30); + assert_eq!(notification.token_usage.total.reasoning_output_tokens, 10); + assert_eq!(notification.token_usage.last.total_tokens, 90); + assert_eq!(notification.token_usage.model_context_window, Some(200_000)); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_can_exclude_turns_and_skip_restored_token_usage() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + exclude_turns: true, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + assert_eq!(thread.forked_from_id, Some(conversation_id)); + assert_eq!(thread.preview, "Saved user message"); + assert!(thread.turns.is_empty()); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await; + assert!( + note.is_err(), + "excludeTurns=true should not replay token usage" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id, + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + let event = thread_initialized_event(&payload)?; + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user"); + Ok(()) +} + +#[tokio::test] +async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let fork_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + assert!( + fork_err + .error + .message + .contains("no rollout found for thread id"), + "unexpected fork error: {}", + fork_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let fork_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + + assert!( + fork_err + .error + .message + .contains("failed to load configuration"), + "unexpected fork error: {}", + fork_err.error.message + ); + assert_eq!( + fork_err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + ephemeral: true, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let fork_result = fork_resp.result.clone(); + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + let fork_thread_id = thread.id.clone(); + + assert!( + thread.ephemeral, + "ephemeral forks should be marked explicitly" + ); + assert_eq!( + thread.path, None, + "ephemeral forks should not expose a path" + ); + assert_eq!(thread.preview, preview); + assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.name, None); + assert_eq!(thread.turns.len(), 1, "expected copied fork history"); + + let turn = &thread.turns[0]; + assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.items.len(), 1, "expected user message item"); + match &turn.items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: preview.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + let thread_json = fork_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(true), + "ephemeral forks should serialize `ephemeral: true`" + ); + + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notif = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notif) = message else { + continue; + }; + if notif.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + if status_changed.thread_id == fork_thread_id { + anyhow::bail!( + "thread/fork should introduce the thread without a preceding thread/status/changed" + ); + } + continue; + } + if notif.method == "thread/started" { + break notif; + } + }; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json + .get("ephemeral") + .and_then(Value::as_bool), + Some(true), + "thread/started should serialize `ephemeral: true` for ephemeral forks" + ); + assert_eq!( + started_thread_json.get("turns"), + Some(&json!([])), + "thread/started must not emit copied ephemeral fork turns" + ); + let started: ThreadStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + let mut expected_started_thread = thread; + expected_started_thread.turns.clear(); + assert_eq!(started.thread, expected_started_thread); + + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, .. } = to_response::(list_resp)?; + assert!( + data.iter().all(|candidate| candidate.id != fork_thread_id), + "ephemeral forks should not appear in thread/list" + ); + assert!( + data.iter().any(|candidate| candidate.id == conversation_id), + "persistent source thread should remain listed" + ); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: fork_thread_id, + input: vec![UserInput::Text { + text: "continue".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn create_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_inject_items.rs b/code-rs/app-server/tests/suite/v2/thread_inject_items.rs new file mode 100644 index 00000000000..5a45e81e1d5 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -0,0 +1,286 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadInjectItemsParams; +use codex_app_server_protocol::ThreadInjectItemsResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::RolloutRecorder; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::RolloutItem; +use core_test_support::responses; +use serde_json::Value; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let injected_text = "Injected assistant context"; + let injected_item = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: injected_text.to_string(), + }], + phase: None, + }; + + let inject_req = mcp + .send_thread_inject_items_request(ThreadInjectItemsParams { + thread_id: thread.id.clone(), + items: vec![serde_json::to_value(&injected_item)?], + }) + .await?; + let inject_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(inject_req)), + ) + .await??; + let _response: ThreadInjectItemsResponse = + to_response::(inject_resp)?; + + let rollout_path = thread.path.as_ref().context("thread path missing")?; + let history = RolloutRecorder::get_rollout_history(rollout_path).await?; + let InitialHistory::Resumed(resumed_history) = history else { + panic!("expected resumed rollout history"); + }; + assert!( + resumed_history + .history + .iter() + .any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if response_item == &injected_item)), + "injected item should be persisted in rollout history" + ); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let injected_value = serde_json::to_value(&injected_item)?; + let model_input = response_mock.single_request().input(); + let environment_context_index = + response_item_text_position(&model_input, "") + .expect("environment context should be injected before the first user turn"); + let injected_index = model_input + .iter() + .position(|item| item == &injected_value) + .expect("injected item should be sent in the next model request"); + let user_prompt_index = response_item_text_position(&model_input, "Hello") + .expect("user prompt should be sent in the next model request"); + assert!( + environment_context_index < injected_index, + "standard initial context should be sent before injected items" + ); + assert!( + injected_index < user_prompt_index, + "injected items should be sent before the user prompt" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "First done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Second done"), + responses::ev_completed("resp-2"), + ]); + let response_mock = responses::mount_sse_sequence(&server, vec![first_body, second_body]).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "First turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let injected_item = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "Injected after first turn".to_string(), + }], + phase: None, + }; + let injected_value = serde_json::to_value(&injected_item)?; + + let inject_req = mcp + .send_thread_inject_items_request(ThreadInjectItemsParams { + thread_id: thread.id.clone(), + items: vec![injected_value.clone()], + }) + .await?; + let inject_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(inject_req)), + ) + .await??; + let _response: ThreadInjectItemsResponse = + to_response::(inject_resp)?; + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Second turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + assert!( + !requests[0].input().contains(&injected_value), + "injected item should not be sent before it is injected" + ); + assert!( + requests[1].input().contains(&injected_value), + "injected item should be sent after being injected into existing history" + ); + + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn response_item_text_position(items: &[Value], needle: &str) -> Option { + items.iter().position(|item| { + item.get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|content| { + content + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| text.contains(needle)) + }) + }) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_list.rs b/code-rs/app-server/tests/suite/v2/thread_list.rs new file mode 100644 index 00000000000..80254d8f47d --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_list.rs @@ -0,0 +1,1759 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::create_fake_rollout_with_source; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::rollout_path; +use app_test_support::test_absolute_path; +use app_test_support::to_response; +use chrono::DateTime; +use chrono::Utc; +use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadListCwdFilter; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_git_utils::GitSha; +use codex_protocol::ThreadId; +use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use std::cmp::Reverse; +use std::fs; +use std::fs::FileTimes; +use std::fs::OpenOptions; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +async fn init_mcp(codex_home: &Path) -> Result { + let mut mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok(mcp) +} + +async fn list_threads( + mcp: &mut McpProcess, + cursor: Option, + limit: Option, + providers: Option>, + source_kinds: Option>, + archived: Option, +) -> Result { + list_threads_with_sort( + mcp, + cursor, + limit, + providers, + source_kinds, + /*sort_key*/ None, + archived, + ) + .await +} + +async fn list_threads_with_sort( + mcp: &mut McpProcess, + cursor: Option, + limit: Option, + providers: Option>, + source_kinds: Option>, + sort_key: Option, + archived: Option, +) -> Result { + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor, + limit, + sort_key, + sort_direction: None, + model_providers: providers, + source_kinds, + archived, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response::(resp) +} + +fn create_fake_rollouts( + codex_home: &Path, + count: usize, + provider_for_index: F, + timestamp_for_index: G, + preview: &str, +) -> Result> +where + F: Fn(usize) -> &'static str, + G: Fn(usize) -> (String, String), +{ + let mut ids = Vec::with_capacity(count); + for i in 0..count { + let (ts_file, ts_rfc) = timestamp_for_index(i); + ids.push(create_fake_rollout( + codex_home, + &ts_file, + &ts_rfc, + preview, + Some(provider_for_index(i)), + /*git_info*/ None, + )?); + } + Ok(ids) +} + +fn timestamp_at( + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, +) -> (String, String) { + ( + format!("{year:04}-{month:02}-{day:02}T{hour:02}-{minute:02}-{second:02}"), + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"), + ) +} + +#[allow(dead_code)] +fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> { + let parsed = DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + OpenOptions::new() + .append(true) + .open(path)? + .set_times(times)?; + Ok(()) +} + +fn set_rollout_cwd(path: &Path, cwd: &Path) -> Result<()> { + let content = fs::read_to_string(path)?; + let mut lines: Vec = content.lines().map(str::to_string).collect(); + let first_line = lines + .first_mut() + .ok_or_else(|| anyhow::anyhow!("rollout at {} is empty", path.display()))?; + let mut rollout_line: RolloutLine = serde_json::from_str(first_line)?; + let RolloutItem::SessionMeta(mut session_meta_line) = rollout_line.item else { + return Err(anyhow::anyhow!( + "rollout at {} does not start with session metadata", + path.display() + )); + }; + session_meta_line.meta.cwd = cwd.to_path_buf(); + rollout_line.item = RolloutItem::SessionMeta(session_meta_line); + *first_line = serde_json::to_string(&rollout_line)?; + fs::write(path, lines.join("\n") + "\n")?; + Ok(()) +} + +#[tokio::test] +async fn thread_list_basic_empty() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data, next_cursor, .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert!(data.is_empty()); + assert_eq!(next_cursor, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Result<()> { + let responses = vec![ + create_final_assistant_message_sse_response("seeded")?, + responses::sse_failed("resp-2", "server_error", "simulated failure"), + ]; + let server = create_mock_responses_server_sequence(responses).await; + + let codex_home = TempDir::new()?; + create_runtime_config(codex_home.path(), &server.uri())?; + let mut mcp = init_mcp(codex_home.path()).await?; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let seed_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(seed_turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let failed_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "fail turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let failed_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(failed_turn_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(failed_turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("error"), + ) + .await??; + + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ + ThreadSourceKind::AppServer, + ThreadSourceKind::Cli, + ThreadSourceKind::VsCode, + ]), + /*archived*/ None, + ) + .await?; + let listed = data + .iter() + .find(|candidate| candidate.id == thread.id) + .expect("expected started thread to be listed"); + assert_eq!(listed.status, ThreadStatus::SystemError,); + + Ok(()) +} + +// Minimal config.toml for listing. +fn create_minimal_config(codex_home: &std::path::Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +"#, + ) +} + +fn create_runtime_config(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +#[tokio::test] +async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Create three rollouts so we can paginate with limit=2. + let _a = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let _b = create_fake_rollout( + codex_home.path(), + "2025-01-01T13-00-00", + "2025-01-01T13:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let _c = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + // Page 1: limit 2 → expect next_cursor Some. + let ThreadListResponse { + data: data1, + next_cursor: cursor1, + .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(2), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert_eq!(data1.len(), 2); + for thread in &data1 { + assert_eq!(thread.preview, "Hello"); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.created_at > 0); + assert_eq!(thread.updated_at, thread.created_at); + assert_eq!(thread.cwd, test_absolute_path("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); + assert_eq!(thread.status, ThreadStatus::NotLoaded); + } + let cursor1 = cursor1.expect("expected nextCursor on first page"); + + // Page 2: with cursor → expect next_cursor None when no more results. + let ThreadListResponse { + data: data2, + next_cursor: cursor2, + .. + } = list_threads( + &mut mcp, + Some(cursor1), + Some(2), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert!(data2.len() <= 2); + for thread in &data2 { + assert_eq!(thread.preview, "Hello"); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.created_at > 0); + assert_eq!(thread.updated_at, thread.created_at); + assert_eq!(thread.cwd, test_absolute_path("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); + assert_eq!(thread.status, ThreadStatus::NotLoaded); + } + assert_eq!(cursor2, None, "expected nextCursor to be null on last page"); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_respects_provider_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Create rollouts under two providers. + let _a = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "X", + Some("mock_provider"), + /*git_info*/ None, + )?; // mock_provider + let _b = create_fake_rollout( + codex_home.path(), + "2025-01-02T11-00-00", + "2025-01-02T11:00:00Z", + "X", + Some("other_provider"), + /*git_info*/ None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + // Filter to only other_provider; expect 1 item, nextCursor None. + let ThreadListResponse { + data, next_cursor, .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["other_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert_eq!(data.len(), 1); + assert_eq!(next_cursor, None); + let thread = &data[0]; + assert_eq!(thread.preview, "X"); + assert_eq!(thread.model_provider, "other_provider"); + let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp(); + assert_eq!(thread.created_at, expected_ts); + assert_eq!(thread.updated_at, expected_ts); + assert_eq!(thread.cwd, test_absolute_path("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_respects_cwd_filters() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let first_filtered_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "first filtered", + Some("mock_provider"), + /*git_info*/ None, + )?; + let second_filtered_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "second filtered", + Some("mock_provider"), + /*git_info*/ None, + )?; + let unfiltered_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T11-00-00", + "2025-01-02T11:00:00Z", + "unfiltered", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let first_target_cwd = codex_home.path().join("first-target-cwd"); + let second_target_cwd = codex_home.path().join("second-target-cwd"); + fs::create_dir_all(&first_target_cwd)?; + fs::create_dir_all(&second_target_cwd)?; + set_rollout_cwd( + rollout_path(codex_home.path(), "2025-01-02T10-00-00", &first_filtered_id).as_path(), + &first_target_cwd, + )?; + set_rollout_cwd( + rollout_path( + codex_home.path(), + "2025-01-02T12-00-00", + &second_filtered_id, + ) + .as_path(), + &second_target_cwd, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(ThreadListCwdFilter::Many(vec![ + first_target_cwd.to_string_lossy().into_owned(), + second_target_cwd.to_string_lossy().into_owned(), + ])), + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadListResponse { + data, next_cursor, .. + } = to_response::(resp)?; + + assert_eq!(next_cursor, None); + let filtered_ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!( + filtered_ids, + vec![second_filtered_id.as_str(), first_filtered_id.as_str()] + ); + assert!(!filtered_ids.contains(&unfiltered_id.as_str())); + assert_eq!(data[0].cwd.as_path(), second_target_cwd.as_path()); + assert_eq!(data[1].cwd.as_path(), first_target_cwd.as_path()); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_respects_search_term_filter() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +model = "mock-model" +approval_policy = "never" +suppress_unstable_features_warning = true + +[features] +sqlite = true +"#, + )?; + + let older_match = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "match: needle", + Some("mock_provider"), + /*git_info*/ None, + )?; + let _non_match = create_fake_rollout( + codex_home.path(), + "2025-01-02T11-00-00", + "2025-01-02T11:00:00Z", + "no hit here", + Some("mock_provider"), + /*git_info*/ None, + )?; + let newer_match = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "needle suffix", + Some("mock_provider"), + /*git_info*/ None, + )?; + + // `thread/list` applies `search_term` on the sqlite fast path. This test creates + // rollouts manually, so mark the DB backfill complete and then run an unsearched + // list large enough to repair every rollout the searched list should find. + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let rollout_config = codex_rollout::RolloutConfig { + codex_home: codex_home.path().to_path_buf(), + sqlite_home: codex_home.path().to_path_buf(), + cwd: codex_home.path().to_path_buf(), + model_provider_id: "mock_provider".to_string(), + generate_memories: false, + }; + let repaired_page = codex_core::RolloutRecorder::list_threads( + Some(state_db.clone()), + &rollout_config, + /*page_size*/ 10, + /*cursor*/ None, + codex_core::ThreadSortKey::CreatedAt, + codex_core::SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ None, + "mock_provider", + /*search_term*/ None, + ) + .await?; + assert_eq!(repaired_page.items.len(), 3); + + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: Some("needle".to_string()), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadListResponse { + data, next_cursor, .. + } = to_response::(resp)?; + + assert_eq!(next_cursor, None); + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![newer_match, older_match]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_state_db_only_returns_sqlite_without_jsonl_repair() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +model = "mock-model" +approval_policy = "never" +suppress_unstable_features_warning = true + +[features] +sqlite = true +"#, + )?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "state db only should not see this before repair", + Some("mock_provider"), + /*git_info*/ None, + )?; + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let mut mcp = init_mcp(codex_home.path()).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let repaired_response = to_response::(resp)?; + let ids: Vec<_> = repaired_response + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![thread_id.as_str()]); + + let thread_uuid = ThreadId::from_string(&thread_id)?; + let stale_cwd = codex_home.path().join("stale-cwd"); + let mut metadata = state_db + .get_thread(thread_uuid) + .await? + .expect("thread should be repaired into sqlite"); + metadata.cwd = stale_cwd.clone(); + state_db.upsert_thread(&metadata).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(ThreadListCwdFilter::One( + stale_cwd.to_string_lossy().into_owned(), + )), + use_state_db_only: true, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let state_db_only_response = to_response::(resp)?; + let ids: Vec<_> = state_db_only_response + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![thread_id.as_str()]); + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(ThreadListCwdFilter::One( + stale_cwd.to_string_lossy().into_owned(), + )), + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let scanned_response = to_response::(resp)?; + assert_eq!(scanned_response.data.len(), 0); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let cli_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "CLI", + Some("mock_provider"), + /*git_info*/ None, + )?; + let exec_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Exec", + Some("mock_provider"), + /*git_info*/ None, + CoreSessionSource::Exec, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data, next_cursor, .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(Vec::new()), + /*archived*/ None, + ) + .await?; + + assert_eq!(next_cursor, None); + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![cli_id.as_str()]); + assert_ne!(cli_id, exec_id); + assert_eq!(data[0].source, SessionSource::Cli); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let cli_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "CLI", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; + let subagent_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "SubAgent", + Some("mock_provider"), + /*git_info*/ None, + CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data, next_cursor, .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), + /*archived*/ None, + ) + .await?; + + assert_eq!(next_cursor, None); + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![subagent_id.as_str()]); + assert_ne!(cli_id, subagent_id); + assert!(matches!(data[0].source, SessionSource::SubAgent(_))); + assert_eq!(data[0].session_id, subagent_id); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_filters_by_subagent_variant() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; + + let review_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T09-00-00", + "2025-02-02T09:00:00Z", + "Review", + Some("mock_provider"), + /*git_info*/ None, + CoreSessionSource::SubAgent(SubAgentSource::Review), + )?; + let compact_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T10-00-00", + "2025-02-02T10:00:00Z", + "Compact", + Some("mock_provider"), + /*git_info*/ None, + CoreSessionSource::SubAgent(SubAgentSource::Compact), + )?; + let spawn_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T11-00-00", + "2025-02-02T11:00:00Z", + "Spawn", + Some("mock_provider"), + /*git_info*/ None, + CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + )?; + let other_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T12-00-00", + "2025-02-02T12:00:00Z", + "Other", + Some("mock_provider"), + /*git_info*/ None, + CoreSessionSource::SubAgent(SubAgentSource::Other("custom".to_string())), + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let review = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentReview]), + /*archived*/ None, + ) + .await?; + let review_ids: Vec<_> = review + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(review_ids, vec![review_id.as_str()]); + + let compact = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentCompact]), + /*archived*/ None, + ) + .await?; + let compact_ids: Vec<_> = compact + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(compact_ids, vec![compact_id.as_str()]); + + let spawn = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), + /*archived*/ None, + ) + .await?; + let spawn_ids: Vec<_> = spawn.data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(spawn_ids, vec![spawn_id.as_str()]); + + let other = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentOther]), + /*archived*/ None, + ) + .await?; + let other_ids: Vec<_> = other.data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(other_ids, vec![other_id.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Newest 16 conversations belong to a different provider; the older 8 are the + // only ones that match the filter. We request 8 so the server must keep + // paging past the first two pages to reach the desired count. + create_fake_rollouts( + codex_home.path(), + /*count*/ 24, + |i| { + if i < 16 { + "skip_provider" + } else { + "target_provider" + } + }, + |i| { + timestamp_at( + /*year*/ 2025, + /*month*/ 3, + 30 - i as u32, + /*hour*/ 12, + /*minute*/ 0, + /*second*/ 0, + ) + }, + "Hello", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + // Request 8 threads for the target provider; the matches only start on the + // third page so we rely on pagination to reach the limit. + let ThreadListResponse { + data, next_cursor, .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(8), + Some(vec!["target_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert_eq!( + data.len(), + 8, + "should keep paging until the requested count is filled" + ); + assert!( + data.iter() + .all(|thread| thread.model_provider == "target_provider"), + "all returned threads must match the requested provider" + ); + assert_eq!( + next_cursor, None, + "once the requested count is satisfied on the final page, nextCursor should be None" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_enforces_max_limit() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + create_fake_rollouts( + codex_home.path(), + /*count*/ 105, + |_| "mock_provider", + |i| { + let month = 5 + (i / 28); + let day = (i % 28) + 1; + timestamp_at( + /*year*/ 2025, + month as u32, + day as u32, + /*hour*/ 0, + /*minute*/ 0, + /*second*/ 0, + ) + }, + "Hello", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data, next_cursor, .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(200), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert_eq!( + data.len(), + 100, + "limit should be clamped to the maximum page size" + ); + assert!( + next_cursor.is_some(), + "when more than the maximum exist, nextCursor should continue pagination" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Only the last 7 conversations match the provider filter; we ask for 10 to + // ensure the server exhausts pagination without looping forever. + create_fake_rollouts( + codex_home.path(), + /*count*/ 22, + |i| { + if i < 15 { + "skip_provider" + } else { + "target_provider" + } + }, + |i| { + timestamp_at( + /*year*/ 2025, + /*month*/ 4, + 28 - i as u32, + /*hour*/ 8, + /*minute*/ 0, + /*second*/ 0, + ) + }, + "Hello", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + // Request more threads than exist after filtering; expect all matches to be + // returned with nextCursor None. + let ThreadListResponse { + data, next_cursor, .. + } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["target_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert_eq!( + data.len(), + 7, + "all available filtered threads should be returned" + ); + assert!( + data.iter() + .all(|thread| thread.model_provider == "target_provider"), + "results should still respect the provider filter" + ); + assert_eq!( + next_cursor, None, + "when results are exhausted before reaching the limit, nextCursor should be None" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_includes_git_info() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let git_info = CoreGitInfo { + commit_hash: Some(GitSha::new("abc123")), + branch: Some("main".to_string()), + repository_url: Some("https://example.com/repo.git".to_string()), + }; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T09-00-00", + "2025-02-01T09:00:00Z", + "Git info preview", + Some("mock_provider"), + Some(git_info), + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + let thread = data + .iter() + .find(|t| t.id == conversation_id) + .expect("expected thread for created rollout"); + + let expected_git = ApiGitInfo { + sha: Some("abc123".to_string()), + branch: Some("main".to_string()), + origin_url: Some("https://example.com/repo.git".to_string()), + }; + assert_eq!(thread.git_info, Some(expected_git)); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.cwd, test_absolute_path("/")); + assert_eq!(thread.cli_version, "0.0.0"); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_default_sorts_by_created_at() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-01-01T13-00-00", + "2025-01-01T13:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_c = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*sort_key*/ None, + /*archived*/ None, + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-01-01T10-00-00", + "2025-01-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_mid = create_fake_rollout( + codex_home.path(), + "2025-01-01T11-00-00", + "2025-01-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_new = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), + "2025-01-03T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T11-00-00", &id_mid).as_path(), + "2025-01-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T12-00-00", &id_new).as_path(), + "2025-01-01T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::UpdatedAt), + /*archived*/ None, + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![id_old.as_str(), id_mid.as_str(), id_new.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_c = create_fake_rollout( + codex_home.path(), + "2025-02-01T12-00-00", + "2025-02-01T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(), + "2025-02-03T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(), + "2025-02-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_c).as_path(), + "2025-02-01T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data: page1, + next_cursor: cursor1, + .. + } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(2), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::UpdatedAt), + /*archived*/ None, + ) + .await?; + let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page1, vec![id_a.as_str(), id_b.as_str()]); + let cursor1 = cursor1.expect("expected nextCursor on first page"); + + let ThreadListResponse { + data: page2, + next_cursor: cursor2, + .. + } = list_threads_with_sort( + &mut mcp, + Some(cursor1), + Some(2), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::UpdatedAt), + /*archived*/ None, + ) + .await?; + let ids_page2: Vec<_> = page2.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page2, vec![id_c.as_str()]); + assert_eq!(cursor2, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_watermark = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_old).as_path(), + "2025-02-02T00:00:00Z", + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_watermark).as_path(), + "2025-02-03T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { + data: page1, + backwards_cursor, + .. + } = { + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(1), + sort_key: Some(ThreadSortKey::UpdatedAt), + sort_direction: Some(SortDirection::Desc), + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response::(resp)? + }; + let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_page1, vec![id_watermark.as_str()]); + let backwards_cursor = backwards_cursor.expect("expected backwardsCursor on first page"); + assert_eq!(backwards_cursor, "2025-02-02T23:59:59.999Z"); + + let id_new = create_fake_rollout( + codex_home.path(), + "2025-02-01T12-00-00", + "2025-02-01T12:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_new).as_path(), + "2025-02-04T00:00:00Z", + )?; + + let ThreadListResponse { + data: delta_page, .. + } = { + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: Some(backwards_cursor), + limit: Some(10), + sort_key: Some(ThreadSortKey::UpdatedAt), + sort_direction: Some(SortDirection::Asc), + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response::(resp)? + }; + let ids_delta: Vec<_> = delta_page.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids_delta, vec![id_watermark.as_str(), id_new.as_str()]); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + let mut expected = [id_a, id_b]; + expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse"))); + let expected: Vec<_> = expected.iter().map(String::as_str).collect(); + assert_eq!(ids, expected); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_a = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_b = create_fake_rollout( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let updated_at = "2025-02-03T00:00:00Z"; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(), + updated_at, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(), + updated_at, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::UpdatedAt), + /*archived*/ None, + ) + .await?; + + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + let mut expected = [id_a, id_b]; + expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse"))); + let expected: Vec<_> = expected.iter().map(String::as_str).collect(); + assert_eq!(ids, expected); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_updated_at_uses_mtime() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-02-01T10-00-00", &thread_id).as_path(), + "2025-02-05T00:00:00Z", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::UpdatedAt), + /*archived*/ None, + ) + .await?; + + let thread = data + .iter() + .find(|item| item.id == thread_id) + .expect("expected thread for created rollout"); + let expected_created = + chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")?.timestamp(); + let expected_updated = + chrono::DateTime::parse_from_rfc3339("2025-02-05T00:00:00Z")?.timestamp(); + assert_eq!(thread.created_at, expected_created); + assert_eq!(thread.updated_at, expected_updated); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_archived_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let active_id = create_fake_rollout( + codex_home.path(), + "2025-03-01T10-00-00", + "2025-03-01T10:00:00Z", + "Active", + Some("mock_provider"), + /*git_info*/ None, + )?; + let archived_id = create_fake_rollout( + codex_home.path(), + "2025-03-01T09-00-00", + "2025-03-01T09:00:00Z", + "Archived", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + fs::create_dir_all(&archived_dir)?; + let archived_source = rollout_path(codex_home.path(), "2025-03-01T09-00-00", &archived_id); + let archived_dest = archived_dir.join( + archived_source + .file_name() + .expect("archived rollout should have a file name"), + ); + fs::rename(&archived_source, &archived_dest)?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, active_id); + + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(true), + ) + .await?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, archived_id); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_invalid_cursor_returns_error() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: Some("not-a-cursor".to_string()), + limit: Some(2), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, "invalid cursor: not-a-cursor"); + + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_loaded_list.rs b/code-rs/app-server/tests/suite/v2/thread_loaded_list.rs new file mode 100644 index 00000000000..245cfafb490 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_loaded_list.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_loaded_list_returns_loaded_thread_ids() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + + let list_id = mcp + .send_thread_loaded_list_request(ThreadLoadedListParams::default()) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadLoadedListResponse { + mut data, + next_cursor, + } = to_response::(resp)?; + data.sort(); + assert_eq!(data, vec![thread_id]); + assert_eq!(next_cursor, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_loaded_list_paginates() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let first = start_thread(&mut mcp).await?; + let second = start_thread(&mut mcp).await?; + + let mut expected = [first, second]; + expected.sort(); + + let list_id = mcp + .send_thread_loaded_list_request(ThreadLoadedListParams { + cursor: None, + limit: Some(1), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadLoadedListResponse { + data: first_page, + next_cursor, + } = to_response::(resp)?; + assert_eq!(first_page, vec![expected[0].clone()]); + assert_eq!(next_cursor, Some(expected[0].clone())); + + let list_id = mcp + .send_thread_loaded_list_request(ThreadLoadedListParams { + cursor: next_cursor, + limit: Some(1), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadLoadedListResponse { + data: second_page, + next_cursor, + } = to_response::(resp)?; + assert_eq!(second_page, vec![expected[1].clone()]); + assert_eq!(next_cursor, None); + + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +async fn start_thread(mcp: &mut McpProcess) -> Result { + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2".to_string()), + ..Default::default() + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(resp)?; + Ok(thread.id) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs b/code-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs new file mode 100644 index 00000000000..bf9bba7b2ff --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs @@ -0,0 +1,138 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadMemoryMode; +use codex_app_server_protocol::ThreadMemoryModeSetParams; +use codex_app_server_protocol::ThreadMemoryModeSetResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_protocol::ThreadId; +use codex_state::StateRuntime; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_memory_mode_set_updates_loaded_thread_state() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let state_db = init_state_db(codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let thread_uuid = ThreadId::from_string(&thread.id)?; + + let set_id = mcp + .send_thread_memory_mode_set_request(ThreadMemoryModeSetParams { + thread_id: thread.id, + mode: ThreadMemoryMode::Disabled, + }) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let _: ThreadMemoryModeSetResponse = to_response::(set_resp)?; + + let memory_mode = state_db.get_thread_memory_mode(thread_uuid).await?; + assert_eq!(memory_mode.as_deref(), Some("disabled")); + Ok(()) +} + +#[tokio::test] +async fn thread_memory_mode_set_updates_stored_thread_state() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let state_db = init_state_db(codex_home.path()).await?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-06T08-30-00", + "2025-01-06T08:30:00Z", + "Stored thread preview", + Some("mock_provider"), + /*git_info*/ None, + )?; + let thread_uuid = ThreadId::from_string(&thread_id)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + for mode in [ThreadMemoryMode::Disabled, ThreadMemoryMode::Enabled] { + let set_id = mcp + .send_thread_memory_mode_set_request(ThreadMemoryModeSetParams { + thread_id: thread_id.clone(), + mode, + }) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let _: ThreadMemoryModeSetResponse = to_response::(set_resp)?; + } + + let memory_mode = state_db.get_thread_memory_mode(thread_uuid).await?; + assert_eq!(memory_mode.as_deref(), Some("enabled")); + Ok(()) +} + +async fn init_state_db(codex_home: &Path) -> Result> { + let state_db = StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into()).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + Ok(state_db) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +suppress_unstable_features_warning = true + +[features] +sqlite = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/code-rs/app-server/tests/suite/v2/thread_metadata_update.rs new file mode 100644 index 00000000000..c78e9b81526 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -0,0 +1,521 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::rollout_path; +use app_test_support::to_response; +use codex_app_server_protocol::GitInfo; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_git_utils::GitSha; +use codex_protocol::ThreadId; +use codex_protocol::protocol::GitInfo as RolloutGitInfo; +use codex_rollout::state_db::reconcile_rollout; +use codex_state::StateRuntime; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test] +async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/sidebar-pr".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let update_result = update_resp.result.clone(); + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread.id); + assert_eq!(updated.session_id, thread.session_id); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/sidebar-pr".to_string()), + origin_url: None, + }) + ); + assert_eq!(updated.status, ThreadStatus::Idle); + let updated_thread_json = update_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/metadata/update result.thread must be an object"); + assert_eq!( + updated_thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); + let updated_git_info_json = updated_thread_json + .get("gitInfo") + .and_then(Value::as_object) + .expect("thread/metadata/update must serialize `thread.gitInfo` on the wire"); + assert_eq!( + updated_git_info_json.get("branch").and_then(Value::as_str), + Some("feature/sidebar-pr") + ); + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread: read, .. } = to_response::(read_resp)?; + + assert_eq!( + read.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/sidebar-pr".to_string()), + origin_url: None, + }) + ); + assert_eq!(read.status, ThreadStatus::Idle); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id, + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: None, + origin_url: None, + }), + }) + .await?; + let update_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(update_id)), + ) + .await??; + + assert_eq!( + update_err.error.message, + "gitInfo must include at least one field" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/ephemeral".to_string())), + origin_url: None, + }), + }) + .await?; + let update_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(update_id)), + ) + .await??; + + assert_eq!(update_err.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + update_err.error.message, + format!( + "ephemeral thread does not support metadata updates: {}", + thread.id + ) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let _state_db = init_state_db(codex_home.path()).await?; + + let preview = "Stored thread preview"; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/stored-thread".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id); + assert_eq!(updated.preview, preview); + assert_eq!(updated.created_at, 1736078400); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/stored-thread".to_string()), + origin_url: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let state_db = init_state_db(codex_home.path()).await?; + + let preview = "Loaded thread preview"; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-06T08-30-00", + "2025-01-06T08:30:00Z", + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + let thread_uuid = ThreadId::from_string(&thread_id)?; + let rollout_path = rollout_path(codex_home.path(), "2025-01-06T08-30-00", &thread_id); + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + "mock_provider", + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let _: ThreadResumeResponse = to_response::(resume_resp)?; + + assert_eq!(state_db.delete_thread(thread_uuid).await?, 1); + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/loaded-thread".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id); + assert_eq!(updated.preview, preview); + assert_eq!(updated.created_at, 1736152200); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/loaded-thread".to_string()), + origin_url: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let _state_db = init_state_db(codex_home.path()).await?; + + let preview = "Archived thread preview"; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-06T08-30-00", + "2025-01-06T08:30:00Z", + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + + let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + fs::create_dir_all(&archived_dir)?; + let archived_source = rollout_path(codex_home.path(), "2025-01-06T08-30-00", &thread_id); + let archived_dest = archived_dir.join( + archived_source + .file_name() + .expect("archived rollout should have a file name"), + ); + fs::rename(&archived_source, &archived_dest)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/archived-thread".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id); + assert_eq!(updated.preview, preview); + assert_eq!(updated.created_at, 1736152200); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/archived-thread".to_string()), + origin_url: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-07T09-15-00", + "2025-01-07T09:15:00Z", + "Thread preview", + Some("mock_provider"), + Some(RolloutGitInfo { + commit_hash: Some(GitSha::new("abc123")), + branch: Some("feature/sidebar-pr".to_string()), + repository_url: Some("git@example.com:openai/codex.git".to_string()), + }), + )?; + let _state_db = init_state_db(codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: Some(None), + branch: Some(None), + origin_url: Some(None), + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id.clone()); + assert_eq!(updated.git_info, None); + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread: read, .. } = to_response::(read_resp)?; + + assert_eq!(read.git_info, None); + + Ok(()) +} + +async fn init_state_db(codex_home: &Path) -> Result> { + let state_db = StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into()).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + Ok(state_db) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +suppress_unstable_features_warning = true + +[features] +sqlite = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_name_websocket.rs b/code-rs/app-server/tests/suite/v2/thread_name_websocket.rs new file mode 100644 index 00000000000..951e4d74e99 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_name_websocket.rs @@ -0,0 +1,196 @@ +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::WsClient; +use super::connection_handling_websocket::assert_no_message; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::create_config_toml; +use super::connection_handling_websocket::read_notification_for_method; +use super::connection_handling_websocket::read_response_and_notification_for_method; +use super::connection_handling_websocket::read_response_for_id; +use super::connection_handling_websocket::send_initialize_request; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; +use anyhow::Context; +use anyhow::Result; +use app_test_support::create_fake_rollout_with_text_elements; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ThreadNameUpdatedNotification; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; +use codex_core::find_thread_name_by_id; +use codex_protocol::ThreadId; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +#[tokio::test] +async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-00-00")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let result = async { + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + initialize_both_clients(&mut ws1, &mut ws2).await?; + + send_request( + &mut ws1, + "thread/resume", + /*id*/ 10, + Some(serde_json::to_value(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + })?), + ) + .await?; + let resume_resp: JSONRPCResponse = read_response_for_id(&mut ws1, /*id*/ 10).await?; + let resume: ThreadResumeResponse = to_response::(resume_resp)?; + assert_eq!(resume.thread.id, conversation_id); + + let renamed = "Loaded rename"; + send_request( + &mut ws1, + "thread/name/set", + /*id*/ 11, + Some(serde_json::to_value(ThreadSetNameParams { + thread_id: conversation_id.clone(), + name: renamed.to_string(), + })?), + ) + .await?; + let (rename_resp, ws1_notification) = read_response_and_notification_for_method( + &mut ws1, + /*id*/ 11, + "thread/name/updated", + ) + .await?; + let _: ThreadSetNameResponse = to_response::(rename_resp)?; + assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?; + + let ws2_notification = + read_notification_for_method(&mut ws2, "thread/name/updated").await?; + assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; + assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; + + assert_no_message(&mut ws1, Duration::from_millis(250)).await?; + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + Ok(()) + } + .await; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + result +} + +#[tokio::test] +async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-05-00")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let result = async { + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + initialize_both_clients(&mut ws1, &mut ws2).await?; + + let renamed = "Stored rename"; + send_request( + &mut ws1, + "thread/name/set", + /*id*/ 20, + Some(serde_json::to_value(ThreadSetNameParams { + thread_id: conversation_id.clone(), + name: renamed.to_string(), + })?), + ) + .await?; + let (rename_resp, ws1_notification) = read_response_and_notification_for_method( + &mut ws1, + /*id*/ 20, + "thread/name/updated", + ) + .await?; + let _: ThreadSetNameResponse = to_response::(rename_resp)?; + assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?; + + let ws2_notification = + read_notification_for_method(&mut ws2, "thread/name/updated").await?; + assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; + assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; + + assert_no_message(&mut ws1, Duration::from_millis(250)).await?; + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + Ok(()) + } + .await; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + result +} + +async fn initialize_both_clients(ws1: &mut WsClient, ws2: &mut WsClient) -> Result<()> { + send_initialize_request(ws1, /*id*/ 1, "ws_client_one").await?; + timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws1, /*id*/ 1)).await??; + + send_initialize_request(ws2, /*id*/ 2, "ws_client_two").await?; + timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws2, /*id*/ 2)).await??; + Ok(()) +} + +fn create_rollout(codex_home: &std::path::Path, filename_ts: &str) -> Result { + create_fake_rollout_with_text_elements( + codex_home, + filename_ts, + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + ) +} + +fn assert_thread_name_updated( + notification: JSONRPCNotification, + thread_id: &str, + thread_name: &str, +) -> Result<()> { + let notification: ThreadNameUpdatedNotification = + serde_json::from_value(notification.params.context("thread/name/updated params")?)?; + assert_eq!(notification.thread_id, thread_id); + assert_eq!(notification.thread_name.as_deref(), Some(thread_name)); + Ok(()) +} + +async fn assert_legacy_thread_name( + codex_home: &Path, + conversation_id: &str, + expected_name: &str, +) -> Result<()> { + let thread_id = ThreadId::from_string(conversation_id)?; + assert_eq!( + find_thread_name_by_id(codex_home, &thread_id) + .await? + .as_deref(), + Some(expected_name) + ); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_read.rs b/code-rs/app-server/tests/suite/v2/thread_read.rs new file mode 100644 index 00000000000..52420c0c804 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_read.rs @@ -0,0 +1,1373 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout_with_text_elements; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::rollout_path; +use app_test_support::test_absolute_path; +use app_test_support::to_response; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadNameUpdatedNotification; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsItemsListParams; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource as ProtocolSessionSource; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::UserMessageEvent; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; +use codex_thread_store::AppendThreadItemsParams; +use codex_thread_store::CreateThreadParams; +use codex_thread_store::InMemoryThreadStore; +use codex_thread_store::ThreadEventPersistenceMode; +use codex_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadPersistenceMetadata; +use codex_thread_store::ThreadStore; +use codex_thread_store::UpdateThreadMetadataParams; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::io::Write; +use std::path::Path; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +#[cfg(windows)] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_read_returns_summary_without_turns() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let text_elements = [TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + text_elements + .iter() + .map(|elem| serde_json::to_value(elem).expect("serialize text element")) + .collect(), + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: conversation_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + + assert_eq!(thread.id, conversation_id); + assert_eq!(thread.preview, preview); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(!thread.ephemeral, "stored rollouts should not be ephemeral"); + assert!(thread.path.as_ref().expect("thread path").is_absolute()); + assert_eq!(thread.cwd, test_absolute_path("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); + assert_eq!(thread.turns.len(), 0); + assert_eq!(thread.status, ThreadStatus::NotLoaded); + + Ok(()) +} + +#[tokio::test] +async fn thread_read_can_include_turns() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + text_elements + .iter() + .map(|elem| serde_json::to_value(elem).expect("serialize text element")) + .collect(), + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: conversation_id.clone(), + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + + assert_eq!(thread.turns.len(), 1); + let turn = &thread.turns[0]; + assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.items_view, TurnItemsView::Full); + assert_eq!(turn.items.len(), 1, "expected user message item"); + match &turn.items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: preview.to_string(), + text_elements: text_elements.clone().into_iter().map(Into::into).collect(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + assert_eq!(thread.status, ThreadStatus::NotLoaded); + + Ok(()) +} + +#[tokio::test] +async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; + append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: None, + limit: Some(2), + sort_direction: Some(SortDirection::Desc), + items_view: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { + data, + next_cursor, + backwards_cursor, + } = to_response::(read_resp)?; + assert_eq!(turn_user_texts(&data), vec!["third", "second"]); + assert!( + data.iter() + .all(|turn| turn.items_view == TurnItemsView::Summary) + ); + let next_cursor = next_cursor.expect("expected nextCursor for older turns"); + let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: Some(next_cursor), + limit: Some(10), + sort_direction: Some(SortDirection::Desc), + items_view: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { data, .. } = to_response::(read_resp)?; + assert_eq!(turn_user_texts(&data), vec!["first"]); + + append_user_message(rollout_path.as_path(), "2025-01-05T12:03:00Z", "fourth")?; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id, + cursor: Some(backwards_cursor), + limit: Some(10), + sort_direction: Some(SortDirection::Asc), + items_view: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { data, .. } = to_response::(read_resp)?; + assert_eq!(turn_user_texts(&data), vec!["third", "fourth"]); + + Ok(()) +} + +#[tokio::test] +async fn thread_turns_list_supports_requested_items_view() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_agent_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "draft")?; + append_agent_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "final")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let full = read_single_turn_items_view( + &mut mcp, + conversation_id.as_str(), + Some(TurnItemsView::Full), + ) + .await?; + assert_eq!(full.items_view, TurnItemsView::Full); + assert_eq!( + turn_agent_texts(std::slice::from_ref(&full)), + vec!["draft", "final"] + ); + + let summary = read_single_turn_items_view( + &mut mcp, + conversation_id.as_str(), + Some(TurnItemsView::Summary), + ) + .await?; + assert_eq!(summary.items_view, TurnItemsView::Summary); + assert_eq!( + turn_user_texts(std::slice::from_ref(&summary)), + vec!["first"] + ); + assert_eq!( + turn_agent_texts(std::slice::from_ref(&summary)), + vec!["final"] + ); + + let not_loaded = read_single_turn_items_view( + &mut mcp, + conversation_id.as_str(), + Some(TurnItemsView::NotLoaded), + ) + .await?; + assert_eq!(not_loaded.items_view, TurnItemsView::NotLoaded); + assert!(not_loaded.items.is_empty()); + assert_eq!(not_loaded.id, full.id); + assert_eq!(not_loaded.status, full.status); + assert_eq!(not_loaded.started_at, full.started_at); + assert_eq!(not_loaded.completed_at, full.completed_at); + assert_eq!(not_loaded.duration_ms, full.duration_ms); + + Ok(()) +} + +#[tokio::test] +async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<()> { + let codex_home = TempDir::new()?; + let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000123")?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_thread_store(codex_home.path(), &store_id)?; + let store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + seed_pathless_store_thread(&store, thread_id).await?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli.into(), + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let result = client + .request(ClientRequest::ThreadTurnsList { + request_id: RequestId::Integer(1), + params: ThreadTurnsListParams { + thread_id: thread_id.to_string(), + cursor: None, + limit: Some(10), + sort_direction: Some(SortDirection::Asc), + items_view: None, + }, + }) + .await? + .expect("thread/turns/list should succeed"); + let ThreadTurnsListResponse { data, .. } = serde_json::from_value(result)?; + + assert_eq!(turn_user_texts(&data), vec!["history from store"]); + + client.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_path() -> Result<()> { + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_thread_store(codex_home.path(), &store_id)?; + let store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli.into(), + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let result = client + .request(ClientRequest::ThreadStart { + request_id: RequestId::Integer(1), + params: ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }, + }) + .await? + .expect("thread/start should succeed"); + let ThreadStartResponse { thread, .. } = serde_json::from_value(result)?; + assert_eq!(thread.path, None); + + let thread_id = codex_protocol::ThreadId::from_string(&thread.id)?; + store + .append_items(AppendThreadItemsParams { + thread_id, + items: store_history_items(), + }) + .await?; + + let result = client + .request(ClientRequest::ThreadRead { + request_id: RequestId::Integer(2), + params: ThreadReadParams { + thread_id: thread.id, + include_turns: true, + }, + }) + .await? + .expect("thread/read should succeed"); + let ThreadReadResponse { thread, .. } = serde_json::from_value(result)?; + + assert_eq!(turn_user_texts(&thread.turns), vec!["history from store"]); + + client.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> { + let codex_home = TempDir::new()?; + let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000124")?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_thread_store(codex_home.path(), &store_id)?; + let store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + seed_pathless_store_thread(&store, thread_id).await?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli.into(), + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let result = client + .request(ClientRequest::ThreadList { + request_id: RequestId::Integer(1), + params: ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(Vec::new()), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }, + }) + .await? + .expect("thread/list should succeed"); + let ThreadListResponse { data, .. } = serde_json::from_value(result)?; + + assert_eq!(data.len(), 1); + let thread = &data[0]; + assert_eq!(thread.id, thread_id.to_string()); + assert_eq!(thread.path, None); + assert_eq!(thread.preview, ""); + assert_eq!(thread.name.as_deref(), Some("named pathless thread")); + + client.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let preview = "Archived saved user message"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + preview, + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let active_rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + std::fs::create_dir_all(&archived_dir)?; + let archived_rollout_path = + archived_dir.join(active_rollout_path.file_name().expect("rollout file name")); + std::fs::rename(&active_rollout_path, &archived_rollout_path)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: conversation_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread } = to_response::(read_resp)?; + + assert_eq!(thread.id, conversation_id); + assert_eq!(thread.preview, preview); + let path = thread.path.expect("thread path"); + assert_eq!(path.canonicalize()?, archived_rollout_path.canonicalize()?); + + Ok(()) +} + +#[tokio::test] +async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; + append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: None, + limit: Some(2), + sort_direction: Some(SortDirection::Desc), + items_view: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { + backwards_cursor, .. + } = to_response::(read_resp)?; + let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); + + append_thread_rollback( + rollout_path.as_path(), + "2025-01-05T12:03:00Z", + /*num_turns*/ 1, + )?; + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id, + cursor: Some(backwards_cursor), + limit: Some(10), + sort_direction: Some(SortDirection::Asc), + items_view: None, + }) + .await?; + let read_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(read_id)), + ) + .await??; + + assert_eq!( + read_err.error.message, + "invalid cursor: anchor turn is no longer present" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread: forked, .. } = to_response::(fork_resp)?; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: forked.id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + + assert_eq!(thread.forked_from_id, Some(conversation_id)); + + Ok(()) +} + +#[tokio::test] +async fn thread_read_loaded_thread_returns_precomputed_path_before_materialization() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let thread_path = thread.path.clone().expect("thread path"); + assert!( + !thread_path.exists(), + "fresh thread rollout should not be materialized yet" + ); + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread: read, .. } = to_response::(read_resp)?; + + assert_eq!(read.id, thread.id); + assert_eq!(read.path, Some(thread_path)); + assert!(read.preview.is_empty()); + assert_eq!(read.turns.len(), 0); + assert_eq!(read.status, ThreadStatus::Idle); + + Ok(()) +} + +#[tokio::test] +async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Set a user-facing thread title. + let new_name = "My renamed thread"; + let set_id = mcp + .send_thread_set_name_request(ThreadSetNameParams { + thread_id: conversation_id.clone(), + name: new_name.to_string(), + }) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let _: ThreadSetNameResponse = to_response::(set_resp)?; + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/name/updated"), + ) + .await??; + let notification: ThreadNameUpdatedNotification = + serde_json::from_value(notification.params.expect("thread/name/updated params"))?; + assert_eq!(notification.thread_id, conversation_id); + assert_eq!(notification.thread_name.as_deref(), Some(new_name)); + + // Read should now surface `thread.name`, and the wire payload must include `name`. + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: conversation_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read_result = read_resp.result.clone(); + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + assert_eq!(thread.id, conversation_id); + assert_eq!(thread.name.as_deref(), Some(new_name)); + let thread_json = read_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/read result.thread must be an object"); + assert_eq!( + thread_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/read must serialize `thread.name` on the wire" + ); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "thread/read must serialize `thread.ephemeral` on the wire" + ); + + // List should also surface the name. + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(50), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let list_result = list_resp.result.clone(); + let ThreadListResponse { data, .. } = to_response::(list_resp)?; + let listed = data + .iter() + .find(|t| t.id == conversation_id) + .expect("thread/list should include the created thread"); + assert_eq!(listed.name.as_deref(), Some(new_name)); + let listed_json = list_result + .get("data") + .and_then(Value::as_array) + .expect("thread/list result.data must be an array") + .iter() + .find(|t| t.get("id").and_then(Value::as_str) == Some(&conversation_id)) + .and_then(Value::as_object) + .expect("thread/list should include the created thread as an object"); + assert_eq!( + listed_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/list must serialize `thread.name` on the wire" + ); + assert_eq!( + listed_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "thread/list must serialize `thread.ephemeral` on the wire" + ); + + // Resume should also surface the name. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let resume_result = resume_resp.result.clone(); + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + assert_eq!(resumed.id, conversation_id); + assert_eq!(resumed.name.as_deref(), Some(new_name)); + let resumed_json = resume_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/resume result.thread must be an object"); + assert_eq!( + resumed_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/resume must serialize `thread.name` on the wire" + ); + assert_eq!( + resumed_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "thread/resume must serialize `thread.ephemeral` on the wire" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let thread_path = thread.path.clone().expect("thread path"); + assert!( + !thread_path.exists(), + "fresh thread rollout should not be materialized yet" + ); + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: true, + }) + .await?; + let read_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(read_id)), + ) + .await??; + + assert!( + read_err + .error + .message + .contains("includeTurns is unavailable before first user message"), + "unexpected error: {}", + read_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let thread_path = thread.path.clone().expect("thread path"); + assert!( + !thread_path.exists(), + "fresh thread rollout should not be materialized yet" + ); + + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: thread.id, + cursor: None, + limit: None, + sort_direction: None, + items_view: None, + }) + .await?; + let read_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(read_id)), + ) + .await??; + + assert!( + read_err + .error + .message + .contains("thread/turns/list is unavailable before first user message"), + "unexpected error: {}", + read_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_turns_items_list_returns_unsupported() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_turns_items_list_request(ThreadTurnsItemsListParams { + thread_id: "thr_123".to_string(), + turn_id: "turn_456".to_string(), + cursor: None, + limit: None, + sort_direction: None, + }) + .await?; + let read_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(read_id)), + ) + .await??; + + assert_eq!(read_err.error.code, -32601); + assert_eq!( + read_err.error.message, + "thread/turns/items/list is not supported yet" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Result<()> { + let server = responses::start_mock_server().await; + let _response_mock = responses::mount_sse_once( + &server, + responses::sse_failed("resp-1", "server_error", "simulated failure"), + ) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "fail this turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_start_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_start_response)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("error"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + + assert_eq!(thread.status, ThreadStatus::SystemError,); + + Ok(()) +} + +fn append_user_message(path: &Path, timestamp: &str, text: &str) -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new().append(true).open(path)?; + writeln!( + file, + "{}", + json!({ + "timestamp": timestamp, + "type":"event_msg", + "payload": { + "type":"user_message", + "message": text, + "text_elements": [], + "local_images": [] + } + }) + ) +} + +fn append_agent_message(path: &Path, timestamp: &str, text: &str) -> anyhow::Result<()> { + let mut file = std::fs::OpenOptions::new().append(true).open(path)?; + writeln!( + file, + "{}", + json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent { + message: text.to_string(), + phase: None, + memory_citation: None, + }))?, + }) + )?; + Ok(()) +} + +fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new().append(true).open(path)?; + writeln!( + file, + "{}", + json!({ + "timestamp": timestamp, + "type":"event_msg", + "payload": { + "type":"thread_rolled_back", + "num_turns": num_turns + } + }) + ) +} + +async fn read_single_turn_items_view( + mcp: &mut McpProcess, + thread_id: &str, + items_view: Option, +) -> anyhow::Result { + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: thread_id.to_string(), + cursor: None, + limit: Some(10), + sort_direction: Some(SortDirection::Asc), + items_view, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { mut data, .. } = + to_response::(read_resp)?; + assert_eq!(data.len(), 1); + Ok(data.remove(0)) +} + +fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { + turns + .iter() + .filter_map(|turn| match turn.items.first()? { + ThreadItem::UserMessage { content, .. } => match content.first()? { + UserInput::Text { text, .. } => Some(text.as_str()), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } => None, + }, + _ => None, + }) + .collect() +} + +fn turn_agent_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { + turns + .iter() + .flat_map(|turn| &turn.items) + .filter_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() +} + +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +async fn seed_pathless_store_thread( + store: &InMemoryThreadStore, + thread_id: codex_protocol::ThreadId, +) -> Result<()> { + store + .create_thread(CreateThreadParams { + thread_id, + forked_from_id: None, + source: ProtocolSessionSource::Cli, + thread_source: None, + base_instructions: BaseInstructions::default(), + dynamic_tools: Vec::new(), + metadata: ThreadPersistenceMetadata { + cwd: None, + model_provider: "test-provider".to_string(), + memory_mode: ThreadMemoryMode::Disabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::default(), + }) + .await?; + store + .append_items(AppendThreadItemsParams { + thread_id, + items: store_history_items(), + }) + .await?; + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + name: Some("named pathless thread".to_string()), + ..Default::default() + }, + include_archived: true, + }) + .await?; + Ok(()) +} + +fn store_history_items() -> Vec { + vec![RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "history from store".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))] +} + +fn create_config_toml_with_thread_store(codex_home: &Path, store_id: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:1/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_resume.rs b/code-rs/app-server/tests/suite/v2/thread_resume.rs new file mode 100644 index 00000000000..2b0eafd00ae --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_resume.rs @@ -0,0 +1,3004 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::create_apply_patch_sse_response; +use app_test_support::create_fake_rollout; +use app_test_support::create_fake_rollout_with_text_elements; +use app_test_support::create_fake_rollout_with_token_usage; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_shell_command_sse_response; +use app_test_support::rollout_path; +use app_test_support::test_absolute_path; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use chrono::Utc; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::ThreadId; +use codex_protocol::config_types::Personality; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource as RolloutSessionSource; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; +use codex_state::StateRuntime; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::fs::FileTimes; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::analytics::assert_basic_thread_initialized_event; +use super::analytics::mount_analytics_capture; +use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_payload; + +#[cfg(windows)] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; + +fn normalized_existing_path(path: impl AsRef) -> Result { + Ok(AbsolutePathBuf::from_absolute_path(path.as_ref().canonicalize()?)?.into_path_buf()) +} + +async fn wait_for_responses_request_count( + server: &wiremock::MockServer, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + anyhow::bail!("wiremock did not record requests"); + }; + let responses_request_count = requests + .iter() + .filter(|request| { + request.method == "POST" && request.url.path().ends_with("/responses") + }) + .count(); + if responses_request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if responses_request_count > expected_count { + anyhow::bail!( + "expected exactly {expected_count} /responses requests, got {responses_request_count}" + ); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread. + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + // Resume should fail before the first user message materializes rollout storage. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + assert!( + resume_err + .error + .message + .contains("no rollout found for thread id"), + "unexpected resume error: {}", + resume_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let goal_id = mcp + .send_raw_request( + "thread/goal/get", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let goal_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(goal_id)), + ) + .await??; + assert!( + goal_err + .error + .message + .contains("ephemeral thread does not support goals"), + "unexpected goal/get error: {}", + goal_err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + set_thread_source_on_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + &conversation_id, + "user", + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); + + let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + let event = thread_initialized_event(&payload)?; + assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user"); + assert_eq!(event["event_params"]["thread_source"], "user"); + Ok(()) +} + +fn set_thread_source_on_fake_rollout( + codex_home: &std::path::Path, + filename_ts: &str, + thread_id: &str, + thread_source: &str, +) -> Result<()> { + let path = rollout_path(codex_home, filename_ts, thread_id); + let contents = std::fs::read_to_string(&path)?; + let mut lines = contents.lines(); + let session_meta = lines + .next() + .ok_or_else(|| anyhow::anyhow!("fake rollout missing session meta"))?; + let mut session_meta: serde_json::Value = serde_json::from_str(session_meta)?; + session_meta["payload"]["thread_source"] = serde_json::json!(thread_source); + let remaining = lines.collect::>().join("\n"); + std::fs::write(&path, format!("{session_meta}\n{remaining}\n"))?; + Ok(()) +} + +#[tokio::test] +async fn thread_resume_returns_rollout_history() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + text_elements + .iter() + .map(|elem| serde_json::to_value(elem).expect("serialize text element")) + .collect(), + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.id, conversation_id); + assert_eq!(thread.preview, preview); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.path.as_ref().expect("thread path").is_absolute()); + assert_eq!(thread.cwd, test_absolute_path("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); + assert_eq!(thread.status, ThreadStatus::Idle); + + assert_eq!( + thread.turns.len(), + 1, + "expected rollouts to include one turn" + ); + let turn = &thread.turns[0]; + assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.items.len(), 1, "expected user message item"); + match &turn.items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: preview.to_string(), + text_elements: text_elements.clone().into_iter().map(Into::into).collect(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.id, conversation_id); + assert!(thread.turns.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + "status": "paused", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + mcp.clear_message_buffer(); + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let _resume: ThreadResumeResponse = to_response(resume_resp)?; + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadGoalUpdated(notification) = notification else { + anyhow::bail!("expected thread goal update notification"); + }; + assert_eq!(notification.goal.status, ThreadGoalStatus::Paused); + assert!( + !mcp.pending_notification_methods() + .iter() + .any(|method| method == "turn/started"), + "paused goal should not continue after thread resume" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + "status": "budgetLimited", + "tokenBudget": 10, + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let goal: ThreadGoalSetResponse = to_response(goal_resp)?; + assert_eq!(goal.goal.status, ThreadGoalStatus::BudgetLimited); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let replacement_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + })), + ) + .await?; + let replacement_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(replacement_id)), + ) + .await??; + let replacement: ThreadGoalSetResponse = to_response(replacement_resp)?; + + assert_eq!(replacement.goal.status, ThreadGoalStatus::BudgetLimited); + assert_eq!(replacement.goal.token_budget, Some(10)); + assert_eq!(replacement.goal.tokens_used, 0); + assert_eq!(replacement.goal.time_used_seconds, 0); + + Ok(()) +} + +#[tokio::test] +async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let clear_id = mcp + .send_raw_request( + "thread/goal/clear", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let clear_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_id)), + ) + .await??; + let clear: ThreadGoalClearResponse = to_response(clear_resp)?; + assert!(clear.cleared); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/cleared"), + ) + .await??; + + let get_id = mcp + .send_raw_request( + "thread/goal/get", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let get: codex_app_server_protocol::ThreadGoalGetResponse = to_response(get_resp)?; + assert_eq!(None, get.goal); + + let clear_again_id = mcp + .send_raw_request( + "thread/goal/clear", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let clear_again_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_again_id)), + ) + .await??; + let clear_again: ThreadGoalClearResponse = to_response(clear_again_resp)?; + assert!(!clear_again.cleared); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else { + panic!("expected thread/tokenUsage/updated notification"); + }; + + assert_eq!(notification.thread_id, thread.id); + assert_eq!(notification.turn_id, thread.turns[0].id); + assert_eq!(notification.token_usage.total.total_tokens, 150); + assert_eq!(notification.token_usage.total.input_tokens, 120); + assert_eq!(notification.token_usage.total.cached_input_tokens, 20); + assert_eq!(notification.token_usage.total.output_tokens, 30); + assert_eq!(notification.token_usage.total.reasoning_output_tokens, 10); + assert_eq!(notification.token_usage.last.total_tokens, 90); + assert_eq!(notification.token_usage.model_context_window, Some(200_000)); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_skips_restored_token_usage_when_turns_are_excluded() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let first_resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let first_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = + to_response::(first_resume_resp)?; + let expected_turn_id = thread.turns[0].id.clone(); + + let first_note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await??; + let parsed: ServerNotification = first_note.try_into()?; + let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else { + panic!("expected thread/tokenUsage/updated notification"); + }; + assert_eq!(notification.turn_id, expected_turn_id); + + let second_resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + exclude_turns: true, + ..Default::default() + }) + .await?; + let second_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed_again, + .. + } = to_response::(second_resume_resp)?; + assert!(resumed_again.turns.is_empty()); + + let second_note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await; + assert!( + second_note.is_err(), + "excludeTurns=true should not replay token usage" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let meta_rfc3339 = "2025-01-05T12:00:00Z"; + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + filename_ts, + meta_rfc3339, + "Saved user message", + Some("mock_provider"), + )?; + let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?; + let stale_turn_id = "incomplete-turn-after-token-usage"; + let appended_rollout = [ + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: stale_turn_id.to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }))?, + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent { + message: "Still running".to_string(), + phase: None, + memory_citation: None, + }))?, + }) + .to_string(), + ] + .join("\n"); + std::fs::write( + &rollout_file_path, + format!("{persisted_rollout}{appended_rollout}\n"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.turns.len(), 2); + assert_eq!(thread.turns[0].status, TurnStatus::Completed); + assert_eq!(thread.turns[1].id, stale_turn_id); + assert_eq!(thread.turns[1].status, TurnStatus::Interrupted); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else { + panic!("expected thread/tokenUsage/updated notification"); + }; + + assert_eq!(notification.thread_id, thread.id); + assert_eq!(notification.turn_id, thread.turns[0].id); + assert_ne!(notification.turn_id, stale_turn_id); + assert_eq!(notification.token_usage.total.total_tokens, 150); + assert_eq!(notification.token_usage.last.total_tokens, 90); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let meta_rfc3339 = "2025-01-05T12:00:00Z"; + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + filename_ts, + meta_rfc3339, + "Saved user message", + Some("mock_provider"), + )?; + let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?; + let interrupted_turn_id = "interrupted-turn-with-token-usage"; + let appended_rollout = [ + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: interrupted_turn_id.to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }))?, + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent { + message: "Interrupted after usage".to_string(), + phase: None, + memory_citation: None, + }))?, + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 180, + cached_input_tokens: 40, + output_tokens: 50, + reasoning_output_tokens: 15, + total_tokens: 230, + }, + last_token_usage: TokenUsage { + input_tokens: 90, + cached_input_tokens: 30, + output_tokens: 40, + reasoning_output_tokens: 12, + total_tokens: 130, + }, + model_context_window: Some(200_000), + }), + rate_limits: None, + }))?, + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(interrupted_turn_id.to_string()), + reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + }))?, + }) + .to_string(), + ] + .join("\n"); + std::fs::write( + &rollout_file_path, + format!("{persisted_rollout}{appended_rollout}\n"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.turns.len(), 2); + assert_eq!(thread.turns[0].status, TurnStatus::Completed); + assert_eq!(thread.turns[1].id, interrupted_turn_id); + assert_eq!(thread.turns[1].status, TurnStatus::Interrupted); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else { + panic!("expected thread/tokenUsage/updated notification"); + }; + + assert_eq!(notification.thread_id, thread.id); + assert_eq!(notification.turn_id, interrupted_turn_id); + assert_eq!(notification.token_usage.total.total_tokens, 230); + assert_eq!(notification.token_usage.last.total_tokens, 130); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let config_toml = codex_home.path().join("config.toml"); + std::fs::write( + &config_toml, + format!( + r#" +model = "gpt-5.3-codex" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +personality = true +sqlite = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"#, + server.uri() + ), + )?; + + let repo_path = codex_home.path().join("repo"); + std::fs::create_dir_all(&repo_path)?; + assert!( + Command::new("git") + .args(["init"]) + .arg(&repo_path) + .status()? + .success() + ); + assert!( + Command::new("git") + .current_dir(&repo_path) + .args(["checkout", "-B", "master"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .current_dir(&repo_path) + .args(["config", "user.name", "Test User"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .current_dir(&repo_path) + .args(["config", "user.email", "test@example.com"]) + .status()? + .success() + ); + std::fs::write(repo_path.join("README.md"), "test\n")?; + assert!( + Command::new("git") + .current_dir(&repo_path) + .args(["add", "README.md"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .current_dir(&repo_path) + .args(["commit", "-m", "initial"]) + .status()? + .success() + ); + let head_branch = Command::new("git") + .current_dir(&repo_path) + .args(["branch", "--show-current"]) + .output()?; + assert_eq!( + String::from_utf8(head_branch.stdout)?.trim(), + "master", + "test repo should stay on master to verify resume ignores live HEAD" + ); + + let thread_id = Uuid::new_v4().to_string(); + let conversation_id = ThreadId::from_string(&thread_id)?; + let rollout_path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &thread_id); + let rollout_dir = rollout_path.parent().expect("rollout parent directory"); + std::fs::create_dir_all(rollout_dir)?; + let session_meta = SessionMeta { + id: conversation_id, + forked_from_id: None, + timestamp: "2025-01-05T12:00:00Z".to_string(), + cwd: repo_path.clone(), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: RolloutSessionSource::Cli, + thread_source: None, + agent_path: None, + agent_nickname: None, + agent_role: None, + model_provider: Some("mock_provider".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }; + std::fs::write( + &rollout_path, + [ + json!({ + "timestamp": "2025-01-05T12:00:00Z", + "type": "session_meta", + "payload": serde_json::to_value(SessionMetaLine { + meta: session_meta, + git: None, + })?, + }) + .to_string(), + json!({ + "timestamp": "2025-01-05T12:00:00Z", + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Saved user message"}] + } + }) + .to_string(), + json!({ + "timestamp": "2025-01-05T12:00:00Z", + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "Saved user message", + "kind": "plain" + } + }) + .to_string(), + ] + .join("\n") + + "\n", + )?; + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/pr-branch".to_string())), + origin_url: None, + }), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!( + thread + .git_info + .as_ref() + .and_then(|git| git.branch.as_deref()), + Some("feature/pr-branch") + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is_idle() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let meta_rfc3339 = "2025-01-05T12:00:00Z"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + meta_rfc3339, + "Saved user message", + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?; + let turn_id = "incomplete-turn"; + let appended_rollout = [ + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }))?, + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent { + message: "Still running".to_string(), + phase: None, + memory_citation: None, + }))?, + }) + .to_string(), + ] + .join("\n"); + std::fs::write( + &rollout_file_path, + format!("{persisted_rollout}{appended_rollout}\n"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.turns.len(), 2); + assert_eq!(thread.turns[0].status, TurnStatus::Completed); + assert_eq!(thread.turns[1].id, turn_id); + assert_eq!(thread.turns[1].status, TurnStatus::Interrupted); + + let second_resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let second_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed_again, + .. + } = to_response::(second_resume_resp)?; + + assert_eq!(resumed_again.status, ThreadStatus::Idle); + assert_eq!(resumed_again.turns.len(), 2); + assert_eq!(resumed_again.turns[1].id, turn_id); + assert_eq!(resumed_again.turns[1].status, TurnStatus::Interrupted); + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: resumed_again.id, + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { + thread: read_thread, + .. + } = to_response::(read_resp)?; + + assert_eq!(read_thread.status, ThreadStatus::Idle); + assert_eq!(read_thread.turns.len(), 2); + assert_eq!(read_thread.turns[1].id, turn_id); + assert_eq!(read_thread.turns[1].status, TurnStatus::Interrupted); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; + let thread_id = rollout.conversation_id.clone(); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { + thread: before_resume, + .. + } = to_response::(read_resp)?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.updated_at, before_resume.updated_at); + assert_eq!(thread.status, ThreadStatus::Idle); + + let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; + assert_eq!(after_modified, rollout.before_modified); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id, + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let after_turn_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; + assert!(after_turn_modified > rollout.before_modified); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "respond with docs".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = secondary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed_thread, + .. + } = to_response::(resume_resp)?; + assert_ne!(resumed_thread.status, ThreadStatus::NotLoaded); + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); + let _first_response_mock = responses::mount_sse_once(&server, first_body).await; + let _second_response_mock = responses::mount_response_once(&server, second_response).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let thread_id = thread.id.clone(); + let running_turn_request_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let running_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_request_id)), + ) + .await??; + let TurnStartResponse { turn: running_turn } = + to_response::(running_turn_resp)?; + assert_eq!(running_turn.items_view, TurnItemsView::NotLoaded); + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + history: Some(vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "history override".to_string(), + }], + phase: None, + }]), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + assert!( + resume_err.error.message.contains("cannot resume thread") + && resume_err.error.message.contains("with history") + && resume_err.error.message.contains("running"), + "unexpected resume error: {}", + resume_err.error.message + ); + + primary + .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); + let _first_response_mock = responses::mount_sse_once(&server, first_body).await; + let _second_response_mock = responses::mount_response_once(&server, second_response).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let thread_id = thread.id.clone(); + let running_turn_request_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let running_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_request_id)), + ) + .await??; + let TurnStartResponse { turn: running_turn } = + to_response::(running_turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let other_thread_id = ThreadId::new().to_string(); + let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id); + std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; + let thread_uuid = Uuid::parse_str(&thread_id)?; + let mut stale_file = std::fs::File::create(&stale_path)?; + let stale_meta = json!({ + "timestamp": "2025-01-01T00:00:00Z", + "type": "session_meta", + "payload": { + "id": thread_uuid, + "timestamp": "2025-01-01T00:00:00Z", + "cwd": codex_home.path(), + "originator": "test_originator", + "cli_version": "test_version", + "source": "cli", + "model_provider": "test-provider", + }, + }); + writeln!(stale_file, "{stale_meta}")?; + let stale_user_event = json!({ + "timestamp": "2025-01-01T00:00:00Z", + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "stale history", + "kind": "plain", + }, + }); + writeln!(stale_file, "{stale_user_event}")?; + + let stale_resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: other_thread_id.clone(), + path: Some(stale_path), + ..Default::default() + }) + .await?; + let stale_resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_error_message(RequestId::Integer(stale_resume_id)), + ) + .await??; + assert!( + stale_resume_err.error.message.contains("stale path"), + "unexpected resume error: {}", + stale_resume_err.error.message + ); + + let resume_by_path_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: other_thread_id.clone(), + path: thread.path, + ..Default::default() + }) + .await?; + let resume_by_path_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_by_path_resp)?; + assert_eq!(resumed.id, thread_id); + + primary + .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> Result<()> { + let server = responses::start_mock_server().await; + let first_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ])); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); + let _response_mock = + responses::mount_response_sequence(&server, vec![first_response, second_response]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + model: Some("not-the-running-model".to_string()), + cwd: Some("/tmp".to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, model, .. } = + to_response::(resume_resp)?; + assert_eq!(model, "gpt-5.4"); + // The running-thread resume response is queued onto the thread listener task. + // If the in-flight turn completes before that queued command runs, the response + // can legitimately observe the thread as idle. + match &thread.status { + ThreadStatus::Active { active_flags } => assert!(active_flags.is_empty()), + ThreadStatus::Idle => {} + status => panic!("unexpected thread status after running resume: {status:?}"), + } + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let _response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let resume_id = secondary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + + assert_eq!(resumed.id, thread.id); + assert_eq!(resumed.status, ThreadStatus::Idle); + assert!(resumed.turns.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_replays_pending_command_execution_request_approval() -> Result<()> { + let responses = vec![ + create_final_assistant_message_sse_response("seeded")?, + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + /*workdir*/ None, + Some(5000), + "call-1", + )?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "run command".to_string(), + text_elements: Vec::new(), + }], + approval_policy: Some(AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + + let original_request = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { .. } = &original_request else { + panic!("expected CommandExecutionRequestApproval request, got {original_request:?}"); + }; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed_thread, + .. + } = to_response::(resume_resp)?; + assert_eq!(resumed_thread.id, thread.id); + assert!( + resumed_thread + .turns + .iter() + .any(|turn| matches!(turn.status, TurnStatus::InProgress)) + ); + + let replayed_request = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_request_message(), + ) + .await??; + pretty_assertions::assert_eq!(replayed_request, original_request); + + let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = replayed_request else { + panic!("expected CommandExecutionRequestApproval request"); + }; + primary + .send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Accept, + })?, + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + wait_for_responses_request_count(&server, /*expected_count*/ 3).await?; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_replays_pending_file_change_request_approval() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let patch = r#"*** Begin Patch +*** Add File: README.md ++new line +*** End Patch +"#; + let responses = vec![ + create_final_assistant_message_sse_response("seeded")?, + create_apply_patch_sse_response(patch, "patch-call")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + create_config_toml(&codex_home, &server.uri())?; + + let mut primary = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "apply patch".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + approval_policy: Some(AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + + let original_started = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification = primary + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(notification.params.clone().expect("item/started params"))?; + if let ThreadItem::FileChange { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let expected_readme_path = workspace.join("README.md"); + let expected_file_change = ThreadItem::FileChange { + id: "patch-call".to_string(), + changes: vec![codex_app_server_protocol::FileUpdateChange { + path: expected_readme_path.to_string_lossy().into_owned(), + kind: PatchChangeKind::Add, + diff: "new line\n".to_string(), + }], + status: PatchApplyStatus::InProgress, + }; + assert_eq!(original_started, expected_file_change); + + let original_request = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::FileChangeRequestApproval { .. } = &original_request else { + panic!("expected FileChangeRequestApproval request, got {original_request:?}"); + }; + primary.clear_message_buffer(); + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed_thread, + .. + } = to_response::(resume_resp)?; + assert_eq!(resumed_thread.id, thread.id); + assert!( + resumed_thread + .turns + .iter() + .any(|turn| matches!(turn.status, TurnStatus::InProgress)) + ); + + let replayed_request = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_request_message(), + ) + .await??; + assert_eq!(replayed_request, original_request); + + let ServerRequest::FileChangeRequestApproval { request_id, .. } = replayed_request else { + panic!("expected FileChangeRequestApproval request"); + }; + primary + .send_response( + request_id, + serde_json::to_value(FileChangeRequestApprovalResponse { + decision: FileChangeApprovalDecision::Accept, + })?, + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + wait_for_responses_request_count(&server, /*expected_count*/ 3).await?; + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let RestartedThreadFixture { + mut mcp, + thread_id, + rollout_file_path, + updated_at, + } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; + let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z"; + set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?; + let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id, + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed_thread, + .. + } = to_response::(resume_resp)?; + + assert_eq!(resumed_thread.updated_at, updated_at); + assert_eq!(resumed_thread.status, ThreadStatus::Idle); + + let after_resume_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + assert_eq!(after_resume_modified, before_modified); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: resumed_thread.id, + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let after_turn_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + assert!(after_turn_modified > before_modified); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; + create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: rollout.conversation_id, + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + + assert!( + err.error + .message + .contains("required MCP servers failed to initialize"), + "unexpected error message: {}", + err.error.message + ); + assert!( + err.error.message.contains("required_broken"), + "unexpected error message: {}", + err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + )?; + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + + assert!( + err.error.message.contains("failed to load configuration"), + "unexpected error message: {}", + err.error.message + ); + assert_eq!( + err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let thread_path = thread.path.clone().expect("thread path"); + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(thread_path.to_path_buf()), + ..Default::default() + }) + .await?; + + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + assert_eq!(resumed.id, thread.id); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_can_load_source_by_external_path() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let external_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let thread_id = create_fake_rollout( + external_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "external path history", + Some("mock_provider"), + /*git_info*/ None, + )?; + let thread_path = rollout_path(external_home.path(), "2025-01-05T12-00-00", &thread_id); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(thread_path.clone()), + ..Default::default() + }) + .await?; + + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + assert_eq!(resumed.id, thread_id); + let resumed_path = resumed.path.as_ref().expect("resumed thread path"); + assert_eq!( + normalized_existing_path(resumed_path)?, + normalized_existing_path(&thread_path)? + ); + assert_eq!(resumed.preview, "external path history"); + assert_eq!(resumed.status, ThreadStatus::Idle); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_supports_history_and_overrides() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let RestartedThreadFixture { + mut mcp, thread_id, .. + } = start_materialized_thread_and_restart(codex_home.path(), "seed history").await?; + + let history_text = "Hello from history"; + let history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: history_text.to_string(), + }], + phase: None, + }]; + + // Resume with explicit history and override the model. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id, + history: Some(history), + model: Some("mock-model".to_string()), + model_provider: Some("mock_provider".to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, + model_provider, + .. + } = to_response::(resume_resp)?; + assert!(!resumed.id.is_empty()); + assert_eq!(model_provider, "mock_provider"); + assert_eq!(resumed.preview, history_text); + assert_eq!(resumed.status, ThreadStatus::Idle); + + Ok(()) +} + +struct RestartedThreadFixture { + mcp: McpProcess, + thread_id: String, + rollout_file_path: PathBuf, + updated_at: i64, +} + +async fn start_materialized_thread_and_restart( + codex_home: &Path, + seed_text: &str, +) -> Result { + let mut first_mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; + + let start_id = first_mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let materialize_turn_id = first_mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: seed_text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(materialize_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let read_id = first_mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + + let thread_id = thread.id; + let rollout_file_path = thread + .path + .ok_or_else(|| anyhow::anyhow!("thread path missing from thread/start response"))?; + let updated_at = thread.updated_at; + + drop(first_mcp); + + let mut second_mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; + + Ok(RestartedThreadFixture { + mcp: second_mcp, + thread_id, + rollout_file_path: rollout_file_path.to_path_buf(), + updated_at, + }) +} + +#[tokio::test] +async fn thread_resume_accepts_personality_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let first_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let second_body = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ]); + let response_mock = responses::mount_sse_sequence(&server, vec![first_body, second_body]).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.3-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let materialize_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(materialize_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let resume_id = secondary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + model: Some("gpt-5.3-codex".to_string()), + personality: Some(Personality::Friendly), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let resume: ThreadResumeResponse = to_response::(resume_resp)?; + assert_eq!(resume.thread.status, ThreadStatus::Idle); + + let turn_id = secondary + .send_turn_start_request(TurnStartParams { + thread_id: resume.thread.id, + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + let request = requests + .last() + .expect("expected request for resumed thread turn"); + let developer_texts = request.message_input_texts("developer"); + assert!( + developer_texts + .iter() + .any(|text| text.contains("")), + "expected a personality update message in developer input, got {developer_texts:?}" + ); + let instructions_text = request.instructions_text(); + assert!( + instructions_text.contains(CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT), + "expected default base instructions from history, got {instructions_text:?}" + ); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "gpt-5.3-codex" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn create_config_toml_with_chatgpt_base_url( + codex_home: &std::path::Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "gpt-5.3-codex" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[features] +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn create_config_toml_with_required_broken_mcp( + codex_home: &std::path::Path, + server_uri: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "gpt-5.3-codex" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[mcp_servers.required_broken] +command = "codex-definitely-not-a-real-binary" +required = true +"# + ), + ) +} + +#[allow(dead_code)] +fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> { + let parsed = chrono::DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(path)? + .set_times(times)?; + Ok(()) +} + +struct RolloutFixture { + conversation_id: String, + rollout_file_path: PathBuf, + before_modified: std::time::SystemTime, +} + +fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result { + create_config_toml(codex_home, server_uri)?; + + let preview = "Saved user message"; + let filename_ts = "2025-01-05T12-00-00"; + let meta_rfc3339 = "2025-01-05T12:00:00Z"; + let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home, + filename_ts, + meta_rfc3339, + preview, + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_file_path = rollout_path(codex_home, filename_ts, &conversation_id); + set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?; + let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?; + Ok(RolloutFixture { + conversation_id, + rollout_file_path, + before_modified, + }) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_rollback.rs b/code-rs/app-server/tests/suite/v2/thread_rollback.rs new file mode 100644 index 00000000000..5f79db0e265 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -0,0 +1,203 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()> { + // Three Codex turns hit the mock model (session start + two turn/start calls). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread. + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + // Two turns. + let first_text = "First"; + let turn1_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: first_text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn1_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn1_id)), + ) + .await??; + let _completed1 = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn2_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Second".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn2_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn2_id)), + ) + .await??; + let _completed2 = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + // Roll back the last turn. + let rollback_id = mcp + .send_thread_rollback_request(ThreadRollbackParams { + thread_id: thread.id.clone(), + num_turns: 1, + }) + .await?; + let rollback_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(rollback_id)), + ) + .await??; + let rollback_result = rollback_resp.result.clone(); + let ThreadRollbackResponse { + thread: rolled_back_thread, + } = to_response::(rollback_resp)?; + + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = rollback_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/rollback result.thread must be an object"); + assert_eq!(rolled_back_thread.name, None); + assert_eq!(rolled_back_thread.session_id, thread.session_id); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "thread/rollback must serialize `name: null` when unset" + ); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); + + assert_eq!(rolled_back_thread.turns.len(), 1); + assert_eq!(rolled_back_thread.status, ThreadStatus::Idle); + assert_eq!(rolled_back_thread.turns[0].items.len(), 2); + match &rolled_back_thread.turns[0].items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![V2UserInput::Text { + text: first_text.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + // Resume and confirm the history is pruned. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.turns.len(), 1); + assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.turns[0].items.len(), 2); + match &thread.turns[0].items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![V2UserInput::Text { + text: first_text.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_shell_command.rs b/code-rs/app-server/tests/suite/v2/thread_shell_command.rs new file mode 100644 index 00000000000..b7cfba2f950 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -0,0 +1,485 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_shell_command_sse_response; +use app_test_support::format_with_current_shell_display; +use app_test_support::to_response; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadShellCommandResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::shell::default_user_shell; +use codex_features::FEATURES; +use codex_features::Feature; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_shell_command_history_responses_exclude_persisted_command_executions() -> Result<()> +{ + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let server = create_mock_responses_server_sequence(vec![]).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + persist_extended_history: true, + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let (shell_command, expected_output) = current_shell_output_command("hello from bang")?; + + let shell_id = mcp + .send_thread_shell_command_request(ThreadShellCommandParams { + thread_id: thread.id.clone(), + command: shell_command, + }) + .await?; + let shell_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(shell_id)), + ) + .await??; + let _: ThreadShellCommandResponse = to_response::(shell_resp)?; + + let started = wait_for_command_execution_started(&mut mcp, /*expected_id*/ None).await?; + let ThreadItem::CommandExecution { + id, source, status, .. + } = &started.item + else { + unreachable!("helper returns command execution item"); + }; + let command_id = id.clone(); + assert_eq!(source, &CommandExecutionSource::UserShell); + assert_eq!(status, &CommandExecutionStatus::InProgress); + + let delta = wait_for_command_execution_output_delta(&mut mcp, &command_id).await?; + assert_eq!( + delta.delta.trim_end_matches(['\r', '\n']), + expected_output.trim_end_matches(['\r', '\n']) + ); + + let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; + let ThreadItem::CommandExecution { + id, + source, + status, + aggregated_output, + exit_code, + .. + } = &completed.item + else { + unreachable!("helper returns command execution item"); + }; + assert_eq!(id, &command_id); + assert_eq!(source, &CommandExecutionSource::UserShell); + assert_eq!(status, &CommandExecutionStatus::Completed); + assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str())); + assert_eq!(*exit_code, Some(0)); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + assert_eq!(thread.turns.len(), 1); + assert_no_command_executions(&thread.turns[0].items, "thread/read"); + + let turns_list_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: thread.id.clone(), + cursor: None, + limit: None, + sort_direction: Some(SortDirection::Asc), + items_view: None, + }) + .await?; + let turns_list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), + ) + .await??; + let ThreadTurnsListResponse { data, .. } = + to_response::(turns_list_resp)?; + assert_eq!(data.len(), 1); + assert_no_command_executions(&data[0].items, "thread/turns/list"); + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + assert_eq!(thread.turns.len(), 1); + assert_no_command_executions(&thread.turns[0].items, "thread/fork"); + + Ok(()) +} + +#[tokio::test] +async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let responses = vec![ + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + /*workdir*/ None, + Some(5000), + "call-approve", + )?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + persist_extended_history: true, + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let (shell_command, expected_output) = current_shell_output_command("active turn bang")?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let agent_started = wait_for_command_execution_started(&mut mcp, Some("call-approve")).await?; + let ThreadItem::CommandExecution { + command, source, .. + } = &agent_started.item + else { + unreachable!("helper returns command execution item"); + }; + assert_eq!(source, &CommandExecutionSource::Agent); + assert_eq!( + command, + &format_with_current_shell_display("python3 -c 'print(42)'") + ); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = server_req else { + panic!("expected approval request"); + }; + + let shell_id = mcp + .send_thread_shell_command_request(ThreadShellCommandParams { + thread_id: thread.id.clone(), + command: shell_command, + }) + .await?; + let shell_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(shell_id)), + ) + .await??; + let _: ThreadShellCommandResponse = to_response::(shell_resp)?; + + let started = + wait_for_command_execution_started_by_source(&mut mcp, CommandExecutionSource::UserShell) + .await?; + assert_eq!(started.turn_id, turn.id); + let command_id = match &started.item { + ThreadItem::CommandExecution { id, .. } => id.clone(), + _ => unreachable!("helper returns command execution item"), + }; + let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; + assert_eq!(completed.turn_id, turn.id); + let ThreadItem::CommandExecution { + source, + aggregated_output, + .. + } = &completed.item + else { + unreachable!("helper returns command execution item"); + }; + assert_eq!(source, &CommandExecutionSource::UserShell); + assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str())); + + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Decline, + })?, + ) + .await?; + let _: TurnCompletedNotification = serde_json::from_value( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await?? + .params + .expect("turn/completed params"), + )?; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + assert_eq!(thread.turns.len(), 1); + assert_no_command_executions(&thread.turns[0].items, "thread/read"); + + Ok(()) +} + +fn assert_no_command_executions(items: &[ThreadItem], context: &str) { + assert!( + items + .iter() + .all(|item| !matches!(item, ThreadItem::CommandExecution { .. })), + "{context} should always exclude command executions from returned turns" + ); +} + +fn current_shell_output_command(text: &str) -> Result<(String, String)> { + let command_and_output = match default_user_shell().name() { + "powershell" => { + let escaped_text = text.replace('\'', "''"); + ( + format!("Write-Output '{escaped_text}'"), + format!("{text}\r\n"), + ) + } + "cmd" => (format!("echo {text}"), format!("{text}\r\n")), + _ => { + let quoted_text = shlex::try_quote(text)?; + (format!("printf '%s\\n' {quoted_text}"), format!("{text}\n")) + } + }; + Ok(command_and_output) +} + +async fn wait_for_command_execution_started( + mcp: &mut McpProcess, + expected_id: Option<&str>, +) -> Result { + loop { + let notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = serde_json::from_value( + notif + .params + .ok_or_else(|| anyhow::anyhow!("missing item/started params"))?, + )?; + let ThreadItem::CommandExecution { id, .. } = &started.item else { + continue; + }; + if expected_id.is_none() || expected_id == Some(id.as_str()) { + return Ok(started); + } + } +} + +async fn wait_for_command_execution_started_by_source( + mcp: &mut McpProcess, + expected_source: CommandExecutionSource, +) -> Result { + loop { + let started = wait_for_command_execution_started(mcp, /*expected_id*/ None).await?; + let ThreadItem::CommandExecution { source, .. } = &started.item else { + continue; + }; + if source == &expected_source { + return Ok(started); + } + } +} + +async fn wait_for_command_execution_completed( + mcp: &mut McpProcess, + expected_id: Option<&str>, +) -> Result { + loop { + let notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notif + .params + .ok_or_else(|| anyhow::anyhow!("missing item/completed params"))?, + )?; + let ThreadItem::CommandExecution { id, .. } = &completed.item else { + continue; + }; + if expected_id.is_none() || expected_id == Some(id.as_str()) { + return Ok(completed); + } + } +} + +async fn wait_for_command_execution_output_delta( + mcp: &mut McpProcess, + item_id: &str, +) -> Result { + loop { + let notif = mcp + .read_stream_until_notification_message("item/commandExecution/outputDelta") + .await?; + let delta: CommandExecutionOutputDeltaNotification = serde_json::from_value( + notif + .params + .ok_or_else(|| anyhow::anyhow!("missing output delta params"))?, + )?; + if delta.item_id == item_id { + return Ok(delta); + } + } +} + +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, + feature_flags: &BTreeMap, +) -> std::io::Result<()> { + let feature_entries = feature_flags + .iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == *feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +{feature_entries} + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_start.rs b/code-rs/app-server/tests/suite/v2/thread_start.rs new file mode 100644 index 00000000000..78155d8c9a0 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_start.rs @@ -0,0 +1,1082 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::PathBufExt; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::DeprecationNoticeNotification; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpServerStartupState; +use codex_app_server_protocol::McpServerStatusUpdatedNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadSource; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_app_server_protocol::TurnEnvironmentParams; +use codex_config::loader::project_trust_key; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::set_project_trust_level; +use codex_exec_server::LOCAL_FS; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use std::path::PathBuf; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::analytics::assert_basic_thread_initialized_event; +use super::analytics::mount_analytics_capture; +use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_payload; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test] +async fn thread_start_deprecates_persist_extended_history_true() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + persist_extended_history: true, + ..Default::default() + }) + .await?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("deprecationNotice"), + ) + .await??; + let notice: DeprecationNoticeNotification = serde_json::from_value( + notification + .params + .expect("deprecationNotice params should be present"), + )?; + assert_eq!( + notice.summary, + "persistExtendedHistory is deprecated and ignored" + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn thread_start_creates_thread_and_emits_started() -> Result<()> { + // Provide a mock server and config so model wiring is valid. + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + // Start server and initialize. + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a v2 thread with an explicit model override. + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2".to_string()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + + // Expect a proper JSON-RPC response with a thread id. + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let resp_result = resp.result.clone(); + let ThreadStartResponse { + thread, + model_provider, + .. + } = to_response::(resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); + assert!(!thread.id.is_empty(), "thread id should not be empty"); + assert!( + thread.preview.is_empty(), + "new threads should start with an empty preview" + ); + assert_eq!(model_provider, "mock_provider"); + assert!( + thread.created_at > 0, + "created_at should be a positive UNIX timestamp" + ); + assert!( + !thread.ephemeral, + "new persistent threads should not be ephemeral" + ); + assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); + let thread_path = thread.path.clone().expect("thread path should be present"); + assert!(thread_path.is_absolute(), "thread path should be absolute"); + assert!( + !thread_path.exists(), + "fresh thread rollout should not be materialized until first user message" + ); + + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = resp_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "new threads should serialize `sessionId` on the thread object" + ); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "new threads should serialize `name: null`" + ); + assert_eq!( + resp_result.get("sessionId"), + None, + "thread/start should not serialize a top-level `sessionId`" + ); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "new persistent threads should serialize `ephemeral: false`" + ); + assert_eq!( + thread_json.get("threadSource").and_then(Value::as_str), + Some("user"), + "new threads should serialize the caller-supplied thread origin" + ); + assert_eq!(thread.name, None); + + // A corresponding thread/started notification should arrive. + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notif = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notif) = message else { + continue; + }; + if notif.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + if status_changed.thread_id == thread.id { + anyhow::bail!( + "thread/start should introduce the thread without a preceding thread/status/changed" + ); + } + continue; + } + if notif.method == "thread/started" { + break notif; + } + }; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json.get("name"), + Some(&Value::Null), + "thread/started should serialize `name: null` for new threads" + ); + assert_eq!( + started_thread_json + .get("ephemeral") + .and_then(Value::as_bool), + Some(false), + "thread/started should serialize `ephemeral: false` for new persistent threads" + ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied thread origin" + ); + let started: ThreadStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + assert_eq!(started.thread, thread); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: "missing".to_string(), + cwd: codex_home.path().to_path_buf().try_into()?, + }]), + ..Default::default() + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(error.error.message, "unknown turn environment id `missing`"); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + let global_agents_path = codex_home.path().join("AGENTS.md"); + std::fs::write(&global_agents_path, "global instructions")?; + let workspace = TempDir::new()?; + let project_agents_path = workspace.path().join("AGENTS.md"); + std::fs::write(&project_agents_path, "project instructions")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + let instruction_sources = instruction_sources + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(); + let expected_instruction_sources = vec![ + std::fs::canonicalize(global_agents_path)?, + std::fs::canonicalize(project_agents_path)?, + ] + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(); + + assert_eq!(instruction_sources, expected_instruction_sources); + + Ok(()) +} + +#[cfg(windows)] +fn normalize_path_for_comparison(path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + let path = path.display().to_string(); + PathBuf::from(path.strip_prefix(r"\\?\").unwrap_or(&path)) +} + +#[cfg(not(windows))] +fn normalize_path_for_comparison(path: impl AsRef) -> PathBuf { + path.as_ref().to_path_buf() +} + +#[tokio::test] +async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(resp)?; + + let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + assert_eq!(payload["events"].as_array().expect("events array").len(), 1); + let event = thread_initialized_event(&payload)?; + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user"); + Ok(()) +} + +#[tokio::test] +async fn thread_start_respects_project_config_from_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + reasoning_effort, .. + } = to_response::(resp)?; + + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + Ok(()) +} + +#[tokio::test] +async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let service_tier_id = "experimental-tier-id".to_string(); + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + service_tier: Some(Some(service_tier_id.clone())), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; + + assert_eq!(service_tier, Some(service_tier_id)); + Ok(()) +} + +#[tokio::test] +async fn thread_start_accepts_metrics_service_name() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + service_name: Some("my_app_server_client".to_string()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(resp)?; + assert!(!thread.id.is_empty(), "thread id should not be empty"); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_ephemeral_remains_pathless() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2".to_string()), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let resp_result = resp.result.clone(); + let ThreadStartResponse { thread, .. } = to_response::(resp)?; + assert!( + thread.ephemeral, + "ephemeral threads should be marked explicitly" + ); + assert_eq!( + thread.path, None, + "ephemeral threads should not expose a path" + ); + let thread_json = resp_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(true), + "ephemeral threads should serialize `ephemeral: true`" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(req_id)), + ) + .await??; + + assert!( + err.error + .message + .contains("required MCP servers failed to initialize"), + "unexpected error message: {}", + err.error.message + ); + assert!( + err.error.message.contains("required_broken"), + "unexpected error message: {}", + err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_optional_broken_mcp(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + + let _: ThreadStartResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??, + )?; + + let starting = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification( + "mcpServer/startupStatus/updated starting", + |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + == Some("optional_broken") + && notification + .params + .as_ref() + .and_then(|params| params.get("status")) + .and_then(Value::as_str) + == Some("starting") + }, + ), + ) + .await??; + let starting: ServerNotification = starting.try_into()?; + let ServerNotification::McpServerStatusUpdated(starting) = starting else { + anyhow::bail!("unexpected notification variant"); + }; + assert_eq!( + starting, + McpServerStatusUpdatedNotification { + name: "optional_broken".to_string(), + status: McpServerStartupState::Starting, + error: None, + } + ); + + let failed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification( + "mcpServer/startupStatus/updated failed", + |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + == Some("optional_broken") + && notification + .params + .as_ref() + .and_then(|params| params.get("status")) + .and_then(Value::as_str) + == Some("failed") + }, + ), + ) + .await??; + let failed: ServerNotification = failed.try_into()?; + let ServerNotification::McpServerStatusUpdated(failed) = failed else { + anyhow::bail!("unexpected notification variant"); + }; + assert_eq!(failed.name, "optional_broken"); + assert_eq!(failed.status, McpServerStartupState::Failed); + assert!( + failed + .error + .as_deref() + .is_some_and(|error| error.contains("MCP client for `optional_broken` failed to start")), + "unexpected MCP startup error: {:?}", + failed.error + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(req_id)), + ) + .await??; + + assert!( + err.error.message.contains("failed to load configuration"), + "unexpected error message: {}", + err.error.message + ); + assert_eq!( + err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_with_elevated_sandbox_trusts_project_and_followup_loads_project_config() +-> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let first_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_request)), + ) + .await??; + + let second_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let second_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request)), + ) + .await??; + let ThreadStartResponse { + approval_policy, + reasoning_effort, + .. + } = to_response::(second_response)?; + + assert_eq!(approval_policy, AskForApproval::OnRequest); + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + let workspace_abs = workspace.path().to_path_buf().abs(); + let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &workspace_abs) + .await + .unwrap_or(workspace_abs); + let trusted_root_key = project_trust_key(trusted_root.as_path()); + assert!(config_toml.contains(&trusted_root_key)); + assert!(config_toml.contains("trust_level = \"trusted\"")); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let repo_root = TempDir::new()?; + std::fs::create_dir(repo_root.path().join(".git"))?; + let nested = repo_root.path().join("nested/project"); + std::fs::create_dir_all(&nested)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(nested.display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + let nested_abs = nested.abs(); + let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested_abs) + .await + .expect("git root should resolve"); + let trusted_root_key = project_trust_key(trusted_root.as_path()); + let nested_key = project_trust_key(&nested); + assert!(config_toml.contains(&trusted_root_key)); + assert!(!config_toml.contains(&nested_key)); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config_toml.contains("trust_level = \"trusted\"")); + assert!(!config_toml.contains(&workspace.path().display().to_string())); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_preserves_untrusted_project_trust() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let config_path = codex_home.path().join("config.toml"); + let workspace_key = workspace.path().display().to_string(); + let mut config_toml = + std::fs::read_to_string(&config_path)?.parse::()?; + config_toml["projects"][workspace_key.as_str()]["trust_level"] = toml_edit::value("untrusted"); + std::fs::write(&config_path, config_toml.to_string())?; + let config_before = std::fs::read_to_string(&config_path)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_after = std::fs::read_to_string(&config_path)?; + assert_eq!(config_after, config_before); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_skips_trust_write_when_project_is_already_trusted() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + let config_before = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + approval_policy, + reasoning_effort, + .. + } = to_response::(response)?; + + assert_eq!(approval_policy, AskForApproval::OnRequest); + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + + let config_after = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert_eq!(config_after, config_before); + + Ok(()) +} + +fn create_config_toml_without_approval_policy( + codex_home: &Path, + server_uri: &str, +) -> std::io::Result<()> { + create_config_toml_with_optional_approval_policy( + codex_home, server_uri, /*approval_policy*/ None, + ) +} + +fn create_config_toml_with_optional_approval_policy( + codex_home: &Path, + server_uri: &str, + approval_policy: Option<&str>, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let approval_policy = approval_policy + .map(|policy| format!("approval_policy = \"{policy}\"\n")) + .unwrap_or_default(); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +{approval_policy}sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn create_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn create_config_toml_with_required_broken_mcp( + codex_home: &Path, + server_uri: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[mcp_servers.required_broken] +{required_broken_transport} +required = true +"#, + required_broken_transport = broken_mcp_transport_toml() + ), + ) +} + +fn create_config_toml_with_optional_broken_mcp( + codex_home: &Path, + server_uri: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[mcp_servers.optional_broken] +{optional_broken_transport} +"#, + optional_broken_transport = broken_mcp_transport_toml() + ), + ) +} + +#[cfg(target_os = "windows")] +fn broken_mcp_transport_toml() -> &'static str { + r#"command = "cmd" +args = ["/C", "exit 1"]"# +} + +#[cfg(not(target_os = "windows"))] +fn broken_mcp_transport_toml() -> &'static str { + r#"command = "/bin/sh" +args = ["-c", "exit 1"]"# +} diff --git a/code-rs/app-server/tests/suite/v2/thread_status.rs b/code-rs/app-server/tests/suite/v2/thread_status.rs new file mode 100644 index 00000000000..ad90e4900af --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_status.rs @@ -0,0 +1,240 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::to_response; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +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 thread_status_changed_emits_runtime_updates() -> Result<()> { + let codex_home = TempDir::new()?; + let responses = vec![create_final_assistant_message_sse_response("done")?]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("RUST_LOG", Some("info"))]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "collect status updates".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_start_resp)?; + + let mut saw_active_running = false; + let mut saw_idle_after_turn = false; + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = match timeout(remaining, mcp.read_next_message()).await { + Ok(Ok(message)) => message, + _ => break, + }; + match message { + JSONRPCMessage::Notification(JSONRPCNotification { + method, + params: Some(params), + }) if method == "thread/status/changed" => { + let notification: ThreadStatusChangedNotification = serde_json::from_value(params)?; + if notification.thread_id != thread.id { + continue; + } + match notification.status { + ThreadStatus::Active { .. } => { + saw_active_running = true; + } + ThreadStatus::Idle => { + if saw_active_running { + saw_idle_after_turn = true; + } + } + ThreadStatus::SystemError => { + if saw_active_running { + saw_idle_after_turn = true; + } + } + ThreadStatus::NotLoaded => { + if saw_active_running { + saw_idle_after_turn = true; + } + } + } + } + _ => {} + } + + if saw_active_running && saw_idle_after_turn { + break; + } + } + + assert!( + saw_active_running, + "expected running active flag in thread/status/changed notifications" + ); + assert!( + saw_idle_after_turn, + "expected idle status after turn completion in thread/status/changed notifications" + ); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn thread_status_changed_can_be_opted_out() -> Result<()> { + let codex_home = TempDir::new()?; + let responses = vec![create_final_assistant_message_sse_response("done")?]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex_vscode".to_string(), + title: Some("Codex VS Code Extension".to_string()), + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), + }), + ), + ) + .await??; + let JSONRPCMessage::Response(_) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "run once".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_start_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let status_update = timeout( + std::time::Duration::from_millis(500), + mcp.read_stream_until_notification_message("thread/status/changed"), + ) + .await; + match status_update { + Err(_) => {} + Ok(Ok(notification)) => { + anyhow::bail!( + "thread/status/changed should be filtered by optOutNotificationMethods; got: {notification:?}" + ); + } + Ok(Err(err)) => { + anyhow::bail!( + "expected timeout waiting for filtered thread/status/changed, got: {err}" + ); + } + } + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +collaboration_modes = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_unarchive.rs b/code-rs/app-server/tests/suite/v2/thread_unarchive.rs new file mode 100644 index 00000000000..5b421dcec5b --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -0,0 +1,356 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; +use codex_app_server_protocol::ThreadUnarchivedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_core::config::ConfigBuilder; +use codex_core::find_archived_thread_path_by_id_str; +use codex_core::find_thread_path_by_id_str; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_thread_store::CreateThreadParams; +use codex_thread_store::InMemoryThreadStore; +use codex_thread_store::ThreadEventPersistenceMode; +use codex_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadPersistenceMetadata; +use codex_thread_store::ThreadStore; +use codex_thread_store::UpdateThreadMetadataParams; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::fs::FileTimes; +use std::fs::OpenOptions; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +#[tokio::test] +async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let rollout_path = thread.path.clone().expect("thread path"); + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_start_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_start_response)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let found_rollout_path = + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) + .await? + .expect("expected rollout path for thread id to exist"); + assert_paths_match_on_disk(&found_rollout_path, &rollout_path)?; + + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + + let archived_path = find_archived_thread_path_by_id_str( + codex_home.path(), + &thread.id, + /*state_db_ctx*/ None, + ) + .await? + .expect("expected archived rollout path for thread id to exist"); + let archived_path_display = archived_path.display(); + assert!( + archived_path.exists(), + "expected {archived_path_display} to exist" + ); + let old_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1); + let old_timestamp = old_time + .duration_since(SystemTime::UNIX_EPOCH) + .expect("old timestamp") + .as_secs() as i64; + let times = FileTimes::new().set_modified(old_time); + OpenOptions::new() + .append(true) + .open(&archived_path)? + .set_times(times)?; + + let unarchive_id = mcp + .send_thread_unarchive_request(ThreadUnarchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let unarchive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)), + ) + .await??; + let unarchive_result = unarchive_resp.result.clone(); + let ThreadUnarchiveResponse { + thread: unarchived_thread, + } = to_response::(unarchive_resp)?; + let unarchive_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/unarchived"), + ) + .await??; + let unarchived_notification: ThreadUnarchivedNotification = serde_json::from_value( + unarchive_notification + .params + .expect("thread/unarchived notification params"), + )?; + assert_eq!(unarchived_notification.thread_id, thread.id); + assert!( + unarchived_thread.updated_at > old_timestamp, + "expected updated_at to be bumped on unarchive" + ); + assert_eq!(unarchived_thread.status, ThreadStatus::NotLoaded); + + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = unarchive_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/unarchive result.thread must be an object"); + assert_eq!(unarchived_thread.name, None); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "thread/unarchive must serialize `name: null` when unset" + ); + + let rollout_path_display = rollout_path.display(); + assert!( + rollout_path.exists(), + "expected rollout path {rollout_path_display} to be restored" + ); + assert!( + !archived_path.exists(), + "expected archived rollout path {archived_path_display} to be moved" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_in_memory_thread_store(codex_home.path(), &store_id)?; + let store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000126")?; + let parent_thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000127")?; + store + .create_thread(CreateThreadParams { + thread_id, + forked_from_id: Some(parent_thread_id), + source: SessionSource::Cli, + thread_source: None, + base_instructions: BaseInstructions::default(), + dynamic_tools: Vec::new(), + metadata: ThreadPersistenceMetadata { + cwd: None, + model_provider: "test-provider".to_string(), + memory_mode: ThreadMemoryMode::Disabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::default(), + }) + .await?; + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + name: Some("named pathless thread".to_string()), + ..Default::default() + }, + include_archived: true, + }) + .await?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let result = client + .request(ClientRequest::ThreadUnarchive { + request_id: RequestId::Integer(1), + params: ThreadUnarchiveParams { + thread_id: thread_id.to_string(), + }, + }) + .await? + .expect("thread/unarchive should succeed"); + let ThreadUnarchiveResponse { thread } = serde_json::from_value(result)?; + + assert_eq!(thread.id, thread_id.to_string()); + assert_eq!(thread.path, None); + assert_eq!(thread.forked_from_id, Some(parent_thread_id.to_string())); + assert_eq!(thread.name, Some("named pathless thread".to_string())); + + client.shutdown().await?; + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, config_contents(server_uri)) +} + +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +fn create_config_toml_with_in_memory_thread_store( + codex_home: &Path, + store_id: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:1/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn config_contents(server_uri: &str) -> String { + format!( + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ) +} + +fn assert_paths_match_on_disk(actual: &Path, expected: &Path) -> std::io::Result<()> { + let actual = actual.canonicalize()?; + let expected = expected.canonicalize()?; + assert_eq!(actual, expected); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/code-rs/app-server/tests/suite/v2/thread_unsubscribe.rs new file mode 100644 index 00000000000..c0188add8ce --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -0,0 +1,433 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallParams; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_app_server_protocol::DynamicToolSpec; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::ThreadUnsubscribeStatus; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; +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] +async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + + let unsubscribe_id = mcp + .send_thread_unsubscribe_request(ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }) + .await?; + let unsubscribe_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unsubscribe_id)), + ) + .await??; + let unsubscribe = to_response::(unsubscribe_resp)?; + assert_eq!(unsubscribe.status, ThreadUnsubscribeStatus::Unsubscribed); + + assert!( + timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("thread/closed"), + ) + .await + .is_err() + ); + + let list_id = mcp + .send_thread_loaded_list_request(ThreadLoadedListParams::default()) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadLoadedListResponse { data, next_cursor } = + to_response::(list_resp)?; + assert_eq!(data, vec![thread_id]); + assert_eq!(next_cursor, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { + let call_id = "deterministic-wait-call"; + let tool_name = "deterministic_wait"; + let tool_args = json!({}); + let tool_call_arguments = serde_json::to_string(&tool_args)?; + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + let (server, mut completions) = start_streaming_sse_server(vec![ + vec![StreamingSseChunk { + gate: None, + body: responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, tool_name, &tool_call_arguments), + responses::ev_completed("resp-1"), + ]), + }], + vec![StreamingSseChunk { + gate: None, + body: responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + }], + ]) + .await; + let first_response_completed = completions.remove(0); + let final_response_completed = completions.remove(0); + create_config_toml(&codex_home, server.uri())?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + dynamic_tools: Some(vec![DynamicToolSpec { + namespace: None, + name: tool_name.to_string(), + description: "Deterministic wait tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }]), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![V2UserInput::Text { + text: "run deterministic tool".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + server.wait_for_request_count(/*count*/ 1), + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, first_response_completed).await??; + + let started = timeout( + DEFAULT_READ_TIMEOUT, + wait_for_dynamic_tool_started(&mut mcp, call_id), + ) + .await??; + assert_eq!(started.thread_id, thread_id); + + let request = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let (request_id, params) = match request { + ServerRequest::DynamicToolCall { request_id, params } => (request_id, params), + other => panic!("expected DynamicToolCall request, got {other:?}"), + }; + assert_eq!( + params, + DynamicToolCallParams { + thread_id: thread_id.clone(), + turn_id: started.turn_id, + call_id: call_id.to_string(), + namespace: None, + tool: tool_name.to_string(), + arguments: tool_args, + } + ); + + let unsubscribe_id = mcp + .send_thread_unsubscribe_request(ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }) + .await?; + let unsubscribe_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unsubscribe_id)), + ) + .await??; + let unsubscribe = to_response::(unsubscribe_resp)?; + assert_eq!(unsubscribe.status, ThreadUnsubscribeStatus::Unsubscribed); + + let closed_while_tool_call_blocked = timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("thread/closed"), + ); + let closed_while_tool_call_blocked = closed_while_tool_call_blocked.await; + assert!(closed_while_tool_call_blocked.is_err()); + + let response = DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }], + success: true, + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + server.wait_for_request_count(/*count*/ 2), + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, final_response_completed).await??; + server.shutdown().await; + + Ok(()) +} + +#[tokio::test] +async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Result<()> { + let server = responses::start_mock_server().await; + let _response_mock = responses::mount_sse_once( + &server, + responses::sse_failed("resp-1", "server_error", "simulated failure"), + ) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![V2UserInput::Text { + text: "fail this turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("error"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + assert_eq!(thread.status, ThreadStatus::SystemError); + + let unsubscribe_id = mcp + .send_thread_unsubscribe_request(ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }) + .await?; + let unsubscribe_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unsubscribe_id)), + ) + .await??; + let unsubscribe = to_response::(unsubscribe_resp)?; + assert_eq!(unsubscribe.status, ThreadUnsubscribeStatus::Unsubscribed); + assert!( + timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("thread/closed"), + ) + .await + .is_err() + ); + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let resume: ThreadResumeResponse = to_response::(resume_resp)?; + assert_eq!(resume.thread.status, ThreadStatus::SystemError); + + Ok(()) +} + +#[tokio::test] +async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_id = start_thread(&mut mcp).await?; + + let first_unsubscribe_id = mcp + .send_thread_unsubscribe_request(ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }) + .await?; + let first_unsubscribe_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_unsubscribe_id)), + ) + .await??; + let first_unsubscribe = to_response::(first_unsubscribe_resp)?; + assert_eq!( + first_unsubscribe.status, + ThreadUnsubscribeStatus::Unsubscribed + ); + + let second_unsubscribe_id = mcp + .send_thread_unsubscribe_request(ThreadUnsubscribeParams { thread_id }) + .await?; + let second_unsubscribe_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_unsubscribe_id)), + ) + .await??; + let second_unsubscribe = to_response::(second_unsubscribe_resp)?; + assert_eq!( + second_unsubscribe.status, + ThreadUnsubscribeStatus::NotSubscribed + ); + + Ok(()) +} + +async fn wait_for_dynamic_tool_started( + mcp: &mut McpProcess, + call_id: &str, +) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let Some(params) = notification.params else { + continue; + }; + let started: ItemStartedNotification = serde_json::from_value(params)?; + if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) { + return Ok(started); + } + } +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_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 = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +async fn start_thread(mcp: &mut McpProcess) -> Result { + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(resp)?; + Ok(thread.id) +} diff --git a/code-rs/app-server/tests/suite/v2/turn_interrupt.rs b/code-rs/app-server/tests/suite/v2/turn_interrupt.rs new file mode 100644 index 00000000000..aedc54e0168 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -0,0 +1,355 @@ +#![cfg(unix)] + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_shell_command_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test] +async fn turn_interrupt_aborts_running_turn() -> Result<()> { + // Use a portable sleep command to keep the turn running. + #[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()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + // Mock server: long-running shell command then (after abort) nothing else needed. + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a v2 thread and capture its id. + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + // Start a turn that triggers a long-running command. + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); + + // Give the command a brief moment to start. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let thread_id = thread.id.clone(); + // Interrupt the in-progress turn by id (v2 API). + let interrupt_id = mcp + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + }) + .await?; + let interrupt_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)), + ) + .await??; + let _resp: TurnInterruptResponse = to_response::(interrupt_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread_id); + assert_eq!(completed.turn.status, TurnStatus::Interrupted); + + Ok(()) +} + +#[tokio::test] +async fn turn_interrupt_rejects_completed_turn() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "say done".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + + let interrupt_id = mcp + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread.id, + turn_id: turn.id, + }) + .await?; + + let interrupt_err: JSONRPCError = timeout( + std::time::Duration::from_millis(500), + mcp.read_stream_until_error_message(RequestId::Integer(interrupt_id)), + ) + .await??; + assert_eq!(interrupt_err.error.code, INVALID_REQUEST_ERROR_CODE); + + Ok(()) +} + +#[tokio::test] +async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> { + #[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![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep_approval", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "untrusted", "read-only")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let request = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = request else { + panic!("expected CommandExecutionRequestApproval request"); + }; + assert_eq!(params.item_id, "call_sleep_approval"); + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + + let interrupt_id = mcp + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread.id.clone(), + turn_id: turn.id.clone(), + }) + .await?; + let interrupt_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)), + ) + .await??; + let _resp: TurnInterruptResponse = to_response::(interrupt_resp)?; + + let resolved_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("serverRequest/resolved"), + ) + .await??; + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + resolved_notification + .params + .clone() + .expect("serverRequest/resolved params must be present"), + )?; + assert_eq!(resolved.thread_id, thread.id); + assert_eq!(resolved.request_id, request_id); + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.status, TurnStatus::Interrupted); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml( + codex_home: &std::path::Path, + server_uri: &str, + approval_policy: &str, + sandbox_mode: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +approvals_reviewer = "user" +sandbox_mode = "{sandbox_mode}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/turn_start.rs b/code-rs/app-server/tests/suite/v2/turn_start.rs new file mode 100644 index 00000000000..e5c5c5adbbe --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/turn_start.rs @@ -0,0 +1,3551 @@ +use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::McpProcess; +use app_test_support::create_apply_patch_sse_response; +use app_test_support::create_exec_command_sse_response; +use app_test_support::create_fake_rollout; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_shell_command_sse_response; +use app_test_support::format_with_current_shell_display; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use app_test_support::write_models_cache; +use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::ByteRange; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::CollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::FileChangePatchUpdatedNotification; +use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PermissionProfileSelectionParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::TextElement; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSource; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_app_server_protocol::WarningNotification; +use codex_config::config_toml::ConfigToml; +use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; +use codex_features::FEATURES; +use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + +#[cfg(windows)] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const TEST_ORIGINATOR: &str = "codex_vscode"; +const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +fn body_contains(req: &wiremock::Request, text: &str) -> bool { + String::from_utf8(req.body.clone()) + .ok() + .is_some_and(|body| body.contains(text)) +} + +#[tokio::test] +async fn turn_start_sends_originator_header() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: TEST_ORIGINATOR.to_string(), + title: Some("Codex VS Code Extension".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + assert!(!requests.is_empty()); + for request in requests { + let originator = request + .headers + .get("originator") + .expect("originator header missing"); + assert_eq!(originator.to_str()?, TEST_ORIGINATOR); + } + + Ok(()) +} + +#[tokio::test] +async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".to_string()), + )]; + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: text_elements.clone(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + + let user_message_item = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let params = notification.params.expect("item/started params"); + let item_started: ItemStartedNotification = + serde_json::from_value(params).expect("deserialize item/started notification"); + if let ThreadItem::UserMessage { .. } = item_started.item { + return Ok::(item_started.item); + } + } + }) + .await??; + + match user_message_item { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements, + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + write_models_cache(codex_home.path())?; + let cache_path = codex_home.path().join("models_cache.json"); + let mut cache: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&cache_path)?)?; + let models = cache["models"] + .as_array_mut() + .expect("models_cache.json models should be an array"); + let entry = models + .first_mut() + .expect("models cache should not be empty"); + let model = entry["slug"] + .as_str() + .expect("model slug should be present") + .to_string(); + entry["context_window"] = serde_json::Value::from(100); + std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("model = \"mock-model\"", &format!("model = \"{model}\"")), + )?; + write_test_skill(codex_home.path(), "alpha-skill")?; + write_test_skill(codex_home.path(), "beta-skill")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("warning"), + ) + .await??; + let params = notification.params.expect("warning params"); + let warning: WarningNotification = + serde_json::from_value(params).expect("deserialize warning notification"); + assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str())); + assert_eq!( + warning.message, + "Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list." + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + let request = requests + .last() + .expect("expected at least one model request"); + assert!( + body_contains(request, "## Skills"), + "expected outgoing request to include the skills section" + ); + assert!( + !body_contains(request, "- alpha-skill:") && !body_contains(request, "- beta-skill:"), + "expected trimmed skills to be omitted from the outgoing request body" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let service_tier_id = "experimental-tier-id".to_string(); + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + service_tier: Some(Some(service_tier_id.clone())), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!( + response_mock.single_request().body_json()["service_tier"], + json!(service_tier_id) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + // TODO(aibrahim): Replace empty string instruction overrides with explicit tri-state + // app-server semantics: omitted, explicitly none, or explicit value. + config: Some(HashMap::from([( + "include_permissions_instructions".to_string(), + json!(false), + )])), + base_instructions: Some(String::new()), + developer_instructions: Some(String::new()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request_body = response_mock.single_request().body_json(); + let empty_developer_input_texts = request_body["input"] + .as_array() + .expect("input array") + .iter() + .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("developer")) + .filter_map(|item| item.get("content").and_then(serde_json::Value::as_array)) + .flatten() + .filter(|content| { + content.get("type").and_then(serde_json::Value::as_str) == Some("input_text") + }) + .filter_map(|content| content.get("text").and_then(serde_json::Value::as_str)) + .filter(|text| text.is_empty()) + .collect::>(); + assert_eq!( + json!({ + "hasInstructions": request_body.get("instructions").is_some(), + "emptyDeveloperInputTexts": empty_developer_input_texts, + }), + json!({ + "hasInstructions": false, + "emptyDeveloperInputTexts": [], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![ + V2UserInput::Text { + text: "x".repeat(MAX_USER_INPUT_TEXT_CHARS), + text_elements: Vec::new(), + }, + V2UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert_eq!(turn.status, TurnStatus::InProgress); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first = "x".repeat(MAX_USER_INPUT_TEXT_CHARS / 2); + let second = "y".repeat(MAX_USER_INPUT_TEXT_CHARS / 2 + 1); + let actual_chars = first.chars().count() + second.chars().count(); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![ + V2UserInput::Text { + text: first, + text_elements: Vec::new(), + }, + V2UserInput::Text { + text: second, + text_elements: Vec::new(), + }, + ], + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = err.error.data.expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], actual_chars); + + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification for rejected input" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + std::fs::write( + codex_home.path().join("managed_config.toml"), + "sandbox_mode = \"read-only\"\n", + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + permissions: Some(PermissionProfileSelectionParams::Profile { + id: ":danger-no-sandbox".to_string(), + modifications: None, + }), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert!(err.error.message.contains("invalid turn context override")); + assert!( + err.error.message.contains("allowed set [ReadOnly]"), + "unexpected error message: {}", + err.error.message + ); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected permissions selection" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + environments: Some(vec![TurnEnvironmentParams { + environment_id: "missing".to_string(), + cwd: codex_home.path().to_path_buf().try_into()?, + }]), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.id, RequestId::Integer(turn_req)); + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(err.error.message, "unknown turn environment id `missing`"); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected environments" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { + // Provide a mock server and config so model wiring is valid. + // Three Codex turns hit the mock model (session start + two turn/start calls). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread (v2) and capture its id. + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + // Start a turn with only input and thread_id set (no overrides). + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + // Expect a turn/started notification. + let notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + assert_eq!(started.thread_id, thread.id); + assert_eq!( + started.turn.status, + codex_app_server_protocol::TurnStatus::InProgress + ); + assert_eq!(started.turn.id, turn.id); + assert_eq!(started.turn.items_view, TurnItemsView::NotLoaded); + assert!(started.turn.items.is_empty()); + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(completed.turn.items_view, TurnItemsView::NotLoaded); + assert!(completed.turn.items.is_empty()); + + // Send a second turn that exercises the overrides path: change the model. + let turn_req2 = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Second".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-override".to_string()), + ..Default::default() + }) + .await?; + let turn_resp2: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req2)), + ) + .await??; + let TurnStartResponse { turn: turn2 } = to_response::(turn_resp2)?; + assert!(!turn2.id.is_empty()); + // Ensure the second turn has a different id than the first. + assert_ne!(turn.id, turn2.id); + + let notif2: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started2: TurnStartedNotification = + serde_json::from_value(notif2.params.expect("params must be present"))?; + assert_eq!(started2.thread_id, thread.id); + assert_eq!(started2.turn.id, turn2.id); + assert_eq!(started2.turn.status, TurnStatus::InProgress); + assert_eq!(started2.turn.items_view, TurnItemsView::NotLoaded); + assert!(started2.turn.items.is_empty()); + + let completed_notif2: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed2: TurnCompletedNotification = serde_json::from_value( + completed_notif2 + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed2.thread_id, thread.id); + assert_eq!(completed2.turn.id, turn2.id); + assert_eq!(completed2.turn.status, TurnStatus::Completed); + assert_eq!(completed2.turn.items_view, TurnItemsView::NotLoaded); + assert!(completed2.turn.items.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.3-codex".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "mock-model-collab".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-override".to_string()), + effort: Some(ReasoningEffort::Low), + summary: Some(ReasoningSummary::Auto), + output_schema: None, + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let payload = request.body_json(); + assert_eq!(payload["model"].as_str(), Some("mock-model-collab")); + let payload_text = payload.to_string(); + assert!(payload_text.contains( + "Use the `request_user_input` tool only when it is listed in the available tools" + )); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_description_v2() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.3-codex".to_string()), + config: Some(HashMap::from([( + "features.default_mode_request_user_input".to_string(), + json!(true), + )])), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "mock-model-collab".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-override".to_string()), + effort: Some(ReasoningEffort::Low), + summary: Some(ReasoningSummary::Auto), + output_schema: None, + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let payload_text = request.body_json().to_string(); + assert!(payload_text.contains("This tool is only available in Default or Plan mode.")); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_accepts_personality_override_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("exp-codex-personality".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + personality: Some(Personality::Friendly), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let developer_texts = request.message_input_texts("developer"); + if developer_texts.is_empty() { + eprintln!("request body: {}", request.body_json()); + } + + assert!( + developer_texts + .iter() + .any(|text| text.contains("")), + "expected personality update message in developer input, got {developer_texts:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let sse1 = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let sse2 = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ]); + let response_mock = responses::mount_sse_sequence(&server, vec![sse1, sse2]).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("exp-codex-personality".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + personality: None, + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_req2 = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello again".to_string(), + text_elements: Vec::new(), + }], + personality: Some(Personality::Friendly), + ..Default::default() + }) + .await?; + let turn_resp2: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req2)), + ) + .await??; + let _turn2: TurnStartResponse = to_response::(turn_resp2)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two requests"); + + let first_developer_texts = requests[0].message_input_texts("developer"); + assert!( + first_developer_texts + .iter() + .all(|text| !text.contains("")), + "expected no personality update message in first request, got {first_developer_texts:?}" + ); + + let second_developer_texts = requests[1].message_input_texts("developer"); + assert!( + second_developer_texts + .iter() + .any(|text| text.contains("")), + "expected personality update message in second request, got {second_developer_texts:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + create_fake_rollout( + codex_home.path(), + "2025-01-01T00-00-00", + "2025-01-01T00:00:00Z", + "history user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let persisted_toml: ConfigToml = toml::from_str(&std::fs::read_to_string( + codex_home.path().join("config.toml"), + )?)?; + assert_eq!(persisted_toml.personality, Some(Personality::Pragmatic)); + assert!( + codex_home + .path() + .join(PERSONALITY_MIGRATION_FILENAME) + .exists(), + "expected personality migration marker to be written on startup" + ); + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.3-codex".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + personality: None, + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let instructions_text = request.instructions_text(); + assert!( + instructions_text.contains(LOCAL_PRAGMATIC_TEMPLATE), + "expected startup-migrated pragmatic personality in model instructions, got: {instructions_text:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_accepts_local_image_input() -> Result<()> { + // Two Codex turns hit the mock model (session start + turn/start). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + // Use the unchecked variant because the request payload includes a LocalImage + // which the strict matcher does not currently cover. + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let image_path = codex_home.path().join("image.png"); + // No need to actually write the file; we just exercise the input path. + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::LocalImage { path: image_path }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_exec_approval_toggle_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().to_path_buf(); + + // Mock server: first turn requests a shell call (elicitation), then completes. + // Second turn same, but we'll set approval_policy=never to avoid elicitation. + let responses = vec![ + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + /*workdir*/ None, + Some(5000), + "call1", + )?, + create_final_assistant_message_sse_response("done 1")?, + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + /*workdir*/ None, + Some(5000), + "call2", + )?, + create_final_assistant_message_sse_response("done 2")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + // Default approval is untrusted to force elicitation on first turn. + create_config_toml( + codex_home.as_path(), + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // thread/start + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + // turn/start — expect CommandExecutionRequestApproval request from server + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + // Acknowledge RPC + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + + // Receive elicitation + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { + panic!("expected CommandExecutionRequestApproval request"); + }; + assert_eq!(params.item_id, "call1"); + let resolved_request_id = request_id.clone(); + + // Approve and wait for task completion + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Accept, + })?, + ) + .await?; + let mut saw_resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(resolved.thread_id, thread.id); + assert_eq!(resolved.request_id, resolved_request_id); + saw_resolved = true; + } + "turn/completed" => { + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + break; + } + _ => {} + } + } + + // Second turn with approval_policy=never should not elicit approval + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python again".to_string(), + text_elements: Vec::new(), + }], + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + + // Ensure we do NOT receive a CommandExecutionRequestApproval request before task completes + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_exec_approval_decline_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().to_path_buf(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let responses = vec![ + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + /*workdir*/ None, + Some(5000), + "call-decline", + )?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; + if let ThreadItem::CommandExecution { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { id, status, .. } = started_command_execution else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "call-decline"); + assert_eq!(status, CommandExecutionStatus::InProgress); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { + panic!("expected CommandExecutionRequestApproval request") + }; + assert_eq!(params.item_id, "call-decline"); + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Decline, + })?, + ) + .await?; + + let completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + completed_notif + .params + .clone() + .expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { .. } = completed.item { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { + id, + status, + exit_code, + aggregated_output, + .. + } = completed_command_execution + else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "call-decline"); + assert_eq!(status, CommandExecutionStatus::Declined); + assert!(exit_code.is_none()); + assert!(aggregated_output.is_none()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace_root = tmp.path().join("workspace"); + std::fs::create_dir(&workspace_root)?; + let first_cwd = workspace_root.join("turn1"); + let second_cwd = workspace_root.join("turn2"); + std::fs::create_dir(&first_cwd)?; + std::fs::create_dir(&second_cwd)?; + + let responses = vec![ + create_shell_command_sse_response( + vec!["echo".to_string(), "first".to_string(), "turn".to_string()], + /*workdir*/ None, + Some(5000), + "call-first", + )?, + create_final_assistant_message_sse_response("done first")?, + create_shell_command_sse_response( + vec!["echo".to_string(), "second".to_string(), "turn".to_string()], + /*workdir*/ None, + Some(5000), + "call-second", + )?, + create_final_assistant_message_sse_response("done second")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + &codex_home, + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // thread/start + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + // first turn with workspace-write sandbox and first_cwd + let first_turn = mcp + .send_turn_start_request(TurnStartParams { + environments: None, + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + cwd: Some(first_cwd.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + approvals_reviewer: None, + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![first_cwd.try_into()?], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }), + permissions: None, + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + service_tier: None, + personality: None, + output_schema: None, + collaboration_mode: None, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + mcp.clear_message_buffer(); + + // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd + let second_turn = mcp + .send_turn_start_request(TurnStartParams { + environments: None, + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + cwd: Some(second_cwd.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + approvals_reviewer: None, + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + permissions: None, + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + service_tier: None, + personality: None, + output_schema: None, + collaboration_mode: None, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn)), + ) + .await??; + + let command_exec_item = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let item_started_notification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let params = item_started_notification + .params + .clone() + .expect("item/started params"); + let item_started: ItemStartedNotification = + serde_json::from_value(params).expect("deserialize item/started notification"); + if matches!(item_started.item, ThreadItem::CommandExecution { .. }) { + return Ok::(item_started.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { + cwd, + command, + status, + .. + } = command_exec_item + else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(cwd.as_path(), second_cwd.as_path()); + let expected_command = format_with_current_shell_display("echo second turn"); + assert_eq!(command, expected_command); + assert_eq!(status, CommandExecutionStatus::InProgress); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let server = create_mock_responses_server_repeating_assistant("done").await; + create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; + + let mut mcp = McpProcess::new_with_env( + &codex_home, + &[("CODEX_EXEC_SERVER_URL", Some("http://127.0.0.1:1"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + for case in [ + EnvironmentSelectionCase { + name: "sticky_unset_turn_unset", + sticky: None, + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_empty_turn_unset", + sticky: Some(&[]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_unset", + sticky: Some(&["local"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_remote_turn_unset", + sticky: Some(&["remote"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_remote_turn_unset", + sticky: Some(&["local", "remote"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_empty", + sticky: Some(&["local"]), + turn: Some(&[]), + }, + EnvironmentSelectionCase { + name: "sticky_empty_turn_local", + sticky: Some(&[]), + turn: Some(&["local"]), + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_remote", + sticky: Some(&["local"]), + turn: Some(&["remote"]), + }, + EnvironmentSelectionCase { + name: "sticky_remote_turn_local", + sticky: Some(&["remote"]), + turn: Some(&["local"]), + }, + EnvironmentSelectionCase { + name: "sticky_unset_turn_local_remote", + sticky: None, + turn: Some(&["local", "remote"]), + }, + ] { + run_environment_selection_case(&mut mcp, &workspace, case).await?; + } + + Ok(()) +} + +struct EnvironmentSelectionCase { + name: &'static str, + sticky: Option<&'static [&'static str]>, + turn: Option<&'static [&'static str]>, +} + +async fn run_environment_selection_case( + mcp: &mut McpProcess, + workspace: &Path, + case: EnvironmentSelectionCase, +) -> Result<()> { + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + environments: environment_params(case.sticky, workspace)?, + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: format!("run {}", case.name), + text_elements: Vec::new(), + }], + environments: environment_params(case.turn, workspace)?, + cwd: Some(workspace.to_path_buf()), + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = serde_json::from_value( + started_notification + .params + .ok_or_else(|| anyhow::anyhow!("turn/started notification should include params"))?, + )?; + assert_eq!(started.turn.id, turn.id, "{}", case.name); + + let completed_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = + serde_json::from_value(completed_notification.params.ok_or_else(|| { + anyhow::anyhow!("turn/completed notification should include params") + })?)?; + assert_eq!(completed.turn.id, turn.id, "{}", case.name); + assert_eq!( + completed.turn.status, + TurnStatus::Completed, + "{}", + case.name + ); + + mcp.clear_message_buffer(); + + Ok(()) +} + +fn environment_params( + ids: Option<&[&str]>, + cwd: &Path, +) -> Result>> { + ids.map(|ids| { + ids.iter() + .map(|id| { + Ok(TurnEnvironmentParams { + environment_id: (*id).to_string(), + cwd: cwd.to_path_buf().try_into()?, + }) + }) + .collect() + }) + .transpose() +} + +#[tokio::test] +async fn turn_start_file_change_approval_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let patch = r#"*** Begin Patch +*** Add File: README.md ++new line +*** End Patch +"#; + let responses = vec![ + create_apply_patch_sse_response(patch, "patch-call")?, + create_final_assistant_message_sse_response("patch applied")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + &codex_home, + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "apply patch".into(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_file_change = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; + if let ThreadItem::FileChange { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::FileChange { + ref id, + status, + ref changes, + } = started_file_change + else { + unreachable!("loop ensures we break on file change items"); + }; + assert_eq!(id, "patch-call"); + assert_eq!(status, PatchApplyStatus::InProgress); + let started_changes = changes.clone(); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::FileChangeRequestApproval { request_id, params } = server_req else { + panic!("expected FileChangeRequestApproval request") + }; + assert_eq!(params.item_id, "patch-call"); + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + let resolved_request_id = request_id.clone(); + let expected_readme_path = workspace.join("README.md"); + let expected_readme_path = expected_readme_path.to_string_lossy().into_owned(); + pretty_assertions::assert_eq!( + started_changes, + vec![codex_app_server_protocol::FileUpdateChange { + path: expected_readme_path.clone(), + kind: PatchChangeKind::Add, + diff: "new line\n".to_string(), + }] + ); + + mcp.send_response( + request_id, + serde_json::to_value(FileChangeRequestApprovalResponse { + decision: FileChangeApprovalDecision::Accept, + })?, + ) + .await?; + let mut saw_resolved = false; + let mut completed_file_change: Option = None; + while completed_file_change.is_none() { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(resolved.thread_id, thread.id); + assert_eq!(resolved.request_id, resolved_request_id); + saw_resolved = true; + } + "item/completed" => { + let completed: ItemCompletedNotification = serde_json::from_value( + notification.params.clone().expect("item/completed params"), + )?; + if let ThreadItem::FileChange { .. } = completed.item { + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + completed_file_change = Some(completed.item); + } + } + _ => {} + } + } + let completed_file_change = + completed_file_change.expect("file change completion should be observed"); + let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else { + unreachable!("loop ensures we break on file change items"); + }; + assert_eq!(id, "patch-call"); + assert_eq!(status, PatchApplyStatus::Completed); + + let readme_contents = std::fs::read_to_string(expected_readme_path)?; + assert_eq!(readme_contents, "new line\n"); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let call_id = "patch-call"; + let item_id = "fc-patch-call"; + let patch = "*** Begin Patch\n*** Add File: live.txt\n+live line\n*** End Patch\n"; + let patch_delta_1 = "*** Begin Patch\n*** Add File: live.txt\n+live"; + let patch_delta_2 = " line\n*** End Patch\n"; + let responses = vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + serde_json::json!({ + "type": "response.output_item.added", + "item": { + "type": "custom_tool_call", + "id": item_id, + "call_id": call_id, + "name": "apply_patch", + "input": "", + "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.custom_tool_call_input.delta", + "item_id": item_id, + "call_id": call_id, + "delta": patch_delta_1, + }), + serde_json::json!({ + "type": "response.custom_tool_call_input.delta", + "item_id": item_id, + "call_id": call_id, + "delta": patch_delta_2, + }), + responses::ev_apply_patch_custom_tool_call(call_id, patch), + responses::ev_completed("resp-1"), + ]), + create_final_assistant_message_sse_response("patch applied")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "apply patch".into(), + text_elements: Vec::new(), + }], + cwd: Some(workspace), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert!( + !mcp.pending_notification_methods() + .iter() + .any(|method| method == "item/fileChange/patchUpdated") + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let call_id = "patch-call"; + let item_id = "fc-patch-call"; + let patch = "*** Begin Patch\n*** Add File: live.txt\n+live line\n*** End Patch\n"; + let patch_delta_1 = "*** Begin Patch\n*** Add File: live.txt\n+live"; + let patch_delta_2 = " line\n*** End Patch\n"; + let responses = vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + serde_json::json!({ + "type": "response.output_item.added", + "item": { + "type": "function_call", + "id": "fc-other-call", + "call_id": "other-call", + "name": "not_apply_patch", + "arguments": "", + "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.function_call_arguments.delta", + "item_id": "fc-other-call", + "delta": r#"{"input":"*** Begin Patch\n*** Add File: ignored.txt\n+ignored"#, + }), + serde_json::json!({ + "type": "response.output_item.added", + "item": { + "type": "custom_tool_call", + "id": item_id, + "call_id": call_id, + "name": "apply_patch", + "input": "", + "status": "in_progress" + } + }), + serde_json::json!({ + "type": "response.custom_tool_call_input.delta", + "item_id": item_id, + "call_id": call_id, + "delta": patch_delta_1, + }), + serde_json::json!({ + "type": "response.custom_tool_call_input.delta", + "item_id": item_id, + "call_id": call_id, + "delta": patch_delta_2, + }), + responses::ev_apply_patch_custom_tool_call(call_id, patch), + responses::ev_completed("resp-1"), + ]), + create_final_assistant_message_sse_response("patch applied")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + &codex_home, + &server.uri(), + "never", + &BTreeMap::from([ + (Feature::ApplyPatchFreeform, true), + (Feature::ApplyPatchStreamingEvents, true), + (Feature::Plugins, false), + (Feature::RemoteModels, false), + (Feature::ShellSnapshot, false), + ]), + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "apply patch".into(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let mut streamed_content = String::new(); + while streamed_content != "live line\n" { + let delta_notif = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/fileChange/patchUpdated"), + ) + .await??; + let delta: FileChangePatchUpdatedNotification = serde_json::from_value( + delta_notif + .params + .clone() + .expect("item/fileChange/patchUpdated params"), + )?; + assert_eq!(delta.thread_id, thread.id); + assert_eq!(delta.turn_id, turn.id); + assert_eq!(delta.item_id, call_id); + let change = delta + .changes + .iter() + .find(|change| change.path == "live.txt") + .expect("live.txt change"); + assert!(matches!(change.kind, PatchChangeKind::Add)); + streamed_content = change.diff.clone(); + } + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + const CHILD_PROMPT: &str = "child: do work"; + const PARENT_PROMPT: &str = "spawn a child and continue"; + const SPAWN_CALL_ID: &str = "spawn-call-1"; + const REQUESTED_MODEL: &str = "gpt-5.2"; + const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; + + let server = responses::start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "model": REQUESTED_MODEL, + "reasoning_effort": REQUESTED_REASONING_EFFORT, + }))?; + let _parent_turn = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), + responses::sse(vec![ + responses::ev_response_created("resp-turn1-1"), + responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_completed("resp-turn1-1"), + ]), + ) + .await; + let _child_turn = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("resp-child-1"), + responses::ev_assistant_message("msg-child-1", "child done"), + responses::ev_completed("resp-child-1"), + ]), + ) + .await; + let _parent_follow_up = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + responses::sse(vec![ + responses::ev_response_created("resp-turn1-2"), + responses::ev_assistant_message("msg-turn1-2", "parent done"), + responses::ev_completed("resp-turn1-2"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Collab, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.3-codex".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: PARENT_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn: TurnStartResponse = to_response::(turn_resp)?; + + let spawn_started = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.expect("item/started params"))?; + if let ThreadItem::CollabAgentToolCall { id, .. } = &started.item + && id == SPAWN_CALL_ID + { + return Ok::(started.item); + } + } + }) + .await??; + assert_eq!( + spawn_started, + ThreadItem::CollabAgentToolCall { + id: SPAWN_CALL_ID.to_string(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: thread.id.clone(), + receiver_thread_ids: Vec::new(), + prompt: Some(CHILD_PROMPT.to_string()), + model: Some(REQUESTED_MODEL.to_string()), + reasoning_effort: Some(REQUESTED_REASONING_EFFORT), + agents_states: HashMap::new(), + } + ); + + let spawn_completed = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = + serde_json::from_value(completed_notif.params.expect("item/completed params"))?; + if let ThreadItem::CollabAgentToolCall { id, .. } = &completed.item + && id == SPAWN_CALL_ID + { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } = spawn_completed + else { + unreachable!("loop ensures we break on collab agent tool call items"); + }; + let receiver_thread_id = receiver_thread_ids + .first() + .cloned() + .expect("spawn completion should include child thread id"); + assert_eq!(id, SPAWN_CALL_ID); + assert_eq!(tool, CollabAgentTool::SpawnAgent); + assert_eq!(status, CollabAgentToolCallStatus::Completed); + assert_eq!(sender_thread_id, thread.id); + assert_eq!(receiver_thread_ids, vec![receiver_thread_id.clone()]); + assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); + assert_eq!(model, Some(REQUESTED_MODEL.to_string())); + assert_eq!(reasoning_effort, Some(REQUESTED_REASONING_EFFORT)); + let agent_state = agents_states + .get(&receiver_thread_id) + .expect("spawn completion should include child agent state"); + assert!( + matches!( + agent_state.status, + CollabAgentStatus::PendingInit | CollabAgentStatus::Running + ), + "child agent should still be initializing or already running, got {:?}", + agent_state.status + ); + assert_eq!(agent_state.message, None); + + let turn_completed = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let turn_completed_notif = mcp + .read_stream_until_notification_message("turn/completed") + .await?; + let turn_completed: TurnCompletedNotification = serde_json::from_value( + turn_completed_notif.params.expect("turn/completed params"), + )?; + if turn_completed.thread_id == thread.id && turn_completed.turn.id == turn.turn.id { + return Ok::(turn_completed); + } + } + }) + .await??; + assert_eq!(turn_completed.thread_id, thread.id); + assert_eq!(turn_completed.turn.id, turn.turn.id); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_emits_spawn_agent_item_with_effective_role_model_metadata_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + const CHILD_PROMPT: &str = "child: do work"; + const PARENT_PROMPT: &str = "spawn a child and continue"; + const SPAWN_CALL_ID: &str = "spawn-call-1"; + const REQUESTED_MODEL: &str = "gpt-5.2"; + const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; + const ROLE_MODEL: &str = "gpt-5.4"; + const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; + + let server = responses::start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "agent_type": "custom", + "model": REQUESTED_MODEL, + "reasoning_effort": REQUESTED_REASONING_EFFORT, + }))?; + let _parent_turn = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), + responses::sse(vec![ + responses::ev_response_created("resp-turn1-1"), + responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_completed("resp-turn1-1"), + ]), + ) + .await; + let _child_turn = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("resp-child-1"), + responses::ev_assistant_message("msg-child-1", "child done"), + responses::ev_completed("resp-child-1"), + ]), + ) + .await; + let _parent_follow_up = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + responses::sse(vec![ + responses::ev_response_created("resp-turn1-2"), + responses::ev_assistant_message("msg-turn1-2", "parent done"), + responses::ev_completed("resp-turn1-2"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Collab, true)]), + )?; + std::fs::write( + codex_home.path().join("custom-role.toml"), + format!("model = \"{ROLE_MODEL}\"\nmodel_reasoning_effort = \"{ROLE_REASONING_EFFORT}\"\n",), + )?; + let config_path = codex_home.path().join("config.toml"); + let base_config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!( + r#"{base_config} + +[agents.custom] +description = "Custom role" +config_file = "./custom-role.toml" +"# + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.3-codex".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: PARENT_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn: TurnStartResponse = to_response::(turn_resp)?; + + let spawn_completed = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = + serde_json::from_value(completed_notif.params.expect("item/completed params"))?; + if let ThreadItem::CollabAgentToolCall { id, .. } = &completed.item + && id == SPAWN_CALL_ID + { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } = spawn_completed + else { + unreachable!("loop ensures we break on collab agent tool call items"); + }; + let receiver_thread_id = receiver_thread_ids + .first() + .cloned() + .expect("spawn completion should include child thread id"); + assert_eq!(id, SPAWN_CALL_ID); + assert_eq!(tool, CollabAgentTool::SpawnAgent); + assert_eq!(status, CollabAgentToolCallStatus::Completed); + assert_eq!(sender_thread_id, thread.id); + assert_eq!(receiver_thread_ids, vec![receiver_thread_id.clone()]); + assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); + assert_eq!(model, Some(ROLE_MODEL.to_string())); + assert_eq!(reasoning_effort, Some(ROLE_REASONING_EFFORT)); + let agent_state = agents_states + .get(&receiver_thread_id) + .expect("spawn completion should include child agent state"); + assert!( + matches!( + agent_state.status, + CollabAgentStatus::PendingInit | CollabAgentStatus::Running + ), + "child agent should still be initializing or already running, got {:?}", + agent_state.status + ); + assert_eq!(agent_state.message, None); + + let turn_completed = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let turn_completed_notif = mcp + .read_stream_until_notification_message("turn/completed") + .await?; + let turn_completed: TurnCompletedNotification = serde_json::from_value( + turn_completed_notif.params.expect("turn/completed params"), + )?; + if turn_completed.thread_id == thread.id && turn_completed.turn.id == turn.turn.id { + return Ok::(turn_completed); + } + } + }) + .await??; + assert_eq!(turn_completed.thread_id, thread.id); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let patch_1 = r#"*** Begin Patch +*** Add File: README.md ++new line +*** End Patch +"#; + let patch_2 = r#"*** Begin Patch +*** Update File: README.md +@@ +-new line ++updated line +*** End Patch +"#; + + let responses = vec![ + create_apply_patch_sse_response(patch_1, "patch-call-1")?, + create_final_assistant_message_sse_response("patch 1 applied")?, + create_apply_patch_sse_response(patch_2, "patch-call-2")?, + create_final_assistant_message_sse_response("patch 2 applied")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + &codex_home, + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + // First turn: expect FileChangeRequestApproval, respond with AcceptForSession, and verify the file exists. + let turn_1_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "apply patch 1".into(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_1_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_1_req)), + ) + .await??; + let TurnStartResponse { turn: turn_1 } = to_response::(turn_1_resp)?; + + let started_file_change_1 = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; + if let ThreadItem::FileChange { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::FileChange { id, status, .. } = started_file_change_1 else { + unreachable!("loop ensures we break on file change items"); + }; + assert_eq!(id, "patch-call-1"); + assert_eq!(status, PatchApplyStatus::InProgress); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::FileChangeRequestApproval { request_id, params } = server_req else { + panic!("expected FileChangeRequestApproval request") + }; + assert_eq!(params.item_id, "patch-call-1"); + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn_1.id); + + mcp.send_response( + request_id, + serde_json::to_value(FileChangeRequestApprovalResponse { + decision: FileChangeApprovalDecision::AcceptForSession, + })?, + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/completed"), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let readme_path = workspace.join("README.md"); + assert_eq!(std::fs::read_to_string(&readme_path)?, "new line\n"); + + // Second turn: apply a patch to the same file. Approval should be skipped due to AcceptForSession. + let turn_2_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "apply patch 2".into(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_2_req)), + ) + .await??; + + let started_file_change_2 = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; + if let ThreadItem::FileChange { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::FileChange { id, status, .. } = started_file_change_2 else { + unreachable!("loop ensures we break on file change items"); + }; + assert_eq!(id, "patch-call-2"); + assert_eq!(status, PatchApplyStatus::InProgress); + + // If the server incorrectly emits FileChangeRequestApproval, the helper below will error + // (it bails on unexpected JSONRPCMessage::Request), causing the test to fail. + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("item/completed"), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!(std::fs::read_to_string(readme_path)?, "updated line\n"); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_file_change_approval_decline_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let patch = r#"*** Begin Patch +*** Add File: README.md ++new line +*** End Patch +"#; + let responses = vec![ + create_apply_patch_sse_response(patch, "patch-call")?, + create_final_assistant_message_sse_response("patch declined")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + &codex_home, + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "apply patch".into(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_file_change = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; + if let ThreadItem::FileChange { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::FileChange { + ref id, + status, + ref changes, + } = started_file_change + else { + unreachable!("loop ensures we break on file change items"); + }; + assert_eq!(id, "patch-call"); + assert_eq!(status, PatchApplyStatus::InProgress); + let started_changes = changes.clone(); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::FileChangeRequestApproval { request_id, params } = server_req else { + panic!("expected FileChangeRequestApproval request") + }; + assert_eq!(params.item_id, "patch-call"); + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + let expected_readme_path = workspace.join("README.md"); + let expected_readme_path_str = expected_readme_path.to_string_lossy().into_owned(); + pretty_assertions::assert_eq!( + started_changes, + vec![codex_app_server_protocol::FileUpdateChange { + path: expected_readme_path_str.clone(), + kind: PatchChangeKind::Add, + diff: "new line\n".to_string(), + }] + ); + + mcp.send_response( + request_id, + serde_json::to_value(FileChangeRequestApprovalResponse { + decision: FileChangeApprovalDecision::Decline, + })?, + ) + .await?; + + let completed_file_change = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + completed_notif + .params + .clone() + .expect("item/completed params"), + )?; + if let ThreadItem::FileChange { .. } = completed.item { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else { + unreachable!("loop ensures we break on file change items"); + }; + assert_eq!(id, "patch-call"); + assert_eq!(status, PatchApplyStatus::Declined); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert!( + !expected_readme_path.exists(), + "declined patch should not be applied" + ); + + Ok(()) +} + +#[tokio::test] +#[cfg_attr(windows, ignore = "process id reporting differs on Windows")] +async fn command_execution_notifications_include_process_id() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses = vec![ + create_exec_command_sse_response("uexec-1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + let codex_home = TempDir::new()?; + create_config_toml_with_sandbox( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::UnifiedExec, true)]), + "danger-full-access", + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run a command".to_string(), + text_elements: Vec::new(), + }], + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn: _turn } = to_response::(turn_resp)?; + + let started_command = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = serde_json::from_value( + notif + .params + .clone() + .expect("item/started should include params"), + )?; + if let ThreadItem::CommandExecution { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { + id, + process_id: started_process_id, + status, + .. + } = started_command + else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "uexec-1"); + assert_eq!(status, CommandExecutionStatus::InProgress); + let started_process_id = started_process_id.expect("process id should be present"); + + let completed_command = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notif + .params + .clone() + .expect("item/completed should include params"), + )?; + if let ThreadItem::CommandExecution { .. } = completed.item { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { + id: completed_id, + process_id: completed_process_id, + status: completed_status, + exit_code, + .. + } = completed_command + else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(completed_id, "uexec-1"); + assert!( + matches!( + completed_status, + CommandExecutionStatus::Completed | CommandExecutionStatus::Failed + ), + "unexpected command execution status: {completed_status:?}" + ); + if completed_status == CommandExecutionStatus::Completed { + assert_eq!(exit_code, Some(0)); + } else { + assert!(exit_code.is_some(), "expected exit_code for failed command"); + } + assert_eq!( + completed_process_id.as_deref(), + Some(started_process_id.as_str()) + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let workspace = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_response)?; + + let turn_request = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + cwd: Some(workspace.path().to_path_buf()), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config_toml.contains("trust_level = \"trusted\"")); + assert!(!config_toml.contains(&workspace.path().display().to_string())); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, + feature_flags: &BTreeMap, +) -> std::io::Result<()> { + create_config_toml_with_sandbox( + codex_home, + server_uri, + approval_policy, + feature_flags, + "read-only", + ) +} + +fn create_config_toml_with_sandbox( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, + feature_flags: &BTreeMap, + sandbox_mode: &str, +) -> std::io::Result<()> { + let mut features = BTreeMap::new(); + for (feature, enabled) in feature_flags { + features.insert(*feature, *enabled); + } + let feature_entries = features + .into_iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "{sandbox_mode}" + +model_provider = "mock_provider" + +[features] +{feature_entries} + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn write_test_skill(codex_home: &Path, name: &str) -> std::io::Result<()> { + let skill_dir = codex_home.join("skills").join(name); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {name} description\n---\n\n# Body\n"), + ) +} diff --git a/code-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/code-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs new file mode 100644 index 00000000000..31247418e51 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -0,0 +1,827 @@ +#![cfg(not(windows))] +// +// Running these tests with the patched zsh fork: +// +// The suite resolves the shared test-only zsh DotSlash file at +// `app-server/tests/suite/zsh` via DotSlash on first use, so `dotslash` and +// network access are required the first time the artifact is fetched. + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_shell_command_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_features::FEATURES; +use codex_features::Feature; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +#[cfg(windows)] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +#[cfg(not(windows))] +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let release_marker = workspace.join("interrupt-release"); + + let Some(zsh_path) = find_test_zsh_path()? else { + eprintln!("skipping zsh fork test: no zsh executable found"); + return Ok(()); + }; + eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + + // Keep the shell command in flight until we interrupt it. A fast command + // like `echo hi` can finish before the interrupt arrives on faster runners, + // which turns this into a test for post-command follow-up behavior instead + // of interrupting an active zsh-fork command. + let release_marker_escaped = release_marker.to_string_lossy().replace('\'', r#"'\''"#); + let wait_for_interrupt = + format!("while [ ! -f '{release_marker_escaped}' ]; do sleep 0.01; done"); + let response = create_shell_command_sse_response( + vec!["/bin/sh".to_string(), "-c".to_string(), wait_for_interrupt], + /*workdir*/ None, + Some(5000), + "call-zsh-fork", + )?; + let no_op_response = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_completed("resp-2"), + ]); + // Interrupting after the shell item starts can race with the follow-up + // model request that reports the aborted tool call. This test only cares + // that zsh-fork launches the expected command, so allow one extra no-op + // `/responses` POST instead of asserting an exact request count. + let server = + create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await; + create_config_toml( + &codex_home, + &server.uri(), + "never", + &BTreeMap::from([ + (Feature::ShellZshFork, true), + (Feature::UnifiedExec, false), + (Feature::ShellSnapshot, false), + ]), + &zsh_path, + )?; + + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run echo hi".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + model: Some("mock-model".to_string()), + effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), + summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; + if let ThreadItem::CommandExecution { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { + id, + status, + command, + cwd, + .. + } = started_command_execution + else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "call-zsh-fork"); + assert_eq!(status, CommandExecutionStatus::InProgress); + assert!(command.starts_with(&zsh_path.display().to_string())); + assert!(command.contains("/bin/sh -c")); + assert!(command.contains("sleep 0.01")); + assert!(command.contains(&release_marker.display().to_string())); + assert_eq!(cwd.as_path(), workspace.as_path()); + + mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let Some(zsh_path) = find_test_zsh_path()? else { + eprintln!("skipping zsh fork decline test: no zsh executable found"); + return Ok(()); + }; + eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + + let responses = vec![ + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + /*workdir*/ None, + Some(5000), + "call-zsh-fork-decline", + )?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + &codex_home, + &server.uri(), + "untrusted", + &BTreeMap::from([ + (Feature::ShellZshFork, true), + (Feature::UnifiedExec, false), + (Feature::ShellSnapshot, false), + ]), + &zsh_path, + )?; + + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { + panic!("expected CommandExecutionRequestApproval request"); + }; + assert_eq!(params.item_id, "call-zsh-fork-decline"); + assert_eq!(params.thread_id, thread.id); + + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Decline, + })?, + ) + .await?; + + let completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + completed_notif + .params + .clone() + .expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { .. } = completed.item { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { + id, + status, + exit_code, + aggregated_output, + .. + } = completed_command_execution + else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "call-zsh-fork-decline"); + assert_eq!(status, CommandExecutionStatus::Declined); + assert!(exit_code.is_none()); + assert!(aggregated_output.is_none()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let Some(zsh_path) = find_test_zsh_path()? else { + eprintln!("skipping zsh fork cancel test: no zsh executable found"); + return Ok(()); + }; + eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + + let responses = vec![create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + /*workdir*/ None, + Some(5000), + "call-zsh-fork-cancel", + )?]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + &codex_home, + &server.uri(), + "untrusted", + &BTreeMap::from([ + (Feature::ShellZshFork, true), + (Feature::UnifiedExec, false), + (Feature::ShellSnapshot, false), + ]), + &zsh_path, + )?; + + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { + panic!("expected CommandExecutionRequestApproval request"); + }; + assert_eq!(params.item_id, "call-zsh-fork-cancel"); + assert_eq!(params.thread_id, thread.id.clone()); + + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Cancel, + })?, + ) + .await?; + + let completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + completed_notif + .params + .clone() + .expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { .. } = completed.item { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { id, status, .. } = completed_command_execution else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "call-zsh-fork-cancel"); + assert_eq!(status, CommandExecutionStatus::Declined); + + let completed_notif = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.status, TurnStatus::Interrupted); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let Some(zsh_path) = find_test_zsh_path()? else { + eprintln!("skipping zsh fork subcommand decline test: no zsh executable found"); + return Ok(()); + }; + if !supports_exec_wrapper_intercept(&zsh_path) { + eprintln!( + "skipping zsh fork subcommand decline test: zsh does not support EXEC_WRAPPER intercepts ({})", + zsh_path.display() + ); + return Ok(()); + } + eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + let first_file = workspace.join("first.txt"); + let second_file = workspace.join("second.txt"); + std::fs::write(&first_file, "one")?; + std::fs::write(&second_file, "two")?; + let shell_command = format!( + "/bin/rm {} && /bin/rm {}", + first_file.display(), + second_file.display() + ); + let tool_call_arguments = serde_json::to_string(&serde_json::json!({ + "command": shell_command, + "workdir": serde_json::Value::Null, + "timeout_ms": 5000 + }))?; + let response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call( + "call-zsh-fork-subcommand-decline", + "shell_command", + &tool_call_arguments, + ), + responses::ev_completed("resp-1"), + ]); + let no_op_response = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_completed("resp-2"), + ]); + // Linux CI has occasionally issued a second `/responses` POST after the + // subcommand-decline flow. This test is about approval/decline behavior in + // the zsh fork, not exact model request count, so allow an extra request + // and return a harmless no-op response if it arrives. + let server = + create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await; + create_config_toml( + &codex_home, + &server.uri(), + "untrusted", + &BTreeMap::from([ + (Feature::ShellZshFork, true), + (Feature::UnifiedExec, false), + (Feature::ShellSnapshot, false), + ]), + &zsh_path, + )?; + + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "remove both files".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![workspace.clone().try_into()?], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }), + model: Some("mock-model".to_string()), + effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), + summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let mut approved_subcommand_strings = Vec::new(); + let mut approved_subcommand_ids = Vec::new(); + let mut saw_parent_approval = false; + let target_decisions = [ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ]; + let mut target_decision_index = 0; + let first_file_str = first_file.to_string_lossy().into_owned(); + let second_file_str = second_file.to_string_lossy().into_owned(); + let parent_shell_hint = format!("&& {}", &first_file_str); + while target_decision_index < target_decisions.len() || !saw_parent_approval { + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req + else { + panic!("expected CommandExecutionRequestApproval request"); + }; + assert_eq!(params.item_id, "call-zsh-fork-subcommand-decline"); + assert_eq!(params.thread_id, thread.id); + let approval_command = params + .command + .as_deref() + .expect("approval command should be present"); + let has_first_file = approval_command.contains(&first_file_str); + let has_second_file = approval_command.contains(&second_file_str); + let mentions_rm_binary = + approval_command.contains("/bin/rm ") || approval_command.contains("/usr/bin/rm "); + let has_rm_action = params.command_actions.as_ref().is_some_and(|actions| { + actions.iter().any(|action| match action { + CommandAction::Read { name, .. } => name == "rm", + CommandAction::Unknown { command } => command.contains("rm"), + _ => false, + }) + }); + let is_target_subcommand = + (has_first_file != has_second_file) && (has_rm_action || mentions_rm_binary); + + if is_target_subcommand { + approved_subcommand_ids.push( + params + .approval_id + .clone() + .expect("approval_id must be present for zsh subcommand approvals"), + ); + approved_subcommand_strings.push(approval_command.to_string()); + } + let is_parent_approval = approval_command.contains(&zsh_path.display().to_string()) + && (approval_command.contains(&shell_command) + || (has_first_file && has_second_file) + || approval_command.contains(&parent_shell_hint)); + let decision = if is_target_subcommand { + let decision = target_decisions[target_decision_index].clone(); + target_decision_index += 1; + decision + } else if is_parent_approval { + assert!( + !saw_parent_approval, + "unexpected extra non-target approval: {approval_command}" + ); + saw_parent_approval = true; + CommandExecutionApprovalDecision::Accept + } else { + // Login shells may run startup helpers (for example path_helper on macOS) + // before the parent shell command or target subcommands are reached. + CommandExecutionApprovalDecision::Accept + }; + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { decision })?, + ) + .await?; + } + + assert!( + saw_parent_approval, + "expected parent shell approval request" + ); + assert_eq!(approved_subcommand_ids.len(), 2); + assert_ne!(approved_subcommand_ids[0], approved_subcommand_ids[1]); + assert_eq!(approved_subcommand_strings.len(), 2); + assert!(approved_subcommand_strings[0].contains(&first_file.display().to_string())); + assert!(approved_subcommand_strings[1].contains(&second_file.display().to_string())); + let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + completed_notif + .params + .clone() + .expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { id, .. } = &completed.item + && id == "call-zsh-fork-subcommand-decline" + { + return Ok::(completed.item); + } + } + }) + .await; + + match parent_completed_command_execution { + Ok(Ok(parent_completed_command_execution)) => { + let ThreadItem::CommandExecution { + id, + status, + aggregated_output, + .. + } = parent_completed_command_execution + else { + unreachable!("loop ensures we break on parent command execution item"); + }; + assert_eq!(id, "call-zsh-fork-subcommand-decline"); + assert_eq!(status, CommandExecutionStatus::Declined); + if let Some(output) = aggregated_output.as_deref() { + assert!( + output == "exec command rejected by user" + || output.contains("sandbox denied exec error"), + "unexpected aggregated output: {output}" + ); + } + + match timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await + { + Ok(Ok(completed_notif)) => { + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert!(matches!( + completed.turn.status, + TurnStatus::Interrupted | TurnStatus::Completed + )); + } + Ok(Err(error)) => return Err(error), + Err(_) => { + mcp.interrupt_turn_and_wait_for_aborted( + thread.id.clone(), + turn.id.clone(), + DEFAULT_READ_TIMEOUT, + ) + .await?; + } + } + } + Ok(Err(error)) => return Err(error), + Err(_) => { + // Some zsh builds abort the turn immediately after the rejected + // subcommand without emitting a parent `item/completed`, and Linux + // sandbox failures can also complete the turn before the parent + // completion item is observed. + let completed_notif = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert!(matches!( + completed.turn.status, + TurnStatus::Interrupted | TurnStatus::Completed + )); + } + } + + Ok(()) +} + +async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Result { + let zdotdir = zdotdir.to_string_lossy().into_owned(); + McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await +} + +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, + feature_flags: &BTreeMap, + zsh_path: &Path, +) -> std::io::Result<()> { + let mut features = BTreeMap::from([(Feature::RemoteModels, false)]); + for (feature, enabled) in feature_flags { + features.insert(*feature, *enabled); + } + let feature_entries = features + .into_iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" +zsh_path = "{zsh_path}" + +model_provider = "mock_provider" + +[features] +{feature_entries} + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"#, + approval_policy = approval_policy, + zsh_path = zsh_path.display() + ), + ) +} + +fn find_test_zsh_path() -> Result> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh"); + if !dotslash_zsh.is_file() { + eprintln!( + "skipping zsh fork test: shared zsh DotSlash file not found at {}", + dotslash_zsh.display() + ); + return Ok(None); + } + match core_test_support::fetch_dotslash_file(&dotslash_zsh, /*dotslash_cache*/ None) { + Ok(path) => return Ok(Some(path)), + Err(error) => { + eprintln!("failed to fetch vendored zsh via dotslash: {error:#}"); + } + } + + Ok(None) +} + +fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool { + let status = std::process::Command::new(zsh_path) + .arg("-fc") + .arg("/usr/bin/true") + .env("EXEC_WRAPPER", "/usr/bin/false") + .status(); + match status { + Ok(status) => !status.success(), + Err(_) => false, + } +} diff --git a/code-rs/app-server/tests/suite/v2/turn_steer.rs b/code-rs/app-server/tests/suite/v2/turn_steer.rs new file mode 100644 index 00000000000..a92b2db5286 --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/turn_steer.rs @@ -0,0 +1,314 @@ +#![cfg(unix)] + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_shell_command_sse_response; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use tempfile::TempDir; +use tokio::time::timeout; + +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn turn_steer_requires_active_turn() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + + let server = create_mock_responses_server_sequence(vec![]).await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, &codex_home).await?; + + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let steer_req = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + expected_turn_id: "turn-does-not-exist".to_string(), + }) + .await?; + let steer_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(steer_req)), + ) + .await??; + assert_eq!(steer_err.error.code, -32600); + + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + + Ok(()) +} + +#[tokio::test] +async fn turn_steer_rejects_oversized_text_input() -> Result<()> { + #[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()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, &codex_home).await?; + + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let _task_started: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + let steer_req = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: oversized_input.clone(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + expected_turn_id: turn.id.clone(), + }) + .await?; + let steer_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(steer_req)), + ) + .await??; + + assert_eq!(steer_err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + steer_err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = steer_err + .error + .data + .expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], oversized_input.chars().count()); + + mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn turn_steer_returns_active_turn_id() -> Result<()> { + #[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()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, &codex_home).await?; + + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let _task_started: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let steer_req = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + expected_turn_id: turn.id.clone(), + }) + .await?; + let steer_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(steer_req)), + ) + .await??; + let steer: TurnSteerResponse = to_response::(steer_resp)?; + assert_eq!(steer.turn_id, turn.id); + + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) + .await?; + + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs b/code-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs new file mode 100644 index 00000000000..a0466a459be --- /dev/null +++ b/code-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs @@ -0,0 +1,91 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; +use codex_app_server_protocol::WindowsSandboxSetupMode; +use codex_app_server_protocol::WindowsSandboxSetupStartParams; +use codex_app_server_protocol::WindowsSandboxSetupStartResponse; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 500_000, + Some(false), + "mock_provider", + "compact prompt", + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_windows_sandbox_setup_start_request(WindowsSandboxSetupStartParams { + mode: WindowsSandboxSetupMode::Unelevated, + cwd: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let start_payload: WindowsSandboxSetupStartResponse = to_response(response)?; + assert!(start_payload.started); + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("windowsSandbox/setupCompleted"), + ) + .await??; + let payload: WindowsSandboxSetupCompletedNotification = serde_json::from_value( + notification + .params + .context("missing windowsSandbox/setupCompleted params")?, + )?; + + assert_eq!(payload.mode, WindowsSandboxSetupMode::Unelevated); + Ok(()) +} + +#[tokio::test] +async fn windows_sandbox_setup_start_rejects_relative_cwd() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "windowsSandbox/setupStart", + Some(serde_json::json!({ + "mode": "unelevated", + "cwd": "relative-root", + })), + ) + .await?; + + let err = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("Invalid request")); + Ok(()) +} diff --git a/code-rs/app-server/tests/suite/zsh b/code-rs/app-server/tests/suite/zsh new file mode 100755 index 00000000000..f796fa7201e --- /dev/null +++ b/code-rs/app-server/tests/suite/zsh @@ -0,0 +1,73 @@ +#!/usr/bin/env dotslash + +// This is the patched zsh fork corresponding to +// `codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch`. +// Fetching the prebuilt version via DotSlash makes it easier to write +// integration tests that exercise the zsh fork behavior in app-server tests. +// +// This checked-in fixture is still pinned to the latest released bundle that +// contains this binary. New releases publish standalone `codex-zsh-*.tar.gz` +// assets plus a generated `codex-zsh` DotSlash release asset, so this file can +// be retargeted when a newer fork build needs to be exercised in tests. +{ + "name": "codex-zsh", + "platforms": { + // macOS 13 builds (and therefore x86_64) were dropped in + // https://github.com/openai/codex/pull/7295, so we only provide an + // Apple Silicon build for now. + "macos-aarch64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/aarch64-apple-darwin/zsh/macos-15/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + "linux-x86_64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/x86_64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + "linux-aarch64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/aarch64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + } +} diff --git a/code-rs/app-server/tests/websocket_parity.rs b/code-rs/app-server/tests/websocket_parity.rs deleted file mode 100644 index e990804d937..00000000000 --- a/code-rs/app-server/tests/websocket_parity.rs +++ /dev/null @@ -1,369 +0,0 @@ -use std::net::SocketAddr; -use std::time::Duration; - -use code_app_server::AppServerTransport; -use code_app_server::run_main_with_transport; -use code_common::CliConfigOverrides; -use futures::SinkExt; -use futures::StreamExt; -use serde_json::Value; -use serde_json::json; -use tokio::net::TcpStream; -use tokio::time::sleep; -use tokio_tungstenite::MaybeTlsStream; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; - -async fn connect_with_retry( - url: &str, -) -> WebSocketStream> { - let mut attempts = 0; - loop { - match connect_async(url).await { - Ok((stream, _)) => return stream, - Err(err) => { - attempts += 1; - assert!(attempts < 40, "failed to connect to {url}: {err}"); - sleep(Duration::from_millis(25)).await; - } - } - } -} - -async fn send_request( - ws: &mut WebSocketStream>, - request: Value, -) { - ws.send(Message::Text(request.to_string().into())) - .await - .expect("request should send"); -} - -async fn recv_response_for_id( - ws: &mut WebSocketStream>, - id: i64, -) -> Value { - loop { - let message = ws - .next() - .await - .expect("websocket should stay open") - .expect("websocket frame should decode"); - let Message::Text(text) = message else { - continue; - }; - let json: Value = serde_json::from_str(text.as_ref()).expect("response must be JSON"); - let json_id = json.get("id").and_then(Value::as_i64); - if json_id == Some(id) { - return json; - } - } -} - -async fn recv_error_for_id( - ws: &mut WebSocketStream>, - id: i64, -) -> Value { - loop { - let message = ws - .next() - .await - .expect("websocket should stay open") - .expect("websocket frame should decode"); - let Message::Text(text) = message else { - continue; - }; - let json: Value = serde_json::from_str(text.as_ref()).expect("response must be JSON"); - let json_id = json.get("id").and_then(Value::as_i64); - if json_id == Some(id) && json.get("error").is_some() { - return json; - } - } -} - -async fn assert_no_message( - ws: &mut WebSocketStream>, - wait_for: Duration, -) { - match tokio::time::timeout(wait_for, ws.next()).await { - Ok(Some(Ok(frame))) => { - panic!("unexpected frame while waiting for silence: {frame:?}"); - } - Ok(Some(Err(err))) => { - panic!("unexpected websocket read error while waiting for silence: {err}"); - } - Ok(None) => { - panic!("websocket closed unexpectedly while waiting for silence"); - } - Err(_) => {} - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_rejects_origin_header_handshakes() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); - let addr: SocketAddr = listener.local_addr().expect("resolve bound address"); - drop(listener); - - let server_handle = tokio::spawn(async move { - run_main_with_transport( - None, - CliConfigOverrides::default(), - AppServerTransport::WebSocket { bind_address: addr }, - ) - .await - }); - - let url = format!("http://{addr}/"); - let mut attempts = 0; - let response = loop { - match reqwest::Client::new() - .get(&url) - .header("Connection", "Upgrade") - .header("Upgrade", "websocket") - .header("Sec-WebSocket-Version", "13") - .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") - .header("Origin", "http://example.test") - .send() - .await - { - Ok(response) => break response, - Err(err) => { - attempts += 1; - assert!(attempts < 40, "failed to reach {url}: {err}"); - sleep(Duration::from_millis(25)).await; - } - } - }; - - assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); - server_handle.abort(); -} - -#[test] -fn websocket_listen_url_requires_loopback_bind() { - let err = AppServerTransport::from_listen_url("ws://0.0.0.0:4242") - .expect_err("non-loopback websocket bind should be rejected"); - - let message = err.to_string(); - assert!( - message.contains("loopback"), - "unexpected non-loopback error: {message}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_user_agent_is_connection_scoped() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); - let addr: SocketAddr = listener.local_addr().expect("resolve bound address"); - drop(listener); - - let server_handle = tokio::spawn(async move { - run_main_with_transport( - None, - CliConfigOverrides::default(), - AppServerTransport::WebSocket { bind_address: addr }, - ) - .await - }); - - let url = format!("ws://{addr}"); - let mut client_a = connect_with_retry(&url).await; - let mut client_b = connect_with_retry(&url).await; - - send_request( - &mut client_a, - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "clientInfo": { - "name": "client-a", - "version": "1.0.0" - } - } - }), - ) - .await; - let _ = recv_response_for_id(&mut client_a, 1).await; - - send_request( - &mut client_b, - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "clientInfo": { - "name": "client-b", - "version": "2.0.0" - } - } - }), - ) - .await; - let _ = recv_response_for_id(&mut client_b, 1).await; - - send_request( - &mut client_a, - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "getUserAgent" - }), - ) - .await; - let response_a = recv_response_for_id(&mut client_a, 2).await; - - send_request( - &mut client_b, - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "getUserAgent" - }), - ) - .await; - let response_b = recv_response_for_id(&mut client_b, 2).await; - - let user_agent_a = response_a - .get("result") - .and_then(|result| result.get("userAgent")) - .and_then(Value::as_str) - .expect("client a should receive user agent"); - let user_agent_b = response_b - .get("result") - .and_then(|result| result.get("userAgent")) - .and_then(Value::as_str) - .expect("client b should receive user agent"); - - assert!( - user_agent_a.contains("(client-a; 1.0.0)"), - "client a user-agent should include its own suffix: {user_agent_a}" - ); - assert!( - user_agent_b.contains("(client-b; 2.0.0)"), - "client b user-agent should include its own suffix: {user_agent_b}" - ); - assert!( - !user_agent_a.contains("client-b; 2.0.0"), - "client a user-agent should not include client b suffix: {user_agent_a}" - ); - assert!( - !user_agent_b.contains("client-a; 1.0.0"), - "client b user-agent should not include client a suffix: {user_agent_b}" - ); - - client_a.close(None).await.expect("client a should close"); - client_b.close(None).await.expect("client b should close"); - server_handle.abort(); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_routes_handshake_and_same_id_requests_per_connection() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); - let addr: SocketAddr = listener.local_addr().expect("resolve bound address"); - drop(listener); - - let server_handle = tokio::spawn(async move { - run_main_with_transport( - None, - CliConfigOverrides::default(), - AppServerTransport::WebSocket { bind_address: addr }, - ) - .await - }); - - let url = format!("ws://{addr}"); - let mut client_a = connect_with_retry(&url).await; - let mut client_b = connect_with_retry(&url).await; - - send_request( - &mut client_a, - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "clientInfo": { - "name": "client-a", - "version": "1.0.0" - } - } - }), - ) - .await; - let _ = recv_response_for_id(&mut client_a, 1).await; - - // Initialize responses are request-scoped and should not leak to other clients. - assert_no_message(&mut client_b, Duration::from_millis(200)).await; - - send_request( - &mut client_b, - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "getUserAgent" - }), - ) - .await; - let pre_init_error = recv_error_for_id(&mut client_b, 2).await; - let pre_init_message = pre_init_error - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .expect("error message should exist"); - assert!( - pre_init_message.contains("Not initialized"), - "unexpected pre-init error: {pre_init_message}" - ); - - send_request( - &mut client_b, - json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "initialize", - "params": { - "clientInfo": { - "name": "client-b", - "version": "2.0.0" - } - } - }), - ) - .await; - let _ = recv_response_for_id(&mut client_b, 3).await; - - // Same request id on different connections should route independently. - send_request( - &mut client_a, - json!({ - "jsonrpc": "2.0", - "id": 77, - "method": "getUserAgent" - }), - ) - .await; - send_request( - &mut client_b, - json!({ - "jsonrpc": "2.0", - "id": 77, - "method": "getUserAgent" - }), - ) - .await; - - let response_a = recv_response_for_id(&mut client_a, 77).await; - let response_b = recv_response_for_id(&mut client_b, 77).await; - - assert!(response_a.get("result").is_some(), "client a should get response"); - assert!(response_b.get("result").is_some(), "client b should get response"); - - client_a.close(None).await.expect("client a should close"); - client_b.close(None).await.expect("client b should close"); - server_handle.abort(); -} diff --git a/code-rs/apply-patch/BUILD.bazel b/code-rs/apply-patch/BUILD.bazel new file mode 100644 index 00000000000..e68984bc373 --- /dev/null +++ b/code-rs/apply-patch/BUILD.bazel @@ -0,0 +1,11 @@ +load("//:defs.bzl", "codex_rust_crate") + +exports_files(["apply_patch_tool_instructions.md"]) + +codex_rust_crate( + name = "apply-patch", + crate_name = "codex_apply_patch", + compile_data = [ + "apply_patch_tool_instructions.md", + ], +) diff --git a/code-rs/apply-patch/Cargo.toml b/code-rs/apply-patch/Cargo.toml index bab0c8c87c4..25843386185 100644 --- a/code-rs/apply-patch/Cargo.toml +++ b/code-rs/apply-patch/Cargo.toml @@ -1,11 +1,13 @@ [package] -edition = "2024" -name = "code-apply-patch" -version = { workspace = true } +name = "codex-apply-patch" +version.workspace = true +edition.workspace = true +license.workspace = true [lib] -name = "code_apply_patch" +name = "codex_apply_patch" path = "src/lib.rs" +doctest = false [[bin]] name = "apply_patch" @@ -16,12 +18,17 @@ workspace = true [dependencies] anyhow = { workspace = true } +codex-exec-server = { workspace = true } +codex-utils-absolute-path = { workspace = true } similar = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } +assert_matches = { workspace = true } +codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/code-rs/apply-patch/src/invocation.rs b/code-rs/apply-patch/src/invocation.rs new file mode 100644 index 00000000000..abe223f1d94 --- /dev/null +++ b/code-rs/apply-patch/src/invocation.rs @@ -0,0 +1,917 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::LazyLock; + +use codex_exec_server::ExecutorFileSystem; +use codex_utils_absolute_path::AbsolutePathBuf; +use tree_sitter::Parser; +use tree_sitter::Query; +use tree_sitter::QueryCursor; +use tree_sitter::StreamingIterator; +use tree_sitter_bash::LANGUAGE as BASH; + +use crate::ApplyPatchAction; +use crate::ApplyPatchArgs; +use crate::ApplyPatchError; +use crate::ApplyPatchFileChange; +use crate::ApplyPatchFileUpdate; +use crate::IoError; +use crate::MaybeApplyPatchVerified; +use crate::parser::Hunk; +use crate::parser::ParseError; +use crate::parser::parse_patch; +use crate::unified_diff_from_chunks; +use std::str::Utf8Error; +use tree_sitter::LanguageError; + +const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApplyPatchShell { + Unix, + PowerShell, + Cmd, +} + +#[derive(Debug, PartialEq)] +pub enum MaybeApplyPatch { + Body(ApplyPatchArgs), + ShellParseError(ExtractHeredocError), + PatchParseError(ParseError), + NotApplyPatch, +} + +#[derive(Debug, PartialEq)] +pub enum ExtractHeredocError { + CommandDidNotStartWithApplyPatch, + FailedToLoadBashGrammar(LanguageError), + HeredocNotUtf8(Utf8Error), + FailedToParsePatchIntoAst, + FailedToFindHeredocBody, +} + +fn classify_shell_name(shell: &str) -> Option { + std::path::Path::new(shell) + .file_stem() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase) +} + +fn classify_shell(shell: &str, flag: &str) -> Option { + classify_shell_name(shell).and_then(|name| match name.as_str() { + "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), + "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { + Some(ApplyPatchShell::PowerShell) + } + "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd), + _ => None, + }) +} + +fn can_skip_flag(shell: &str, flag: &str) -> bool { + classify_shell_name(shell).is_some_and(|name| { + matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile") + }) +} + +fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> { + match argv { + [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| { + let script = script.as_str(); + (shell_type, script) + }), + [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => { + classify_shell(shell, flag).map(|shell_type| { + let script = script.as_str(); + (shell_type, script) + }) + } + _ => None, + } +} + +fn extract_apply_patch_from_shell( + shell: ApplyPatchShell, + script: &str, +) -> std::result::Result<(String, Option), ExtractHeredocError> { + match shell { + ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => { + extract_apply_patch_from_bash(script) + } + } +} + +// TODO: make private once we remove tests in lib.rs +pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { + match argv { + // Direct invocation: apply_patch + [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { + Ok(source) => MaybeApplyPatch::Body(source), + Err(e) => MaybeApplyPatch::PatchParseError(e), + }, + // Shell heredoc form: (optional `cd &&`) apply_patch <<'EOF' ... + _ => match parse_shell_script(argv) { + Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) { + Ok((body, workdir)) => match parse_patch(&body) { + Ok(mut source) => { + source.workdir = workdir; + MaybeApplyPatch::Body(source) + } + Err(e) => MaybeApplyPatch::PatchParseError(e), + }, + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => { + MaybeApplyPatch::NotApplyPatch + } + Err(e) => MaybeApplyPatch::ShellParseError(e), + }, + None => MaybeApplyPatch::NotApplyPatch, + }, + } +} + +/// cwd must be an absolute path so that we can resolve relative paths in the +/// patch. +pub async fn maybe_parse_apply_patch_verified( + argv: &[String], + cwd: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, +) -> MaybeApplyPatchVerified { + // Detect a raw patch body passed directly as the command or as the body of a shell + // script. In these cases, report an explicit error rather than applying the patch. + if let [body] = argv + && parse_patch(body).is_ok() + { + return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); + } + if let Some((_, script)) = parse_shell_script(argv) + && parse_patch(script).is_ok() + { + return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); + } + + match maybe_parse_apply_patch(argv) { + MaybeApplyPatch::Body(ApplyPatchArgs { + patch, + hunks, + workdir, + }) => { + let effective_cwd = workdir + .as_ref() + .map(|dir| cwd.join(Path::new(dir))) + .unwrap_or_else(|| cwd.clone()); + let mut changes = HashMap::new(); + for hunk in hunks { + let path = hunk.resolve_path(&effective_cwd); + match hunk { + Hunk::AddFile { contents, .. } => { + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Add { content: contents }, + ); + } + Hunk::DeleteFile { .. } => { + let content = match fs.read_file_text(&path, sandbox).await { + Ok(content) => content, + Err(e) => { + return MaybeApplyPatchVerified::CorrectnessError( + ApplyPatchError::IoError(IoError { + context: format!("Failed to read {}", path.display()), + source: e, + }), + ); + } + }; + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Delete { content }, + ); + } + Hunk::UpdateFile { + move_path, chunks, .. + } => { + let ApplyPatchFileUpdate { + unified_diff, + content: contents, + .. + } = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await { + Ok(diff) => diff, + Err(e) => { + return MaybeApplyPatchVerified::CorrectnessError(e); + } + }; + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Update { + unified_diff, + move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()), + new_content: contents, + }, + ); + } + } + } + MaybeApplyPatchVerified::Body(ApplyPatchAction { + changes, + patch, + cwd: effective_cwd, + }) + } + MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), + MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()), + MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch, + } +} + +/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script +/// that invokes the apply_patch tool using a heredoc. +/// +/// Supported top‑level forms (must be the only top‑level statement): +/// - `apply_patch <<'EOF'\n...\nEOF` +/// - `cd && apply_patch <<'EOF'\n...\nEOF` +/// +/// Notes about matching: +/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the +/// heredoc‑redirected statement is the only top‑level statement. +/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`). +/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted +/// strings, no second argument). +/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch` +/// or `applypatch`. +/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match. +/// +/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or +/// `(heredoc_body, None)` for the direct form. Errors are returned if the script +/// cannot be parsed or does not match the allowed patterns. +fn extract_apply_patch_from_bash( + src: &str, +) -> std::result::Result<(String, Option), ExtractHeredocError> { + // This function uses a Tree-sitter query to recognize one of two + // whole-script forms, each expressed as a single top-level statement: + // + // 1. apply_patch <<'EOF'\n...\nEOF + // 2. cd && apply_patch <<'EOF'\n...\nEOF + // + // Key ideas when reading the query: + // - dots (`.`) between named nodes enforces adjacency among named children and + // anchor to the start/end of the expression. + // - we match a single redirected_statement directly under program with leading + // and trailing anchors (`.`). This ensures it is the only top-level statement + // (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match). + // + // Overall, we want to be conservative and only match the intended forms, as other + // forms are likely to be model errors, or incorrectly interpreted by later code. + // + // If you're editing this query, it's helpful to start by creating a debugging binary + // which will let you see the AST of an arbitrary bash script passed in, and optionally + // also run an arbitrary query against the AST. This is useful for understanding + // how tree-sitter parses the script and whether the query syntax is correct. Be sure + // to test both positive and negative cases. + static APPLY_PATCH_QUERY: LazyLock = LazyLock::new(|| { + let language = BASH.into(); + #[expect(clippy::expect_used)] + Query::new( + &language, + r#" + ( + program + . (redirected_statement + body: (command + name: (command_name (word) @apply_name) .) + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + + ( + program + . (redirected_statement + body: (list + . (command + name: (command_name (word) @cd_name) . + argument: [ + (word) @cd_path + (string (string_content) @cd_path) + (raw_string) @cd_raw_string + ] .) + "&&" + . (command + name: (command_name (word) @apply_name)) + .) + (#eq? @cd_name "cd") + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + "#, + ) + .expect("valid bash query") + }); + + let lang = BASH.into(); + let mut parser = Parser::new(); + parser + .set_language(&lang) + .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?; + let tree = parser + .parse(src, None) + .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?; + + let bytes = src.as_bytes(); + let root = tree.root_node(); + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes); + while let Some(m) = matches.next() { + let mut heredoc_text: Option = None; + let mut cd_path: Option = None; + + for capture in m.captures.iter() { + let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize]; + match name { + "heredoc" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .trim_end_matches('\n') + .to_string(); + heredoc_text = Some(text); + } + "cd_path" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .to_string(); + cd_path = Some(text); + } + "cd_raw_string" => { + let raw = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)?; + let trimmed = raw + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(raw); + cd_path = Some(trimmed.to_string()); + } + _ => {} + } + } + + if let Some(heredoc) = heredoc_text { + return Ok((heredoc, cd_path)); + } + } + + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::unified_diff_from_chunks; + use assert_matches::assert_matches; + use codex_exec_server::LOCAL_FS; + use codex_utils_absolute_path::test_support::PathExt; + use pretty_assertions::assert_eq; + use std::fs; + use std::path::PathBuf; + use std::string::ToString; + use tempfile::tempdir; + + /// Helper to construct a patch with the given body. + fn wrap_patch(body: &str) -> String { + format!("*** Begin Patch\n{body}\n*** End Patch") + } + + fn strs_to_strings(strs: &[&str]) -> Vec { + strs.iter().map(ToString::to_string).collect() + } + + // Test helpers to reduce repetition when building bash -lc heredoc scripts + fn args_bash(script: &str) -> Vec { + strs_to_strings(&["bash", "-lc", script]) + } + + fn args_powershell(script: &str) -> Vec { + strs_to_strings(&["powershell.exe", "-Command", script]) + } + + fn args_powershell_no_profile(script: &str) -> Vec { + strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script]) + } + + fn args_pwsh(script: &str) -> Vec { + strs_to_strings(&["pwsh", "-NoProfile", "-Command", script]) + } + + fn args_cmd(script: &str) -> Vec { + strs_to_strings(&["cmd.exe", "/c", script]) + } + + fn heredoc_script(prefix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" + ) + } + + fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" + ) + } + + fn expected_single_add() -> Vec { + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string(), + }] + } + + fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir.as_deref(), expected_workdir); + assert_eq!(hunks, expected_single_add()); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + fn assert_match(script: &str, expected_workdir: Option<&str>) { + let args = args_bash(script); + assert_match_args(args, expected_workdir); + } + + fn assert_not_match(script: &str) { + let args = args_bash(script); + assert_matches!( + maybe_parse_apply_patch(&args), + MaybeApplyPatch::NotApplyPatch + ); + } + + #[tokio::test] + async fn test_implicit_patch_single_arg_is_error() { + let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); + let args = vec![patch]; + let dir = tempdir().unwrap(); + assert_matches!( + maybe_parse_apply_patch_verified( + &args, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await, + MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) + ); + } + + #[tokio::test] + async fn test_implicit_patch_bash_script_is_error() { + let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; + let args = args_bash(script); + let dir = tempdir().unwrap(); + assert_matches!( + maybe_parse_apply_patch_verified( + &args, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await, + MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) + ); + } + + #[tokio::test] + async fn test_literal() { + let args = strs_to_strings(&[ + "apply_patch", + r#"*** Begin Patch +*** Add File: foo ++hi +*** End Patch +"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[tokio::test] + async fn test_literal_applypatch() { + let args = strs_to_strings(&[ + "applypatch", + r#"*** Begin Patch +*** Add File: foo ++hi +*** End Patch +"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[tokio::test] + async fn test_heredoc() { + assert_match(&heredoc_script(""), /*expected_workdir*/ None); + } + + #[tokio::test] + async fn test_heredoc_non_login_shell() { + let script = heredoc_script(""); + let args = strs_to_strings(&["bash", "-c", &script]); + assert_match_args(args, /*expected_workdir*/ None); + } + + #[tokio::test] + async fn test_heredoc_applypatch() { + let args = strs_to_strings(&[ + "bash", + "-lc", + r#"applypatch <<'PATCH' +*** Begin Patch +*** Add File: foo ++hi +*** End Patch +PATCH"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir, None); + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[tokio::test] + async fn test_powershell_heredoc() { + let script = heredoc_script(""); + assert_match_args(args_powershell(&script), /*expected_workdir*/ None); + } + #[tokio::test] + async fn test_powershell_heredoc_no_profile() { + let script = heredoc_script(""); + assert_match_args( + args_powershell_no_profile(&script), + /*expected_workdir*/ None, + ); + } + #[tokio::test] + async fn test_pwsh_heredoc() { + let script = heredoc_script(""); + assert_match_args(args_pwsh(&script), /*expected_workdir*/ None); + } + + #[tokio::test] + async fn test_cmd_heredoc_with_cd() { + let script = heredoc_script("cd foo && "); + assert_match_args(args_cmd(&script), Some("foo")); + } + + #[tokio::test] + async fn test_heredoc_with_leading_cd() { + assert_match(&heredoc_script("cd foo && "), Some("foo")); + } + + #[tokio::test] + async fn test_cd_with_semicolon_is_ignored() { + assert_not_match(&heredoc_script("cd foo; ")); + } + + #[tokio::test] + async fn test_cd_or_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar || ")); + } + + #[tokio::test] + async fn test_cd_pipe_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar | ")); + } + + #[tokio::test] + async fn test_cd_single_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); + } + + #[tokio::test] + async fn test_cd_double_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); + } + + #[tokio::test] + async fn test_echo_and_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("echo foo && ")); + } + + #[tokio::test] + async fn test_apply_patch_with_arg_is_ignored() { + let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; + assert_not_match(script); + } + + #[tokio::test] + async fn test_double_cd_then_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd foo && cd bar && ")); + } + + #[tokio::test] + async fn test_cd_two_args_is_ignored() { + assert_not_match(&heredoc_script("cd foo bar && ")); + } + + #[tokio::test] + async fn test_cd_then_apply_patch_then_extra_is_ignored() { + let script = heredoc_script_ps("cd bar && ", " && echo done"); + assert_not_match(&script); + } + + #[tokio::test] + async fn test_echo_then_cd_and_apply_patch_is_ignored() { + // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. + assert_not_match(&heredoc_script("echo foo; cd bar && ")); + } + + #[tokio::test] + async fn test_unified_diff_last_line_replacement() { + // Replace the very last line of the file. + let dir = tempdir().unwrap(); + let path = dir.path().join("last.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo + bar +-baz ++BAZ +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let path_abs = path.as_path().abs(); + let diff = + unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + .await + .unwrap(); + let expected_diff = r#"@@ -2,2 +2,2 @@ + bar +-baz ++BAZ +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), + content: "foo\nbar\nBAZ\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[tokio::test] + async fn test_unified_diff_insert_at_eof() { + // Insert a new line at end‑of‑file. + let dir = tempdir().unwrap(); + let path = dir.path().join("insert.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++quux +*** End of File +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let path_abs = path.as_path().abs(); + let diff = + unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + .await + .unwrap(); + let expected_diff = r#"@@ -3 +3,2 @@ + baz ++quux +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), + content: "foo\nbar\nbaz\nquux\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[tokio::test] + async fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { + let session_dir = tempdir().unwrap(); + let relative_path = "source.txt"; + + // Note that we need this file to exist for the patch to be "verified" + // and parsed correctly. + let session_file_path = session_dir.path().join(relative_path); + fs::write(&session_file_path, "session directory content\n").unwrap(); + + let argv = vec![ + "apply_patch".to_string(), + r#"*** Begin Patch +*** Update File: source.txt +@@ +-session directory content ++updated session directory content +*** End Patch"# + .to_string(), + ]; + + let result = maybe_parse_apply_patch_verified( + &argv, + &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await; + + // Verify the patch contents - as otherwise we may have pulled contents + // from the wrong file (as we're using relative paths) + assert_eq!( + result, + MaybeApplyPatchVerified::Body(ApplyPatchAction { + changes: HashMap::from([( + session_dir.path().join(relative_path), + ApplyPatchFileChange::Update { + unified_diff: r#"@@ -1 +1 @@ +-session directory content ++updated session directory content +"# + .to_string(), + move_path: None, + new_content: "updated session directory content\n".to_string(), + }, + )]), + patch: argv[1].clone(), + cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + }) + ); + } + + #[tokio::test] + async fn test_apply_patch_resolves_move_path_with_effective_cwd() { + let session_dir = tempdir().unwrap(); + let worktree_rel = "alt"; + let worktree_dir = session_dir.path().join(worktree_rel); + fs::create_dir_all(&worktree_dir).unwrap(); + + let source_name = "old.txt"; + let dest_name = "renamed.txt"; + let source_path = worktree_dir.join(source_name); + fs::write(&source_path, "before\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {source_name} +*** Move to: {dest_name} +@@ +-before ++after"# + )); + + let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); + let argv = vec!["bash".into(), "-lc".into(), shell_script]; + + let result = maybe_parse_apply_patch_verified( + &argv, + &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await; + let action = match result { + MaybeApplyPatchVerified::Body(action) => action, + other => panic!("expected verified body, got {other:?}"), + }; + + assert_eq!(action.cwd.as_path(), worktree_dir.as_path()); + + let source_path = worktree_dir.join(source_name); + let change = action + .changes() + .get(source_path.as_path()) + .expect("source file change present"); + + match change { + ApplyPatchFileChange::Update { move_path, .. } => { + assert_eq!( + move_path.as_deref(), + Some(worktree_dir.join(dest_name).as_path()) + ); + } + other => panic!("expected update change, got {other:?}"), + } + } + + #[tokio::test] + async fn test_unreadable_destinations_still_verify() { + let session_dir = tempdir().unwrap(); + fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap(); + let cwd = AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(); + let add_argv = vec![ + "apply_patch".to_string(), + "*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(), + ]; + fs::write(session_dir.path().join("source.txt"), "before\n").unwrap(); + let move_argv = vec![ + "apply_patch".to_string(), + "*** Begin Patch\n*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after\n*** End Patch".to_string(), + ]; + + for argv in [add_argv, move_argv] { + let result = maybe_parse_apply_patch_verified( + &argv, + &cwd, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await; + + assert!(matches!(result, MaybeApplyPatchVerified::Body(_))); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_delete_symlink_still_verifies() { + use std::os::unix::fs::symlink; + + let session_dir = tempdir().unwrap(); + fs::write(session_dir.path().join("target.txt"), "target\n").unwrap(); + symlink( + session_dir.path().join("target.txt"), + session_dir.path().join("link.txt"), + ) + .unwrap(); + let argv = vec![ + "apply_patch".to_string(), + "*** Begin Patch\n*** Delete File: link.txt\n*** End Patch".to_string(), + ]; + + let result = maybe_parse_apply_patch_verified( + &argv, + &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await; + + assert!(matches!(result, MaybeApplyPatchVerified::Body(_))); + } +} diff --git a/code-rs/apply-patch/src/lib.rs b/code-rs/apply-patch/src/lib.rs index 90c74db7d05..99a63e3ace5 100644 --- a/code-rs/apply-patch/src/lib.rs +++ b/code-rs/apply-patch/src/lib.rs @@ -1,117 +1,46 @@ +mod invocation; mod parser; mod seek_sequence; mod standalone_executable; +mod streaming_parser; use std::collections::HashMap; +use std::io; use std::path::Path; use std::path::PathBuf; -use std::str::Utf8Error; -use std::sync::LazyLock; use anyhow::Context; use anyhow::Result; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::FileSystemSandboxContext; +use codex_exec_server::RemoveOptions; +use codex_utils_absolute_path::AbsolutePathBuf; pub use parser::Hunk; pub use parser::ParseError; use parser::ParseError::*; -use parser::UpdateFileChunk; +pub use parser::UpdateFileChunk; pub use parser::parse_patch; use similar::TextDiff; +pub use streaming_parser::StreamingPatchParser; use thiserror::Error; -use tree_sitter::LanguageError; -use tree_sitter::Parser; -use tree_sitter::Query; -use tree_sitter::QueryCursor; -use tree_sitter::StreamingIterator; -use tree_sitter_bash::LANGUAGE as BASH; +pub use invocation::maybe_parse_apply_patch_verified; pub use standalone_executable::main; -// Back-compat shim for codex-core callers -// The core crate expects a simple async FileSystem abstraction and a default -// StdFileSystem implementation. Upstream refactored apply-patch to operate -// directly on std::fs; we preserve these minimal exports here so downstream -// code (core/acp.rs, core/apply_patch.rs, core/codex.rs) continues to compile -// without changes. -#[allow(async_fn_in_trait)] -pub trait FileSystem { - async fn read_text_file(&self, path: &Path) -> std::io::Result; - async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()>; -} - -pub struct StdFileSystem; - -impl FileSystem for StdFileSystem { - async fn read_text_file(&self, path: &Path) -> std::io::Result { - std::fs::read_to_string(path) - } - - async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()> { - std::fs::write(path, contents) - } -} +use crate::invocation::ExtractHeredocError; /// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); -const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ApplyPatchShell { - Unix, - PowerShell, - Cmd, -} - -fn classify_shell_name(shell: &str) -> Option { - Path::new(shell) - .file_stem() - .and_then(|name| name.to_str()) - .map(str::to_ascii_lowercase) -} - -fn classify_shell(shell: &str, flag: &str) -> Option { - classify_shell_name(shell).and_then(|name| match name.as_str() { - "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), - "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { - Some(ApplyPatchShell::PowerShell) - } - "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd), - _ => None, - }) -} - -fn can_skip_flag(shell: &str, flag: &str) -> bool { - classify_shell_name(shell).is_some_and(|name| { - matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile") - }) -} - -fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> { - match argv { - [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| { - let script = script.as_str(); - (shell_type, script) - }), - [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => { - classify_shell(shell, flag).map(|shell_type| { - let script = script.as_str(); - (shell_type, script) - }) - } - _ => None, - } -} - -fn extract_apply_patch_from_shell( - shell: ApplyPatchShell, - script: &str, -) -> std::result::Result<(String, Option), ExtractHeredocError> { - match shell { - ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => { - extract_apply_patch_from_bash(script) - } - } -} +/// Special argv[1] flag used when the Codex executable self-invokes to run the +/// internal `apply_patch` path. +/// +/// Although this constant lives in `codex-apply-patch` (to avoid forcing +/// `codex-arg0` to depend on `codex-core`), it remains part of the "codex core" +/// process-invocation contract for the standalone `apply_patch` command +/// surface. +pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; #[derive(Debug, Error, PartialEq)] pub enum ApplyPatchError { @@ -127,10 +56,6 @@ pub enum ApplyPatchError { "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]" )] ImplicitInvocation, - #[error( - "apply_patch heredoc argv is malformed. Pass the patch as [\"apply_patch\", \"\"] or put the full heredoc in a single shell script string" - )] - MalformedHeredocInvocation, } impl From for ApplyPatchError { @@ -165,14 +90,6 @@ impl PartialEq for IoError { } } -#[derive(Debug, PartialEq)] -pub enum MaybeApplyPatch { - Body(ApplyPatchArgs), - ShellParseError(ExtractHeredocError), - PatchParseError(ParseError), - NotApplyPatch, -} - /// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument /// parsed into hunks. #[derive(Debug, PartialEq)] @@ -182,44 +99,6 @@ pub struct ApplyPatchArgs { pub workdir: Option, } -pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { - match argv { - [script] => match extract_apply_patch_from_bash(script) { - Ok((body, workdir)) => match parse_patch(&body) { - Ok(mut source) => { - source.workdir = workdir; - MaybeApplyPatch::Body(source) - } - Err(e) => MaybeApplyPatch::PatchParseError(e), - }, - Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => MaybeApplyPatch::NotApplyPatch, - Err(e) => MaybeApplyPatch::ShellParseError(e), - }, - // Direct invocation: apply_patch - [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { - Ok(source) => MaybeApplyPatch::Body(source), - Err(e) => MaybeApplyPatch::PatchParseError(e), - }, - // Shell heredoc form: (optional `cd &&`) apply_patch <<'EOF' ... - _ => match parse_shell_script(argv) { - Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) { - Ok((body, workdir)) => match parse_patch(&body) { - Ok(mut source) => { - source.workdir = workdir; - MaybeApplyPatch::Body(source) - } - Err(e) => MaybeApplyPatch::PatchParseError(e), - }, - Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => { - MaybeApplyPatch::NotApplyPatch - } - Err(e) => MaybeApplyPatch::ShellParseError(e), - }, - None => MaybeApplyPatch::NotApplyPatch, - }, - } -} - #[derive(Debug, PartialEq)] pub enum ApplyPatchFileChange { Add { @@ -257,13 +136,13 @@ pub enum MaybeApplyPatchVerified { pub struct ApplyPatchAction { changes: HashMap, - /// The raw patch argument that can be used with `apply_patch` as an exec - /// call. i.e., if the original arg was parsed in "lenient" mode with a + /// The raw patch argument that can be used to apply the patch. i.e., if the + /// original arg was parsed in "lenient" mode with a /// heredoc, this should be the value without the heredoc wrapper. pub patch: String, /// The working directory that was used to resolve relative paths in the patch. - pub cwd: PathBuf, + pub cwd: AbsolutePathBuf, } impl ApplyPatchAction { @@ -278,11 +157,7 @@ impl ApplyPatchAction { /// Should be used exclusively for testing. (Not worth the overhead of /// creating a feature flag for this.) - pub fn new_add_for_test(path: &Path, content: String) -> Self { - if !path.is_absolute() { - panic!("path must be absolute"); - } - + pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self { #[expect(clippy::expect_used)] let filename = path .file_name() @@ -299,290 +174,122 @@ impl ApplyPatchAction { #[expect(clippy::expect_used)] Self { changes, - cwd: path - .parent() - .expect("path should have parent") - .to_path_buf(), + cwd: path.parent().expect("path should have parent"), patch, } } } -/// cwd must be an absolute path so that we can resolve relative paths in the -/// patch. -pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified { - if matches!(argv.first().map(String::as_str), Some(cmd) if APPLY_PATCH_COMMANDS.contains(&cmd)) - && argv - .iter() - .skip(1) - .any(|arg| arg.trim_start().starts_with("<<")) - { - return MaybeApplyPatchVerified::CorrectnessError( - ApplyPatchError::MalformedHeredocInvocation, - ); +/// Textual file changes that were actually committed while applying a patch. +#[derive(Clone, Debug, PartialEq)] +pub struct AppliedPatchDelta { + changes: Vec, + exact: bool, +} + +impl AppliedPatchDelta { + fn new(changes: Vec, exact: bool) -> Self { + Self { changes, exact } } - // Detect a raw patch body passed directly as the command or as the body of a shell - // script. In these cases, report an explicit error rather than applying the patch. - if argv.len() == 1 { - let body = &argv[0]; - if parse_patch(body).is_ok() { - return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); - } + fn empty() -> Self { + Self::new(Vec::new(), /*exact*/ true) } - if let Some((_, script)) = parse_shell_script(argv) { - if parse_patch(script).is_ok() { - return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); - } + pub fn changes(&self) -> &[AppliedPatchChange] { + &self.changes } - match maybe_parse_apply_patch(argv) { - MaybeApplyPatch::Body(ApplyPatchArgs { - patch, - hunks, - workdir, - }) => { - let effective_cwd = workdir - .as_ref() - .map(|dir| { - let path = Path::new(dir); - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } - }) - .unwrap_or_else(|| cwd.to_path_buf()); - let mut changes = HashMap::new(); - for hunk in hunks { - let path = hunk.resolve_path(&effective_cwd); - match hunk { - Hunk::AddFile { contents, .. } => { - changes.insert(path, ApplyPatchFileChange::Add { content: contents }); - } - Hunk::DeleteFile { .. } => { - let content = match std::fs::read_to_string(&path) { - Ok(content) => content, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError( - ApplyPatchError::IoError(IoError { - context: format!("Failed to read {}", path.display()), - source: e, - }), - ); - } - }; - changes.insert(path, ApplyPatchFileChange::Delete { content }); - } - Hunk::UpdateFile { - move_path, chunks, .. - } => { - let ApplyPatchFileUpdate { - unified_diff, - content: contents, - } = match unified_diff_from_chunks(&path, &chunks) { - Ok(diff) => diff, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError(e); - } - }; - changes.insert( - path, - ApplyPatchFileChange::Update { - unified_diff, - move_path: move_path.map(|p| effective_cwd.join(p)), - new_content: contents, - }, - ); - } - } - } - MaybeApplyPatchVerified::Body(ApplyPatchAction { - changes, - patch, - cwd: effective_cwd, - }) - } - MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), - MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()), - MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch, + pub fn is_empty(&self) -> bool { + self.changes.is_empty() } -} -/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script -/// that invokes the apply_patch tool using a heredoc. -/// -/// Supported top‑level forms (must be the only top‑level statement): -/// - `apply_patch <<'EOF'\n...\nEOF` -/// - `cd && apply_patch <<'EOF'\n...\nEOF` -/// -/// Notes about matching: -/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the -/// heredoc‑redirected statement is the only top‑level statement. -/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`). -/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted -/// strings, no second argument). -/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch` -/// or `applypatch`. -/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match. -/// -/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or -/// `(heredoc_body, None)` for the direct form. Errors are returned if the script -/// cannot be parsed or does not match the allowed patterns. -fn extract_apply_patch_from_bash( - src: &str, -) -> std::result::Result<(String, Option), ExtractHeredocError> { - // This function uses a Tree-sitter query to recognize one of two - // whole-script forms, each expressed as a single top-level statement: - // - // 1. apply_patch <<'EOF'\n...\nEOF - // 2. cd && apply_patch <<'EOF'\n...\nEOF - // - // Key ideas when reading the query: - // - dots (`.`) between named nodes enforces adjacency among named children and - // anchor to the start/end of the expression. - // - we match a single redirected_statement directly under program with leading - // and trailing anchors (`.`). This ensures it is the only top-level statement - // (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match). - // - // Overall, we want to be conservative and only match the intended forms, as other - // forms are likely to be model errors, or incorrectly interpreted by later code. - // - // If you're editing this query, it's helpful to start by creating a debugging binary - // which will let you see the AST of an arbitrary bash script passed in, and optionally - // also run an arbitrary query against the AST. This is useful for understanding - // how tree-sitter parses the script and whether the query syntax is correct. Be sure - // to test both positive and negative cases. - static APPLY_PATCH_QUERY: LazyLock = LazyLock::new(|| { - let language = BASH.into(); - #[expect(clippy::expect_used)] - Query::new( - &language, - r#" - ( - program - . (redirected_statement - body: (command - name: (command_name (word) @apply_name) .) - (#any-of? @apply_name "apply_patch" "applypatch") - redirect: (heredoc_redirect - . (heredoc_start) - . (heredoc_body) @heredoc - . (heredoc_end) - .)) - .) - - ( - program - . (redirected_statement - body: (list - . (command - name: (command_name (word) @cd_name) . - argument: [ - (word) @cd_path - (string (string_content) @cd_path) - (raw_string) @cd_raw_string - ] .) - "&&" - . (command - name: (command_name (word) @apply_name)) - .) - (#eq? @cd_name "cd") - (#any-of? @apply_name "apply_patch" "applypatch") - redirect: (heredoc_redirect - . (heredoc_start) - . (heredoc_body) @heredoc - . (heredoc_end) - .)) - .) - "#, - ) - .expect("valid bash query") - }); - - let lang = BASH.into(); - let mut parser = Parser::new(); - parser - .set_language(&lang) - .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?; - let tree = parser - .parse(src, None) - .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?; - - let bytes = src.as_bytes(); - let root = tree.root_node(); - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes); - while let Some(m) = matches.next() { - let mut heredoc_text: Option = None; - let mut cd_path: Option = None; - - for capture in m.captures.iter() { - let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize]; - match name { - "heredoc" => { - let text = capture - .node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)? - .trim_end_matches('\n') - .to_string(); - heredoc_text = Some(text); - } - "cd_path" => { - let text = capture - .node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)? - .to_string(); - cd_path = Some(text); - } - "cd_raw_string" => { - let raw = capture - .node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)?; - let trimmed = raw - .strip_prefix('\'') - .and_then(|s| s.strip_suffix('\'')) - .unwrap_or(raw); - cd_path = Some(trimmed.to_string()); - } - _ => {} - } - } + pub fn is_exact(&self) -> bool { + self.exact + } - if let Some(heredoc) = heredoc_text { - return Ok((heredoc, cd_path)); - } + /// Appends a later committed prefix while preserving the aggregate exactness. + pub fn append(&mut self, other: Self) { + self.changes.extend(other.changes); + self.exact &= other.exact; } +} - Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) +impl Default for AppliedPatchDelta { + fn default() -> Self { + Self::empty() + } } -#[derive(Debug, PartialEq)] -pub enum ExtractHeredocError { - CommandDidNotStartWithApplyPatch, - FailedToLoadBashGrammar(LanguageError), - HeredocNotUtf8(Utf8Error), - FailedToParsePatchIntoAst, - FailedToFindHeredocBody, +/// A committed file change, preserved in the order it was applied. +#[derive(Clone, Debug, PartialEq)] +pub struct AppliedPatchChange { + pub path: PathBuf, + pub change: AppliedPatchFileChange, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AppliedPatchFileChange { + Add { + content: String, + overwritten_content: Option, + }, + Delete { + content: String, + }, + Update { + move_path: Option, + old_content: String, + overwritten_move_content: Option, + new_content: String, + }, +} + +/// A failed patch application together with the textual mutations that were +/// definitely committed before the failure was observed. +#[derive(Debug, Error)] +#[error("{error}")] +pub struct ApplyPatchFailure { + #[source] + error: ApplyPatchError, + delta: AppliedPatchDelta, +} + +impl ApplyPatchFailure { + fn new(error: ApplyPatchError, delta: AppliedPatchDelta) -> Self { + Self { error, delta } + } + + fn without_delta(error: ApplyPatchError) -> Self { + Self::new(error, AppliedPatchDelta::empty()) + } + + pub fn delta(&self) -> &AppliedPatchDelta { + &self.delta + } + + pub fn into_parts(self) -> (ApplyPatchError, AppliedPatchDelta) { + (self.error, self.delta) + } } /// Applies the patch and prints the result to stdout/stderr. -pub fn apply_patch( +pub async fn apply_patch( patch: &str, + cwd: &AbsolutePathBuf, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, -) -> Result<(), ApplyPatchError> { + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, +) -> Result { let hunks = match parse_patch(patch) { Ok(source) => source.hunks, Err(e) => { match &e { InvalidPatchError(message) => { - writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?; + writeln!(stderr, "Invalid patch: {message}") + .map_err(ApplyPatchError::from) + .map_err(ApplyPatchFailure::without_delta)?; } InvalidHunkError { message, @@ -592,74 +299,58 @@ pub fn apply_patch( stderr, "Invalid patch hunk on line {line_number}: {message}" ) - .map_err(ApplyPatchError::from)?; + .map_err(ApplyPatchError::from) + .map_err(ApplyPatchFailure::without_delta)?; } } - return Err(ApplyPatchError::ParseError(e)); + return Err(ApplyPatchFailure::without_delta( + ApplyPatchError::ParseError(e), + )); } }; - apply_hunks(&hunks, stdout, stderr)?; - - Ok(()) + apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await } /// Applies hunks and continues to update stdout/stderr -pub fn apply_hunks( +pub async fn apply_hunks( hunks: &[Hunk], + cwd: &AbsolutePathBuf, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, -) -> Result<(), ApplyPatchError> { - let _existing_paths: Vec<&Path> = hunks - .iter() - .filter_map(|hunk| match hunk { - Hunk::AddFile { .. } => { - // The file is being added, so it doesn't exist yet. - None - } - Hunk::DeleteFile { path } => Some(path.as_path()), - Hunk::UpdateFile { - path, move_path, .. - } => match move_path { - Some(move_path) => { - if std::fs::metadata(move_path) - .map(|m| m.is_file()) - .unwrap_or(false) - { - Some(move_path.as_path()) - } else { - None - } - } - None => Some(path.as_path()), - }, - }) - .collect::>(); - - // Delegate to a helper that applies each hunk to the filesystem. - match apply_hunks_to_files(hunks) { - Ok(affected) => { - print_summary(&affected, stdout).map_err(ApplyPatchError::from)?; - Ok(()) + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, +) -> Result { + let mut delta = AppliedPatchDelta::empty(); + match apply_hunks_to_files(hunks, cwd, fs, sandbox, &mut delta).await { + Ok(affected_paths) => { + print_summary(&affected_paths, stdout).map_err(|error| { + ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone()) + })?; + Ok(delta) } - Err(err) => { - let msg = err.to_string(); - writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?; - if let Some(io) = err.downcast_ref::() { - Err(ApplyPatchError::from(io)) + Err(error) => { + let msg = error.to_string(); + writeln!(stderr, "{msg}").map_err(|error| { + ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone()) + })?; + let error = if let Some(io) = error.downcast_ref::() { + ApplyPatchError::from(io) } else { - Err(ApplyPatchError::IoError(IoError { + ApplyPatchError::IoError(IoError { context: msg, - source: std::io::Error::other(err), - })) - } + source: std::io::Error::other(error), + }) + }; + Err(ApplyPatchFailure::new(error, delta)) } } } /// Applies each parsed patch hunk to the filesystem. /// Returns an error if any of the changes could not be applied. -/// Tracks file paths affected by applying a patch. +/// Tracks file paths affected by applying a patch, preserving the path spelling +/// from the patch for user-facing summaries. pub struct AffectedPaths { pub added: Vec, pub modified: Vec, @@ -668,7 +359,13 @@ pub struct AffectedPaths { /// Apply the hunks to the filesystem, returning which files were added, modified, or deleted. /// Returns an error if the patch could not be applied. -fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { +async fn apply_hunks_to_files( + hunks: &[Hunk], + cwd: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, + delta: &mut AppliedPatchDelta, +) -> anyhow::Result { if hunks.is_empty() { anyhow::bail!("No files were modified."); } @@ -676,49 +373,172 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { let mut added: Vec = Vec::new(); let mut modified: Vec = Vec::new(); let mut deleted: Vec = Vec::new(); + // A failed write can still have modified the target before surfacing an + // error (for example by truncating before ENOSPC), so the accumulated + // delta is no longer exact when a write fails. + macro_rules! try_write { + ($result:expr) => { + match $result { + Ok(value) => value, + Err(error) => { + delta.exact = false; + return Err(anyhow::Error::from(error)); + } + } + }; + } + for hunk in hunks { + let affected_path = hunk.path().to_path_buf(); + let path_abs = hunk.resolve_path(cwd); match hunk { - Hunk::AddFile { path, contents } => { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() + Hunk::AddFile { contents, .. } => { + let overwritten_content = + read_optional_file_text_for_delta(&path_abs, fs, sandbox, &mut delta.exact) + .await; + try_write!( + write_file_with_missing_parent_retry( + fs, + &path_abs, + contents.clone().into_bytes(), + sandbox, + ) + .await + ); + delta.changes.push(AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Add { + content: contents.clone(), + overwritten_content, + }, + }); + added.push(affected_path); + } + Hunk::DeleteFile { .. } => { + note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + let deleted_content = fs.read_file_text(&path_abs, sandbox).await.ok(); + if deleted_content.is_none() { + delta.exact = false; + } + ensure_not_directory(&path_abs, fs, sandbox) + .await + .with_context(|| format!("Failed to delete file {}", path_abs.display()))?; + if let Err(error) = fs + .remove( + &path_abs, + RemoveOptions { + recursive: false, + force: false, + }, + sandbox, + ) + .await + .with_context(|| format!("Failed to delete file {}", path_abs.display())) { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", path.display()) - })?; + delta.exact &= remove_failure_was_side_effect_free( + &path_abs, + deleted_content.as_deref(), + fs, + sandbox, + ) + .await; + return Err(error); } - std::fs::write(path, contents) - .with_context(|| format!("Failed to write file {}", path.display()))?; - added.push(path.clone()); - } - Hunk::DeleteFile { path } => { - std::fs::remove_file(path) - .with_context(|| format!("Failed to delete file {}", path.display()))?; - deleted.push(path.clone()); + if let Some(content) = deleted_content { + delta.changes.push(AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Delete { content }, + }); + } + deleted.push(affected_path); } Hunk::UpdateFile { - path, - move_path, - chunks, + move_path, chunks, .. } => { - let AppliedPatch { new_contents, .. } = - derive_new_contents_from_chunks(path, chunks)?; + note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + let AppliedPatch { + original_contents, + new_contents, + } = derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?; if let Some(dest) = move_path { - if let Some(parent) = dest.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", dest.display()) + let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd); + let overwritten_move_content = + read_optional_file_text_for_delta(&dest_abs, fs, sandbox, &mut delta.exact) + .await; + try_write!( + write_file_with_missing_parent_retry( + fs, + &dest_abs, + new_contents.clone().into_bytes(), + sandbox, + ) + .await + ); + let dest_write_change_index = delta.changes.len(); + delta.changes.push(AppliedPatchChange { + path: dest_abs.to_path_buf(), + change: AppliedPatchFileChange::Add { + content: new_contents.clone(), + overwritten_content: overwritten_move_content.clone(), + }, + }); + ensure_not_directory(&path_abs, fs, sandbox) + .await + .with_context(|| { + format!("Failed to remove original {}", path_abs.display()) })?; + if let Err(error) = fs + .remove( + &path_abs, + RemoveOptions { + recursive: false, + force: false, + }, + sandbox, + ) + .await + .with_context(|| { + format!("Failed to remove original {}", path_abs.display()) + }) + { + delta.exact &= remove_failure_was_side_effect_free( + &path_abs, + Some(&original_contents), + fs, + sandbox, + ) + .await; + return Err(error); } - std::fs::write(dest, new_contents) - .with_context(|| format!("Failed to write file {}", dest.display()))?; - std::fs::remove_file(path) - .with_context(|| format!("Failed to remove original {}", path.display()))?; - modified.push(dest.clone()); + delta.changes[dest_write_change_index] = AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Update { + move_path: Some(dest_abs.into_path_buf()), + old_content: original_contents, + overwritten_move_content, + new_content: new_contents, + }, + }; + modified.push(affected_path); } else { - std::fs::write(path, new_contents) - .with_context(|| format!("Failed to write file {}", path.display()))?; - modified.push(path.clone()); + try_write!( + fs.write_file(&path_abs, new_contents.clone().into_bytes(), sandbox) + .await + .with_context(|| format!( + "Failed to write file {}", + path_abs.display() + )) + ); + delta.changes.push(AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Update { + move_path: None, + old_content: original_contents, + overwritten_move_content: None, + new_content: new_contents, + }, + }); + modified.push(affected_path); } } } @@ -730,59 +550,122 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { }) } -struct AppliedPatch { - original_contents: String, - new_contents: String, +async fn ensure_not_directory( + path: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, +) -> io::Result<()> { + let metadata = fs.get_metadata(path, sandbox).await?; + if metadata.is_directory { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path is a directory", + )); + } + Ok(()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LineEnding { - Lf, - Crlf, +async fn remove_failure_was_side_effect_free( + path: &AbsolutePathBuf, + expected_content: Option<&str>, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, +) -> bool { + match expected_content { + Some(expected_content) => fs + .read_file_text(path, sandbox) + .await + .is_ok_and(|content| content == expected_content), + None => false, + } } -impl LineEnding { - fn detect(contents: &str) -> Self { - if contents.contains("\r\n") { - Self::Crlf - } else { - Self::Lf +async fn read_optional_file_text_for_delta( + path: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, + exact: &mut bool, +) -> Option { + note_existing_path_delta_support(path, fs, sandbox, exact).await; + match fs.read_file_text(path, sandbox).await { + Ok(content) => Some(content), + Err(source) if source.kind() == io::ErrorKind::NotFound => None, + Err(_) => { + *exact = false; + None } } +} + +async fn note_existing_path_delta_support( + path: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, + exact: &mut bool, +) { + match fs.get_metadata(path, sandbox).await { + Ok(metadata) if metadata.is_file && !metadata.is_symlink => {} + Ok(_) => *exact = false, + Err(source) if source.kind() == io::ErrorKind::NotFound => {} + Err(_) => *exact = false, + } +} - fn joiner(self) -> &'static str { - match self { - Self::Lf => "\n", - Self::Crlf => "\r\n", +async fn write_file_with_missing_parent_retry( + fs: &dyn ExecutorFileSystem, + path_abs: &AbsolutePathBuf, + contents: Vec, + sandbox: Option<&FileSystemSandboxContext>, +) -> anyhow::Result<()> { + match fs.write_file(path_abs, contents.clone(), sandbox).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + if let Some(parent_abs) = path_abs.parent() { + fs.create_directory( + &parent_abs, + CreateDirectoryOptions { recursive: true }, + sandbox, + ) + .await + .with_context(|| { + format!( + "Failed to create parent directories for {}", + path_abs.display() + ) + })?; + } + fs.write_file(path_abs, contents, sandbox) + .await + .with_context(|| format!("Failed to write file {}", path_abs.display()))?; + Ok(()) + } + Err(err) => { + Err(err).with_context(|| format!("Failed to write file {}", path_abs.display())) } } } -fn normalize_line_ending(line: &str) -> &str { - line.strip_suffix('\r').unwrap_or(line) +struct AppliedPatch { + original_contents: String, + new_contents: String, } /// Return *only* the new file contents (joined into a single `String`) after /// applying the chunks to the file at `path`. -fn derive_new_contents_from_chunks( - path: &Path, +async fn derive_new_contents_from_chunks( + path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { - let original_contents = match std::fs::read_to_string(path) { - Ok(contents) => contents, - Err(err) => { - return Err(ApplyPatchError::IoError(IoError { - context: format!("Failed to read file to update {}", path.display()), - source: err, - })); - } - }; + let original_contents = fs.read_file_text(path_abs, sandbox).await.map_err(|err| { + ApplyPatchError::IoError(IoError { + context: format!("Failed to read file to update {}", path_abs.display()), + source: err, + }) + })?; - let line_ending = LineEnding::detect(&original_contents); - let mut original_lines: Vec = original_contents - .split('\n') - .map(|line| normalize_line_ending(line).to_string()) - .collect(); + let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); // Drop the trailing empty element that results from the final newline so // that line counts match the behaviour of standard `diff`. @@ -790,13 +673,13 @@ fn derive_new_contents_from_chunks( original_lines.pop(); } - let replacements = compute_replacements(&original_lines, path, chunks)?; + let replacements = compute_replacements(&original_lines, path_abs.as_path(), chunks)?; let new_lines = apply_replacements(original_lines, &replacements); let mut new_lines = new_lines; if !new_lines.last().is_some_and(String::is_empty) { new_lines.push(String::new()); } - let new_contents = new_lines.join(line_ending.joiner()); + let new_contents = new_lines.join("\n"); Ok(AppliedPatch { original_contents, new_contents, @@ -822,7 +705,7 @@ fn compute_replacements( original_lines, std::slice::from_ref(ctx_line), line_index, - false, + /*eof*/ false, ) { line_index = idx + 1; } else { @@ -842,12 +725,7 @@ fn compute_replacements( } else { original_lines.len() }; - let normalized_new_lines: Vec = chunk - .new_lines - .iter() - .map(|line| normalize_line_ending(line).to_string()) - .collect(); - replacements.push((insertion_idx, 0, normalized_new_lines)); + replacements.push((insertion_idx, 0, chunk.new_lines.clone())); continue; } @@ -862,22 +740,11 @@ fn compute_replacements( // final element so that modifications touching the end‑of‑file can be // located reliably. - let normalized_old_lines: Vec = chunk - .old_lines - .iter() - .map(|line| normalize_line_ending(line).to_string()) - .collect(); - let normalized_new_lines: Vec = chunk - .new_lines - .iter() - .map(|line| normalize_line_ending(line).to_string()) - .collect(); - - let mut pattern: &[String] = &normalized_old_lines; + let mut pattern: &[String] = &chunk.old_lines; let mut found = seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file); - let mut new_slice: &[String] = &normalized_new_lines; + let mut new_slice: &[String] = &chunk.new_lines; if found.is_none() && pattern.last().is_some_and(String::is_empty) { // Retry without the trailing empty line which represents the final @@ -902,7 +769,7 @@ fn compute_replacements( return Err(ApplyPatchError::ComputeReplacements(format!( "Failed to find expected lines in {}:\n{}", path.display(), - normalized_old_lines.join("\n"), + chunk.old_lines.join("\n"), ))); } } @@ -944,29 +811,35 @@ fn apply_replacements( #[derive(Debug, Eq, PartialEq)] pub struct ApplyPatchFileUpdate { unified_diff: String, + original_content: String, content: String, } -pub fn unified_diff_from_chunks( - path: &Path, +pub async fn unified_diff_from_chunks( + path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { - unified_diff_from_chunks_with_context(path, chunks, 1) + unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await } -pub fn unified_diff_from_chunks_with_context( - path: &Path, +pub async fn unified_diff_from_chunks_with_context( + path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], context: usize, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { let AppliedPatch { original_contents, new_contents, - } = derive_new_contents_from_chunks(path, chunks)?; + } = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).await?; let text_diff = TextDiff::from_lines(&original_contents, &new_contents); let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); Ok(ApplyPatchFileUpdate { unified_diff, + original_content: original_contents, content: new_contents, }) } @@ -993,6 +866,8 @@ pub fn print_summary( #[cfg(test)] mod tests { use super::*; + use codex_exec_server::LOCAL_FS; + use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; @@ -1003,282 +878,8 @@ mod tests { format!("*** Begin Patch\n{body}\n*** End Patch") } - fn strs_to_strings(strs: &[&str]) -> Vec { - strs.iter().map(ToString::to_string).collect() - } - - // Test helpers to reduce repetition when building bash -lc heredoc scripts - fn args_bash(script: &str) -> Vec { - strs_to_strings(&["bash", "-lc", script]) - } - - fn args_abs_bash(script: &str) -> Vec { - strs_to_strings(&["/bin/bash", "-lc", script]) - } - - fn heredoc_script(prefix: &str) -> String { - format!( - "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" - ) - } - - fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { - format!( - "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" - ) - } - - fn expected_single_add() -> Vec { - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string(), - }] - } - - fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { - assert_eq!(workdir.as_deref(), expected_workdir); - assert_eq!(hunks, expected_single_add()); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - fn assert_match(script: &str, expected_workdir: Option<&str>) { - assert_match_args(args_bash(script), expected_workdir); - assert_match_args(args_abs_bash(script), expected_workdir); - } - - fn assert_not_match_args(args: Vec) { - assert!(matches!( - maybe_parse_apply_patch(&args), - MaybeApplyPatch::NotApplyPatch - )); - } - - fn assert_not_match(script: &str) { - assert_not_match_args(args_bash(script)); - assert_not_match_args(args_abs_bash(script)); - } - - #[test] - fn test_implicit_patch_single_arg_is_error() { - let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); - let args = vec![patch]; - let dir = tempdir().unwrap(); - assert!(matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), - MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - )); - } - - #[test] - fn test_implicit_patch_bash_script_is_error() { - let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; - let dir = tempdir().unwrap(); - for args in [args_bash(script), args_abs_bash(script)] { - assert!(matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), - MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - )); - } - } - - #[test] - fn test_malformed_apply_patch_heredoc_argv_is_error() { - let args = strs_to_strings(&[ - "apply_patch", - "< { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_literal_applypatch() { - let args = strs_to_strings(&[ - "applypatch", - r#"*** Begin Patch -*** Add File: foo -+hi -*** End Patch -"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_heredoc() { - assert_match(&heredoc_script(""), None); - } - - #[test] - fn test_raw_shell_command_heredoc() { - assert_match_args(vec![heredoc_script("")], None); - } - - #[test] - fn test_verified_raw_shell_command_heredoc() { - let dir = tempdir().unwrap(); - assert!(matches!( - maybe_parse_apply_patch_verified(&vec![heredoc_script("")], dir.path()), - MaybeApplyPatchVerified::Body(_) - )); - } - - #[test] - fn test_heredoc_applypatch() { - for args in [ - strs_to_strings(&[ - "bash", - "-lc", - r#"applypatch <<'PATCH' -*** Begin Patch -*** Add File: foo -+hi -*** End Patch -PATCH"#, - ]), - strs_to_strings(&[ - "/bin/bash", - "-lc", - r#"applypatch <<'PATCH' -*** Begin Patch -*** Add File: foo -+hi -*** End Patch -PATCH"#, - ]), - ] { - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { - assert_eq!(workdir, None); - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - } - - #[test] - fn test_heredoc_with_leading_cd() { - assert_match(&heredoc_script("cd foo && "), Some("foo")); - } - - #[test] - fn test_cd_with_semicolon_is_ignored() { - assert_not_match(&heredoc_script("cd foo; ")); - } - - #[test] - fn test_cd_or_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd bar || ")); - } - - #[test] - fn test_cd_pipe_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd bar | ")); - } - - #[test] - fn test_cd_single_quoted_path_with_spaces() { - assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); - } - - #[test] - fn test_cd_double_quoted_path_with_spaces() { - assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); - } - - #[test] - fn test_echo_and_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("echo foo && ")); - } - - #[test] - fn test_apply_patch_with_arg_is_ignored() { - let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; - assert_not_match(script); - } - - #[test] - fn test_double_cd_then_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd foo && cd bar && ")); - } - - #[test] - fn test_cd_two_args_is_ignored() { - assert_not_match(&heredoc_script("cd foo bar && ")); - } - - #[test] - fn test_cd_then_apply_patch_then_extra_is_ignored() { - let script = heredoc_script_ps("cd bar && ", " && echo done"); - assert_not_match(&script); - } - - #[test] - fn test_echo_then_cd_and_apply_patch_is_ignored() { - // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. - assert_not_match(&heredoc_script("echo foo; cd bar && ")); - } - - #[test] - fn test_add_file_hunk_creates_file_with_contents() { + #[tokio::test] + async fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); let path = dir.path().join("add.txt"); let patch = wrap_patch(&format!( @@ -1289,7 +890,16 @@ PATCH"#, )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); // Verify expected stdout and stderr outputs. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -1303,15 +913,96 @@ PATCH"#, assert_eq!(contents, "ab\ncd\n"); } - #[test] - fn test_delete_file_hunk_removes_file() { + #[tokio::test] + async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() { + let dir = tempdir().unwrap(); + let cwd = dir.path().abs(); + let relative_add = dir.path().join("relative-add.txt"); + let absolute_add = dir.path().join("absolute-add.txt"); + let relative_delete = dir.path().join("relative-delete.txt"); + let absolute_delete = dir.path().join("absolute-delete.txt"); + let relative_update = dir.path().join("relative-update.txt"); + let absolute_update = dir.path().join("absolute-update.txt"); + fs::write(&relative_delete, "delete relative\n").unwrap(); + fs::write(&absolute_delete, "delete absolute\n").unwrap(); + fs::write(&relative_update, "relative old\n").unwrap(); + fs::write(&absolute_update, "absolute old\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Add File: relative-add.txt ++relative add +*** Add File: {} ++absolute add +*** Delete File: relative-delete.txt +*** Delete File: {} +*** Update File: relative-update.txt +@@ +-relative old ++relative new +*** Update File: {} +@@ +-absolute old ++absolute new"#, + absolute_add.display(), + absolute_delete.display(), + absolute_update.display(), + )); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + apply_patch( + &patch, + &cwd, + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); + + assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n"); + assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n"); + assert!(!relative_delete.exists()); + assert!(!absolute_delete.exists()); + assert_eq!( + fs::read_to_string(&relative_update).unwrap(), + "relative new\n" + ); + assert_eq!( + fs::read_to_string(&absolute_update).unwrap(), + "absolute new\n" + ); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(stdout).unwrap(), + format!( + "Success. Updated the following files:\nA relative-add.txt\nA {}\nM relative-update.txt\nM {}\nD relative-delete.txt\nD {}\n", + absolute_add.display(), + absolute_update.display(), + absolute_delete.display(), + ) + ); + } + + #[tokio::test] + async fn test_delete_file_hunk_removes_file() { let dir = tempdir().unwrap(); let path = dir.path().join("del.txt"); fs::write(&path, "x").unwrap(); let patch = wrap_patch(&format!("*** Delete File: {}", path.display())); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( @@ -1323,8 +1014,8 @@ PATCH"#, assert!(!path.exists()); } - #[test] - fn test_update_file_hunk_modifies_content() { + #[tokio::test] + async fn test_update_file_hunk_modifies_content() { let dir = tempdir().unwrap(); let path = dir.path().join("update.txt"); fs::write(&path, "foo\nbar\n").unwrap(); @@ -1338,7 +1029,16 @@ PATCH"#, )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); // Validate modified file contents and expected stdout/stderr. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -1352,38 +1052,8 @@ PATCH"#, assert_eq!(contents, "foo\nbaz\n"); } - #[test] - fn test_update_file_hunk_preserves_crlf_line_endings() { - let dir = tempdir().unwrap(); - let path = dir.path().join("update_crlf.txt"); - fs::write(&path, "foo\r\nbar\r\n").unwrap(); - - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ - foo --bar -+baz -"#, - path.display() - )); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - - let bytes = fs::read(&path).unwrap(); - assert!(bytes.ends_with(b"\r\n"), "file should end with CRLF"); - for (idx, byte) in bytes.iter().enumerate() { - if *byte == b'\n' { - assert!(idx > 0 && bytes[idx - 1] == b'\r', "found bare LF at index {idx}"); - } - } - assert_eq!(String::from_utf8_lossy(&bytes), "foo\r\nbaz\r\n"); - } - - #[test] - fn test_update_file_hunk_can_move_file() { + #[tokio::test] + async fn test_update_file_hunk_can_move_file() { let dir = tempdir().unwrap(); let src = dir.path().join("src.txt"); let dest = dir.path().join("dst.txt"); @@ -1399,7 +1069,16 @@ PATCH"#, )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); // Validate move semantics and expected stdout/stderr. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -1414,10 +1093,65 @@ PATCH"#, assert_eq!(contents, "line2\n"); } + #[cfg(unix)] + #[tokio::test] + async fn test_failed_move_returns_committed_destination_delta() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let source_dir = dir.path().join("locked"); + let dest_dir = dir.path().join("out"); + fs::create_dir(&source_dir).unwrap(); + fs::create_dir(&dest_dir).unwrap(); + let src = source_dir.join("src.txt"); + let dest = dest_dir.join("dst.txt"); + fs::write(&src, "line\n").unwrap(); + fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o555)).unwrap(); + + let patch = wrap_patch( + "*** Update File: locked/src.txt\n*** Move to: out/dst.txt\n@@\n-line\n+line2", + ); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let failure = apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .expect_err("source removal should fail after destination write"); + + fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!( + String::from_utf8(stderr) + .unwrap() + .contains(&format!("Failed to remove original {}", src.display())) + ); + assert_eq!( + failure.delta(), + &AppliedPatchDelta::new( + vec![AppliedPatchChange { + path: dest.clone(), + change: AppliedPatchFileChange::Add { + content: "line2\n".to_string(), + overwritten_content: None, + }, + }], + /*exact*/ true, + ) + ); + assert_eq!(fs::read_to_string(src).unwrap(), "line\n"); + assert_eq!(fs::read_to_string(dest).unwrap(), "line2\n"); + } + /// Verify that a single `Update File` hunk with multiple change chunks can update different /// parts of a file and that the file is listed only once in the summary. - #[test] - fn test_multiple_update_chunks_apply_to_single_file() { + #[tokio::test] + async fn test_multiple_update_chunks_apply_to_single_file() { // Start with a file containing four lines. let dir = tempdir().unwrap(); let path = dir.path().join("multi.txt"); @@ -1439,7 +1173,16 @@ PATCH"#, )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( @@ -1456,8 +1199,8 @@ PATCH"#, /// replacements in separate chunks that appear in non‑adjacent parts of the /// file. Verifies that all edits are applied and that the summary lists the /// file only once. - #[test] - fn test_update_file_hunk_interleaved_changes() { + #[tokio::test] + async fn test_update_file_hunk_interleaved_changes() { let dir = tempdir().unwrap(); let path = dir.path().join("interleaved.txt"); @@ -1488,7 +1231,16 @@ PATCH"#, let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -1504,8 +1256,8 @@ PATCH"#, assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); } - #[test] - fn test_pure_addition_chunk_followed_by_removal() { + #[tokio::test] + async fn test_pure_addition_chunk_followed_by_removal() { let dir = tempdir().unwrap(); let path = dir.path().join("panic.txt"); fs::write(&path, "line1\nline2\nline3\n").unwrap(); @@ -1523,7 +1275,16 @@ PATCH"#, )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let contents = fs::read_to_string(path).unwrap(); assert_eq!( contents, @@ -1537,8 +1298,8 @@ PATCH"#, /// internal matcher failed requiring an exact byte-for-byte match. The /// fuzzy-matching pass that normalises common punctuation should now bridge /// the gap. - #[test] - fn test_update_line_with_unicode_dash() { + #[tokio::test] + async fn test_update_line_with_unicode_dash() { let dir = tempdir().unwrap(); let path = dir.path().join("unicode.py"); @@ -1557,7 +1318,16 @@ PATCH"#, let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); // File should now contain the replaced comment. let expected = "import asyncio # HELLO\n"; @@ -1576,8 +1346,8 @@ PATCH"#, assert_eq!(String::from_utf8(stderr).unwrap(), ""); } - #[test] - fn test_unified_diff() { + #[tokio::test] + async fn test_unified_diff() { // Start with a file containing four lines. let dir = tempdir().unwrap(); let path = dir.path().join("multi.txt"); @@ -1600,7 +1370,15 @@ PATCH"#, [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks( + &path_abs, + update_file_chunks, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let expected_diff = r#"@@ -1,4 +1,4 @@ foo -bar @@ -1611,13 +1389,14 @@ PATCH"#, "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\nqux\n".to_string(), content: "foo\nBAR\nbaz\nQUX\n".to_string(), }; assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_first_line_replacement() { + #[tokio::test] + async fn test_unified_diff_first_line_replacement() { // Replace the very first line of the file. let dir = tempdir().unwrap(); let path = dir.path().join("first.txt"); @@ -1639,7 +1418,11 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = + unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + .await + .unwrap(); let expected_diff = r#"@@ -1,2 +1,2 @@ -foo +FOO @@ -1647,13 +1430,14 @@ PATCH"#, "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "FOO\nbar\nbaz\n".to_string(), }; assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_last_line_replacement() { + #[tokio::test] + async fn test_unified_diff_last_line_replacement() { // Replace the very last line of the file. let dir = tempdir().unwrap(); let path = dir.path().join("last.txt"); @@ -1676,7 +1460,11 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = + unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + .await + .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ bar -baz @@ -1684,13 +1472,14 @@ PATCH"#, "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nBAZ\n".to_string(), }; assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_insert_at_eof() { + #[tokio::test] + async fn test_unified_diff_insert_at_eof() { // Insert a new line at end‑of‑file. let dir = tempdir().unwrap(); let path = dir.path().join("insert.txt"); @@ -1711,20 +1500,25 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = + unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + .await + .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ baz +quux "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nbaz\nquux\n".to_string(), }; assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_interleaved_changes() { + #[tokio::test] + async fn test_unified_diff_interleaved_changes() { // Original file with six lines. let dir = tempdir().unwrap(); let path = dir.path().join("interleaved.txt"); @@ -1757,7 +1551,11 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = + unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + .await + .unwrap(); let expected_diff = r#"@@ -1,6 +1,7 @@ a @@ -1773,6 +1571,7 @@ PATCH"#, let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "a\nb\nc\nd\ne\nf\n".to_string(), content: "a\nB\nc\nd\nE\nf\ng\n".to_string(), }; @@ -1780,7 +1579,16 @@ PATCH"#, let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let contents = fs::read_to_string(path).unwrap(); assert_eq!( contents, @@ -1795,69 +1603,88 @@ g ); } - #[test] - fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { - let session_dir = tempdir().unwrap(); - let relative_path = "source.txt"; + #[cfg(unix)] + #[tokio::test] + async fn test_apply_patch_fails_on_write_error() { + use std::os::unix::fs::PermissionsExt; - // Note that we need this file to exist for the patch to be "verified" - // and parsed correctly. - let session_file_path = session_dir.path().join(relative_path); - fs::write(&session_file_path, "session directory content\n").unwrap(); + let dir = tempdir().unwrap(); + let locked_dir = dir.path().join("locked"); + fs::create_dir(&locked_dir).unwrap(); + fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap(); - let argv = vec![ - "apply_patch".to_string(), - r#"*** Begin Patch -*** Update File: source.txt -@@ --session directory content -+updated session directory content -*** End Patch"# - .to_string(), - ]; + let patch = wrap_patch("*** Add File: locked/new.txt\n+after"); - let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let result = apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await; + let failure = result.expect_err("write should fail"); - // Verify the patch contents - as otherwise we may have pulled contents - // from the wrong file (as we're using relative paths) - assert_eq!( - result, - MaybeApplyPatchVerified::Body(ApplyPatchAction { - changes: HashMap::from([( - session_dir.path().join(relative_path), - ApplyPatchFileChange::Update { - unified_diff: r#"@@ -1 +1 @@ --session directory content -+updated session directory content -"# - .to_string(), - move_path: None, - new_content: "updated session directory content\n".to_string(), - }, - )]), - patch: argv[1].clone(), - cwd: session_dir.path().to_path_buf(), - }) - ); + fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!(!failure.delta().is_exact()); } - #[test] - fn test_apply_patch_fails_on_write_error() { + #[tokio::test] + async fn test_unreadable_destinations_return_inexact_delta() { let dir = tempdir().unwrap(); - let path = dir.path().join("readonly.txt"); - fs::write(&path, "before\n").unwrap(); - let mut perms = fs::metadata(&path).unwrap().permissions(); - perms.set_readonly(true); - fs::set_permissions(&path, perms).unwrap(); + let path = dir.path().join("binary.dat"); + fs::write(dir.path().join("source.txt"), "before\n").unwrap(); + let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(); - let patch = wrap_patch(&format!( - "*** Update File: {}\n@@\n-before\n+after\n*** End Patch", - path.display() - )); + for patch in [ + wrap_patch("*** Add File: binary.dat\n+text"), + wrap_patch("*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after"), + ] { + fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let delta = apply_patch( + &patch, + &cwd, + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); + + assert!(!delta.is_exact()); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_delete_symlink_returns_inexact_delta() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + fs::write(dir.path().join("target.txt"), "target\n").unwrap(); + symlink(dir.path().join("target.txt"), dir.path().join("link.txt")).unwrap(); + let patch = wrap_patch("*** Delete File: link.txt"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let result = apply_patch(&patch, &mut stdout, &mut stderr); - assert!(result.is_err()); + let delta = apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); + + assert!(!delta.is_exact()); } } diff --git a/code-rs/apply-patch/src/main.rs b/code-rs/apply-patch/src/main.rs index a8d288baf49..9d3ed03361f 100644 --- a/code-rs/apply-patch/src/main.rs +++ b/code-rs/apply-patch/src/main.rs @@ -1,3 +1,3 @@ pub fn main() -> ! { - code_apply_patch::main() + codex_apply_patch::main() } diff --git a/code-rs/apply-patch/src/parser.rs b/code-rs/apply-patch/src/parser.rs index 768c89ad781..54030552728 100644 --- a/code-rs/apply-patch/src/parser.rs +++ b/code-rs/apply-patch/src/parser.rs @@ -23,20 +23,23 @@ //! 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 codex_utils_absolute_path::AbsolutePathBuf; +#[cfg(test)] +use codex_utils_absolute_path::test_support::PathBufExt; 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 = "@@"; +pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; +pub(crate) const END_PATCH_MARKER: &str = "*** End Patch"; +pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: "; +pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: "; +pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: "; +pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: "; +pub(crate) const EOF_MARKER: &str = "*** End of File"; +pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ "; +pub(crate) 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 @@ -76,11 +79,28 @@ pub enum Hunk { } impl Hunk { - pub fn resolve_path(&self, cwd: &Path) -> PathBuf { + pub fn resolve_path(&self, cwd: &AbsolutePathBuf) -> AbsolutePathBuf { + let path = match self { + Hunk::UpdateFile { path, .. } => path, + Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(), + }; + AbsolutePathBuf::resolve_path_against_base(path, cwd) + } + + /// Returns the path affected by this hunk, using the move destination for rename hunks. + pub fn path(&self) -> &Path { match self { - Hunk::AddFile { path, .. } => cwd.join(path), - Hunk::DeleteFile { path } => cwd.join(path), - Hunk::UpdateFile { path, .. } => cwd.join(path), + Hunk::AddFile { path, .. } => path, + Hunk::DeleteFile { path } => path, + Hunk::UpdateFile { + move_path: Some(path), + .. + } => path, + Hunk::UpdateFile { + path, + move_path: None, + .. + } => path, } } } @@ -153,20 +173,13 @@ enum ParseMode { fn parse_patch_text(patch: &str, mode: ParseMode) -> Result { 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 (patch_lines, hunk_lines) = match mode { + ParseMode::Strict => check_patch_boundaries_strict(&lines)?, + ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?, }; let mut hunks: Vec = 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 remaining_lines = hunk_lines; let mut line_number = 2; while !remaining_lines.is_empty() { let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number)?; @@ -174,7 +187,7 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result Result Result<(), ParseError> { +fn check_patch_boundaries_strict<'a>( + lines: &'a [&'a str], +) -> Result<(&'a [&'a str], &'a [&'a str]), 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) + check_start_and_end_lines_strict(first_line, last_line)?; + Ok((lines, &lines[1..lines.len() - 1])) } /// If we are in lenient mode, we check if the first line starts with `< Result<(), ParseError> { /// 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> { +) -> Result<(&'a [&'a str], &'a [&'a str]), ParseError> { + let original_parse_error = match check_patch_boundaries_strict(original_lines) { + Ok(lines) => return Ok(lines), + Err(e) => e, + }; + match original_lines { [first, .., last] => { if (first == &"<( && 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), - } + check_patch_boundaries_strict(inner_lines) } else { Err(original_parse_error) } @@ -227,11 +244,14 @@ fn check_start_and_end_lines_strict( first_line: Option<&&str>, last_line: Option<&&str>, ) -> Result<(), ParseError> { + let first_line = first_line.map(|line| line.trim()); + let last_line = last_line.map(|line| line.trim()); + match (first_line, last_line) { - (Some(&first), Some(&last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => { + (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( + (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( @@ -243,10 +263,8 @@ fn check_start_and_end_lines_strict( /// 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..] { @@ -266,7 +284,6 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P parsed_lines, )); } else if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) { - // Delete File return Ok(( DeleteFile { path: PathBuf::from(path), @@ -274,11 +291,8 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P 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)); @@ -289,16 +303,14 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P } 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("***") { + if remaining_lines[0].starts_with('*') { break; } @@ -314,7 +326,10 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P if chunks.is_empty() { return Err(InvalidHunkError { - message: format!("Update file hunk for path '{path}' is empty"), + message: format!( + "Update file hunk for path '{}' is empty", + Path::new(path).display() + ), line_number, }); } @@ -348,8 +363,6 @@ fn parse_update_file_chunk( line_number, }); } - // If we see an explicit context marker @@ or @@ , 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) { @@ -430,6 +443,117 @@ fn parse_update_file_chunk( Ok((chunk, parsed_lines + start_index)) } +#[test] +fn test_parse_one_hunk() { + assert_eq!( + parse_one_hunk(&["bad"], /*line_number*/ 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 + }) + ); +} + +#[test] +fn test_update_file_chunk() { + assert_eq!( + parse_update_file_chunk( + &["bad"], + /*line_number*/ 123, + /*allow_missing_context*/ 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( + &["@@"], + /*line_number*/ 123, + /*allow_missing_context*/ false, + ), + Err(InvalidHunkError { + message: "Update hunk does not contain any lines".to_string(), + line_number: 124 + }) + ); + assert_eq!( + parse_update_file_chunk( + &["@@", "bad"], + /*line_number*/ 123, + /*allow_missing_context*/ 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"], + /*line_number*/ 123, + /*allow_missing_context*/ 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", + ], + /*line_number*/ 123, + /*allow_missing_context*/ false, + ), + Ok(( + UpdateFileChunk { + change_context: Some("change_context".to_string()), + old_lines: vec![ + String::new(), + "context".to_string(), + "remove".to_string(), + "context2".to_string(), + ], + new_lines: vec![ + String::new(), + "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"], + /*line_number*/ 123, + /*allow_missing_context*/ false, + ), + Ok(( + UpdateFileChunk { + change_context: None, + old_lines: Vec::new(), + new_lines: vec!["line".to_string()], + is_end_of_file: true, + }, + 3, + )) + ); +} + #[test] fn test_parse_patch() { assert_eq!( @@ -444,6 +568,25 @@ fn test_parse_patch() { "The last line of the patch must be '*** End Patch'".to_string() )) ); + + assert_eq!( + parse_patch_text( + concat!( + "*** Begin Patch", + " ", + "\n*** Add File: foo\n+hi\n", + " ", + "*** End Patch" + ), + ParseMode::Strict + ) + .unwrap() + .hunks, + vec![AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); assert_eq!( parse_patch_text( "*** Begin Patch\n\ @@ -561,6 +704,108 @@ fn test_parse_patch() { ); } +#[test] +fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() { + let dir = tempfile::tempdir().unwrap(); + let absolute_delete = dir.path().join("absolute-delete.py").abs(); + let absolute_update = dir.path().join("absolute-update.py").abs(); + let patch_text = format!( + r#"*** Begin Patch +*** Add File: relative-add.py ++content +*** Delete File: {} +*** Update File: {} +@@ +-old ++new +*** End Patch"#, + absolute_delete.display(), + absolute_update.display() + ); + + assert_eq!( + parse_patch_text(&patch_text, ParseMode::Strict) + .unwrap() + .hunks, + vec![ + AddFile { + path: PathBuf::from("relative-add.py"), + contents: "content\n".to_string() + }, + DeleteFile { + path: absolute_delete.to_path_buf() + }, + UpdateFile { + path: absolute_update.to_path_buf(), + move_path: None, + chunks: vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["old".to_string()], + new_lines: vec!["new".to_string()], + is_end_of_file: false + }] + }, + ] + ); +} + +#[test] +fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { + let cwd_dir = tempfile::tempdir().unwrap(); + let cwd = cwd_dir.path().to_path_buf().abs(); + let absolute_dir = tempfile::tempdir().unwrap(); + let absolute_add = absolute_dir.path().join("absolute-add.py").abs(); + let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs(); + let absolute_update = absolute_dir.path().join("absolute-update.py").abs(); + + for (hunk, expected_path) in [ + ( + AddFile { + path: PathBuf::from("relative-add.py"), + contents: String::new(), + }, + cwd.join("relative-add.py"), + ), + ( + DeleteFile { + path: PathBuf::from("relative-delete.py"), + }, + cwd.join("relative-delete.py"), + ), + ( + UpdateFile { + path: PathBuf::from("relative-update.py"), + move_path: None, + chunks: Vec::new(), + }, + cwd.join("relative-update.py"), + ), + ( + AddFile { + path: absolute_add.to_path_buf(), + contents: String::new(), + }, + absolute_add, + ), + ( + DeleteFile { + path: absolute_delete.to_path_buf(), + }, + absolute_delete, + ), + ( + UpdateFile { + path: absolute_update.to_path_buf(), + move_path: None, + chunks: Vec::new(), + }, + absolute_update, + ), + ] { + assert_eq!(hunk.resolve_path(&cwd), expected_path); + } +} + #[test] fn test_parse_patch_lenient() { let patch_text = r#"*** Begin Patch @@ -646,96 +891,3 @@ fn test_parse_patch_lenient() { )) ); } - -#[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 - )) - ); -} diff --git a/code-rs/apply-patch/src/seek_sequence.rs b/code-rs/apply-patch/src/seek_sequence.rs index b005b08c754..3555963120e 100644 --- a/code-rs/apply-patch/src/seek_sequence.rs +++ b/code-rs/apply-patch/src/seek_sequence.rs @@ -122,7 +122,10 @@ mod tests { 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)); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + Some(1) + ); } #[test] @@ -130,7 +133,10 @@ mod tests { 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)); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + Some(0) + ); } #[test] @@ -138,7 +144,10 @@ mod tests { 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)); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + Some(0) + ); } #[test] @@ -146,6 +155,9 @@ mod tests { 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); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + None + ); } } diff --git a/code-rs/apply-patch/src/standalone_executable.rs b/code-rs/apply-patch/src/standalone_executable.rs index ba31465c8d4..45ca0d0619c 100644 --- a/code-rs/apply-patch/src/standalone_executable.rs +++ b/code-rs/apply-patch/src/standalone_executable.rs @@ -27,7 +27,7 @@ pub fn run_main() -> i32 { match std::io::stdin().read_to_string(&mut buf) { Ok(_) => { if buf.is_empty() { - eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply-patch"); + eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply_patch"); return 2; } buf @@ -48,8 +48,32 @@ pub fn run_main() -> i32 { let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); - match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) { - Ok(()) => { + let cwd = match codex_utils_absolute_path::AbsolutePathBuf::current_dir() { + Ok(cwd) => cwd, + Err(err) => { + eprintln!("Error: Failed to determine current directory.\n{err}"); + return 1; + } + }; + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("Error: Failed to initialize runtime.\n{err}"); + return 1; + } + }; + match runtime.block_on(crate::apply_patch( + &patch_arg, + &cwd, + &mut stdout, + &mut stderr, + codex_exec_server::LOCAL_FS.as_ref(), + /*sandbox*/ None, + )) { + Ok(_) => { // Flush to ensure output ordering when used in pipelines. let _ = stdout.flush(); 0 diff --git a/code-rs/apply-patch/src/streaming_parser.rs b/code-rs/apply-patch/src/streaming_parser.rs new file mode 100644 index 00000000000..4acfad67208 --- /dev/null +++ b/code-rs/apply-patch/src/streaming_parser.rs @@ -0,0 +1,813 @@ +use std::path::PathBuf; + +use crate::parser::ADD_FILE_MARKER; +use crate::parser::BEGIN_PATCH_MARKER; +use crate::parser::CHANGE_CONTEXT_MARKER; +use crate::parser::DELETE_FILE_MARKER; +use crate::parser::EMPTY_CHANGE_CONTEXT_MARKER; +use crate::parser::END_PATCH_MARKER; +use crate::parser::EOF_MARKER; +use crate::parser::Hunk; +use crate::parser::MOVE_TO_MARKER; +use crate::parser::ParseError; +use crate::parser::UPDATE_FILE_MARKER; +use crate::parser::UpdateFileChunk; + +use Hunk::*; +use ParseError::*; + +#[derive(Debug, Default, Clone)] +pub struct StreamingPatchParser { + line_buffer: String, + state: StreamingParserState, + line_number: usize, +} + +#[derive(Debug, Default, Clone)] +struct StreamingParserState { + mode: StreamingParserMode, + hunks: Vec, +} + +#[derive(Debug, Default, Clone)] +enum StreamingParserMode { + #[default] + NotStarted, + StartedPatch, + AddFile, + DeleteFile, + UpdateFile { + hunk_line_number: usize, + }, + EndedPatch, +} + +impl StreamingPatchParser { + fn ensure_update_hunk_is_not_empty(&self, line: &str) -> Result<(), ParseError> { + if let Some(UpdateFile { path, chunks, .. }) = self.state.hunks.last() { + if chunks.is_empty() + && let StreamingParserMode::UpdateFile { hunk_line_number } = self.state.mode + { + return Err(InvalidHunkError { + message: format!("Update file hunk for path '{}' is empty", path.display()), + line_number: hunk_line_number, + }); + } + if chunks + .last() + .is_some_and(|chunk| chunk.old_lines.is_empty() && chunk.new_lines.is_empty()) + { + if line == END_PATCH_MARKER { + return Err(InvalidHunkError { + message: "Update hunk does not contain any lines".to_string(), + line_number: self.line_number, + }); + } + return Err(InvalidHunkError { + message: format!( + "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" + ), + line_number: self.line_number, + }); + } + } + Ok(()) + } + + fn handle_hunk_headers_and_end_patch(&mut self, trimmed: &str) -> Result { + if trimmed == END_PATCH_MARKER { + self.ensure_update_hunk_is_not_empty(trimmed)?; + self.state.mode = StreamingParserMode::EndedPatch; + return Ok(true); + } + if let Some(path) = trimmed.strip_prefix(ADD_FILE_MARKER) { + self.ensure_update_hunk_is_not_empty(trimmed)?; + self.state.hunks.push(AddFile { + path: PathBuf::from(path), + contents: String::new(), + }); + self.state.mode = StreamingParserMode::AddFile; + return Ok(true); + } + if let Some(path) = trimmed.strip_prefix(DELETE_FILE_MARKER) { + self.ensure_update_hunk_is_not_empty(trimmed)?; + self.state.hunks.push(DeleteFile { + path: PathBuf::from(path), + }); + self.state.mode = StreamingParserMode::DeleteFile; + return Ok(true); + } + if let Some(path) = trimmed.strip_prefix(UPDATE_FILE_MARKER) { + self.ensure_update_hunk_is_not_empty(trimmed)?; + self.state.hunks.push(UpdateFile { + path: PathBuf::from(path), + move_path: None, + chunks: Vec::new(), + }); + self.state.mode = StreamingParserMode::UpdateFile { + hunk_line_number: self.line_number, + }; + return Ok(true); + } + Ok(false) + } + + pub fn push_delta(&mut self, delta: &str) -> Result, ParseError> { + for ch in delta.chars() { + if ch == '\n' { + let mut line = std::mem::take(&mut self.line_buffer); + line.truncate(line.strip_suffix('\r').map_or(line.len(), str::len)); + self.line_number += 1; + self.process_line(&line)?; + } else { + self.line_buffer.push(ch); + } + } + + Ok(self.state.hunks.clone()) + } + + pub fn finish(&mut self) -> Result, ParseError> { + if !self.line_buffer.is_empty() { + let line = std::mem::take(&mut self.line_buffer); + self.line_number += 1; + if line.trim() == END_PATCH_MARKER { + self.ensure_update_hunk_is_not_empty(line.trim())?; + self.state.mode = StreamingParserMode::EndedPatch; + } else { + self.process_line(&line)?; + } + } + + if !matches!(self.state.mode, StreamingParserMode::EndedPatch) { + return Err(InvalidPatchError( + "The last line of the patch must be '*** End Patch'".to_string(), + )); + } + + Ok(self.state.hunks.clone()) + } + + fn process_line(&mut self, line: &str) -> Result<(), ParseError> { + let trimmed = line.trim(); + match self.state.mode.clone() { + StreamingParserMode::NotStarted => { + if trimmed == BEGIN_PATCH_MARKER { + self.state.mode = StreamingParserMode::StartedPatch; + return Ok(()); + } + Err(InvalidPatchError( + "The first line of the patch must be '*** Begin Patch'".to_string(), + )) + } + StreamingParserMode::StartedPatch => { + if self.handle_hunk_headers_and_end_patch(trimmed)? { + return Ok(()); + } + Err(InvalidHunkError { + message: format!( + "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'" + ), + line_number: self.line_number, + }) + } + StreamingParserMode::AddFile => { + if self.handle_hunk_headers_and_end_patch(trimmed)? { + return Ok(()); + } + if let Some(line_to_add) = line.strip_prefix('+') + && let Some(AddFile { contents, .. }) = self.state.hunks.last_mut() + { + contents.push_str(line_to_add); + contents.push('\n'); + return Ok(()); + } + Err(InvalidHunkError { + message: format!( + "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'" + ), + line_number: self.line_number, + }) + } + StreamingParserMode::DeleteFile => { + if self.handle_hunk_headers_and_end_patch(trimmed)? { + return Ok(()); + } + Err(InvalidHunkError { + message: format!( + "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'" + ), + line_number: self.line_number, + }) + } + StreamingParserMode::UpdateFile { hunk_line_number } => { + let update_line = line.trim_end(); + if self.handle_hunk_headers_and_end_patch(update_line)? { + return Ok(()); + } + + if let Some(UpdateFile { + move_path, chunks, .. + }) = self.state.hunks.last_mut() + { + if chunks.is_empty() + && move_path.is_none() + && let Some(move_to_path) = update_line.strip_prefix(MOVE_TO_MARKER) + { + *move_path = Some(PathBuf::from(move_to_path)); + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if (update_line == EMPTY_CHANGE_CONTEXT_MARKER + || update_line.starts_with(CHANGE_CONTEXT_MARKER)) + && chunks.last().is_some_and(|chunk| { + chunk.old_lines.is_empty() && chunk.new_lines.is_empty() + }) + { + return Err(InvalidHunkError { + message: format!( + "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" + ), + line_number: self.line_number, + }); + } + + if update_line == EMPTY_CHANGE_CONTEXT_MARKER { + chunks.push(UpdateFileChunk { + change_context: None, + old_lines: Vec::new(), + new_lines: Vec::new(), + is_end_of_file: false, + }); + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if let Some(change_context) = update_line.strip_prefix(CHANGE_CONTEXT_MARKER) { + chunks.push(UpdateFileChunk { + change_context: Some(change_context.to_string()), + old_lines: Vec::new(), + new_lines: Vec::new(), + is_end_of_file: false, + }); + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if update_line == EOF_MARKER { + if chunks.last().is_some_and(|chunk| { + chunk.old_lines.is_empty() && chunk.new_lines.is_empty() + }) { + return Err(InvalidHunkError { + message: "Update hunk does not contain any lines".to_string(), + line_number: self.line_number, + }); + } + if let Some(chunk) = chunks.last_mut() { + chunk.is_end_of_file = true; + } + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if line.is_empty() { + if chunks.is_empty() { + chunks.push(UpdateFileChunk { + change_context: None, + old_lines: Vec::new(), + new_lines: Vec::new(), + is_end_of_file: false, + }); + } + if let Some(chunk) = chunks.last_mut() { + chunk.old_lines.push(String::new()); + chunk.new_lines.push(String::new()); + } + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if let Some(line_to_add) = line.strip_prefix(' ') { + if chunks.is_empty() { + chunks.push(UpdateFileChunk { + change_context: None, + old_lines: Vec::new(), + new_lines: Vec::new(), + is_end_of_file: false, + }); + } + if let Some(chunk) = chunks.last_mut() { + chunk.old_lines.push(line_to_add.to_string()); + chunk.new_lines.push(line_to_add.to_string()); + } + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if let Some(line_to_add) = line.strip_prefix('+') { + if chunks.is_empty() { + chunks.push(UpdateFileChunk { + change_context: None, + old_lines: Vec::new(), + new_lines: Vec::new(), + is_end_of_file: false, + }); + } + if let Some(chunk) = chunks.last_mut() { + chunk.new_lines.push(line_to_add.to_string()); + } + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if let Some(line_to_remove) = line.strip_prefix('-') { + if chunks.is_empty() { + chunks.push(UpdateFileChunk { + change_context: None, + old_lines: Vec::new(), + new_lines: Vec::new(), + is_end_of_file: false, + }); + } + if let Some(chunk) = chunks.last_mut() { + chunk.old_lines.push(line_to_remove.to_string()); + } + self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number }; + return Ok(()); + } + + if chunks.last().is_some_and(|chunk| { + !chunk.old_lines.is_empty() || !chunk.new_lines.is_empty() + }) { + return Err(InvalidHunkError { + message: format!( + "Expected update hunk to start with a @@ context marker, got: '{line}'" + ), + line_number: self.line_number, + }); + } + } + Err(InvalidHunkError { + message: format!( + "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" + ), + line_number: self.line_number, + }) + } + StreamingParserMode::EndedPatch => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + use super::*; + + #[test] + fn test_streaming_patch_parser_streams_complete_lines_before_end_patch() { + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"), + Ok(vec![AddFile { + path: PathBuf::from("src/hello.txt"), + contents: "hello\n".to_string(), + }]) + ); + assert_eq!( + parser.push_delta("ld\n"), + Ok(vec![AddFile { + path: PathBuf::from("src/hello.txt"), + contents: "hello\nworld\n".to_string(), + }]) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta( + "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n", + ), + Ok(vec![UpdateFile { + path: PathBuf::from("src/old.rs"), + move_path: Some(PathBuf::from("src/new.rs")), + chunks: vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["old".to_string()], + new_lines: vec!["new".to_string()], + is_end_of_file: false, + }], + }]) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Delete File: gone.txt"), + Ok(Vec::new()) + ); + assert_eq!( + parser.push_delta("\n"), + Ok(vec![DeleteFile { + path: PathBuf::from("gone.txt"), + }]) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta( + "*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n", + ), + Ok(vec![ + AddFile { + path: PathBuf::from("src/one.txt"), + contents: "one\n".to_string(), + }, + DeleteFile { + path: PathBuf::from("src/two.txt"), + }, + ]) + ); + } + + #[test] + fn test_streaming_patch_parser_large_patch_split_by_character() { + let patch = "\ +*** Begin Patch +*** Add File: docs/release-notes.md ++# Release notes ++ ++## CLI ++- Surface apply_patch progress while arguments stream. ++- Keep final patch application gated on the completed tool call. ++- Include file summaries in the progress event payload. +*** Update File: src/config.rs +@@ impl Config +- pub apply_patch_progress: bool, ++ pub stream_apply_patch_progress: bool, + pub include_diagnostics: bool, +@@ fn default_progress_interval() +- Duration::from_millis(500) ++ Duration::from_millis(250) +*** Delete File: src/legacy_patch_progress.rs +*** Update File: crates/cli/src/main.rs +*** Move to: crates/cli/src/bin/codex.rs +@@ fn run() +- let args = Args::parse(); +- dispatch(args) ++ let cli = Cli::parse(); ++ dispatch(cli) +*** Add File: tests/fixtures/apply_patch_progress.json ++{ ++ \"type\": \"apply_patch_progress\", ++ \"hunks\": [ ++ { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" }, ++ { \"operation\": \"update\", \"path\": \"src/config.rs\" } ++ ] ++} +*** Update File: README.md +@@ Development workflow + Build the Rust workspace before opening a pull request. ++When touching streamed tool calls, include parser coverage for partial input. ++Prefer tests that exercise the exact event payload shape. +*** Delete File: docs/old-apply-patch-progress.md +*** End Patch"; + + let mut parser = StreamingPatchParser::default(); + let mut max_hunk_count = 0; + let mut saw_hunk_counts = Vec::new(); + let mut hunks = Vec::new(); + for ch in patch.chars() { + let updated_hunks = parser.push_delta(&ch.to_string()).unwrap(); + if !updated_hunks.is_empty() { + let hunk_count = updated_hunks.len(); + assert!( + hunk_count >= max_hunk_count, + "hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count}", + ); + if hunk_count > max_hunk_count { + saw_hunk_counts.push(hunk_count); + max_hunk_count = hunk_count; + } + hunks = updated_hunks; + } + } + + assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]); + assert_eq!(hunks.len(), 7); + assert_eq!( + hunks + .iter() + .map(|hunk| match hunk { + AddFile { .. } => "add", + DeleteFile { .. } => "delete", + UpdateFile { + move_path: Some(_), .. + } => "move-update", + UpdateFile { + move_path: None, .. + } => "update", + }) + .collect::>(), + vec![ + "add", + "update", + "delete", + "move-update", + "add", + "update", + "delete" + ] + ); + } + + #[test] + fn test_streaming_patch_parser_keeps_indented_update_markers_as_context_lines() { + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta( + "\ +*** Begin Patch +*** Update File: a.txt +@@ +-old a ++new a + *** Update File: b.txt +@@ +-old b ++new b +*** End Patch +", + ), + Ok(vec![UpdateFile { + path: PathBuf::from("a.txt"), + move_path: None, + chunks: vec![ + UpdateFileChunk { + change_context: None, + old_lines: vec!["old a".to_string(), "*** Update File: b.txt".to_string()], + new_lines: vec!["new a".to_string(), "*** Update File: b.txt".to_string()], + is_end_of_file: false, + }, + UpdateFileChunk { + change_context: None, + old_lines: vec!["old b".to_string()], + new_lines: vec!["new b".to_string()], + is_end_of_file: false, + }, + ], + }]) + ); + } + + #[test] + fn test_streaming_patch_parser_preserves_bare_empty_update_lines() { + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta( + "\ +*** Begin Patch +*** Update File: file.txt +@@ + context before + + context after +*** End Patch +", + ), + Ok(vec![UpdateFile { + path: PathBuf::from("file.txt"), + move_path: None, + chunks: vec![UpdateFileChunk { + change_context: None, + // The normal parser treats a bare empty line in an update hunk as an + // empty context line. Preserve that leniency in the streaming parser. + old_lines: vec![ + "context before".to_string(), + String::new(), + "context after".to_string(), + ], + new_lines: vec![ + "context before".to_string(), + String::new(), + "context after".to_string(), + ], + is_end_of_file: false, + }], + }]) + ); + } + + #[test] + fn test_streaming_patch_parser_matches_line_ending_behavior() { + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n"), + Ok(vec![UpdateFile { + path: PathBuf::from("file.txt"), + move_path: None, + chunks: vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["old".to_string()], + new_lines: vec!["new".to_string()], + is_end_of_file: false, + }], + }]) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n"), + Ok(vec![UpdateFile { + path: PathBuf::from("file.txt"), + move_path: None, + chunks: vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["old\r".to_string()], + new_lines: vec!["new".to_string()], + is_end_of_file: false, + }], + }]) + ); + } + + #[test] + fn test_streaming_patch_parser_finish_processes_final_line_without_newline() { + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch"), + Ok(vec![AddFile { + path: PathBuf::from("file.txt"), + contents: "hello\n".to_string(), + }]) + ); + assert_eq!( + parser.finish(), + Ok(vec![AddFile { + path: PathBuf::from("file.txt"), + contents: "hello\n".to_string(), + }]) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta( + "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n *** End Patch", + ), + Ok(vec![UpdateFile { + path: PathBuf::from("file.txt"), + move_path: None, + chunks: vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["old".to_string()], + new_lines: vec!["new".to_string()], + is_end_of_file: false, + }], + }]) + ); + assert_eq!( + parser.finish(), + Ok(vec![UpdateFile { + path: PathBuf::from("file.txt"), + move_path: None, + chunks: vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["old".to_string()], + new_lines: vec!["new".to_string()], + is_end_of_file: false, + }], + }]) + ); + } + + #[test] + fn test_streaming_patch_parser_finish_requires_end_patch() { + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n"), + Ok(vec![AddFile { + path: PathBuf::from("file.txt"), + contents: "hello\n".to_string(), + }]) + ); + assert_eq!( + parser.finish(), + Err(InvalidPatchError( + "The last line of the patch must be '*** End Patch'".to_string(), + )) + ); + } + + #[test] + fn test_streaming_patch_parser_returns_errors() { + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("bad\n"), + Err(InvalidPatchError( + "The first line of the patch must be '*** Begin Patch'".to_string(), + )) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!(parser.push_delta("*** Begin Patch\n"), Ok(Vec::new())); + assert_eq!( + parser.push_delta("bad\n"), + 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: 2, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Add File: file.txt\nbad\n"), + 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: 3, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Delete File: file.txt\nbad\n"), + 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: 3, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n*** End Patch\n"), + Err(InvalidHunkError { + message: "Update file hunk for path 'file.txt' is empty".to_string(), + line_number: 2, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta( + "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n", + ), + Err(InvalidHunkError { + message: "Update file hunk for path 'old.txt' is empty".to_string(), + line_number: 2, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch\n"), + Err(InvalidHunkError { + message: "Update hunk does not contain any lines".to_string(), + line_number: 4, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n"), + Err(InvalidHunkError { + message: "Update hunk does not contain any lines".to_string(), + line_number: 4, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n"), + Err(InvalidHunkError { + message: "Unexpected line found in update hunk: '@@'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" + .to_string(), + line_number: 4, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n"), + Err(InvalidHunkError { + message: "Expected update hunk to start with a @@ context marker, got: 'bad'" + .to_string(), + line_number: 5, + }) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta( + "*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n", + ), + Err(InvalidHunkError { + message: "Unexpected line found in update hunk: '*** Update File: other.txt'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)" + .to_string(), + line_number: 4, + }) + ); + } +} diff --git a/code-rs/apply-patch/src/tree_sitter_utils.rs b/code-rs/apply-patch/src/tree_sitter_utils.rs deleted file mode 100644 index 456121e45af..00000000000 --- a/code-rs/apply-patch/src/tree_sitter_utils.rs +++ /dev/null @@ -1,138 +0,0 @@ -use crate::{EmbeddedApplyPatch}; - -/// Locate an embedded `apply_patch < &&` 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, ()> { - 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 = 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 &&` - 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 = None; - let mut stmt_begin = start; - { - let prefix = before_apply.trim_end(); - // Try to match "cd &&" 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) -} diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/expected/keep.txt b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/expected/keep.txt new file mode 100644 index 00000000000..2fa992c0b8b --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/expected/keep.txt @@ -0,0 +1 @@ +keep diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/input/keep.txt b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/input/keep.txt new file mode 100644 index 00000000000..2fa992c0b8b --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/input/keep.txt @@ -0,0 +1 @@ +keep diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/input/obsolete.txt b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/input/obsolete.txt new file mode 100644 index 00000000000..6e263abce10 --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/input/obsolete.txt @@ -0,0 +1 @@ +obsolete diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/patch.txt b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/patch.txt new file mode 100644 index 00000000000..5978f738894 --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/020_delete_file_success/patch.txt @@ -0,0 +1,3 @@ +*** Begin Patch +*** Delete File: obsolete.txt +*** End Patch diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/expected/file.txt b/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/expected/file.txt new file mode 100644 index 00000000000..f719efd430d --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/expected/file.txt @@ -0,0 +1 @@ +two diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/input/file.txt b/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/input/file.txt new file mode 100644 index 00000000000..5626abf0f72 --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/input/file.txt @@ -0,0 +1 @@ +one diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/patch.txt b/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/patch.txt new file mode 100644 index 00000000000..3d2a1dbe5ec --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/patch.txt @@ -0,0 +1,6 @@ +*** Begin Patch +*** Update File: file.txt +@@ +-one ++two + *** End Patch diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/expected/lines.txt b/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/expected/lines.txt new file mode 100644 index 00000000000..8129d305c8e --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/expected/lines.txt @@ -0,0 +1,2 @@ +line1 +line3 diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/input/lines.txt b/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/input/lines.txt new file mode 100644 index 00000000000..83db48f84ec --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/input/lines.txt @@ -0,0 +1,3 @@ +line1 +line2 +line3 diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/patch.txt b/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/patch.txt new file mode 100644 index 00000000000..860c6c9a990 --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/021_update_file_deletion_only/patch.txt @@ -0,0 +1,7 @@ +*** Begin Patch +*** Update File: lines.txt +@@ + line1 +-line2 + line3 +*** End Patch diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/expected/tail.txt b/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/expected/tail.txt new file mode 100644 index 00000000000..87463f92d71 --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/expected/tail.txt @@ -0,0 +1,2 @@ +first +second updated diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/input/tail.txt b/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/input/tail.txt new file mode 100644 index 00000000000..66a52ee7a1d --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/input/tail.txt @@ -0,0 +1,2 @@ +first +second diff --git a/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/patch.txt b/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/patch.txt new file mode 100644 index 00000000000..8b16b5bd9ee --- /dev/null +++ b/code-rs/apply-patch/tests/fixtures/scenarios/022_update_file_end_of_file_marker/patch.txt @@ -0,0 +1,8 @@ +*** Begin Patch +*** Update File: tail.txt +@@ + first +-second ++second updated +*** End of File +*** End Patch diff --git a/code-rs/apply-patch/tests/suite/cli.rs b/code-rs/apply-patch/tests/suite/cli.rs index 268c629bd71..c982c7aa864 100644 --- a/code-rs/apply-patch/tests/suite/cli.rs +++ b/code-rs/apply-patch/tests/suite/cli.rs @@ -1,6 +1,13 @@ +use assert_cmd::Command; use std::fs; use tempfile::tempdir; +fn apply_patch_command() -> anyhow::Result { + Ok(Command::new(codex_utils_cargo_bin::cargo_bin( + "apply_patch", + )?)) +} + #[test] fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -14,7 +21,7 @@ fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> { +hello *** End Patch"# ); - assert_cmd::cargo::cargo_bin_cmd!("apply_patch") + apply_patch_command()? .arg(add_patch) .current_dir(tmp.path()) .assert() @@ -31,7 +38,7 @@ fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> { +world *** End Patch"# ); - assert_cmd::cargo::cargo_bin_cmd!("apply_patch") + apply_patch_command()? .arg(update_patch) .current_dir(tmp.path()) .assert() @@ -55,9 +62,9 @@ fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> { +hello *** End Patch"# ); - let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("apply_patch"); - cmd.current_dir(tmp.path()); - cmd.write_stdin(add_patch) + apply_patch_command()? + .current_dir(tmp.path()) + .write_stdin(add_patch) .assert() .success() .stdout(format!("Success. Updated the following files:\nA {file}\n")); @@ -72,9 +79,9 @@ fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> { +world *** End Patch"# ); - let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("apply_patch"); - cmd.current_dir(tmp.path()); - cmd.write_stdin(update_patch) + apply_patch_command()? + .current_dir(tmp.path()) + .write_stdin(update_patch) .assert() .success() .stdout(format!("Success. Updated the following files:\nM {file}\n")); diff --git a/code-rs/apply-patch/tests/suite/scenarios.rs b/code-rs/apply-patch/tests/suite/scenarios.rs index 9fbd818096f..3beb7d652d5 100644 --- a/code-rs/apply-patch/tests/suite/scenarios.rs +++ b/code-rs/apply-patch/tests/suite/scenarios.rs @@ -1,13 +1,21 @@ +use codex_utils_cargo_bin::repo_root; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::fs; use std::path::Path; use std::path::PathBuf; +use std::process::Command; use tempfile::tempdir; #[test] fn test_apply_patch_scenarios() -> anyhow::Result<()> { - for scenario in fs::read_dir("tests/fixtures/scenarios")? { + let scenarios_dir = repo_root()? + .join("codex-rs") + .join("apply-patch") + .join("tests") + .join("fixtures") + .join("scenarios"); + for scenario in fs::read_dir(scenarios_dir)? { let scenario = scenario?; let path = scenario.path(); if path.is_dir() { @@ -34,7 +42,7 @@ fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> { // Run apply_patch in the temporary directory. We intentionally do not assert // on the exit status here; the scenarios are specified purely in terms of // final filesystem state, which we compare below. - assert_cmd::cargo::cargo_bin_cmd!("apply_patch") + Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?) .arg(patch) .current_dir(tmp.path()) .output()?; @@ -80,11 +88,15 @@ fn snapshot_dir_recursive( continue; }; let rel = stripped.to_path_buf(); - let file_type = entry.file_type()?; - if file_type.is_dir() { + + // Under Buck2, files in `__srcs` are often materialized as symlinks. + // Use `metadata()` (follows symlinks) so our fixture snapshots work + // under both Cargo and Buck2. + let metadata = fs::metadata(&path)?; + if metadata.is_dir() { entries.insert(rel.clone(), Entry::Dir); snapshot_dir_recursive(base, &path, entries)?; - } else if file_type.is_file() { + } else if metadata.is_file() { let contents = fs::read(&path)?; entries.insert(rel, Entry::File(contents)); } @@ -96,12 +108,14 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> { for entry in fs::read_dir(src)? { let entry = entry?; let path = entry.path(); - let file_type = entry.file_type()?; let dest_path = dst.join(entry.file_name()); - if file_type.is_dir() { + + // See note in `snapshot_dir_recursive` about Buck2 symlink trees. + let metadata = fs::metadata(&path)?; + if metadata.is_dir() { fs::create_dir_all(&dest_path)?; copy_dir_recursive(&path, &dest_path)?; - } else if file_type.is_file() { + } else if metadata.is_file() { if let Some(parent) = dest_path.parent() { fs::create_dir_all(parent)?; } diff --git a/code-rs/apply-patch/tests/suite/tool.rs b/code-rs/apply-patch/tests/suite/tool.rs index c29ee16b40b..8499d0fb906 100644 --- a/code-rs/apply-patch/tests/suite/tool.rs +++ b/code-rs/apply-patch/tests/suite/tool.rs @@ -2,23 +2,29 @@ use assert_cmd::Command; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; +use std::path::PathBuf; use tempfile::tempdir; fn run_apply_patch_in_dir(dir: &Path, patch: &str) -> anyhow::Result { - let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("apply_patch"); + let mut cmd = Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?); cmd.current_dir(dir); Ok(cmd.arg(patch).assert()) } fn apply_patch_command(dir: &Path) -> anyhow::Result { - let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("apply_patch"); + let mut cmd = Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?); cmd.current_dir(dir); Ok(cmd) } +fn resolved_under(root: &Path, path: &str) -> anyhow::Result { + Ok(root.canonicalize()?.join(path)) +} + #[test] fn test_apply_patch_cli_applies_multiple_operations() -> anyhow::Result<()> { let tmp = tempdir()?; + let add_path = tmp.path().join("nested/new.txt"); let modify_path = tmp.path().join("modify.txt"); let delete_path = tmp.path().join("delete.txt"); @@ -31,10 +37,7 @@ fn test_apply_patch_cli_applies_multiple_operations() -> anyhow::Result<()> { "Success. Updated the following files:\nA nested/new.txt\nM modify.txt\nD delete.txt\n", ); - assert_eq!( - fs::read_to_string(tmp.path().join("nested/new.txt"))?, - "created\n" - ); + assert_eq!(fs::read_to_string(add_path)?, "created\n"); assert_eq!(fs::read_to_string(&modify_path)?, "line1\nchanged\n"); assert!(!delete_path.exists()); @@ -98,13 +101,17 @@ fn test_apply_patch_cli_rejects_empty_patch() -> anyhow::Result<()> { fn test_apply_patch_cli_reports_missing_context() -> anyhow::Result<()> { let tmp = tempdir()?; let target_path = tmp.path().join("modify.txt"); + let expected_target_path = resolved_under(tmp.path(), "modify.txt")?; fs::write(&target_path, "line1\nline2\n")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch") .assert() .failure() - .stderr("Failed to find expected lines in modify.txt:\nmissing\n"); + .stderr(format!( + "Failed to find expected lines in {}:\nmissing\n", + expected_target_path.display() + )); assert_eq!(fs::read_to_string(&target_path)?, "line1\nline2\n"); Ok(()) @@ -113,12 +120,16 @@ fn test_apply_patch_cli_reports_missing_context() -> anyhow::Result<()> { #[test] fn test_apply_patch_cli_rejects_missing_file_delete() -> anyhow::Result<()> { let tmp = tempdir()?; + let missing_path = resolved_under(tmp.path(), "missing.txt")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch") .assert() .failure() - .stderr("Failed to delete file missing.txt\n"); + .stderr(format!( + "Failed to delete file {}\n", + missing_path.display() + )); Ok(()) } @@ -139,14 +150,16 @@ fn test_apply_patch_cli_rejects_empty_update_hunk() -> anyhow::Result<()> { #[test] fn test_apply_patch_cli_requires_existing_file_for_update() -> anyhow::Result<()> { let tmp = tempdir()?; + let missing_path = resolved_under(tmp.path(), "missing.txt")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") .assert() .failure() - .stderr( - "Failed to read file to update missing.txt: No such file or directory (os error 2)\n", - ); + .stderr(format!( + "Failed to read file to update {}: No such file or directory (os error 2)\n", + missing_path.display() + )); Ok(()) } @@ -195,13 +208,18 @@ fn test_apply_patch_cli_add_overwrites_existing_file() -> anyhow::Result<()> { #[test] fn test_apply_patch_cli_delete_directory_fails() -> anyhow::Result<()> { let tmp = tempdir()?; - fs::create_dir(tmp.path().join("dir"))?; + let dir = tmp.path().join("dir"); + let expected_dir = resolved_under(tmp.path(), "dir")?; + fs::create_dir(&dir)?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Delete File: dir\n*** End Patch") .assert() .failure() - .stderr("Failed to delete file dir\n"); + .stderr(format!( + "Failed to delete file {}\n", + expected_dir.display() + )); Ok(()) } @@ -243,13 +261,17 @@ fn test_apply_patch_cli_updates_file_appends_trailing_newline() -> anyhow::Resul fn test_apply_patch_cli_failure_after_partial_success_leaves_changes() -> anyhow::Result<()> { let tmp = tempdir()?; let new_file = tmp.path().join("created.txt"); + let missing_file = resolved_under(tmp.path(), "missing.txt")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") .assert() .failure() .stdout("") - .stderr("Failed to read file to update missing.txt: No such file or directory (os error 2)\n"); + .stderr(format!( + "Failed to read file to update {}: No such file or directory (os error 2)\n", + missing_file.display() + )); assert_eq!(fs::read_to_string(&new_file)?, "hello\n"); diff --git a/code-rs/arg0/BUILD.bazel b/code-rs/arg0/BUILD.bazel new file mode 100644 index 00000000000..4493ee15047 --- /dev/null +++ b/code-rs/arg0/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "arg0", + crate_name = "codex_arg0", +) diff --git a/code-rs/arg0/Cargo.toml b/code-rs/arg0/Cargo.toml index cb881597485..7ee21a770e4 100644 --- a/code-rs/arg0/Cargo.toml +++ b/code-rs/arg0/Cargo.toml @@ -1,20 +1,26 @@ [package] -edition = "2024" -name = "code-arg0" -version = { workspace = true } +name = "codex-arg0" +version.workspace = true +edition.workspace = true +license.workspace = true [lib] -name = "code_arg0" +name = "codex_arg0" path = "src/lib.rs" +doctest = false [lints] workspace = true [dependencies] anyhow = { workspace = true } -code-apply-patch = { workspace = true } -code-core = { workspace = true } -code-linux-sandbox = { workspace = true } +codex-apply-patch = { workspace = true } +codex-exec-server = { workspace = true } +codex-linux-sandbox = { workspace = true } +codex-sandboxing = { workspace = true } +codex-shell-escalation = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/code-rs/arg0/src/lib.rs b/code-rs/arg0/src/lib.rs index c194771821b..2f6ae4653c6 100644 --- a/code-rs/arg0/src/lib.rs +++ b/code-rs/arg0/src/lib.rs @@ -1,44 +1,57 @@ +use std::fs::File; 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; +use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_exec_server::CODEX_FS_HELPER_ARG1; +use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; +use codex_utils_home_dir::find_codex_home; #[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"; +#[cfg(unix)] +const EXECVE_WRAPPER_ARG0: &str = "codex-execve-wrapper"; +const LOCK_FILENAME: &str = ".lock"; +const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Arg0DispatchPaths { + /// Stable path to the current Codex executable for child re-execs. + /// + /// Prefer this over [`std::env::current_exe()`] in code that may run under + /// a test harness, where `current_exe()` can point at the harness binary + /// instead of the real Codex CLI. + pub codex_self_exe: Option, + pub codex_linux_sandbox_exe: Option, + pub main_execve_wrapper_exe: Option, +} -/// 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`, 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(main_fn: F) -> anyhow::Result<()> -where - F: FnOnce(Option) -> Fut, - Fut: Future>, -{ +/// Keeps the per-session PATH entry alive and locked for the process lifetime. +pub struct Arg0PathEntryGuard { + _temp_dir: TempDir, + _lock_file: File, + paths: Arg0DispatchPaths, +} + +impl Arg0PathEntryGuard { + fn new(temp_dir: TempDir, lock_file: File, paths: Arg0DispatchPaths) -> Self { + Self { + _temp_dir: temp_dir, + _lock_file: lock_file, + paths, + } + } + + pub fn paths(&self) -> &Arg0DispatchPaths { + &self.paths + } +} + +pub fn arg0_dispatch() -> Option { // Determine if we were invoked via the special alias. let mut args = std::env::args_os(); let argv0 = args.next().unwrap_or_default(); @@ -47,27 +60,74 @@ where .and_then(|s| s.to_str()) .unwrap_or(""); - if exe_name == LINUX_SANDBOX_ARG0 { + #[cfg(unix)] + if exe_name == EXECVE_WRAPPER_ARG0 { + let mut args = std::env::args(); + let _ = args.next(); + let file = match args.next() { + Some(file) => file, + None => std::process::exit(1), + }; + let argv = args.collect::>(); + + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(_) => std::process::exit(1), + }; + let exit_code = runtime.block_on( + codex_shell_escalation::run_shell_escalation_execve_wrapper(file, argv), + ); + match exit_code { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(1), + } + } + + if exe_name == CODEX_LINUX_SANDBOX_ARG0 { // Safety: [`run_main`] never returns. - code_linux_sandbox::run_main(); + codex_linux_sandbox::run_main(); } else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 { - code_apply_patch::main(); + codex_apply_patch::main(); } let argv1 = args.next().unwrap_or_default(); - if argv1 == CODEX_APPLY_PATCH_ARG1 { + if argv1 == CODEX_FS_HELPER_ARG1 { + codex_exec_server::run_fs_helper_main(); + } + if argv1 == CODEX_CORE_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, + let cwd = match codex_utils_absolute_path::AbsolutePathBuf::current_dir() { + Ok(cwd) => cwd, + Err(_) => std::process::exit(1), + }; + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(_) => std::process::exit(1), + }; + match runtime.block_on(codex_apply_patch::apply_patch( + &patch_arg, + &cwd, + &mut stdout, + &mut stderr, + codex_exec_server::LOCAL_FS.as_ref(), + /*sandbox*/ None, + )) { + Ok(_) => 0, Err(_) => 1, } } None => { - eprintln!("Error: {CODEX_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); + eprintln!("Error: {CODEX_CORE_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); 1 } }; @@ -78,10 +138,7 @@ where // 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() { + match prepend_path_entry_for_codex_aliases() { Ok(path_entry) => Some(path_entry), Err(err) => { // It is possible that Codex will proceed successfully even if @@ -89,86 +146,110 @@ where eprintln!("WARNING: proceeding, even though we could not update PATH: {err}"); None } - }; + } +} + +/// 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 +/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: +/// +/// 1. Load `.env` values from `~/.codex/.env` before creating any threads. +/// 2. Construct a Tokio multi-thread runtime. +/// 3. Capture the current executable path and derive the +/// `codex-linux-sandbox` helper path (falling back to the current +/// executable if needed) 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 [`Arg0DispatchPaths`], which +/// contains the helper executable paths needed to construct +/// [`codex_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(main_fn: F) -> anyhow::Result<()> +where + F: FnOnce(Arg0DispatchPaths) -> Fut, + Fut: Future>, +{ + // 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_guard = arg0_dispatch(); // 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 = if cfg!(target_os = "linux") { - std::env::current_exe().ok() + let runtime = build_runtime()?; + runtime.block_on(run_main_with_arg0_guard( + path_entry_guard, + std::env::current_exe().ok(), + main_fn, + )) +} + +async fn run_main_with_arg0_guard( + path_entry_guard: Option, + current_exe: Option, + main_fn: F, +) -> anyhow::Result<()> +where + F: FnOnce(Arg0DispatchPaths) -> Fut, + Fut: Future>, +{ + let paths = Arg0DispatchPaths { + codex_self_exe: current_exe.clone(), + codex_linux_sandbox_exe: if cfg!(target_os = "linux") { + linux_sandbox_exe_path(path_entry_guard.as_ref(), current_exe) } else { None - }; + }, + main_execve_wrapper_exe: path_entry_guard + .as_ref() + .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), + }; - main_fn(code_linux_sandbox_exe).await - }) + let result = main_fn(paths).await; + // Keep the arg0 tempdir guard alive until the async entry point finishes; + // runtime paths above can point at aliases inside that directory. + drop(path_entry_guard); + result +} + +fn linux_sandbox_exe_path( + path_entry_guard: Option<&Arg0PathEntryGuard>, + current_exe: Option, +) -> Option { + // Prefer the `codex-linux-sandbox` alias when available so callers can + // re-exec through a path whose basename still triggers arg0 dispatch on + // bubblewrap builds that do not support `--argv0`. + path_entry_guard + .and_then(|path_entry| path_entry.paths().codex_linux_sandbox_exe.clone()) + .or(current_exe) +} + +fn build_runtime() -> anyhow::Result { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.enable_all(); + builder.thread_stack_size(TOKIO_WORKER_STACK_SIZE_BYTES); + Ok(builder.build()?) } const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_"; -/// Load env vars from ~/.code/.env (legacy ~/.codex/.env is still read) and `$(pwd)/.env`. +/// Load env vars from ~/.codex/.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, ANTHROPIC_API_KEY, - // GOOGLE_API_KEY, QWEN_API_KEY). - // - Users can opt back in via CODEX_ALLOW_PROJECT_OPENAI_KEYS=1 (either exported - // in the shell or placed in ~/.code/.env). - // NOTE: use an explicit cwd/.env path instead of dotenv_iter() because - // dotenv_iter() walks parent directories. Parent-walking can accidentally - // load unrelated files such as ~/.env when the working directory is nested - // under $HOME. - if let Ok(cwd) = std::env::current_dir() { - let project_env_path = cwd.join(".env"); - if let Ok(iter) = dotenvy::from_path_iter(project_env_path) { - // 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) && upper != "CODEX_HOME" { continue; } - // Always ignore provider API keys from project .env (must be set globally or in shell). - if matches!( - upper.as_str(), - "OPENAI_API_KEY" - | "AZURE_OPENAI_API_KEY" - | "ANTHROPIC_API_KEY" - | "CLAUDE_API_KEY" - | "GOOGLE_API_KEY" - | "GEMINI_API_KEY" - | "QWEN_API_KEY" - | "DASHSCOPE_API_KEY" - ) { - continue; - } - // Safe: still single-threaded during startup. - unsafe { std::env::set_var(&key, &value) }; - } - } - } - - // Bridge CODE_HOME to CODEX_HOME for legacy components that still read only CODEX_HOME. - let codex_home_missing = std::env::var("CODEX_HOME") - .map(|v| v.trim().is_empty()) - .unwrap_or(true); - if codex_home_missing { - if let Ok(code_home) = std::env::var("CODE_HOME") { - if !code_home.trim().is_empty() { - // Safe: still single-threaded during startup. - unsafe { std::env::set_var("CODEX_HOME", code_home) }; - } - } + if let Ok(codex_home) = find_codex_home() + && let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) + { + set_filtered(iter); } } @@ -190,20 +271,71 @@ where /// /// - 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. +/// with the hidden `--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. +/// Note: In debug builds the temp-dir guard is disabled to ease local testing. /// /// 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 { - let temp_dir = TempDir::new()?; +pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { + let codex_home = find_codex_home()?; + #[cfg(not(debug_assertions))] + { + // Guard against placing helpers in system temp directories outside debug builds. + let temp_root = std::env::temp_dir(); + if codex_home.starts_with(&temp_root) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Refusing to create helper binaries under temporary dir {temp_root:?} (codex_home: {codex_home:?})" + ), + )); + } + } + + std::fs::create_dir_all(&codex_home)?; + // Use a CODEX_HOME-scoped temp root to avoid cluttering the top-level directory. + let temp_root = codex_home.join("tmp").join("arg0"); + std::fs::create_dir_all(&temp_root)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + // Ensure only the current user can access the temp directory. + std::fs::set_permissions(&temp_root, std::fs::Permissions::from_mode(0o700))?; + } + + // Best-effort cleanup of stale per-session dirs. Ignore failures so startup proceeds. + if let Err(err) = janitor_cleanup(&temp_root) { + eprintln!("WARNING: failed to clean up stale arg0 temp dirs: {err}"); + } + + let temp_dir = tempfile::Builder::new() + .prefix("codex-arg0") + .tempdir_in(&temp_root)?; let path = temp_dir.path(); - for filename in &[APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0] { + let lock_path = path.join(LOCK_FILENAME); + let lock_file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + lock_file.try_lock()?; + + for filename in &[ + APPLY_PATCH_ARG0, + MISSPELLED_APPLY_PATCH_ARG0, + #[cfg(target_os = "linux")] + CODEX_LINUX_SANDBOX_ARG0, + #[cfg(unix)] + EXECVE_WRAPPER_ARG0, + ] { let exe = std::env::current_exe()?; #[cfg(unix)] @@ -215,13 +347,13 @@ fn prepend_path_entry_for_apply_patch() -> std::io::Result { #[cfg(windows)] { let batch_script = path.join(format!("{filename}.bat")); + let exe = exe.display(); std::fs::write( &batch_script, format!( r#"@echo off -"{}" {CODEX_APPLY_PATCH_ARG1} %* +"{exe}" {CODEX_CORE_APPLY_PATCH_ARG1} %* "#, - exe.display() ), )?; } @@ -233,125 +365,221 @@ fn prepend_path_entry_for_apply_patch() -> std::io::Result { #[cfg(windows)] const PATH_SEPARATOR: &str = ";"; - let path_element = path.display(); - - let mut existing_path = std::env::var("PATH").unwrap_or_default(); - if existing_path.is_empty() { - existing_path = default_path_env_var(); - } else if !path_has_standard_dirs(&existing_path) { - existing_path = join_path_env(&existing_path, &default_path_env_var(), PATH_SEPARATOR); - } - - let updated_path_env_var = join_path_env(&path_element.to_string(), &existing_path, PATH_SEPARATOR); + let updated_path_env_var = match std::env::var_os("PATH") { + Some(existing_path) => { + let mut path_env_var = + std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len()); + path_env_var.push(path); + path_env_var.push(PATH_SEPARATOR); + path_env_var.push(existing_path); + path_env_var + } + None => path.as_os_str().to_owned(), + }; unsafe { std::env::set_var("PATH", updated_path_env_var); } - Ok(temp_dir) -} + let paths = Arg0DispatchPaths { + codex_self_exe: std::env::current_exe().ok(), + codex_linux_sandbox_exe: { + #[cfg(target_os = "linux")] + { + Some(path.join(CODEX_LINUX_SANDBOX_ARG0)) + } + #[cfg(not(target_os = "linux"))] + { + None + } + }, + main_execve_wrapper_exe: { + #[cfg(unix)] + { + Some(path.join(EXECVE_WRAPPER_ARG0)) + } + #[cfg(not(unix))] + { + None + } + }, + }; -#[cfg(unix)] -fn default_path_env_var() -> String { - // When PATH is missing (common in headless/non-interactive runners), many - // libc implementations fall back to a built-in default search path (e.g. - // /bin:/usr/bin). As soon as we set PATH, that fallback stops applying. - // Ensure we always include standard system locations. - if cfg!(target_os = "macos") { - "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string() - } else { - "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string() - } + Ok(Arg0PathEntryGuard::new(temp_dir, lock_file, paths)) } -#[cfg(windows)] -fn default_path_env_var() -> String { - // On Windows, rely on the system-provided default search behavior. - // If PATH is missing, leave it empty to avoid guessing. - String::new() -} +fn janitor_cleanup(temp_root: &Path) -> std::io::Result<()> { + let entries = match std::fs::read_dir(temp_root) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // Skip the directory if locking fails or the lock is currently held. + let Some(_lock_file) = try_lock_dir(&path)? else { + continue; + }; -fn join_path_env(prefix: &str, suffix: &str, sep: &str) -> String { - match (prefix.is_empty(), suffix.is_empty()) { - (true, true) => String::new(), - (false, true) => prefix.to_string(), - (true, false) => suffix.to_string(), - (false, false) => format!("{prefix}{sep}{suffix}"), + match std::fs::remove_dir_all(&path) { + Ok(()) => {} + // Expected TOCTOU race: directory can disappear after read_dir/lock checks. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + } } -} -#[cfg(unix)] -fn path_has_standard_dirs(path: &str) -> bool { - use std::ffi::OsString; - - let os = OsString::from(path); - std::env::split_paths(&os).any(|p| { - p == std::path::Path::new("/usr/bin") - || p == std::path::Path::new("/bin") - || p == std::path::Path::new("/usr/sbin") - || p == std::path::Path::new("/sbin") - }) + Ok(()) } -#[cfg(windows)] -fn path_has_standard_dirs(_path: &str) -> bool { - true +fn try_lock_dir(dir: &Path) -> std::io::Result> { + let lock_path = dir.join(LOCK_FILENAME); + let lock_file = match File::options().read(true).write(true).open(&lock_path) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + + match lock_file.try_lock() { + Ok(()) => Ok(Some(lock_file)), + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(err) => Err(err.into()), + } } #[cfg(test)] mod tests { - use super::*; - use std::sync::Mutex; - - static PATH_LOCK: Mutex<()> = Mutex::new(()); + use super::Arg0DispatchPaths; + use super::Arg0PathEntryGuard; + use super::LOCK_FILENAME; + use super::janitor_cleanup; + use super::linux_sandbox_exe_path; + #[cfg(unix)] + use super::run_main_with_arg0_guard; + #[cfg(unix)] + use anyhow::ensure; + use std::fs; + use std::fs::File; + use std::path::Path; + use std::path::PathBuf; + use tempfile::TempDir; + + fn create_lock(dir: &Path) -> std::io::Result { + let lock_path = dir.join(LOCK_FILENAME); + File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path) + } #[test] - fn prepend_path_seeds_default_when_missing() { - let _guard = PATH_LOCK.lock().unwrap(); + fn linux_sandbox_exe_path_prefers_codex_linux_sandbox_alias() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let lock_file = create_lock(temp_dir.path())?; + let alias_path = temp_dir.path().join("codex-linux-sandbox"); + let path_entry = Arg0PathEntryGuard::new( + temp_dir, + lock_file, + Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/usr/bin/codex")), + codex_linux_sandbox_exe: Some(alias_path.clone()), + main_execve_wrapper_exe: None, + }, + ); - let before = std::env::var("PATH").ok(); - unsafe { std::env::remove_var("PATH") }; + assert_eq!( + linux_sandbox_exe_path(Some(&path_entry), Some(PathBuf::from("/usr/bin/codex"))), + Some(alias_path), + ); + Ok(()) + } - let td = prepend_path_entry_for_apply_patch().unwrap(); - let path = std::env::var("PATH").unwrap(); + #[cfg(unix)] + #[test] + fn run_main_with_arg0_guard_keeps_aliases_alive_until_main_returns() -> anyhow::Result<()> { + let temp_dir = TempDir::new()?; + let alias_path = temp_dir.path().join("codex-helper-alias"); + fs::write(&alias_path, b"")?; + let lock_file = create_lock(temp_dir.path())?; + let path_entry = Arg0PathEntryGuard::new( + temp_dir, + lock_file, + Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/usr/bin/codex")), + codex_linux_sandbox_exe: Some(alias_path.clone()), + main_execve_wrapper_exe: Some(alias_path), + }, + ); - // The temp dir should be the first search entry so `apply_patch` resolves. - assert!(path.starts_with(&format!("{}", td.path().display()))); + super::build_runtime()?.block_on(run_main_with_arg0_guard( + /*path_entry_guard*/ Some(path_entry), + Some(PathBuf::from("/usr/bin/codex")), + |paths| async move { + let alias_path = paths + .codex_linux_sandbox_exe + .or(paths.main_execve_wrapper_exe) + .expect("unix dispatch should create at least one alias path"); + ensure!( + alias_path.exists(), + "alias path disappeared before main future was polled: {}", + alias_path.display() + ); + + tokio::task::yield_now().await; + + ensure!( + alias_path.exists(), + "alias path disappeared while main future was running: {}", + alias_path.display() + ); + Ok(()) + }, + )) + } - #[cfg(unix)] - assert!( - path.contains("/usr/bin") || path.contains(":/bin") || path.contains("/bin:"), - "PATH should include standard system dirs, got: {path}" - ); + #[test] + fn janitor_skips_dirs_without_lock_file() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let dir = root.path().join("no-lock"); + fs::create_dir(&dir)?; - // Restore. - match before { - Some(v) => unsafe { std::env::set_var("PATH", v) }, - None => unsafe { std::env::remove_var("PATH") }, - } + janitor_cleanup(root.path())?; + + assert!(dir.exists()); + Ok(()) } #[test] - fn prepend_path_seeds_default_when_incomplete() { - let _guard = PATH_LOCK.lock().unwrap(); + fn janitor_skips_dirs_with_held_lock() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let dir = root.path().join("locked"); + fs::create_dir(&dir)?; + let lock_file = create_lock(&dir)?; + lock_file.try_lock()?; - let before = std::env::var("PATH").ok(); - unsafe { std::env::set_var("PATH", "/usr/local/bin") }; + janitor_cleanup(root.path())?; - let td = prepend_path_entry_for_apply_patch().unwrap(); - let path = std::env::var("PATH").unwrap(); + assert!(dir.exists()); + Ok(()) + } - assert!(path.starts_with(&format!("{}", td.path().display()))); + #[test] + fn janitor_removes_dirs_with_unlocked_lock() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let dir = root.path().join("stale"); + fs::create_dir(&dir)?; + create_lock(&dir)?; - #[cfg(unix)] - assert!( - path.contains("/usr/bin") || path.contains(":/bin") || path.contains("/bin:"), - "PATH should include standard system dirs, got: {path}" - ); + janitor_cleanup(root.path())?; - match before { - Some(v) => unsafe { std::env::set_var("PATH", v) }, - None => unsafe { std::env::remove_var("PATH") }, - } + assert!(!dir.exists()); + Ok(()) } } diff --git a/code-rs/async-utils/BUILD.bazel b/code-rs/async-utils/BUILD.bazel new file mode 100644 index 00000000000..7eb4a9413d1 --- /dev/null +++ b/code-rs/async-utils/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "async-utils", + crate_name = "codex_async_utils", +) diff --git a/code-rs/async-utils/Cargo.toml b/code-rs/async-utils/Cargo.toml new file mode 100644 index 00000000000..9f81ff818e6 --- /dev/null +++ b/code-rs/async-utils/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-async-utils" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } +tokio-util.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true + +[lib] +doctest = false diff --git a/code-rs/async-utils/src/lib.rs b/code-rs/async-utils/src/lib.rs new file mode 100644 index 00000000000..bd880ae1fb9 --- /dev/null +++ b/code-rs/async-utils/src/lib.rs @@ -0,0 +1,86 @@ +use async_trait::async_trait; +use std::future::Future; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, PartialEq, Eq)] +pub enum CancelErr { + Cancelled, +} + +#[async_trait] +pub trait OrCancelExt: Sized { + type Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result; +} + +#[async_trait] +impl OrCancelExt for F +where + F: Future + Send, + F::Output: Send, +{ + type Output = F::Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result { + tokio::select! { + _ = token.cancelled() => Err(CancelErr::Cancelled), + res = self => Ok(res), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::time::Duration; + use tokio::task; + use tokio::time::sleep; + + #[tokio::test] + async fn returns_ok_when_future_completes_first() { + let token = CancellationToken::new(); + let value = async { 42 }; + + let result = value.or_cancel(&token).await; + + assert_eq!(Ok(42), result); + } + + #[tokio::test] + async fn returns_err_when_token_cancelled_first() { + let token = CancellationToken::new(); + let token_clone = token.clone(); + + let cancel_handle = task::spawn(async move { + sleep(Duration::from_millis(10)).await; + token_clone.cancel(); + }); + + let result = async { + sleep(Duration::from_millis(100)).await; + 7 + } + .or_cancel(&token) + .await; + + cancel_handle.await.expect("cancel task panicked"); + assert_eq!(Err(CancelErr::Cancelled), result); + } + + #[tokio::test] + async fn returns_err_when_token_already_cancelled() { + let token = CancellationToken::new(); + token.cancel(); + + let result = async { + sleep(Duration::from_millis(50)).await; + 5 + } + .or_cancel(&token) + .await; + + assert_eq!(Err(CancelErr::Cancelled), result); + } +} diff --git a/code-rs/aws-auth/BUILD.bazel b/code-rs/aws-auth/BUILD.bazel new file mode 100644 index 00000000000..d278d5599c7 --- /dev/null +++ b/code-rs/aws-auth/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "aws-auth", + crate_name = "codex_aws_auth", +) diff --git a/code-rs/aws-auth/Cargo.toml b/code-rs/aws-auth/Cargo.toml new file mode 100644 index 00000000000..6bb5a69ae9d --- /dev/null +++ b/code-rs/aws-auth/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-aws-auth" +version.workspace = true + +[lib] +doctest = false +name = "codex_aws_auth" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +aws-config = { workspace = true, features = ["credentials-login"] } +aws-credential-types = { workspace = true } +aws-sigv4 = { workspace = true } +aws-types = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/code-rs/aws-auth/src/config.rs b/code-rs/aws-auth/src/config.rs new file mode 100644 index 00000000000..3d62832e0eb --- /dev/null +++ b/code-rs/aws-auth/src/config.rs @@ -0,0 +1,38 @@ +use aws_config::BehaviorVersion; +use aws_config::SdkConfig; +use aws_credential_types::provider::SharedCredentialsProvider; +use aws_types::region::Region; + +use crate::AwsAuthConfig; +use crate::AwsAuthError; + +pub(crate) async fn load_sdk_config(config: &AwsAuthConfig) -> Result { + if config.service.trim().is_empty() { + return Err(AwsAuthError::EmptyService); + } + + let mut loader = aws_config::defaults(BehaviorVersion::latest()); + if let Some(profile) = config.profile.as_ref() { + loader = loader.profile_name(profile); + } + if let Some(region) = config.region.as_ref() { + loader = loader.region(Region::new(region.clone())); + } + + Ok(loader.load().await) +} + +pub(crate) fn credentials_provider( + sdk_config: &SdkConfig, +) -> Result { + sdk_config + .credentials_provider() + .ok_or(AwsAuthError::MissingCredentialsProvider) +} + +pub(crate) fn resolved_region(sdk_config: &SdkConfig) -> Result { + sdk_config + .region() + .map(ToString::to_string) + .ok_or(AwsAuthError::MissingRegion) +} diff --git a/code-rs/aws-auth/src/lib.rs b/code-rs/aws-auth/src/lib.rs new file mode 100644 index 00000000000..13425f22975 --- /dev/null +++ b/code-rs/aws-auth/src/lib.rs @@ -0,0 +1,261 @@ +mod config; +mod signing; + +use std::time::SystemTime; + +use aws_credential_types::provider::ProvideCredentials; +use aws_credential_types::provider::SharedCredentialsProvider; +use bytes::Bytes; +use http::HeaderMap; +use http::Method; +use thiserror::Error; + +/// AWS auth configuration used to resolve credentials and sign requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsAuthConfig { + pub profile: Option, + pub region: Option, + pub service: String, +} + +/// Generic HTTP request shape consumed by SigV4 signing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsRequestToSign { + pub method: Method, + pub url: String, + pub headers: HeaderMap, + pub body: Bytes, +} + +/// Signed request parts returned to the caller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsSignedRequest { + pub url: String, + pub headers: HeaderMap, +} + +/// Errors returned by credential loading or SigV4 signing. +#[derive(Debug, Error)] +pub enum AwsAuthError { + #[error("AWS service name must not be empty")] + EmptyService, + #[error("AWS SDK config did not resolve a credentials provider")] + MissingCredentialsProvider, + #[error("AWS SDK config did not resolve a region")] + MissingRegion, + #[error("failed to load AWS credentials: {0}")] + Credentials(#[from] aws_credential_types::provider::error::CredentialsError), + #[error("request URL is not a valid URI: {0}")] + InvalidUri(#[source] http::uri::InvalidUri), + #[error("failed to construct HTTP request for signing: {0}")] + BuildHttpRequest(#[source] http::Error), + #[error("request contains a non-UTF8 header value: {0}")] + InvalidHeaderValue(#[source] http::header::ToStrError), + #[error("failed to build signable request: {0}")] + SigningRequest(#[source] aws_sigv4::http_request::SigningError), + #[error("failed to build SigV4 signing params: {0}")] + SigningParams(String), + #[error("SigV4 signing failed: {0}")] + SigningFailure(#[source] aws_sigv4::http_request::SigningError), +} + +/// Loaded AWS auth context that can sign outbound HTTP requests. +#[derive(Clone)] +pub struct AwsAuthContext { + credentials_provider: SharedCredentialsProvider, + region: String, + service: String, +} + +impl std::fmt::Debug for AwsAuthContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AwsAuthContext") + .field("region", &self.region) + .field("service", &self.service) + .finish_non_exhaustive() + } +} + +impl AwsAuthContext { + pub async fn load(config: AwsAuthConfig) -> Result { + let sdk_config = config::load_sdk_config(&config).await?; + let credentials_provider = config::credentials_provider(&sdk_config)?; + let region = config::resolved_region(&sdk_config)?; + + Ok(Self { + credentials_provider, + region, + service: config.service.trim().to_string(), + }) + } + + pub fn region(&self) -> &str { + &self.region + } + + pub fn service(&self) -> &str { + &self.service + } + + pub async fn sign(&self, request: AwsRequestToSign) -> Result { + self.sign_at(request, SystemTime::now()).await + } + + async fn sign_at( + &self, + request: AwsRequestToSign, + time: SystemTime, + ) -> Result { + let credentials = self.credentials_provider.provide_credentials().await?; + signing::sign_request(&credentials, &self.region, &self.service, request, time) + } +} + +impl AwsAuthError { + /// Returns whether retrying the outbound request can reasonably recover from this auth error. + pub fn is_retryable(&self) -> bool { + match self { + AwsAuthError::Credentials(error) => matches!( + error, + aws_credential_types::provider::error::CredentialsError::ProviderTimedOut(_) + | aws_credential_types::provider::error::CredentialsError::ProviderError(_) + ), + AwsAuthError::EmptyService + | AwsAuthError::MissingCredentialsProvider + | AwsAuthError::MissingRegion + | AwsAuthError::InvalidUri(_) + | AwsAuthError::BuildHttpRequest(_) + | AwsAuthError::InvalidHeaderValue(_) + | AwsAuthError::SigningRequest(_) + | AwsAuthError::SigningParams(_) + | AwsAuthError::SigningFailure(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + use std::time::UNIX_EPOCH; + + use aws_credential_types::Credentials; + use aws_credential_types::provider::error::CredentialsError; + use pretty_assertions::assert_eq; + + use super::*; + + fn test_context(session_token: Option<&str>) -> AwsAuthContext { + AwsAuthContext { + credentials_provider: SharedCredentialsProvider::new(Credentials::new( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + session_token.map(str::to_string), + /*expires_after*/ None, + "unit-test", + )), + region: "us-east-1".to_string(), + service: "bedrock".to_string(), + } + } + + fn test_request() -> AwsRequestToSign { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + headers.insert("x-test-header", http::HeaderValue::from_static("present")); + AwsRequestToSign { + method: Method::POST, + url: "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses".to_string(), + headers, + body: Bytes::from_static(br#"{"model":"openai.gpt-oss-120b-1:0"}"#), + } + } + + #[tokio::test] + async fn sign_adds_sigv4_headers_and_preserves_existing_headers() { + let signed = test_context(/*session_token*/ None) + .sign_at( + test_request(), + UNIX_EPOCH + Duration::from_secs(1_700_000_000), + ) + .await + .expect("request should sign"); + + assert_eq!( + signing::header_value(&signed.headers, http::header::CONTENT_TYPE.as_str()), + Some("application/json".to_string()) + ); + assert_eq!( + signing::header_value(&signed.headers, "x-test-header"), + Some("present".to_string()) + ); + assert_eq!( + signed.url, + "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses" + ); + assert!( + signing::header_value(&signed.headers, http::header::AUTHORIZATION.as_str()) + .is_some_and(|value| value.starts_with("AWS4-HMAC-SHA256 ")) + ); + assert!(signing::header_value(&signed.headers, "x-amz-date").is_some()); + } + + #[test] + fn credentials_provider_failures_are_retryable() { + assert!( + AwsAuthError::Credentials(CredentialsError::provider_error("temporarily unavailable")) + .is_retryable() + ); + assert!( + AwsAuthError::Credentials(CredentialsError::provider_timed_out(Duration::from_secs(1))) + .is_retryable() + ); + } + + #[test] + fn deterministic_aws_auth_errors_are_not_retryable() { + assert!(!AwsAuthError::EmptyService.is_retryable()); + assert!( + !AwsAuthError::Credentials(CredentialsError::not_loaded_no_source()).is_retryable() + ); + assert!( + !AwsAuthError::Credentials(CredentialsError::invalid_configuration("bad profile")) + .is_retryable() + ); + assert!( + !AwsAuthError::Credentials(CredentialsError::unhandled("unexpected response")) + .is_retryable() + ); + } + + #[tokio::test] + async fn sign_includes_session_token_when_credentials_have_one() { + let signed = test_context(Some("session-token")) + .sign_at( + test_request(), + UNIX_EPOCH + Duration::from_secs(1_700_000_000), + ) + .await + .expect("request should sign"); + + assert_eq!( + signing::header_value(&signed.headers, "x-amz-security-token"), + Some("session-token".to_string()) + ); + } + + #[tokio::test] + async fn load_rejects_empty_service_name() { + let err = AwsAuthContext::load(AwsAuthConfig { + profile: None, + region: None, + service: " ".to_string(), + }) + .await + .expect_err("empty service should be rejected"); + + assert_eq!(err.to_string(), "AWS service name must not be empty"); + } +} diff --git a/code-rs/aws-auth/src/signing.rs b/code-rs/aws-auth/src/signing.rs new file mode 100644 index 00000000000..ac3d3fd3077 --- /dev/null +++ b/code-rs/aws-auth/src/signing.rs @@ -0,0 +1,76 @@ +use std::str::FromStr; +use std::time::SystemTime; + +use aws_credential_types::Credentials; +use aws_sigv4::http_request::SignableBody; +use aws_sigv4::http_request::SignableRequest; +use aws_sigv4::http_request::SigningSettings; +use aws_sigv4::http_request::sign; +use aws_sigv4::sign::v4; +use http::Request; +use http::Uri; + +use crate::AwsAuthError; +use crate::AwsRequestToSign; +use crate::AwsSignedRequest; + +pub(crate) fn sign_request( + credentials: &Credentials, + region: &str, + service: &str, + request: AwsRequestToSign, + time: SystemTime, +) -> Result { + let signable_headers = request + .headers + .iter() + .map(|(name, value)| { + Ok::<_, AwsAuthError>(( + name.as_str(), + value.to_str().map_err(AwsAuthError::InvalidHeaderValue)?, + )) + }) + .collect::, _>>()?; + let signable_request = SignableRequest::new( + request.method.as_str(), + request.url.as_str(), + signable_headers.into_iter(), + SignableBody::Bytes(request.body.as_ref()), + ) + .map_err(AwsAuthError::SigningRequest)?; + let identity = credentials.clone().into(); + + let signing_params = v4::SigningParams::builder() + .identity(&identity) + .region(region) + .name(service) + .time(time) + .settings(SigningSettings::default()) + .build() + .map_err(|err| AwsAuthError::SigningParams(err.to_string()))?; + let (instructions, _signature) = sign(signable_request, &signing_params.into()) + .map_err(AwsAuthError::SigningFailure)? + .into_parts(); + + let uri = Uri::from_str(&request.url).map_err(AwsAuthError::InvalidUri)?; + let mut http_request = Request::builder() + .method(request.method) + .uri(uri) + .body(()) + .map_err(AwsAuthError::BuildHttpRequest)?; + *http_request.headers_mut() = request.headers; + instructions.apply_to_request_http1x(&mut http_request); + + Ok(AwsSignedRequest { + url: http_request.uri().to_string(), + headers: http_request.headers().clone(), + }) +} + +#[cfg(test)] +pub(crate) fn header_value(headers: &http::HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} diff --git a/code-rs/backend-client/BUILD.bazel b/code-rs/backend-client/BUILD.bazel new file mode 100644 index 00000000000..359f7e149e8 --- /dev/null +++ b/code-rs/backend-client/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "backend-client", + crate_name = "codex_backend_client", + compile_data = glob(["tests/fixtures/**"]), +) diff --git a/code-rs/backend-client/Cargo.toml b/code-rs/backend-client/Cargo.toml index 052cd4771ce..f7b0c8b0f5d 100644 --- a/code-rs/backend-client/Cargo.toml +++ b/code-rs/backend-client/Cargo.toml @@ -1,21 +1,28 @@ [package] -name = "code-backend-client" -version = "0.0.0" -edition = "2024" +name = "codex-backend-client" +version.workspace = true +edition.workspace = true +license.workspace = true publish = false [lib] path = "src/lib.rs" +doctest = false [lints] workspace = true [dependencies] -anyhow = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies"] } -code-backend-openapi-models = { workspace = true } +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } +codex-api = { workspace = true } +codex-client = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-protocol = { workspace = true } [dev-dependencies] -pretty_assertions = { workspace = true } +pretty_assertions = "1" diff --git a/code-rs/backend-client/src/client.rs b/code-rs/backend-client/src/client.rs index 4002291bd5b..6365d527ed5 100644 --- a/code-rs/backend-client/src/client.rs +++ b/code-rs/backend-client/src/client.rs @@ -1,129 +1,29 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; +use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; +use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; +use codex_api::SharedAuthProvider; +use codex_client::build_reqwest_client_with_custom_ca; +use codex_client::with_chatgpt_cloudflare_cookie_store; +use codex_login::CodexAuth; +use codex_login::default_client::get_codex_user_agent; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; use reqwest::StatusCode; -use reqwest::cookie::CookieStore; -use reqwest::cookie::Jar; -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::Serialize; use serde::de::DeserializeOwned; use std::fmt; -use std::sync::Arc; -use std::sync::LazyLock; - -static SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE: LazyLock> = - LazyLock::new(|| Arc::new(ChatGptCloudflareCookieStore::default())); - -#[derive(Debug, Default)] -struct ChatGptCloudflareCookieStore { - jar: Jar, -} - -impl CookieStore for ChatGptCloudflareCookieStore { - fn set_cookies( - &self, - cookie_headers: &mut dyn Iterator, - url: &reqwest::Url, - ) { - if !is_chatgpt_cookie_url(url) { - return; - } - - let mut cloudflare_cookie_headers = - cookie_headers.filter(|header| is_allowed_cloudflare_set_cookie_header(header)); - self.jar.set_cookies(&mut cloudflare_cookie_headers, url); - } - - fn cookies(&self, url: &reqwest::Url) -> Option { - if is_chatgpt_cookie_url(url) { - self.jar.cookies(url).and_then(only_cloudflare_cookies) - } else { - None - } - } -} - -fn is_allowed_chatgpt_host(host: &str) -> bool { - const EXACT_HOSTS: &[&str] = &["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"]; - const SUBDOMAIN_SUFFIXES: &[&str] = &[".chatgpt.com", ".chatgpt-staging.com"]; - - EXACT_HOSTS.contains(&host) - || SUBDOMAIN_SUFFIXES - .iter() - .any(|suffix| host.ends_with(suffix)) -} - -fn with_chatgpt_cloudflare_cookie_store( - builder: reqwest::ClientBuilder, -) -> reqwest::ClientBuilder { - builder.cookie_provider(Arc::clone(&SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE)) -} - -fn is_chatgpt_cookie_url(url: &reqwest::Url) -> bool { - if url.scheme() != "https" { - return false; - } - - let Some(host) = url.host_str() else { - return false; - }; - - is_allowed_chatgpt_host(host) -} - -fn is_allowed_cloudflare_set_cookie_header(header: &HeaderValue) -> bool { - header - .to_str() - .ok() - .and_then(set_cookie_name) - .is_some_and(is_allowed_cloudflare_cookie_name) -} - -fn set_cookie_name(header: &str) -> Option<&str> { - let (name, _) = header.split_once('=')?; - let name = name.trim(); - (!name.is_empty()).then_some(name) -} - -fn only_cloudflare_cookies(header: HeaderValue) -> Option { - let header = header.to_str().ok()?; - let cookies = header - .split(';') - .filter_map(|cookie| { - let cookie = cookie.trim(); - let name = cookie.split_once('=')?.0.trim(); - is_allowed_cloudflare_cookie_name(name).then_some(cookie) - }) - .collect::>() - .join("; "); - - if cookies.is_empty() { - None - } else { - HeaderValue::from_str(&cookies).ok() - } -} - -fn is_allowed_cloudflare_cookie_name(name: &str) -> bool { - matches!( - name, - "__cf_bm" - | "__cflb" - | "__cfruid" - | "__cfseq" - | "__cfwaitingroom" - | "_cfuvid" - | "cf_clearance" - | "cf_ob_info" - | "cf_use_ob" - ) || name.starts_with("cf_chl_") -} #[derive(Debug)] pub enum RequestError { @@ -183,6 +83,18 @@ impl From for RequestError { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize)] +struct SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PathStyle { /// /api/codex/… @@ -201,17 +113,33 @@ impl PathStyle { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Client { base_url: String, http: reqwest::Client, - bearer_token: Option, + auth_provider: SharedAuthProvider, user_agent: Option, chatgpt_account_id: Option, chatgpt_account_is_fedramp: bool, path_style: PathStyle, } +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("base_url", &self.base_url) + .field("auth_provider", &"") + .field("user_agent", &self.user_agent) + .field("chatgpt_account_id", &self.chatgpt_account_id) + .field( + "chatgpt_account_is_fedramp", + &self.chatgpt_account_is_fedramp, + ) + .field("path_style", &self.path_style) + .finish_non_exhaustive() + } +} + impl Client { pub fn new(base_url: impl Into) -> Result { let mut base_url = base_url.into(); @@ -226,12 +154,14 @@ impl Client { { base_url = format!("{base_url}/backend-api"); } - let http = with_chatgpt_cloudflare_cookie_store(reqwest::Client::builder()).build()?; + let http = build_reqwest_client_with_custom_ca(with_chatgpt_cloudflare_cookie_store( + reqwest::Client::builder(), + ))?; let path_style = PathStyle::from_base_url(&base_url); Ok(Self { base_url, http, - bearer_token: None, + auth_provider: codex_model_provider::unauthenticated_auth_provider(), user_agent: None, chatgpt_account_id: None, chatgpt_account_is_fedramp: false, @@ -239,8 +169,14 @@ impl Client { }) } - pub fn with_bearer_token(mut self, token: impl Into) -> Self { - self.bearer_token = Some(token.into()); + pub fn from_auth(base_url: impl Into, auth: &CodexAuth) -> Result { + Ok(Self::new(base_url)? + .with_user_agent(get_codex_user_agent()) + .with_auth_provider(codex_model_provider::auth_provider_from_auth(auth))) + } + + pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self { + self.auth_provider = auth; self } @@ -273,12 +209,7 @@ impl Client { } 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); - } - } + self.auth_provider.add_auth_headers(&mut h); 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) @@ -350,11 +281,47 @@ impl Client { } } + pub async fn get_rate_limits(&self) -> Result { + let snapshots = self.get_rate_limits_many().await?; + let preferred = snapshots + .iter() + .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) + .cloned(); + Ok(preferred.unwrap_or_else(|| snapshots[0].clone())) + } + + pub async fn get_rate_limits_many(&self) -> Result> { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?; + Ok(Self::rate_limit_snapshots_from_payload(payload)) + } + + pub async fn send_add_credits_nudge_email( + &self, + credit_type: AddCreditsNudgeCreditType, + ) -> std::result::Result<(), RequestError> { + let url = self.send_add_credits_nudge_email_url(); + let req = self + .http + .post(&url) + .headers(self.headers()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(&SendAddCreditsNudgeEmailRequest { credit_type }); + self.exec_request_detailed(req, "POST", &url).await?; + Ok(()) + } + pub async fn list_tasks( &self, limit: Option, task_filter: Option<&str>, environment_id: Option<&str>, + cursor: Option<&str>, ) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), @@ -371,6 +338,11 @@ impl Client { } else { req }; + let req = if let Some(c) = cursor { + req.query(&[("cursor", c)]) + } else { + req + }; let req = if let Some(id) = environment_id { req.query(&[("environment_id", id)]) } else { @@ -419,7 +391,7 @@ impl Client { self.decode_json::(&url, &ct, &body) } - /// Fetch the managed requirements file from code-backend. + /// Fetch the managed requirements file from codex-backend. /// /// `GET /api/codex/config/requirements` (Codex API style) or /// `GET /wham/config/requirements` (ChatGPT backend-api style). @@ -470,4 +442,424 @@ impl Client { Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"), } } + + // rate limit helpers + fn rate_limit_snapshots_from_payload( + payload: RateLimitStatusPayload, + ) -> Vec { + let plan_type = Some(Self::map_plan_type(payload.plan_type)); + let rate_limit_reached_type = payload + .rate_limit_reached_type + .flatten() + .and_then(|details| Self::map_rate_limit_reached_type(details.kind)); + let mut snapshots = vec![Self::make_rate_limit_snapshot( + Some("codex".to_string()), + /*limit_name*/ None, + payload.rate_limit.flatten().map(|details| *details), + payload.credits.flatten().map(|details| *details), + plan_type, + rate_limit_reached_type, + )]; + if let Some(additional) = payload.additional_rate_limits.flatten() { + snapshots.extend(additional.into_iter().map(|details| { + Self::make_rate_limit_snapshot( + Some(details.metered_feature), + Some(details.limit_name), + details.rate_limit.flatten().map(|rate_limit| *rate_limit), + /*credits*/ None, + plan_type, + /*rate_limit_reached_type*/ None, + ) + })); + } + snapshots + } + + fn make_rate_limit_snapshot( + limit_id: Option, + limit_name: Option, + rate_limit: Option, + credits: Option, + plan_type: Option, + rate_limit_reached_type: Option, + ) -> RateLimitSnapshot { + let (primary, secondary) = match rate_limit { + Some(details) => ( + Self::map_rate_limit_window(details.primary_window), + Self::map_rate_limit_window(details.secondary_window), + ), + None => (None, None), + }; + RateLimitSnapshot { + limit_id, + limit_name, + primary, + secondary, + credits: Self::map_credits(credits), + plan_type, + rate_limit_reached_type, + } + } + + fn map_rate_limit_reached_type( + kind: BackendRateLimitReachedKind, + ) -> Option { + match kind { + BackendRateLimitReachedKind::RateLimitReached => { + Some(RateLimitReachedType::RateLimitReached) + } + BackendRateLimitReachedKind::WorkspaceOwnerCreditsDepleted => { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) + } + BackendRateLimitReachedKind::WorkspaceMemberCreditsDepleted => { + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) + } + BackendRateLimitReachedKind::WorkspaceOwnerUsageLimitReached => { + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) + } + BackendRateLimitReachedKind::WorkspaceMemberUsageLimitReached => { + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) + } + BackendRateLimitReachedKind::Unknown => None, + } + } + + fn send_add_credits_nudge_email_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!( + "{}/api/codex/accounts/send_add_credits_nudge_email", + self.base_url + ), + PathStyle::ChatGptApi => { + format!( + "{}/wham/accounts/send_add_credits_nudge_email", + self.base_url + ) + } + } + } + + fn map_rate_limit_window( + window: Option>>, + ) -> Option { + let snapshot = window.flatten().map(|details| *details)?; + + let used_percent = f64::from(snapshot.used_percent); + let window_minutes = Self::window_minutes_from_seconds(snapshot.limit_window_seconds); + let resets_at = Some(i64::from(snapshot.reset_at)); + Some(RateLimitWindow { + used_percent, + window_minutes, + resets_at, + }) + } + + fn map_credits(credits: Option) -> Option { + let details = credits?; + + Some(CreditsSnapshot { + has_credits: details.has_credits, + unlimited: details.unlimited, + balance: details.balance.flatten(), + }) + } + + fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType { + match plan_type { + crate::types::PlanType::Free => AccountPlanType::Free, + crate::types::PlanType::Go => AccountPlanType::Go, + crate::types::PlanType::Plus => AccountPlanType::Plus, + crate::types::PlanType::Pro => AccountPlanType::Pro, + crate::types::PlanType::ProLite => AccountPlanType::ProLite, + crate::types::PlanType::Team => AccountPlanType::Team, + crate::types::PlanType::SelfServeBusinessUsageBased => { + AccountPlanType::SelfServeBusinessUsageBased + } + crate::types::PlanType::Business => AccountPlanType::Business, + crate::types::PlanType::EnterpriseCbpUsageBased => { + AccountPlanType::EnterpriseCbpUsageBased + } + crate::types::PlanType::Enterprise => AccountPlanType::Enterprise, + crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu, + crate::types::PlanType::Guest + | crate::types::PlanType::FreeWorkspace + | crate::types::PlanType::Quorum + | crate::types::PlanType::K12 + | crate::types::PlanType::Unknown => AccountPlanType::Unknown, + } + } + + fn window_minutes_from_seconds(seconds: i32) -> Option { + if seconds <= 0 { + return None; + } + + let seconds_i64 = i64::from(seconds); + Some((seconds_i64 + 59) / 60) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_backend_openapi_models::models::AdditionalRateLimitDetails; + use codex_backend_openapi_models::models::RateLimitReachedKind; + use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType; + use pretty_assertions::assert_eq; + + #[test] + fn map_plan_type_supports_usage_based_business_variants() { + assert_eq!( + Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased), + AccountPlanType::SelfServeBusinessUsageBased + ); + assert_eq!( + Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased), + AccountPlanType::EnterpriseCbpUsageBased + ); + } + + #[test] + fn usage_payload_maps_primary_and_additional_rate_limits() { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Pro, + rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { + primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { + used_percent: 42, + limit_window_seconds: 300, + reset_after_seconds: 0, + reset_at: 123, + }))), + secondary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { + used_percent: 84, + limit_window_seconds: 3600, + reset_after_seconds: 0, + reset_at: 456, + }))), + ..Default::default() + }))), + additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { + limit_name: "codex_other".to_string(), + metered_feature: "codex_other".to_string(), + rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { + primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { + used_percent: 70, + limit_window_seconds: 900, + reset_after_seconds: 0, + reset_at: 789, + }))), + secondary_window: None, + ..Default::default() + }))), + }])), + credits: Some(Some(Box::new(crate::types::CreditStatusDetails { + has_credits: true, + unlimited: false, + balance: Some(Some("9.99".to_string())), + ..Default::default() + }))), + rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { + kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, + })), + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots.len(), 2); + + assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex")); + assert_eq!(snapshots[0].limit_name, None); + assert_eq!( + snapshots[0].primary.as_ref().map(|w| w.used_percent), + Some(42.0) + ); + assert_eq!( + snapshots[0].secondary.as_ref().map(|w| w.used_percent), + Some(84.0) + ); + assert_eq!( + snapshots[0].credits, + Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("9.99".to_string()), + }) + ); + assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro)); + assert_eq!( + snapshots[0].rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) + ); + + assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); + assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); + assert_eq!( + snapshots[1].primary.as_ref().map(|w| w.used_percent), + Some(70.0) + ); + assert_eq!(snapshots[1].credits, None); + assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); + assert_eq!(snapshots[1].rate_limit_reached_type, None); + } + + #[test] + fn usage_payload_maps_zero_rate_limit_when_primary_absent() { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Plus, + rate_limit: None, + additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { + limit_name: "codex_other".to_string(), + metered_feature: "codex_other".to_string(), + rate_limit: None, + }])), + credits: None, + rate_limit_reached_type: None, + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex")); + assert_eq!(snapshots[0].limit_name, None); + assert_eq!(snapshots[0].primary, None); + assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); + assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); + } + + #[test] + fn preferred_snapshot_selection_matches_get_rate_limits_behavior() { + let snapshots = [ + RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: Some("codex_other".to_string()), + primary: Some(RateLimitWindow { + used_percent: 90.0, + window_minutes: Some(60), + resets_at: Some(1), + }), + secondary: None, + credits: None, + plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, + }, + RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: Some("codex".to_string()), + primary: Some(RateLimitWindow { + used_percent: 10.0, + window_minutes: Some(60), + resets_at: Some(2), + }), + secondary: None, + credits: None, + plan_type: Some(AccountPlanType::Pro), + rate_limit_reached_type: None, + }, + ]; + + let preferred = snapshots + .iter() + .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) + .cloned() + .unwrap_or_else(|| snapshots[0].clone()); + assert_eq!(preferred.limit_id.as_deref(), Some("codex")); + } + + #[test] + fn usage_payload_maps_every_rate_limit_reached_type() { + let cases = [ + ( + RateLimitReachedKind::RateLimitReached, + Some(RateLimitReachedType::RateLimitReached), + ), + ( + RateLimitReachedKind::WorkspaceOwnerCreditsDepleted, + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted), + ), + ( + RateLimitReachedKind::WorkspaceMemberCreditsDepleted, + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted), + ), + ( + RateLimitReachedKind::WorkspaceOwnerUsageLimitReached, + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached), + ), + ( + RateLimitReachedKind::WorkspaceMemberUsageLimitReached, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + ), + (RateLimitReachedKind::Unknown, None), + ]; + + for (kind, expected) in cases { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Plus, + rate_limit: None, + credits: None, + additional_rate_limits: None, + rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })), + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots[0].rate_limit_reached_type, expected); + } + } + + #[test] + fn usage_payload_preserves_absent_rate_limit_reached_type() { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::Plus, + rate_limit: None, + credits: None, + additional_rate_limits: None, + rate_limit_reached_type: None, + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + assert_eq!(snapshots[0].rate_limit_reached_type, None); + } + + #[test] + fn add_credits_nudge_email_uses_expected_paths_and_bodies() { + let codex_client = Client { + base_url: "https://example.test".to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::CodexApi, + }; + assert_eq!( + codex_client.send_add_credits_nudge_email_url(), + "https://example.test/api/codex/accounts/send_add_credits_nudge_email" + ); + + let chatgpt_client = Client { + base_url: "https://chatgpt.com/backend-api".to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::ChatGptApi, + }; + assert_eq!( + chatgpt_client.send_add_credits_nudge_email_url(), + "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email" + ); + + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .unwrap(), + serde_json::json!({ "credit_type": "credits" }) + ); + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .unwrap(), + serde_json::json!({ "credit_type": "usage_limit" }) + ); + } } diff --git a/code-rs/backend-client/src/lib.rs b/code-rs/backend-client/src/lib.rs index a3b2f37608c..300da815682 100644 --- a/code-rs/backend-client/src/lib.rs +++ b/code-rs/backend-client/src/lib.rs @@ -1,6 +1,7 @@ mod client; -pub mod types; +pub(crate) mod types; +pub use client::AddCreditsNudgeCreditType; pub use client::Client; pub use client::RequestError; pub use types::CodeTaskDetailsResponse; diff --git a/code-rs/backend-client/src/types.rs b/code-rs/backend-client/src/types.rs index 38b57d73783..d8d24ab9fce 100644 --- a/code-rs/backend-client/src/types.rs +++ b/code-rs/backend-client/src/types.rs @@ -1,6 +1,12 @@ -pub use code_backend_openapi_models::models::ConfigFileResponse; -pub use code_backend_openapi_models::models::PaginatedListTaskListItem; -pub use code_backend_openapi_models::models::TaskListItem; +pub use codex_backend_openapi_models::models::ConfigFileResponse; +pub use codex_backend_openapi_models::models::CreditStatusDetails; +pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; +pub use codex_backend_openapi_models::models::PlanType; +pub use codex_backend_openapi_models::models::RateLimitReachedKind; +pub use codex_backend_openapi_models::models::RateLimitStatusDetails; +pub use codex_backend_openapi_models::models::RateLimitStatusPayload; +pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; +pub use codex_backend_openapi_models::models::TaskListItem; use serde::Deserialize; use serde::de::Deserializer; @@ -303,7 +309,7 @@ where D: Deserializer<'de>, T: Deserialize<'de>, { - Option::>::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) + Option::>::deserialize(deserializer).map(Option::unwrap_or_default) } #[derive(Clone, Debug, Deserialize)] diff --git a/code-rs/browser/Cargo.toml b/code-rs/browser/Cargo.toml deleted file mode 100644 index 52ff983a616..00000000000 --- a/code-rs/browser/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[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"] } -fs2 = "0.4" -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" diff --git a/code-rs/browser/cursor.svg b/code-rs/browser/cursor.svg deleted file mode 100644 index 8c8f6f2a373..00000000000 --- a/code-rs/browser/cursor.svg +++ /dev/null @@ -1,28 +0,0 @@ - \ No newline at end of file diff --git a/code-rs/browser/src/assets.rs b/code-rs/browser/src/assets.rs deleted file mode 100644 index 8818baf556c..00000000000 --- a/code-rs/browser/src/assets.rs +++ /dev/null @@ -1,150 +0,0 @@ -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, -} - -pub struct AssetManager { - base_dir: PathBuf, - #[allow(dead_code)] - session_id: String, - assets: Arc>>, -} - -impl AssetManager { - pub async fn new() -> Result { - 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 { - 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, - ttl_ms: u64, - ) -> Result> { - 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; - }); - } -} diff --git a/code-rs/browser/src/config.rs b/code-rs/browser/src/config.rs deleted file mode 100644 index ce6ea726e8c..00000000000 --- a/code-rs/browser/src/config.rs +++ /dev/null @@ -1,169 +0,0 @@ -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, - - /// Or discover the WS endpoint from a --remote-debugging-port (e.g. 9222). - #[serde(default)] - pub connect_port: Option, - - /// Use a persistent profile instead of temp. If set, we won't delete it. - #[serde(default)] - pub user_data_dir: Option, - - /// 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, // e.g. Some("en-AU".into()) - - #[serde(default)] - pub timezone: Option, // e.g. Some("Australia/Brisbane".into()) - - #[serde(default)] - pub accept_language: Option, // e.g. Some("en-AU,en;q=0.9".into()) - - #[serde(default)] - pub user_agent: Option, // 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, - /// 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 -} diff --git a/code-rs/browser/src/global.rs b/code-rs/browser/src/global.rs deleted file mode 100644 index 3455ab5e22e..00000000000 --- a/code-rs/browser/src/global.rs +++ /dev/null @@ -1,63 +0,0 @@ -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>>>> = - Lazy::new(|| Arc::new(RwLock::new(None))); - -/// Cache of the last successful external Chrome connection (port/ws) -static LAST_CONNECTION: Lazy, Option)>>> = - Lazy::new(|| Arc::new(RwLock::new((None, None)))); - -/// Get or create the global browser manager -pub async fn get_or_create_browser_manager() -> Arc { - // 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> { - 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) { - 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, Option) { - 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, ws: Option) { - 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); -} diff --git a/code-rs/browser/src/hooks/browser_injector.rs b/code-rs/browser/src/hooks/browser_injector.rs deleted file mode 100644 index 29cd27975ef..00000000000 --- a/code-rs/browser/src/hooks/browser_injector.rs +++ /dev/null @@ -1,105 +0,0 @@ -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, - asset_manager: Arc, -} - -impl BrowserInjector { - pub fn new(manager: Arc, asset_manager: Arc) -> Self { - Self { - manager, - asset_manager, - } - } - - pub async fn inject_pre_llm_call(&self) -> Result> { - if !self.manager.is_enabled().await { - return Ok(None); - } - - if let Some((elapsed, timeout)) = self.manager.idle_elapsed_past_timeout().await { - info!( - "Skipping pre-LLM screenshot; browser idle for {:?} (timeout {:?})", - elapsed, - timeout - ); - 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 = 86_400_000; // keep screenshots for 24 hours - 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, - pub system_hint: String, - pub metadata: InjectionMetadata, -} - -#[derive(Debug)] -pub struct InjectionMetadata { - pub url: String, - pub fullpage: bool, - pub segments_captured: usize, -} diff --git a/code-rs/browser/src/hooks/mod.rs b/code-rs/browser/src/hooks/mod.rs deleted file mode 100644 index fcf02ca0cb3..00000000000 --- a/code-rs/browser/src/hooks/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod browser_injector; - -pub use browser_injector::BrowserInjector; -pub use browser_injector::InjectionResult; diff --git a/code-rs/browser/src/js/virtual_cursor.js b/code-rs/browser/src/js/virtual_cursor.js deleted file mode 100644 index b4dff9c1e2d..00000000000 --- a/code-rs/browser/src/js/virtual_cursor.js +++ /dev/null @@ -1,684 +0,0 @@ -// 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; } -})(); diff --git a/code-rs/browser/src/lib.rs b/code-rs/browser/src/lib.rs deleted file mode 100644 index bbbdcf4e863..00000000000 --- a/code-rs/browser/src/lib.rs +++ /dev/null @@ -1,49 +0,0 @@ -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 for BrowserError { - fn from(e: chromiumoxide::error::CdpError) -> Self { - BrowserError::CdpError(e.to_string()) - } -} - -pub type Result = std::result::Result; diff --git a/code-rs/browser/src/manager.rs b/code-rs/browser/src/manager.rs deleted file mode 100644 index a3001439788..00000000000 --- a/code-rs/browser/src/manager.rs +++ /dev/null @@ -1,2731 +0,0 @@ -use crate::BrowserError; -use crate::Result; -use crate::config::BrowserConfig; -use crate::config::WaitStrategy; -use crate::page::Page; -use chromiumoxide::Browser; -use chromiumoxide::BrowserConfig as CdpConfig; -use chromiumoxide::browser::HeadlessMode; -use chromiumoxide::cdp::browser_protocol::emulation; -use chromiumoxide::cdp::browser_protocol::network; -use fs2::FileExt; -use futures::StreamExt; -use once_cell::sync::Lazy; -use reqwest::Client; -use serde::Deserialize; -use serde_json::Value; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::Mutex; -use tokio::sync::RwLock; -use tokio::time::Duration; -use tokio::time::Instant; -use tokio::time::sleep; -use tracing::debug; -use tracing::info; -use tracing::warn; -use crate::global; - -#[derive(Deserialize)] -struct JsonVersion { - #[serde(rename = "webSocketDebuggerUrl")] - web_socket_debugger_url: String, -} - -static INTERNAL_BROWSER_LAUNCH_GUARD: Lazy> = Lazy::new(|| Mutex::new(())); - -struct BrowserLaunchLockFile { - file: std::fs::File, -} - -impl BrowserLaunchLockFile { - async fn acquire(timeout: Duration) -> Result { - let lock_path = std::env::temp_dir().join("code-browser-launch.lock"); - let file = std::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .open(&lock_path)?; - - let start = Instant::now(); - loop { - match file.try_lock_exclusive() { - Ok(()) => return Ok(Self { file }), - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - if start.elapsed() >= timeout { - return Err(BrowserError::CdpError(format!( - "Timed out waiting for browser launch lock at {} (another Code instance may be launching a browser).", - lock_path.display() - ))); - } - sleep(Duration::from_millis(50)).await; - } - Err(err) => return Err(BrowserError::IoError(err)), - } - } - } -} - -impl Drop for BrowserLaunchLockFile { - fn drop(&mut self) { - let _ = self.file.unlock(); - } -} - -fn is_temporary_internal_launch_error_message(message: &str) -> bool { - let message = message.to_ascii_lowercase(); - // macOS: EAGAIN = os error 35 - // Linux: ENOMEM = os error 12 - // Common: "Too many open files" (EMFILE) - message.contains("resource temporarily unavailable") - || message.contains("temporarily unavailable") - || message.contains("os error 35") - || message.contains("eagain") - || message.contains("os error 12") - || message.contains("cannot allocate memory") - || message.contains("too many open files") - || message.contains("os error 24") -} - -fn chrome_logging_enabled() -> bool { - env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("CODEX_BROWSER_LOG") -} - -fn env_truthy(key: &str) -> bool { - std::env::var(key) - .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) -} - -fn resolve_chrome_log_path() -> Option { - if !chrome_logging_enabled() { - return None; - } - - if let Ok(path) = std::env::var("CODEX_BROWSER_LOG_PATH") { - let trimmed = path.trim(); - if !trimmed.is_empty() { - return Some(PathBuf::from(trimmed)); - } - } - - let base = if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("CODEX_HOME")) { - PathBuf::from(home).join("debug_logs") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".code").join("debug_logs") - } else { - return Some(std::env::temp_dir().join("code-chrome.log")); - }; - - let path = base.join("code-chrome.log"); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - Some(path) -} - -const HANDLER_ERROR_LIMIT: u32 = 3; - -fn should_restart_handler(consecutive_errors: u32) -> bool { - consecutive_errors >= HANDLER_ERROR_LIMIT -} - -fn should_ignore_handler_error(message_lower: &str) -> bool { - // Chromiumoxide uses oneshot channels internally for CDP request/response. - // When we cancel or time out an in-flight request (dropping its future), the - // CDP runtime may surface this as an error string containing "oneshot". - // These should not be treated as connection failures. - if message_lower.contains("oneshot") { - return true; - } - - // Chromium can emit occasional CDP payloads that chromiumoxide fails to - // deserialize into its internal Message enum. These do not necessarily mean - // the browser session is unhealthy; treating them as fatal causes the - // manager to discard a perfectly good page right after navigation. - if message_lower.contains("data did not match any variant of untagged enum message") { - return true; - } - - // These can happen for individual targets/tabs while the overall CDP - // connection is still healthy (e.g. tabs closing, navigations, reloads). - const TRANSIENT_SUBSTRINGS: &[&str] = &[ - "no such session", - "session closed", - "invalid session", - "target closed", - "target crashed", - "context destroyed", - "execution context was destroyed", - "cannot find context", - ]; - - TRANSIENT_SUBSTRINGS - .iter() - .any(|needle| message_lower.contains(needle)) -} - -fn should_stop_handler( - label: &'static str, - result: std::result::Result<(), E>, - consecutive_errors: &mut u32, -) -> bool { - match result { - Ok(()) => { - *consecutive_errors = 0; - false - } - Err(err) => { - let message = err.to_string(); - let message_lower = message.to_ascii_lowercase(); - if should_ignore_handler_error(&message_lower) { - *consecutive_errors = 0; - debug!("{label} event handler error ignored: {message}"); - return false; - } - *consecutive_errors = consecutive_errors.saturating_add(1); - let count = *consecutive_errors; - if count <= HANDLER_ERROR_LIMIT { - debug!("{label} event handler error: {err} (count: {count})"); - } - if should_restart_handler(count) { - warn!("{label} event handler errors exceeded limit; restarting browser connection"); - return true; - } - false - } - } -} - -async fn discover_ws_via_host_port(host: &str, port: u16) -> Result { - let url = format!("http://{}:{}/json/version", host, port); - debug!("Requesting Chrome version info from: {}", url); - - let client_start = tokio::time::Instant::now(); - let client = Client::builder() - .no_proxy() - .timeout(Duration::from_secs(5)) // Allow Chrome time to bring up /json/version on fresh launch - .build() - .map_err(|e| BrowserError::CdpError(format!("Failed to build HTTP client: {}", e)))?; - debug!("HTTP client created in {:?}", client_start.elapsed()); - - let req_start = tokio::time::Instant::now(); - let resp = client.get(&url).send().await.map_err(|e| { - BrowserError::CdpError(format!("Failed to connect to Chrome debug port: {}", e)) - })?; - debug!( - "HTTP request completed in {:?}, status: {}", - req_start.elapsed(), - resp.status() - ); - - if !resp.status().is_success() { - return Err(BrowserError::CdpError(format!( - "Chrome /json/version returned {}", - resp.status() - ))); - } - - let parse_start = tokio::time::Instant::now(); - let body: JsonVersion = resp.json().await.map_err(|e| { - BrowserError::CdpError(format!("Failed to parse Chrome debug response: {}", e)) - })?; - debug!("Response parsed in {:?}", parse_start.elapsed()); - - Ok(body.web_socket_debugger_url) -} - -/// Scan for Chrome processes with debug ports and verify accessibility -async fn scan_for_chrome_debug_port() -> Option { - use std::process::Command; - - // Use ps to find Chrome processes with remote-debugging-port - let output = Command::new("ps").args(&["aux"]).output().ok()?; - - let ps_output = String::from_utf8_lossy(&output.stdout); - - // Find all Chrome processes with debug ports - let mut found_ports = Vec::new(); - for line in ps_output.lines() { - // Look for Chrome/Chromium processes with remote-debugging-port - if (line.contains("chrome") || line.contains("Chrome") || line.contains("chromium")) - && line.contains("--remote-debugging-port=") - { - // Extract the port number - if let Some(port_str) = line.split("--remote-debugging-port=").nth(1) { - // Take everything up to the next space or end of line - let port_str = port_str.split_whitespace().next().unwrap_or(port_str); - - // Parse the port number - if let Ok(port) = port_str.parse::() { - // Skip port 0 (means random port, not accessible) - if port > 0 { - found_ports.push(port); - } - } - } - } - } - - // Remove duplicates - found_ports.sort_unstable(); - found_ports.dedup(); - - info!( - "Found {} Chrome process(es) with debug ports: {:?}", - found_ports.len(), - found_ports - ); - - // Test each found port to see if it's accessible (test in parallel for speed) - if found_ports.is_empty() { - return None; - } - - debug!("Testing {} port(s) for accessibility...", found_ports.len()); - let test_start = tokio::time::Instant::now(); - - // Create futures for testing all ports in parallel - let mut port_tests = Vec::new(); - for port in found_ports { - let test_future = async move { - let url = format!("http://127.0.0.1:{}/json/version", port); - let client = Client::builder() - .no_proxy() - .timeout(Duration::from_millis(200)) // Shorter timeout for parallel tests - .build() - .ok()?; - - match client.get(&url).send().await { - Ok(resp) if resp.status().is_success() => { - debug!("Chrome port {} is accessible", port); - Some(port) - } - Ok(resp) => { - debug!("Chrome port {} returned status: {}", port, resp.status()); - None - } - Err(_) => { - debug!("Could not connect to Chrome port {}", port); - None - } - } - }; - port_tests.push(test_future); - } - - // Test all ports in parallel and return the first accessible one - let results = futures::future::join_all(port_tests).await; - debug!( - "Port accessibility tests completed in {:?}", - test_start.elapsed() - ); - - for port in results.into_iter().flatten() { - info!("Verified Chrome debug port at {} is accessible", port); - return Some(port); - } - - warn!("No accessible Chrome debug ports found"); - None -} - -pub struct BrowserManager { - pub config: Arc>, - browser: Arc>>, - page: Arc>>>, - // Dedicated background page for screenshots to prevent focus stealing - background_page: Arc>>>, - last_activity: Arc>, - idle_monitor_handle: Arc>>>, - event_task: Arc>>>, - assets: Arc>>>, - user_data_dir: Arc>>, - cleanup_profile_on_drop: Arc>, - navigation_callback: Arc>>>, - navigation_monitor_handle: Arc>>>, - viewport_monitor_handle: Arc>>>, - /// Gate to temporarily disable all automatic viewport corrections (post-initial set) - auto_viewport_correction_enabled: Arc>, - /// Track last applied device metrics to avoid redundant overrides - last_metrics_applied: Arc>>, -} - -#[derive(Debug)] -struct PageDebugInfo { - target_id: String, - session_id: String, - opener_id: Option, - cached_url: Option, - live_url: Option, -} - -#[derive(Debug)] -struct TargetSnapshot { - total: usize, - sample: Vec, - truncated: bool, -} - -impl BrowserManager { - const SCREENSHOT_TTL_MS: u64 = 86_400_000; // 24 hours - - pub fn new(config: BrowserConfig) -> Self { - Self { - config: Arc::new(RwLock::new(config)), - browser: Arc::new(Mutex::new(None)), - page: Arc::new(Mutex::new(None)), - background_page: Arc::new(Mutex::new(None)), - last_activity: Arc::new(Mutex::new(Instant::now())), - idle_monitor_handle: Arc::new(Mutex::new(None)), - event_task: Arc::new(Mutex::new(None)), - assets: Arc::new(Mutex::new(None)), - user_data_dir: Arc::new(Mutex::new(None)), - cleanup_profile_on_drop: Arc::new(Mutex::new(false)), - navigation_callback: Arc::new(tokio::sync::RwLock::new(None)), - navigation_monitor_handle: Arc::new(Mutex::new(None)), - viewport_monitor_handle: Arc::new(Mutex::new(None)), - auto_viewport_correction_enabled: Arc::new(tokio::sync::RwLock::new(true)), - last_metrics_applied: Arc::new(Mutex::new(None)), - } - } - - /// Try to connect to Chrome via CDP only - no fallback to internal browser - pub async fn connect_to_chrome_only(&self) -> Result<()> { - tracing::info!("[cdp/bm] connect_to_chrome_only: begin"); - // Quick check without holding the lock during IO - if self.browser.lock().await.is_some() { - tracing::info!("[cdp/bm] already connected; early return"); - return Ok(()); - } - - let config = self.config.read().await.clone(); - tracing::info!( - "[cdp/bm] config: connect_host={:?}, connect_port={:?}, connect_ws={:?}", - config.connect_host, config.connect_port, config.connect_ws - ); - - // If a WebSocket is configured explicitly, try that first - if let Some(ws) = config.connect_ws.clone() { - info!("[cdp/bm] Connecting to Chrome via configured WebSocket: {}", ws); - let attempt_timeout = Duration::from_millis(config.connect_attempt_timeout_ms); - let attempts = std::cmp::max(1, config.connect_attempts as i32); - let mut last_err: Option = None; - - for attempt in 1..=attempts { - info!( - "[cdp/bm] WS connect attempt {}/{} (timeout={}ms)", - attempt, - attempts, - attempt_timeout.as_millis() - ); - let ws_clone = ws.clone(); - let handle = tokio::spawn(async move { Browser::connect(ws_clone).await }); - match tokio::time::timeout(attempt_timeout, handle).await { - Ok(Ok(Ok((browser, mut handler)))) => { - info!("[cdp/bm] WS connect attempt {} succeeded", attempt); - - // Start event handler loop - let browser_arc = self.browser.clone(); - let page_arc = self.page.clone(); - let background_page_arc = self.background_page.clone(); - let task = tokio::spawn(async move { - let mut consecutive_errors = 0u32; - while let Some(result) = handler.next().await { - if should_stop_handler("[cdp/bm]", result, &mut consecutive_errors) { - break; - } - } - warn!("[cdp/bm] event handler ended; clearing browser state so it can restart"); - *browser_arc.lock().await = None; - *page_arc.lock().await = None; - *background_page_arc.lock().await = None; - }); - *self.event_task.lock().await = Some(task); - { - let mut guard = self.browser.lock().await; - *guard = Some(browser); - } - *self.cleanup_profile_on_drop.lock().await = false; - - // Fire-and-forget targets warmup - { - let browser_arc = self.browser.clone(); - tokio::spawn(async move { - if let Some(browser) = browser_arc.lock().await.as_mut() { - let _ = tokio::time::timeout(Duration::from_millis(100), browser.fetch_targets()).await; - } - }); - } - - self.start_idle_monitor().await; - self.update_activity().await; - // Cache last connection (ws only) - global::set_last_connection(None, Some(ws.clone())).await; - return Ok(()); - } - Ok(Ok(Err(e))) => { - let msg = format!("CDP WebSocket connect failed: {}", e); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Ok(Err(join_err)) => { - let msg = format!("Join error during connect attempt: {}", join_err); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Err(_) => { - warn!( - "[cdp/bm] WS connect attempt {} timed out after {}ms; aborting attempt", - attempt, - attempt_timeout.as_millis() - ); - } - } - sleep(Duration::from_millis(200)).await; - } - - let base = "CDP WebSocket connect failed after all attempts".to_string(); - let msg = if let Some(e) = last_err { format!("{}: {}", base, e) } else { base }; - return Err(BrowserError::CdpError(msg)); - } - - // Only try CDP connection via port, no fallback - if let Some(port) = config.connect_port { - let host = config.connect_host.as_deref().unwrap_or("127.0.0.1"); - let actual_port = if port == 0 { - info!("Auto-scanning for Chrome debug ports..."); - let start = tokio::time::Instant::now(); - let result = scan_for_chrome_debug_port().await.unwrap_or(0); - info!( - "Auto-scan completed in {:?}, found port: {}", - start.elapsed(), - result - ); - result - } else { - info!("[cdp/bm] Using specified Chrome debug port: {}", port); - port - }; - - if actual_port > 0 { - info!("[cdp/bm] Discovering Chrome WebSocket URL via {}:{}...", host, actual_port); - // Retry discovery for up to 15s to allow a freshly launched Chrome to initialize - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - let ws = loop { - let discover_start = tokio::time::Instant::now(); - match discover_ws_via_host_port(host, actual_port).await { - Ok(ws) => { - info!("[cdp/bm] WS discovered in {:?}: {}", discover_start.elapsed(), ws); - break ws; - } - Err(e) => { - if tokio::time::Instant::now() >= deadline { - return Err(BrowserError::CdpError(format!( - "Failed to discover Chrome WebSocket on port {} within 15s: {}", - actual_port, e - ))); - } - tokio::time::sleep(Duration::from_millis(300)).await; - } - } - }; - - info!("[cdp/bm] Connecting to Chrome via WebSocket..."); - let connect_start = tokio::time::Instant::now(); - - // Enforce per-attempt timeouts via spawned task to avoid hangs - let attempt_timeout = Duration::from_millis(config.connect_attempt_timeout_ms); - let attempts = std::cmp::max(1, config.connect_attempts as i32); - let mut last_err: Option = None; - - for attempt in 1..=attempts { - info!( - "[cdp/bm] WS connect attempt {}/{} (timeout={}ms)", - attempt, - attempts, - attempt_timeout.as_millis() - ); - - let ws_clone = ws.clone(); - let handle = tokio::spawn(async move { Browser::connect(ws_clone).await }); - - match tokio::time::timeout(attempt_timeout, handle).await { - Ok(Ok(Ok((browser, mut handler)))) => { - info!("[cdp/bm] WS connect attempt {} succeeded", attempt); - info!("[cdp/bm] Connected to Chrome in {:?}", connect_start.elapsed()); - - // Start event handler loop - let browser_arc = self.browser.clone(); - let page_arc = self.page.clone(); - let background_page_arc = self.background_page.clone(); - let task = tokio::spawn(async move { - let mut consecutive_errors = 0u32; - while let Some(result) = handler.next().await { - if should_stop_handler("[cdp/bm]", result, &mut consecutive_errors) { - break; - } - } - warn!("[cdp/bm] event handler ended; clearing browser state so it can restart"); - *browser_arc.lock().await = None; - *page_arc.lock().await = None; - *background_page_arc.lock().await = None; - }); - *self.event_task.lock().await = Some(task); - - // Install browser - { - let mut guard = self.browser.lock().await; - *guard = Some(browser); - } - *self.cleanup_profile_on_drop.lock().await = false; - - // Fire-and-forget targets warmup after browser is installed - { - let browser_arc = self.browser.clone(); - tokio::spawn(async move { - if let Some(browser) = browser_arc.lock().await.as_mut() { - let _ = tokio::time::timeout(Duration::from_millis(100), browser.fetch_targets()).await; - } - }); - } - - self.start_idle_monitor().await; - self.update_activity().await; - // Update last connection cache - global::set_last_connection(Some(actual_port), Some(ws.clone())).await; - return Ok(()); - } - Ok(Ok(Err(e))) => { - let msg = format!("CDP WebSocket connect failed: {}", e); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Ok(Err(join_err)) => { - let msg = format!("Join error during connect attempt: {}", join_err); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Err(_) => { - warn!( - "[cdp/bm] WS connect attempt {} timed out after {}ms; aborting attempt", - attempt, - attempt_timeout.as_millis() - ); - // Best-effort abort; if connect is internally blocking, it may keep a worker thread busy, - // but our caller remains responsive and we can retry. - // We cannot await the handle here without risking another stall. - } - } - - // Small backoff between attempts - sleep(Duration::from_millis(200)).await; - } - - let base = "CDP WebSocket connect failed after all attempts".to_string(); - let msg = if let Some(e) = last_err { format!("{}: {}", base, e) } else { base }; - return Err(BrowserError::CdpError(msg)); - } else { - return Err(BrowserError::CdpError( - "No Chrome instance found with debug port".to_string(), - )); - } - } else { - return Err(BrowserError::CdpError( - "No CDP port configured for Chrome connection".to_string(), - )); - } - } - - pub async fn start(&self) -> Result<()> { - if self.browser.lock().await.is_some() { - return Ok(()); - } - - let config = self.config.read().await.clone(); - - // 1) Attach to a live Chrome, if requested - if let Some(ws) = config.connect_ws.clone() { - info!("Connecting to Chrome via WebSocket: {}", ws); - // Use the same guarded connect strategy as connect_to_chrome_only - let attempt_timeout = Duration::from_millis(config.connect_attempt_timeout_ms); - let attempts = std::cmp::max(1, config.connect_attempts as i32); - let mut last_err: Option = None; - - for attempt in 1..=attempts { - info!( - "[cdp/bm] WS connect attempt {}/{} (timeout={}ms)", - attempt, - attempts, - attempt_timeout.as_millis() - ); - let ws_clone = ws.clone(); - let handle = tokio::spawn(async move { Browser::connect(ws_clone).await }); - match tokio::time::timeout(attempt_timeout, handle).await { - Ok(Ok(Ok((browser, mut handler)))) => { - info!("[cdp/bm] WS connect attempt {} succeeded", attempt); - // Start event handler loop - let browser_arc = self.browser.clone(); - let page_arc = self.page.clone(); - let background_page_arc = self.background_page.clone(); - let task = tokio::spawn(async move { - let mut consecutive_errors = 0u32; - while let Some(result) = handler.next().await { - if should_stop_handler("[cdp/bm]", result, &mut consecutive_errors) { - break; - } - } - warn!("[cdp/bm] event handler ended; clearing browser state so it can restart"); - *browser_arc.lock().await = None; - *page_arc.lock().await = None; - *background_page_arc.lock().await = None; - }); - *self.event_task.lock().await = Some(task); - { - let mut guard = self.browser.lock().await; - *guard = Some(browser); - } - *self.cleanup_profile_on_drop.lock().await = false; - - // Fire-and-forget targets warmup after browser is installed - { - let browser_arc = self.browser.clone(); - tokio::spawn(async move { - if let Some(browser) = browser_arc.lock().await.as_mut() { - let _ = tokio::time::timeout(Duration::from_millis(100), browser.fetch_targets()).await; - } - }); - } - self.start_idle_monitor().await; - self.update_activity().await; - return Ok(()); - } - Ok(Ok(Err(e))) => { - let msg = format!("CDP WebSocket connect failed: {}", e); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Ok(Err(join_err)) => { - let msg = format!("Join error during connect attempt: {}", join_err); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Err(_) => { - warn!( - "[cdp/bm] WS connect attempt {} timed out after {}ms; aborting attempt", - attempt, - attempt_timeout.as_millis() - ); - } - } - sleep(Duration::from_millis(200)).await; - } - - let base = "CDP WebSocket connect failed after all attempts".to_string(); - let msg = if let Some(e) = last_err { format!("{}: {}", base, e) } else { base }; - return Err(BrowserError::CdpError(msg)); - } - - if let Some(port) = config.connect_port { - let host = config.connect_host.as_deref().unwrap_or("127.0.0.1"); - let actual_port = if port == 0 { - info!("Auto-scanning for Chrome debug ports..."); - let start = tokio::time::Instant::now(); - let result = scan_for_chrome_debug_port().await.unwrap_or(0); - info!( - "Auto-scan completed in {:?}, found port: {}", - start.elapsed(), - result - ); - result - } else { - info!("Using specified Chrome debug port: {}", port); - port - }; - - if actual_port > 0 { - info!("Step 1: Discovering Chrome WebSocket URL via {}:{}...", host, actual_port); - let ws = loop { - let discover_start = tokio::time::Instant::now(); - match discover_ws_via_host_port(host, actual_port).await { - Ok(ws) => { - info!( - "Step 2: WebSocket URL discovered in {:?}: {}", - discover_start.elapsed(), - ws - ); - break ws; - } - Err(e) => { - if tokio::time::Instant::now() - discover_start > Duration::from_secs(15) { - return Err(BrowserError::CdpError(format!( - "Failed to discover Chrome WebSocket on port {} within 15s: {}", - actual_port, e - ))); - } - tokio::time::sleep(Duration::from_millis(300)).await; - } - } - }; - - info!("Step 3: Connecting to Chrome via WebSocket..."); - let connect_start = tokio::time::Instant::now(); - // Use guarded connect strategy with retries - let attempt_timeout = Duration::from_millis(config.connect_attempt_timeout_ms); - let attempts = std::cmp::max(1, config.connect_attempts as i32); - let mut last_err: Option = None; - - for attempt in 1..=attempts { - info!( - "[cdp/bm] WS connect attempt {}/{} (timeout={}ms)", - attempt, - attempts, - attempt_timeout.as_millis() - ); - let ws_clone = ws.clone(); - let handle = tokio::spawn(async move { Browser::connect(ws_clone).await }); - match tokio::time::timeout(attempt_timeout, handle).await { - Ok(Ok(Ok((browser, mut handler)))) => { - info!("[cdp/bm] WS connect attempt {} succeeded", attempt); - info!( - "Step 4: Connected to Chrome in {:?}", - connect_start.elapsed() - ); - - // Start event handler - let browser_arc = self.browser.clone(); - let page_arc = self.page.clone(); - let background_page_arc = self.background_page.clone(); - let task = tokio::spawn(async move { - let mut consecutive_errors = 0u32; - while let Some(result) = handler.next().await { - if should_stop_handler("[cdp/bm]", result, &mut consecutive_errors) { - break; - } - } - warn!("[cdp/bm] event handler ended; clearing browser state so it can restart"); - *browser_arc.lock().await = None; - *page_arc.lock().await = None; - *background_page_arc.lock().await = None; - }); - *self.event_task.lock().await = Some(task); - { - let mut guard = self.browser.lock().await; - *guard = Some(browser); - } - *self.cleanup_profile_on_drop.lock().await = false; - - // Fire-and-forget targets warmup after browser is installed - { - let browser_arc = self.browser.clone(); - tokio::spawn(async move { - if let Some(browser) = browser_arc.lock().await.as_mut() { - let _ = tokio::time::timeout(Duration::from_millis(100), browser.fetch_targets()).await; - } - }); - } - - info!("Step 5: Starting idle monitor..."); - self.start_idle_monitor().await; - self.update_activity().await; - info!("Step 6: Chrome connection complete!"); - return Ok(()); - } - Ok(Ok(Err(e))) => { - let msg = format!("CDP WebSocket connect failed: {}", e); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Ok(Err(join_err)) => { - let msg = format!("Join error during connect attempt: {}", join_err); - warn!("[cdp/bm] {}", msg); - last_err = Some(msg); - } - Err(_) => { - warn!( - "[cdp/bm] WS connect attempt {} timed out after {}ms; aborting attempt", - attempt, - attempt_timeout.as_millis() - ); - } - } - sleep(Duration::from_millis(200)).await; - } - - let base = "CDP WebSocket connect failed after all attempts".to_string(); - let msg = if let Some(e) = last_err { format!("{}: {}", base, e) } else { base }; - return Err(BrowserError::CdpError(msg)); - } - } - - // 2) Launch a browser - info!("Launching new browser instance"); - // Prevent redundant browser launches within the same process. - let _launch_guard = INTERNAL_BROWSER_LAUNCH_GUARD.lock().await; - // Also serialize launches across Code processes; concurrent Chromium spawns - // are a common source of transient EAGAIN/ENOMEM failures on macOS. - let _launch_lock = BrowserLaunchLockFile::acquire(Duration::from_secs(30)).await?; - - const INTERNAL_LAUNCH_RETRY_DELAYS_MS: [u64; 5] = [50, 200, 500, 1000, 2000]; - let max_attempts = INTERNAL_LAUNCH_RETRY_DELAYS_MS.len() + 1; - - // Add browser launch flags (keep minimal set for screenshot functionality) - let log_file = resolve_chrome_log_path(); - - let (browser, mut handler, user_data_path) = { - let mut attempt = 1usize; - loop { - // Profile dir - let (user_data_path, is_temp_profile) = if let Some(dir) = &config.user_data_dir { - (dir.to_string_lossy().to_string(), false) - } else { - let pid = std::process::id(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let temp_path = format!("/tmp/code-browser-{pid}-{timestamp}-{attempt}"); - if tokio::fs::metadata(&temp_path).await.is_ok() { - let _ = tokio::fs::remove_dir_all(&temp_path).await; - } - (temp_path, true) - }; - - let mut builder = CdpConfig::builder().user_data_dir(&user_data_path); - - // Set headless mode based on config (keep original approach for stability) - if config.headless { - builder = builder.headless_mode(HeadlessMode::New); - } - - // Configure viewport (revert to original approach for screenshot stability) - builder = builder.window_size(config.viewport.width, config.viewport.height); - - builder = builder - .arg("--disable-blink-features=AutomationControlled") - .arg("--no-first-run") - .arg("--no-default-browser-check") - .arg("--disable-component-extensions-with-background-pages") - .arg("--disable-background-networking") - .arg("--silent-debugger-extension-api") - .arg("--remote-allow-origins=*") - .arg("--disable-features=ChromeWhatsNewUI,TriggerFirstRunUI") - // Disable timeout for slow networks/pages - .arg("--disable-hang-monitor") - .arg("--disable-background-timer-throttling") - // Suppress console output - .arg("--silent-launch") - // Set a longer timeout for CDP requests (60 seconds instead of default 30) - .request_timeout(Duration::from_secs(60)); - - if let Some(ref log_file) = log_file { - builder = builder - .arg("--enable-logging") - .arg("--log-level=1") - .arg(format!("--log-file={}", log_file.display())); - } - - let browser_config = builder - .build() - .map_err(|e| BrowserError::CdpError(e.to_string()))?; - - match Browser::launch(browser_config).await { - Ok((browser, handler)) => break (browser, handler, user_data_path), - Err(e) => { - let message = e.to_string(); - - let is_temporary = is_temporary_internal_launch_error_message(&message); - if is_temp_profile { - let _ = tokio::fs::remove_dir_all(&user_data_path).await; - } - - if is_temporary && attempt < max_attempts { - let delay_ms = INTERNAL_LAUNCH_RETRY_DELAYS_MS[attempt - 1]; - warn!( - error = %message, - attempt, - delay_ms, - "Internal browser launch failed with transient error; retrying" - ); - sleep(Duration::from_millis(delay_ms)).await; - attempt += 1; - continue; - } - - #[cfg(target_os = "macos")] - let hint = "Ensure Google Chrome or Chromium is installed and runnable (e.g., /Applications/Google Chrome.app)."; - #[cfg(target_os = "linux")] - let hint = "Ensure google-chrome or chromium is installed and available on PATH."; - #[cfg(target_os = "windows")] - let hint = "Ensure Chrome is installed and chrome.exe is available (typically in Program Files)."; - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - let hint = "Ensure Chrome/Chromium is installed and available on PATH."; - - let log_note = log_file - .as_ref() - .map(|path| format!(" Chrome log: {}", path.display())) - .unwrap_or_default(); - return Err(BrowserError::CdpError(format!( - "Failed to launch internal browser: {message}. Hint: {hint}.{log_note}" - ))); - } - } - } - }; - // Optionally: browser.fetch_targets().await.ok(); - - let browser_arc = self.browser.clone(); - let page_arc = self.page.clone(); - let background_page_arc = self.background_page.clone(); - let task = tokio::spawn(async move { - let mut consecutive_errors = 0u32; - while let Some(result) = handler.next().await { - if should_stop_handler("[cdp/bm]", result, &mut consecutive_errors) { - break; - } - } - warn!("[cdp/bm] event handler ended; clearing browser state so it can restart"); - *browser_arc.lock().await = None; - *page_arc.lock().await = None; - *background_page_arc.lock().await = None; - }); - *self.event_task.lock().await = Some(task); - - { - let mut guard = self.browser.lock().await; - *guard = Some(browser); - } - *self.user_data_dir.lock().await = Some(user_data_path.clone()); - - let should_cleanup = config.user_data_dir.is_none() || !config.persist_profile; - *self.cleanup_profile_on_drop.lock().await = should_cleanup; - - self.start_idle_monitor().await; - self.update_activity().await; - Ok(()) - } - - pub async fn stop(&self) -> Result<()> { - self.stop_idle_monitor().await; - - // stop event handler task cleanly - if let Some(task) = self.event_task.lock().await.take() { - task.abort(); - } - - self.stop_navigation_monitor().await; - - let mut page_guard = self.page.lock().await; - *page_guard = None; - - // Also cleanup the background page - let mut background_page_guard = self.background_page.lock().await; - *background_page_guard = None; - - let config = self.config.read().await; - let is_external_chrome = config.connect_port.is_some() || config.connect_ws.is_some(); - drop(config); - - let mut browser_guard = self.browser.lock().await; - if let Some(mut browser) = browser_guard.take() { - if is_external_chrome { - info!("Disconnecting from external Chrome (not closing it)"); - // Just drop the connection, don't close the browser - } else { - info!("Stopping browser we launched"); - browser.close().await?; - } - } - - // When cleaning profiles, respect the flag everywhere: - let should_cleanup = *self.cleanup_profile_on_drop.lock().await; - if should_cleanup { - let mut user_data_guard = self.user_data_dir.lock().await; - if let Some(user_data_path) = user_data_guard.take() { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - let _ = tokio::fs::remove_dir_all(&user_data_path).await; - #[cfg(target_os = "macos")] - { - let _ = tokio::process::Command::new("rm") - .arg("-rf") - .arg(&user_data_path) - .output() - .await; - } - } - } - - Ok(()) - } - - pub async fn get_or_create_page(&self) -> Result> { - let overall_start = Instant::now(); - info!("[bm] get_or_create_page: begin"); - self.ensure_browser().await?; - info!("[bm] get_or_create_page: ensure_browser in {:?}", overall_start.elapsed()); - self.update_activity().await; - - let mut page_guard = self.page.lock().await; - if let Some(page) = page_guard.as_ref() { - // Verify the page is still responsive - let check_result = - tokio::time::timeout(Duration::from_secs(2), page.get_current_url()).await; - - match check_result { - Ok(Ok(_)) => { - // Page is responsive - info!( - "[bm] get_or_create_page: reused responsive page in {:?}", - overall_start.elapsed() - ); - return Ok(Arc::clone(page)); - } - Ok(Err(e)) => { - warn!("Existing page returned error: {}, will create new page", e); - *page_guard = None; - } - Err(_) => { - // Timeout checking URL; prefer to reuse instead of re-applying overrides repeatedly - warn!("Existing page timed out checking URL; reusing current page to avoid churn"); - return Ok(Arc::clone(page)); - } - } - } - - let browser_guard = self.browser.lock().await; - let browser = browser_guard.as_ref().ok_or(BrowserError::NotInitialized)?; - - let config = self.config.read().await; - - // If we're connected to an existing Chrome (via connect_port or connect_ws), - // try to use the current active tab instead of creating a new one - let cdp_page = if config.connect_port.is_some() || config.connect_ws.is_some() { - info!("[bm] get_or_create_page: selecting an existing tab"); - // Try to get existing pages - let mut pages = browser.pages().await?; - if pages.is_empty() { - // brief retry loop to allow targets to populate - for _ in 0..10 { - tokio::time::sleep(Duration::from_millis(50)).await; - pages = browser.pages().await?; - if !pages.is_empty() { break; } - } - } - - if !pages.is_empty() { - // Try to find the active/visible tab - // We'll check each page to see if it's visible/focused - let mut active_page = None; // focused && visible - let mut first_visible: Option = None; // visible - let mut last_allowed: Option = None; // allowed regardless of visibility - - // Helper: determine if a URL is controllable (we can inject/evaluate) - let is_allowed = |u: &str| { - let lu = u.to_lowercase(); - if lu.starts_with("chrome://") - || lu.starts_with("devtools://") - || lu.starts_with("edge://") - || lu.starts_with("chrome-extension://") - || lu.starts_with("brave://") - || lu.starts_with("vivaldi://") - || lu.starts_with("opera://") - { - return false; - } - // Allow http/https/file/about:blank - lu.starts_with("http://") - || lu.starts_with("https://") - || lu.starts_with("file://") - || lu == "about:blank" - }; - - for page in &pages { - // Quick URL check first to skip uninjectable pages - let url = match tokio::time::timeout(Duration::from_millis(200), page.url()).await { - Ok(Ok(Some(u))) => u, - _ => "unknown".to_string(), - }; - if !is_allowed(&url) { - debug!("Skipping uncontrollable tab: {}", url); - continue; - } else { - last_allowed = Some(page.clone()); - } - // Evaluate visibility/focus of the tab. We avoid focus listeners since they won't fire when attaching. - let eval = page.evaluate( - "(() => {\n" - .to_string() - + " return {\n" - + " visible: document.visibilityState === 'visible',\n" - + " focused: (document.hasFocus && document.hasFocus()) || false,\n" - + " url: String(window.location.href || '')\n" - + " };\n" - + "})()", - ); - // Guard against hung targets by timing out quickly - let is_visible = tokio::time::timeout(Duration::from_millis(300), eval).await; - - if let Ok(Ok(result)) = is_visible { - if let Ok(obj) = result.into_value::() { - let visible = obj - .get("visible") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let focused = obj - .get("focused") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let url = obj.get("url").and_then(|v| v.as_str()).unwrap_or("unknown"); - - debug!("Tab check - URL: {}, Visible: {}, Focused: {}", url, visible, focused); - - // Selection heuristic (revised to avoid minimized windows): - // 1) Focused AND visible wins immediately. - // 2) Otherwise, remember the first visible tab. - // 3) Otherwise, fallback to the last allowed tab. - if focused && visible { - info!("Found focused & visible tab: {}", url); - active_page = Some(page.clone()); - break; - } else if focused && !visible { - info!("Focused but not visible (likely minimized): skipping {}", url); - } else if visible && first_visible.is_none() { - info!("Found visible tab: {}", url); - first_visible = Some(page.clone()); - } - } else { - debug!("Tab visibility check returned non-JSON; skipping"); - } - } else { - debug!("Tab visibility check timed out or failed; skipping unresponsive tab"); - } - } - - // Use focused & visible if found, else first visible, else last allowed - if let Some(page) = active_page { - info!("Using active/visible Chrome tab"); - page - } else if let Some(page) = first_visible { - info!("Using first visible Chrome tab"); - page - } else { - if let Some(p) = last_allowed { - info!("No active tab found, using last allowed tab"); - p - } else { - // No allowed pages at all, create an about:blank tab - warn!("No controllable tabs found; creating about:blank"); - browser.new_page("about:blank").await? - } - } - } else { - // No existing tabs found. Do NOT create a new tab for external Chrome if avoidable. - info!("No existing tabs found; waiting briefly for targets"); - tokio::time::sleep(Duration::from_millis(200)).await; - let mut pages2 = browser.pages().await?; - if !pages2.is_empty() { - pages2.pop().unwrap() - } else { - // As a last resort, still create a tab, but log it clearly - warn!("Creating a new about:blank tab because none were available"); - browser.new_page("about:blank").await? - } - } - } else { - // We launched Chrome ourselves, create a new page - info!("[bm] get_or_create_page: creating new about:blank tab"); - browser.new_page("about:blank").await? - }; - - // Apply page overrides (UA, locale, timezone, viewport, etc.) - let overrides_start = Instant::now(); - self.apply_page_overrides(&cdp_page).await?; - info!("[bm] get_or_create_page: overrides in {:?}", overrides_start.elapsed()); - - let page = Arc::new(Page::new(cdp_page, config.clone())); - *page_guard = Some(Arc::clone(&page)); - - // Inject the virtual cursor when page is created - debug!("Injecting virtual cursor for new page"); - if let Err(e) = page.inject_virtual_cursor().await { - warn!("Failed to inject virtual cursor on page creation: {}", e); - // Continue even if cursor injection fails - } - - // Ensure console capture is installed immediately for the current document. - // Without this, connecting to an already-loaded tab would only register - // the bootstrap for future documents, and an initial Browser Console read - // would return no logs. This eagerly hooks console methods now. - let console_hook = r#"(function(){ - try { - if (!window.__code_console_logs) { - window.__code_console_logs = []; - const push = (level, message) => { - try { - window.__code_console_logs.push({ timestamp: new Date().toISOString(), level, message }); - if (window.__code_console_logs.length > 2000) window.__code_console_logs.shift(); - } catch (_) {} - }; - - ['log','warn','error','info','debug'].forEach(function(method) { - try { - const orig = console[method]; - console[method] = function() { - try { - var args = Array.prototype.slice.call(arguments); - var msg = args.map(function(a) { - try { - if (a && typeof a === 'object') return JSON.stringify(a); - return String(a); - } catch (_) { return String(a); } - }).join(' '); - push(method, msg); - } catch(_) {} - if (orig) return orig.apply(console, arguments); - }; - } catch(_) {} - }); - - window.addEventListener('error', function(e) { - try { - var msg = e && e.message ? e.message : 'Script error'; - var stack = e && e.error && e.error.stack ? ('\n' + e.error.stack) : ''; - push('exception', msg + stack); - } catch(_) {} - }); - window.addEventListener('unhandledrejection', function(e) { - try { - var reason = e && e.reason; - if (reason && typeof reason === 'object') { try { reason = JSON.stringify(reason); } catch(_) {} } - push('unhandledrejection', String(reason)); - } catch(_) {} - }); - } - return true; - } catch (_) { return false; } - })()"#; - if let Err(e) = page.inject_js(console_hook).await { - warn!("Failed to install console capture on page creation: {}", e); - } - - // Start navigation monitoring for this page - self.start_navigation_monitor(Arc::clone(&page)).await; - // Start viewport monitor (low-frequency, non-invasive) - self.start_viewport_monitor(Arc::clone(&page)).await; - // TEMP: disable auto-corrections post-initial set to validate no unintended resizes - // This affects both external and internal; explicit browser.setViewport still works - self.set_auto_viewport_correction(false).await; - info!( - "[bm] get_or_create_page: complete in {:?}", - overall_start.elapsed() - ); - - Ok(page) - } - - pub async fn close_page(&self) -> Result<()> { - let mut page_guard = self.page.lock().await; - if let Some(page) = page_guard.take() { - page.close().await?; - } - Ok(()) - } - - pub async fn is_enabled(&self) -> bool { - self.config.read().await.enabled - } - - pub fn is_enabled_sync(&self) -> bool { - self.config.try_read().map(|c| c.enabled).unwrap_or(false) - } - - /// Returns how long the browser has been idle when it exceeds the configured timeout. - /// Used to decide whether we should avoid taking fresh screenshots (which would reset - /// the idle timer) until the user interacts with browser_* tools again. - pub async fn idle_elapsed_past_timeout(&self) -> Option<(Duration, Duration)> { - let idle_timeout = Duration::from_millis(self.config.read().await.idle_timeout_ms); - let last = *self.last_activity.lock().await; - let elapsed = last.elapsed(); - if elapsed > idle_timeout { - Some((elapsed, idle_timeout)) - } else { - None - } - } - - /// Get a description of the browser connection type - pub async fn get_browser_type(&self) -> String { - let config = self.config.read().await; - if config.connect_ws.is_some() || config.connect_port.is_some() { - "CDP-connected to user's Chrome browser".to_string() - } else if config.headless { - "internal headless Chrome browser".to_string() - } else { - "internal Chrome browser (headed mode)".to_string() - } - } - - pub async fn set_enabled(&self, enabled: bool) -> Result<()> { - let mut config = self.config.write().await; - config.enabled = enabled; - - if enabled { - self.start().await?; - } else { - self.stop().await?; - } - - Ok(()) - } - - pub async fn update_config(&self, updates: impl FnOnce(&mut BrowserConfig)) -> Result<()> { - let mut config = self.config.write().await; - updates(&mut config); - - if let Some(page) = self.page.lock().await.as_ref() { - // Avoid viewport manipulation for external CDP connections to prevent focus/flicker - let is_external = config.connect_port.is_some() || config.connect_ws.is_some(); - if !is_external { - page.update_viewport(config.viewport.clone()).await?; - } - } - - Ok(()) - } - - pub async fn get_config(&self) -> BrowserConfig { - self.config.read().await.clone() - } - - pub async fn get_current_url(&self) -> Option { - let page_guard = self.page.lock().await; - if let Some(page) = page_guard.as_ref() { - page.get_current_url().await.ok() - } else { - None - } - } - - pub async fn get_status(&self) -> BrowserStatus { - let config = self.config.read().await; - let browser_active = self.browser.lock().await.is_some(); - let current_url = self.get_current_url().await; - - BrowserStatus { - enabled: config.enabled, - browser_active, - current_url, - viewport: config.viewport.clone(), - fullpage: config.fullpage, - } - } - - /// Apply environment overrides on page creation. - /// - For external CDP connections: set viewport once on connect; skip humanization (UA, locale, etc.). - /// - For internal (launched) Chrome: apply humanization; skip viewport here (kept minimal). - pub async fn apply_page_overrides(&self, page: &chromiumoxide::Page) -> Result<()> { - let config = self.config.read().await; - let is_external = config.connect_port.is_some() || config.connect_ws.is_some(); - - // Always enable Network domain once - page.execute(network::EnableParams::default()).await?; - - if is_external { - // External Chrome: set viewport once on connection; skip humanization. - let w = config.viewport.width as i64; - let h = config.viewport.height as i64; - let dpr = config.viewport.device_scale_factor as f64; - let mob = config.viewport.mobile; - - // Skip redundant overrides within a short window to prevent flash - { - let guard = self.last_metrics_applied.lock().await; - if let Some((lw, lh, ldpr, lmob, ts)) = *guard { - let same = lw == w && lh == h && (ldpr - dpr).abs() < 0.001 && lmob == mob; - let recent = ts.elapsed() < std::time::Duration::from_secs(30); - if same && recent { - debug!("Skipping redundant device metrics override (external, recent)"); - return Ok(()); - } - } - } - - let viewport_params = emulation::SetDeviceMetricsOverrideParams::builder() - .width(w) - .height(h) - .device_scale_factor(dpr) - .mobile(mob) - .build() - .map_err(BrowserError::CdpError)?; - info!("Applying external device metrics override: {}x{} @ {} (mobile={})", w, h, dpr, mob); - page.execute(viewport_params).await?; - let mut guard = self.last_metrics_applied.lock().await; - *guard = Some((w, h, dpr, mob, std::time::Instant::now())); - } else { - // Internal (launched) Chrome: apply human settings; avoid CDP viewport override here - if let Some(ua) = &config.user_agent { - let mut b = network::SetUserAgentOverrideParams::builder().user_agent(ua); - if let Some(al) = &config.accept_language { - b = b.accept_language(al); - } - page.execute(b.build().map_err(BrowserError::CdpError)?) - .await?; - } else if let Some(al) = &config.accept_language { - let mut headers_map = std::collections::HashMap::new(); - headers_map.insert( - "Accept-Language".to_string(), - serde_json::Value::String(al.clone()), - ); - let headers = network::Headers::new(serde_json::Value::Object( - headers_map.into_iter().map(|(k, v)| (k, v)).collect(), - )); - let p = network::SetExtraHttpHeadersParams::builder() - .headers(headers) - .build() - .map_err(BrowserError::CdpError)?; - page.execute(p).await?; - } - - if let Some(tz) = &config.timezone { - page.execute(emulation::SetTimezoneOverrideParams { - timezone_id: tz.clone(), - }) - .await?; - } - if let Some(locale) = &config.locale { - let p = emulation::SetLocaleOverrideParams::builder() - .locale(locale) - .build(); - page.execute(p).await?; - } - } - - Ok(()) - } - - async fn ensure_browser(&self) -> Result<()> { - let mut browser_guard = self.browser.lock().await; - - // Check if we have a browser instance - if let Some(browser) = browser_guard.as_ref() { - // Try to verify it's still connected with a simple operation - let check_result = - tokio::time::timeout(Duration::from_secs(2), browser.version()).await; - - match check_result { - Ok(Ok(_)) => { - // Browser is responsive - return Ok(()); - } - Ok(Err(e)) => { - warn!("Browser check failed: {}, will restart", e); - *browser_guard = None; - } - Err(_) => { - warn!("Browser check timed out, likely disconnected. Will restart"); - *browser_guard = None; - } - } - } - - // Need to start or restart the browser - drop(browser_guard); - info!("Starting/restarting browser connection..."); - self.start().await?; - Ok(()) - } - - async fn update_activity(&self) { - let mut last_activity = self.last_activity.lock().await; - *last_activity = Instant::now(); - } - - pub fn set_enabled_sync(&self, enabled: bool) { - // Try to set immediately if possible, otherwise spawn a task - if let Ok(mut cfg) = self.config.try_write() { - cfg.enabled = enabled; - } else { - let config = self.config.clone(); - tokio::spawn(async move { - let mut cfg = config.write().await; - cfg.enabled = enabled; - }); - } - } - - pub fn set_fullpage_sync(&self, fullpage: bool) { - if let Ok(mut cfg) = self.config.try_write() { - cfg.fullpage = fullpage; - } else { - let config = self.config.clone(); - tokio::spawn(async move { - let mut cfg = config.write().await; - cfg.fullpage = fullpage; - }); - } - } - - pub fn set_viewport_sync(&self, width: u32, height: u32) { - if let Ok(mut cfg) = self.config.try_write() { - cfg.viewport.width = width; - cfg.viewport.height = height; - } else { - let config = self.config.clone(); - tokio::spawn(async move { - let mut cfg = config.write().await; - cfg.viewport.width = width; - cfg.viewport.height = height; - }); - } - } - - pub fn set_segments_max_sync(&self, segments_max: usize) { - if let Ok(mut cfg) = self.config.try_write() { - cfg.segments_max = segments_max; - } else { - let config = self.config.clone(); - tokio::spawn(async move { - let mut cfg = config.write().await; - cfg.segments_max = segments_max; - }); - } - } - - pub fn get_status_sync(&self) -> String { - // Use try operations to avoid blocking - return cached/default values if locks are held - let cfg = self - .config - .try_read() - .map(|c| { - let enabled = c.enabled; - let viewport_width = c.viewport.width; - let viewport_height = c.viewport.height; - let fullpage = c.fullpage; - (enabled, viewport_width, viewport_height, fullpage) - }) - .unwrap_or((false, 1024, 768, false)); - - let browser_active = self - .browser - .try_lock() - .map(|b| b.is_some()) - .unwrap_or(false); - - let mode = if cfg.0 { "enabled" } else { "disabled" }; - let fullpage = if cfg.3 { "on" } else { "off" }; - - let mut status = format!( - "Browser status:\n• Mode: {}\n• Viewport: {}×{}\n• Full-page: {}", - mode, cfg.1, cfg.2, fullpage - ); - - if browser_active { - status.push_str("\n• Browser: active"); - } - - status - } - - async fn start_idle_monitor(&self) { - let config = self.config.read().await; - let idle_timeout = Duration::from_millis(config.idle_timeout_ms); - let is_external_chrome = config.connect_port.is_some() || config.connect_ws.is_some(); - let should_cleanup = *self.cleanup_profile_on_drop.lock().await; // <-- respect this - drop(config); - - if is_external_chrome { - info!("Skipping idle monitor for external Chrome connection"); - return; - } - - let browser = Arc::clone(&self.browser); - let last_activity = Arc::clone(&self.last_activity); - let user_data_dir = Arc::clone(&self.user_data_dir); - - let handle = tokio::spawn(async move { - loop { - sleep(Duration::from_secs(10)).await; - let last = *last_activity.lock().await; - if last.elapsed() > idle_timeout { - warn!("Browser idle timeout reached, closing"); - let mut browser_guard = browser.lock().await; - if let Some(mut browser) = browser_guard.take() { - let _ = browser.close().await; - } - if should_cleanup { - if let Some(user_data_path) = user_data_dir.lock().await.take() { - let _ = tokio::fs::remove_dir_all(&user_data_path).await; - } - } - break; - } - } - }); - - *self.idle_monitor_handle.lock().await = Some(handle); - } - - async fn stop_idle_monitor(&self) { - let mut handle_guard = self.idle_monitor_handle.lock().await; - if let Some(handle) = handle_guard.take() { - handle.abort(); - } - } - - pub async fn goto(&self, url: &str) -> Result { - const MAX_RECOVERY_ATTEMPTS: usize = 2; // number of retries after the initial attempt - let mut recovery_attempts = 0usize; - - loop { - match self.goto_once(url).await { - Ok(result) => { - if recovery_attempts > 0 { - info!( - "Browser navigation succeeded after {} recovery attempt(s)", - recovery_attempts - ); - } - return Ok(result); - } - Err(err) => { - let should_retry = - recovery_attempts < MAX_RECOVERY_ATTEMPTS - && self.should_retry_after_goto_error(&err).await; - - self - .log_navigation_failure(url, &err, recovery_attempts, should_retry) - .await; - - if !should_retry { - return Err(err); - } - - warn!( - error = %err, - recovery_attempt = recovery_attempts + 1, - "Browser navigation failed; restarting browser before retry" - ); - - if let Err(stop_err) = self.stop().await { - warn!("Failed to stop browser during recovery: {}", stop_err); - } - - tokio::time::sleep(Duration::from_millis(400)).await; - recovery_attempts += 1; - } - } - } - } - - async fn log_navigation_failure( - &self, - url: &str, - err: &BrowserError, - recovery_attempt: usize, - will_retry: bool, - ) { - let error_string = err.to_string(); - let config = self.config.read().await.clone(); - let is_external = config.connect_port.is_some() || config.connect_ws.is_some(); - let browser_active = self.browser.lock().await.is_some(); - let wait_desc = match &config.wait { - WaitStrategy::Event(event) => format!("event:{event}"), - WaitStrategy::Delay { delay_ms } => format!("delay:{delay_ms}ms"), - }; - - warn!( - url = %url, - error = %error_string, - recovery_attempt, - will_retry, - browser_active, - is_external, - headless = config.headless, - connect_port = ?config.connect_port, - connect_ws = ?config.connect_ws, - viewport_width = config.viewport.width, - viewport_height = config.viewport.height, - viewport_dpr = config.viewport.device_scale_factor, - viewport_mobile = config.viewport.mobile, - wait = %wait_desc, - "Browser navigation failed" - ); - - let page_snapshot = self.page.lock().await.clone(); - if let Some(page) = page_snapshot.as_ref() { - let page_debug = Self::collect_page_debug_info(page).await; - warn!( - target_id = %page_debug.target_id, - session_id = %page_debug.session_id, - opener_id = ?page_debug.opener_id, - cached_url = ?page_debug.cached_url, - live_url = ?page_debug.live_url, - "Browser page debug context" - ); - } else { - warn!("Browser navigation failed without an active page"); - } - - if let Some(snapshot) = self.collect_target_snapshot().await { - warn!( - targets_total = snapshot.total, - targets_truncated = snapshot.truncated, - targets_sample = ?snapshot.sample, - "Browser target snapshot" - ); - } - } - - async fn collect_page_debug_info(page: &Page) -> PageDebugInfo { - let cached_url = page.get_url().await.ok(); - let live_url = match tokio::time::timeout(Duration::from_millis(600), page.get_current_url()).await { - Ok(Ok(url)) => Some(url), - Ok(Err(err)) => { - debug!(error = %err, "Navigation telemetry failed to read live URL"); - None - } - Err(_) => { - debug!("Navigation telemetry timed out reading live URL"); - None - } - }; - - PageDebugInfo { - target_id: page.target_id_debug(), - session_id: page.session_id_debug(), - opener_id: page.opener_id_debug(), - cached_url, - live_url, - } - } - - async fn collect_target_snapshot(&self) -> Option { - let mut browser_guard = self.browser.lock().await; - let browser = browser_guard.as_mut()?; - let fetch = tokio::time::timeout(Duration::from_millis(1200), browser.fetch_targets()).await; - let targets = match fetch { - Ok(Ok(targets)) => targets, - Ok(Err(err)) => { - warn!(error = %err, "Failed to fetch CDP targets for navigation telemetry"); - return None; - } - Err(_) => { - warn!("Timed out fetching CDP targets for navigation telemetry"); - return None; - } - }; - - let total = targets.len(); - let mut sample = Vec::new(); - for (index, target) in targets.iter().take(12).enumerate() { - let target_id = &target.target_id; - let target_type = &target.r#type; - let subtype = target.subtype.as_deref().unwrap_or("-"); - let opener = target - .opener_id - .as_ref() - .map(|opener_id| format!("{opener_id:?}")) - .unwrap_or_else(|| "-".to_string()); - let url = &target.url; - let title = &target.title; - let attached = target.attached; - sample.push(format!( - "#{index} id={target_id:?} type={target_type} subtype={subtype} attached={attached} opener={opener} url={url} title={title}", - index = index + 1, - target_id = target_id, - target_type = target_type, - subtype = subtype, - attached = attached, - opener = opener, - url = url, - title = title - )); - } - - Some(TargetSnapshot { - total, - truncated: total > sample.len(), - sample, - }) - } - - async fn should_retry_after_goto_error(&self, err: &BrowserError) -> bool { - let is_internal = { - let cfg = self.config.read().await; - cfg.connect_port.is_none() && cfg.connect_ws.is_none() - }; - - if !is_internal { - return false; - } - - match err { - BrowserError::NotInitialized => true, - BrowserError::IoError(e) => matches!( - e.kind(), - std::io::ErrorKind::WouldBlock - | std::io::ErrorKind::TimedOut - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::BrokenPipe - ), - BrowserError::CdpError(msg) => { - let msg_lower = msg.to_ascii_lowercase(); - const RECOVERABLE_SUBSTRINGS: &[&str] = &[ - "connection closed", - "browser closed", - "target crashed", - "context destroyed", - "no such session", - "disconnected", - "transport", - "timeout", - "timed out", - "oneshot error", - "oneshot canceled", - "oneshot cancelled", - "resource temporarily unavailable", - "temporarily unavailable", - "eagain", - ]; - - RECOVERABLE_SUBSTRINGS - .iter() - .any(|needle| msg_lower.contains(needle)) - } - _ => false, - } - } - - async fn goto_once(&self, url: &str) -> Result { - // Get or create page - let page = self.get_or_create_page().await?; - - let nav_start = std::time::Instant::now(); - info!("Navigating to URL: {}", url); - let config = self.config.read().await; - let result = page.goto(url, Some(config.wait.clone())).await?; - info!( - "Navigation complete to: {} in {:?}", - result.url, - nav_start.elapsed() - ); - - // Manually trigger navigation callback for immediate response - if let Some(ref callback) = *self.navigation_callback.read().await { - debug!("Manually triggering navigation callback after goto"); - callback(result.url.clone()); - } - - self.update_activity().await; - Ok(result) - } - - pub async fn capture_screenshot_with_url( - &self, - ) -> Result<(Vec, Option)> { - let (paths, url) = self.capture_screenshot_internal().await?; - Ok((paths, Some(url))) - } - - pub async fn capture_screenshot(&self) -> Result> { - let (paths, _) = self.capture_screenshot_internal().await?; - Ok(paths) - } - - async fn capture_screenshot_internal(&self) -> Result<(Vec, String)> { - // Always capture from the active page; do not create background tabs. - self.capture_screenshot_regular().await - } - - /// Capture screenshot using regular strategy (launched Chrome) - async fn capture_screenshot_regular(&self) -> Result<(Vec, String)> { - // For launched Chrome, use the regular approach since it's already isolated - let page = self.get_or_create_page().await?; - - // Viewport correction is handled inside Page::screenshot for all connections - - // Initialize assets manager if needed - let mut assets_guard = self.assets.lock().await; - if assets_guard.is_none() { - *assets_guard = Some(Arc::new(crate::assets::AssetManager::new().await?)); - } - let assets = assets_guard.as_ref().unwrap().clone(); - drop(assets_guard); - - // Get current config - let config = self.config.read().await; - - // Determine screenshot mode - let mode = if config.fullpage { - crate::page::ScreenshotMode::FullPage { - segments_max: Some(config.segments_max), - } - } else { - crate::page::ScreenshotMode::Viewport - }; - - // Get current URL with timeout - let current_url = - match tokio::time::timeout(Duration::from_secs(3), page.get_current_url()).await { - Ok(Ok(url)) => url, - Ok(Err(_)) | Err(_) => { - warn!("Failed to get current URL, using default"); - "about:blank".to_string() - } - }; - - // Capture screenshots with timeout - let screenshot_result = tokio::time::timeout( - Duration::from_secs(15), // Allow up to 15 seconds for screenshot - page.screenshot(mode), - ) - .await; - - let screenshots = match screenshot_result { - Ok(Ok(shots)) => shots, - Ok(Err(e)) => { - return Err(BrowserError::ScreenshotError(format!( - "Screenshot capture failed: {}", - e - ))); - } - Err(_) => { - return Err(BrowserError::ScreenshotError( - "Screenshot capture timed out after 15 seconds".to_string(), - )); - } - }; - - // Store screenshots and get paths - let mut paths = Vec::new(); - for screenshot in screenshots { - let image_ref = assets - .store_screenshot( - &screenshot.data, - screenshot.format, - screenshot.width, - screenshot.height, - Self::SCREENSHOT_TTL_MS, - ) - .await?; - paths.push(std::path::PathBuf::from(image_ref.path)); - } - - self.update_activity().await; - Ok((paths, current_url)) - } - - pub async fn close(&self) -> Result<()> { - // Just delegate to stop() which handles cleanup properly - self.stop().await - } - - /// Move the mouse to the specified coordinates - pub async fn move_mouse(&self, x: f64, y: f64) -> Result<()> { - let page = self.get_or_create_page().await?; - page.move_mouse(x, y).await - } - - /// Move the mouse by relative offset from current position - pub async fn move_mouse_relative(&self, dx: f64, dy: f64) -> Result<(f64, f64)> { - let page = self.get_or_create_page().await?; - page.move_mouse_relative(dx, dy).await - } - - /// Click at the specified coordinates - pub async fn click(&self, x: f64, y: f64) -> Result<()> { - let page = self.get_or_create_page().await?; - page.click(x, y).await - } - - /// Click at the current mouse position - pub async fn click_at_current(&self) -> Result<(f64, f64)> { - let page = self.get_or_create_page().await?; - page.click_at_current().await - } - - /// Perform mouse down at the current position - pub async fn mouse_down_at_current(&self) -> Result<(f64, f64)> { - let page = self.get_or_create_page().await?; - page.mouse_down_at_current().await - } - - /// Perform mouse up at the current position - pub async fn mouse_up_at_current(&self) -> Result<(f64, f64)> { - let page = self.get_or_create_page().await?; - page.mouse_up_at_current().await - } - - /// Type text into the currently focused element - pub async fn type_text(&self, text: &str) -> Result<()> { - let page = self.get_or_create_page().await?; - page.type_text(text).await - } - - /// Press a key (e.g., "Enter", "Tab", "Escape", "ArrowDown") - pub async fn press_key(&self, key: &str) -> Result<()> { - let page = self.get_or_create_page().await?; - page.press_key(key).await - } - - /// Execute JavaScript code with enhanced return value handling - pub async fn execute_javascript(&self, code: &str) -> Result { - let page = self.get_or_create_page().await?; - page.execute_javascript(code).await - } - - /// Scroll the page by the given delta in pixels - pub async fn scroll_by(&self, dx: f64, dy: f64) -> Result<()> { - let page = self.get_or_create_page().await?; - page.scroll_by(dx, dy).await - } - - /// Navigate browser history backward one entry - pub async fn history_back(&self) -> Result<()> { - let page = self.get_or_create_page().await?; - page.go_back().await - } - - /// Navigate browser history forward one entry - pub async fn history_forward(&self) -> Result<()> { - let page = self.get_or_create_page().await?; - page.go_forward().await - } - - /// Capture console logs from the browser, including errors and unhandled rejections - pub async fn get_console_logs(&self, lines: Option) -> Result { - let page = self.get_or_create_page().await?; - - // 1) Prefer CDP-captured buffer (event-based). If we have entries, return them. - let cdp_logs = page.get_console_logs_tail(lines).await; - if cdp_logs.as_array().map(|a| !a.is_empty()).unwrap_or(false) { - return Ok(cdp_logs); - } - - // 2) Fallback to JS-installed hook (ensures capture on pages where events are unavailable). - let requested = lines.unwrap_or(0); - let script = format!( - r#"(function() {{ - try {{ - if (!window.__code_console_logs) {{ - window.__code_console_logs = []; - const push = (level, message) => {{ - try {{ - window.__code_console_logs.push({{ timestamp: new Date().toISOString(), level, message }}); - if (window.__code_console_logs.length > 2000) window.__code_console_logs.shift(); - }} catch (_) {{}} - }}; - - ['log','warn','error','info','debug'].forEach(function(method) {{ - try {{ - const orig = console[method]; - console[method] = function() {{ - try {{ - var args = Array.prototype.slice.call(arguments); - var msg = args.map(function(a) {{ - try {{ if (a && typeof a === 'object') return JSON.stringify(a); return String(a); }} - catch (_) {{ return String(a); }} - }}).join(' '); - push(method, msg); - }} catch(_) {{}} - if (orig) return orig.apply(console, arguments); - }}; - }} catch(_) {{}} - }}); - - window.addEventListener('error', function(e) {{ - try {{ - var msg = e && e.message ? e.message : 'Script error'; - var stack = e && e.error && e.error.stack ? ('\n' + e.error.stack) : ''; - push('exception', msg + stack); - }} catch(_) {{}} - }}); - window.addEventListener('unhandledrejection', function(e) {{ - try {{ - var reason = e && e.reason; - if (reason && typeof reason === 'object') {{ try {{ reason = JSON.stringify(reason); }} catch(_) {{}} }} - push('unhandledrejection', String(reason)); - }} catch(_) {{}} - }}); - }} - - var logs = window.__code_console_logs || []; - var n = {requested}; - return (n && n > 0) ? logs.slice(-n) : logs; - }} catch (err) {{ - return [{{ timestamp: new Date().toISOString(), level: 'error', message: 'capture failed: ' + (err && err.message ? err.message : String(err)) }}]; - }} - }})()"# - ); - - page.inject_js(&script).await - } - - /// Execute an arbitrary CDP command against the active page session - pub async fn execute_cdp( - &self, - method: &str, - params: Value, - ) -> Result { - let page = self.get_or_create_page().await?; - page.execute_cdp_raw(method, params).await - } - - /// Execute an arbitrary CDP command at the browser (no session) scope - pub async fn execute_cdp_browser( - &self, - method: &str, - params: Value, - ) -> Result { - // Ensure a browser is connected - self.ensure_browser().await?; - let browser_guard = self.browser.lock().await; - let browser = browser_guard - .as_ref() - .ok_or_else(|| BrowserError::CdpError("Browser not available".to_string()))?; - - // Local raw command type (serialize only params) - #[derive(Debug, Clone)] - struct RawCdpCommandBrowser { - method: String, - params: Value, - } - impl serde::Serialize for RawCdpCommandBrowser { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - self.params.serialize(serializer) - } - } - impl chromiumoxide_types::Method for RawCdpCommandBrowser { - fn identifier(&self) -> chromiumoxide_types::MethodId { - self.method.clone().into() - } - } - impl chromiumoxide_types::Command for RawCdpCommandBrowser { - type Response = Value; - } - - let cmd = RawCdpCommandBrowser { - method: method.to_string(), - params, - }; - let resp = browser.execute(cmd).await?; - Ok(resp.result) - } - - /// Clean up injected artifacts and restore viewport/state where possible. - /// This does not close the browser; it is safe to call when connected. - pub async fn cleanup(&self) -> Result<()> { - // Hide any overlay highlight - let _ = self.execute_cdp("Overlay.hideHighlight", serde_json::json!({})).await; - - // Reset device metrics override (best-effort) - let _ = self - .execute_cdp("Emulation.clearDeviceMetricsOverride", serde_json::json!({})) - .await; - - // Remove virtual cursor and related overlays if present - let page = self.get_or_create_page().await?; - let cleanup_js = r#" - (function(){ - try { if (window.__vc && typeof window.__vc.destroy === 'function') window.__vc.destroy(); } catch(_) {} - try { if (window.__code_console_logs) delete window.__code_console_logs; } catch(_) {} - return true; - })() - "#; - let _ = page.inject_js(cleanup_js).await; - Ok(()) - } - - /// Get the current cursor position - pub async fn get_cursor_position(&self) -> Result<(f64, f64)> { - let page = self.get_or_create_page().await?; - page.get_cursor_position().await - } - - /// Get the current viewport dimensions - pub async fn get_viewport_size(&self) -> (u32, u32) { - let config = self.config.read().await; - (config.viewport.width, config.viewport.height) - } - - /// Set a callback to be called when navigation occurs - pub async fn set_navigation_callback(&self, callback: F) - where - F: Fn(String) + Send + Sync + 'static, - { - let mut callback_guard = self.navigation_callback.write().await; - *callback_guard = Some(Box::new(callback)); - } - - /// Start monitoring for page navigation changes - async fn start_navigation_monitor(&self, page: Arc) { - // Stop any existing monitor - self.stop_navigation_monitor().await; - - let navigation_callback = Arc::clone(&self.navigation_callback); - let page_target_id = page.target_id_debug(); - let page_session_id = page.session_id_debug(); - let page_weak = Arc::downgrade(&page); - - let assets_arc = Arc::clone(&self.assets); - let config_arc = Arc::clone(&self.config); - let handle = tokio::spawn(async move { - let mut last_url = String::new(); - let mut last_seq: u64 = 0; - let mut _check_count = 0; // reserved for future periodic checks - - debug!( - target_id = %page_target_id, - session_id = %page_session_id, - "Starting navigation monitor" - ); - - loop { - // Check if page is still alive - let page = match page_weak.upgrade() { - Some(p) => p, - None => { - debug!( - target_id = %page_target_id, - session_id = %page_session_id, - "Page dropped, stopping navigation monitor" - ); - break; - } - }; - - // Get current URL - if let Ok(current_url) = page.get_current_url().await { - // Check if URL changed (ignore about:blank) - if current_url != last_url && current_url != "about:blank" { - info!( - "Navigation detected: {} -> {}", - if last_url.is_empty() { - "initial" - } else { - &last_url - }, - current_url - ); - last_url = current_url.clone(); - - // Call the callback if set (immediate) - if let Some(ref callback) = *navigation_callback.read().await { - debug!("Triggering navigation callback for URL: {}", current_url); - callback(current_url.clone()); - } - - // Schedule a delayed callback for fully loaded page - let navigation_callback_delayed = Arc::clone(&navigation_callback); - let current_url_delayed = current_url.clone(); - tokio::spawn(async move { - // Wait for page to fully load - tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await; - - // Call the callback again with a marker that it's fully loaded - if let Some(ref callback) = *navigation_callback_delayed.read().await { - info!("Page fully loaded callback for: {}", current_url_delayed); - callback(current_url_delayed); - } - }); - } - } - - // Listen for SPA changes via codex:locationchange (only when attached to external Chrome) - let cfg_now = config_arc.read().await.clone(); - if cfg_now.connect_port.is_some() || cfg_now.connect_ws.is_some() { - // Install listener once and poll sequence counter - let listener_script = r#" - (function(){ - try { - if (!window.__code_nav_listening) { - window.__code_nav_listening = true; - window.__code_nav_seq = 0; - window.__code_nav_url = String(location.href || ''); - window.addEventListener('codex:locationchange', function(){ - window.__code_nav_seq += 1; - window.__code_nav_url = String(location.href || ''); - }, { capture: true }); - } - return { seq: Number(window.__code_nav_seq||0), url: String(window.__code_nav_url||location.href) }; - } catch (e) { return { seq: 0, url: String(location.href||'') }; } - })() - "#; - - if let Ok(result) = page.execute_javascript(listener_script).await { - let seq = result.get("seq").and_then(|v| v.as_u64()).unwrap_or(0); - let url = result - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - if seq > last_seq { - info!("SPA locationchange detected: {} (seq {} -> {})", url, last_seq, seq); - last_seq = seq; - - // Fire callback - if let Some(ref callback) = *navigation_callback.read().await { - callback(url.clone()); - } - - // Capture a screenshot asynchronously - let assets_arc2 = Arc::clone(&assets_arc); - let config_arc2 = Arc::clone(&config_arc); - let page_for_shot = Arc::clone(&page); - tokio::spawn(async move { - // Initialize assets manager if needed - if assets_arc2.lock().await.is_none() { - if let Ok(am) = crate::assets::AssetManager::new().await { - *assets_arc2.lock().await = Some(Arc::new(am)); - } - } - let assets_opt = assets_arc2.lock().await.clone(); - drop(assets_arc2); - if let Some(assets) = assets_opt { - let cfg = config_arc2.read().await.clone(); - let mode = if cfg.fullpage { - crate::page::ScreenshotMode::FullPage { segments_max: Some(cfg.segments_max) } - } else { crate::page::ScreenshotMode::Viewport }; - // small delay to allow SPA content to render - tokio::time::sleep(Duration::from_millis(400)).await; - if let Ok(shots) = page_for_shot.screenshot(mode).await { - for s in shots { - let _ = assets - .store_screenshot( - &s.data, - s.format, - s.width, - s.height, - Self::SCREENSHOT_TTL_MS, - ) - .await; - } - } - } - }); - } - } - } - - // periodic counter disabled; listener-based SPA detection in place - - // Check every 500ms - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - } - }); - - let mut handle_guard = self.navigation_monitor_handle.lock().await; - *handle_guard = Some(handle); - } - - /// Stop navigation monitoring - async fn stop_navigation_monitor(&self) { - let mut handle_guard = self.navigation_monitor_handle.lock().await; - if let Some(handle) = handle_guard.take() { - handle.abort(); - } - } - - /// Start a low-frequency viewport monitor that checks for drift without forcing resyncs. - /// Applies the same logic to internal and external: only correct after two consecutive - /// mismatches and at most once per minute to avoid jank. Logs when throttled. - async fn start_viewport_monitor(&self, page: Arc) { - // Stop any existing monitor first - self.stop_viewport_monitor().await; - - let config_arc = Arc::clone(&self.config); - let correction_enabled = Arc::clone(&self.auto_viewport_correction_enabled); - let handle = tokio::spawn(async move { - let mut consecutive_mismatch = 0u32; - let mut last_warn: Option = None; - let mut last_correction: Option = None; - let check_interval = std::time::Duration::from_secs(60); - let warn_interval = std::time::Duration::from_secs(300); - let min_correction_interval = std::time::Duration::from_secs(60); - - loop { - tokio::time::sleep(check_interval).await; - - // Snapshot expected config - let cfg = config_arc.read().await.clone(); - let is_external = cfg.connect_port.is_some() || cfg.connect_ws.is_some(); - let expected_w = cfg.viewport.width as f64; - let expected_h = cfg.viewport.height as f64; - let expected_dpr = cfg.viewport.device_scale_factor as f64; - - // Probe current viewport via JS (cheap and non-invasive) - let probe_js = r#"(() => ({ - w: (document.documentElement.clientWidth|0), - h: (document.documentElement.clientHeight|0), - dpr: (window.devicePixelRatio||1) - }))()"#; - - if let Ok(val) = page.inject_js(probe_js).await { - let cw = val.get("w").and_then(|v| v.as_u64()).unwrap_or(0) as f64; - let ch = val.get("h").and_then(|v| v.as_u64()).unwrap_or(0) as f64; - let cdpr = val.get("dpr").and_then(|v| v.as_f64()).unwrap_or(1.0); - - let w_ok = (cw - expected_w).abs() <= 5.0; - let h_ok = (ch - expected_h).abs() <= 5.0; - let dpr_ok = (cdpr - expected_dpr).abs() <= 0.05; - let mismatch = !(w_ok && h_ok && dpr_ok); - - if mismatch { - consecutive_mismatch += 1; - let now = std::time::Instant::now(); - let can_correct = last_correction - .map(|t| now.duration_since(t) >= min_correction_interval) - .unwrap_or(true); - - // Check gate: allow disabling auto-corrections at runtime - let enabled = *correction_enabled.read().await; - if consecutive_mismatch >= 2 && can_correct && enabled { - info!( - "Correcting viewport: {}x{}@{} -> {}x{}@{} (external={})", - cw, ch, cdpr, expected_w, expected_h, expected_dpr, is_external - ); - let _ = page - .set_viewport(crate::page::SetViewportParams { - width: cfg.viewport.width, - height: cfg.viewport.height, - device_scale_factor: Some(cfg.viewport.device_scale_factor), - mobile: Some(cfg.viewport.mobile), - }) - .await; - last_correction = Some(now); - consecutive_mismatch = 0; - } else { - // Throttled: log at most every 5 minutes - let should_warn = last_warn - .map(|t| now.duration_since(t) >= warn_interval) - .unwrap_or(true); - if should_warn { - warn!( - "Viewport drift detected (throttled): {}x{}@{} vs expected {}x{}@{} (external={}, can_correct={})", - cw, ch, cdpr, expected_w, expected_h, expected_dpr, is_external, can_correct - ); - last_warn = Some(now); - } - } - } else { - consecutive_mismatch = 0; - } - } - } - }); - - *self.viewport_monitor_handle.lock().await = Some(handle); - } - - async fn stop_viewport_monitor(&self) { - let mut handle_guard = self.viewport_monitor_handle.lock().await; - if let Some(handle) = handle_guard.take() { - handle.abort(); - } - } - - /// Temporarily enable/disable automatic viewport correction (monitor-driven) - pub async fn set_auto_viewport_correction(&self, enabled: bool) { - let mut guard = self.auto_viewport_correction_enabled.write().await; - *guard = enabled; - } -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct BrowserStatus { - pub enabled: bool, - pub browser_active: bool, - pub current_url: Option, - pub viewport: crate::config::ViewportConfig, - pub fullpage: bool, -} - -#[cfg(test)] -mod tests { - use super::discover_ws_via_host_port; - use super::should_restart_handler; - use super::should_stop_handler; - use std::io::Read; - use std::io::Write; - use std::net::TcpListener; - use std::process::Command; - use std::sync::Arc; - use std::sync::atomic::AtomicBool; - use std::sync::atomic::Ordering; - use std::thread; - use std::time::Duration; - use std::time::Instant; - - #[derive(Debug)] - struct TestError(&'static str); - - impl std::fmt::Display for TestError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.0) - } - } - - #[test] - fn handler_restarts_after_repeated_errors() { - assert!(!should_restart_handler(0)); - assert!(!should_restart_handler(1)); - assert!(!should_restart_handler(2)); - assert!(should_restart_handler(3)); - } - - #[test] - fn handler_ignores_oneshot_cancellations() { - let mut consecutive_errors = 0u32; - for _ in 0..10 { - let should_stop = should_stop_handler( - "[test]", - Err(TestError("oneshot canceled")), - &mut consecutive_errors, - ); - assert!(!should_stop); - assert_eq!(consecutive_errors, 0); - } - for _ in 0..10 { - let should_stop = should_stop_handler( - "[test]", - Err(TestError("oneshot error")), - &mut consecutive_errors, - ); - assert!(!should_stop); - assert_eq!(consecutive_errors, 0); - } - } - - #[test] - fn handler_ignores_message_deserialize_errors() { - let mut consecutive_errors = 0u32; - for _ in 0..10 { - let should_stop = should_stop_handler( - "[test]", - Err(TestError("data did not match any variant of untagged enum Message")), - &mut consecutive_errors, - ); - assert!(!should_stop); - assert_eq!(consecutive_errors, 0); - } - } - - const TEST_PROXY_WS_URL: &str = "ws://proxy.invalid/devtools/browser/proxy"; - const TEST_TARGET_WS_URL: &str = "ws://target.invalid/devtools/browser/target"; - - fn spawn_json_version_server( - ws_url: &str, - ) -> (u16, Arc, thread::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind server"); - listener - .set_nonblocking(true) - .expect("set non-blocking"); - let port = listener.local_addr().expect("server addr").port(); - let stop = Arc::new(AtomicBool::new(false)); - let stop_thread = Arc::clone(&stop); - let ws_url = ws_url.to_string(); - - let handle = thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(10); - while !stop_thread.load(Ordering::Relaxed) && Instant::now() < deadline { - match listener.accept() { - Ok((mut stream, _)) => { - let mut buffer = [0u8; 1024]; - let _ = stream.read(&mut buffer); - - let body = format!(r#"{{"webSocketDebuggerUrl":"{ws_url}"}}"#); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(response.as_bytes()); - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(_) => break, - } - } - }); - - (port, stop, handle) - } - - #[test] - fn cdp_discovery_ignores_proxy_env_vars() { - let (proxy_port, proxy_stop, proxy_handle) = - spawn_json_version_server(TEST_PROXY_WS_URL); - let (target_port, target_stop, target_handle) = - spawn_json_version_server(TEST_TARGET_WS_URL); - - let exe = std::env::current_exe().expect("current exe"); - let proxy_url = format!("http://127.0.0.1:{proxy_port}"); - - let output = Command::new(exe) - .arg("--exact") - .arg("manager::tests::cdp_discovery_ignores_proxy_env_vars_child") - .arg("--ignored") - .arg("--nocapture") - .env("CODE_BROWSER_TEST_TARGET_PORT", target_port.to_string()) - .env("HTTP_PROXY", &proxy_url) - .env("HTTPS_PROXY", &proxy_url) - .env("ALL_PROXY", &proxy_url) - .env("http_proxy", &proxy_url) - .env("https_proxy", &proxy_url) - .env("all_proxy", &proxy_url) - .env("NO_PROXY", "example.invalid") - .env("no_proxy", "example.invalid") - .output() - .expect("spawn child test"); - - proxy_stop.store(true, Ordering::Relaxed); - target_stop.store(true, Ordering::Relaxed); - let _ = proxy_handle.join(); - let _ = target_handle.join(); - - if !output.status.success() { - panic!( - "child test failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - } - } - - #[ignore] - #[tokio::test] - async fn cdp_discovery_ignores_proxy_env_vars_child() { - let target_port: u16 = std::env::var("CODE_BROWSER_TEST_TARGET_PORT") - .expect("CODE_BROWSER_TEST_TARGET_PORT") - .parse() - .expect("valid port"); - - let url = format!("http://127.0.0.1:{target_port}/json/version"); - let default_client = reqwest::Client::builder() - .timeout(Duration::from_secs(2)) - .build() - .expect("build default client"); - let resp = default_client - .get(&url) - .send() - .await - .expect("default request"); - - let proxy_version: super::JsonVersion = - resp.json().await.expect("parse proxy json"); - assert_eq!(proxy_version.web_socket_debugger_url, TEST_PROXY_WS_URL); - - let discovered = discover_ws_via_host_port("127.0.0.1", target_port) - .await - .expect("discover ws url"); - assert_eq!(discovered, TEST_TARGET_WS_URL); - } - -} diff --git a/code-rs/browser/src/page.rs b/code-rs/browser/src/page.rs deleted file mode 100644 index 2a40a66c88c..00000000000 --- a/code-rs/browser/src/page.rs +++ /dev/null @@ -1,2394 +0,0 @@ -use crate::BrowserError; -use crate::Result; -use crate::config::BrowserConfig; -use crate::config::ImageFormat; -use crate::config::ViewportConfig; -use crate::config::WaitStrategy; -use chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventParams; -use chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventType; -use chromiumoxide::cdp::browser_protocol::input::DispatchMouseEventParams; -use chromiumoxide::cdp::browser_protocol::input::DispatchMouseEventType; -// Import MouseButton (New) -use chromiumoxide::cdp::browser_protocol::input::MouseButton; -// Import AddScriptToEvaluateOnNewDocumentParams (New) -use base64::Engine as _; -use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams; -use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams; -use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat; -use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams; -use chromiumoxide::page::Page as CdpPage; -use chromiumoxide::cdp::js_protocol::runtime as cdp_runtime; -use chromiumoxide::cdp::browser_protocol::log as cdp_log; -use futures::StreamExt; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -// Use Mutex for cursor state (New) -use tokio::sync::Mutex; -use tracing::debug; -use tracing::info; -use tracing::warn; - -// Externalized virtual cursor script (editable JS) -const VIRTUAL_CURSOR_JS: &str = include_str!("js/virtual_cursor.js"); - -// Define CursorState struct (New) -#[derive(Debug, Clone)] -pub struct CursorState { - pub x: f64, - pub y: f64, - // Include button state, mirroring the TS implementation - pub button: MouseButton, - // Track whether mouse button is currently pressed - pub is_mouse_down: bool, -} - -pub struct Page { - cdp_page: Arc, - config: BrowserConfig, - current_url: Arc>>, - // Add cursor state tracking (New) - cursor_state: Arc>, - // Buffer for CDP-captured console logs - console_logs: Arc>>, - // Screenshot path preflight cache: - // - We strongly prefer compositor captures via from_surface(false) to avoid visible flashes in the - // user's real Chrome window. However, that path can be flaky or unavailable when the window is not - // visible/minimized. A tiny 8×8 probe (guarded by a ~350ms timeout) predicts viability and is cached - // for ~5 seconds to avoid repeated probes while navigating. - // - IMPORTANT: This cache and probe logic protect both UX (no flash on visible windows) and reliability - // (preventing repeated long timeouts when minimized). If you change this, ensure visible windows never - // start with from_surface(true), and keep a short/cheap probe for hidden/minimized states. - preflight_cache: Arc>>, -} - -#[derive(Clone, Copy, Debug)] -enum ReadyStateTarget { - InteractiveOrComplete, - Complete, -} - -async fn wait_ready_state( - cdp_page: &CdpPage, - target: ReadyStateTarget, - timeout: Duration, - interval: Duration, -) { - let script = "document.readyState"; - let start = Instant::now(); - loop { - let state = cdp_page - .evaluate(script) - .await - .ok() - .and_then(|r| r.value().and_then(|v| v.as_str().map(|s| s.to_string()))); - let done = match target { - ReadyStateTarget::InteractiveOrComplete => { - matches!(state.as_deref(), Some("interactive") | Some("complete")) - } - ReadyStateTarget::Complete => matches!(state.as_deref(), Some("complete")), - }; - if done || start.elapsed() >= timeout { - break; - } - tokio::time::sleep(interval).await; - } -} - -async fn url_looks_loaded(cdp_page: &CdpPage, timeout: Duration) -> bool { - let result = tokio::time::timeout(timeout, cdp_page.url()).await; - match result { - Ok(Ok(Some(url))) => { - let url = url.trim(); - if url.is_empty() || url == "about:blank" { - return false; - } - url.starts_with("http://") || url.starts_with("https://") - } - _ => false, - } -} - -impl Page { - pub fn new(cdp_page: CdpPage, config: BrowserConfig) -> Self { - // Initialize cursor position (Updated) - let initial_cursor = CursorState { - x: (config.viewport.width as f64 / 2.0).floor(), - y: (config.viewport.height as f64 / 4.0).floor(), - button: MouseButton::None, - is_mouse_down: false, - }; - - let page = Self { - cdp_page: Arc::new(cdp_page), - config, - current_url: Arc::new(RwLock::new(None)), - cursor_state: Arc::new(Mutex::new(initial_cursor)), - preflight_cache: Arc::new(Mutex::new(None)), - console_logs: Arc::new(Mutex::new(Vec::new())), - }; - - // Register a unified bootstrap (runs on every new document): - // - Blocks _blank/tab opens - // - Installs minimal virtual cursor early - // - Hooks SPA history to signal route changes - let cdp_page_boot = page.cdp_page.clone(); - tokio::spawn(async move { - if let Err(e) = Self::inject_bootstrap_script(&cdp_page_boot).await { - warn!("Failed to inject unified bootstrap script: {}", e); - } else { - debug!("Unified bootstrap script registered for new documents"); - } - }); - - // Enable CDP Runtime/Log and start capturing console events into an internal buffer. - // This complements the JS hook and works even if the page overwrites console later. - let cdp_page_events = page.cdp_page.clone(); - let logs_buf = page.console_logs.clone(); - tokio::spawn(async move { - // Best-effort enable; ignore failures silently to avoid breaking page creation. - let _ = cdp_page_events.execute(cdp_runtime::EnableParams::default()).await; - let _ = cdp_page_events.execute(cdp_log::EnableParams::default()).await; - - // Listen for Runtime.consoleAPICalled - if let Ok(mut stream) = cdp_page_events - .event_listener::() - .await - { - while let Some(evt) = stream.next().await { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i128) - .unwrap_or(0); - // Join args into a readable string; also keep raw values - let text = match serde_json::to_string(&evt.args) { - Ok(s) => s, - Err(_) => String::new(), - }; - let item = serde_json::json!({ - "ts_unix_ms": ts, - "level": format!("{:?}", evt.r#type), - "message": text, - "source": "cdp:runtime" - }); - let mut buf = logs_buf.lock().await; - buf.push(item); - if buf.len() > 2000 { buf.remove(0); } - } - } - - // Also listen for Log.entryAdded (browser-side logs) - if let Ok(mut stream) = cdp_page_events - .event_listener::() - .await - { - while let Some(evt) = stream.next().await { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i128) - .unwrap_or(0); - let entry = &evt.entry; - let item = serde_json::json!({ - "ts_unix_ms": ts, - "level": format!("{:?}", entry.level), - "message": entry.text, - "source": "cdp:log", - "url": entry.url, - "line": entry.line_number - }); - let mut buf = logs_buf.lock().await; - buf.push(item); - if buf.len() > 2000 { buf.remove(0); } - } - } - }); - - page - } - - /// Ensure the virtual cursor is present; inject if missing, then update to current position. - async fn ensure_virtual_cursor(&self) -> Result { - // Desired runtime version of the virtual cursor script - let desired_version: i32 = 11; - // Quick existence check - // Check existence and version - let status = self - .cdp_page - .evaluate(format!( - r#"(function(v) {{ - if (typeof window.__vc === 'undefined') return 'missing'; - try {{ - var cur = window.__vc.__version|0; - if (!cur || cur !== v) {{ - if (window.__vc && typeof window.__vc.destroy === 'function') try {{ window.__vc.destroy(); }} catch (e) {{}} - return 'reinstall'; - }} - return 'ok'; - }} catch (e) {{ return 'reinstall'; }} - }})({})"#, - desired_version - )) - .await - .ok() - .and_then(|r| r.value().and_then(|v| v.as_str().map(|s| s.to_string()))) - .unwrap_or_else(|| "missing".to_string()); - - if status != "ok" { - // Inject if missing - if let Err(e) = self.inject_virtual_cursor().await { - warn!("Failed to inject virtual cursor: {}", e); - return Err(e); - } - return Ok(true); - } - - Ok(false) - } - - /// (NEW) Injects the script to prevent new tabs from opening and redirect them to the current tab. - #[allow(dead_code)] - async fn inject_tab_interception_script(cdp_page: &Arc) -> Result<()> { - // The comprehensive script ported from browser_session.ts - let script = r#" - (() => { - // Hardened window.open override with Proxy - const originalOpen = window.open; - const openProxy = new Proxy(originalOpen, { - apply(_t, _this, args) { - const url = args[0]; - if (url) location.href = url; - return null; - } - }); - // Lock down the property - Object.defineProperty(window, 'open', { - value: openProxy, - writable: false, - configurable: false - }); - - // Extract URL helper (including specific attributes from TS version) - const urlFrom = n => n?.href ?? - n?.getAttribute?.('href') ?? - n?.getAttribute?.('post-outbound-link') ?? - n?.dataset?.url ?? - n?.dataset?.href ?? null; - - // Intercept handler (handles shadow DOM) - const intercept = e => { - const path = e.composedPath?.() ?? []; - for (const n of path) { - if (!n?.getAttribute) continue; - if (n.getAttribute('target') === '_blank') { - const url = urlFrom(n); - if (url) { - e.preventDefault(); - e.stopImmediatePropagation(); - location.href = url; - } - return; - } - } - }; - - // Attach listeners - ['pointerdown', 'click', 'auxclick'].forEach(ev => - document.addEventListener(ev, intercept, { capture: true }) - ); - - // Handle keyboard navigation - document.addEventListener('keydown', e => { - if ((e.key === 'Enter' || e.key === ' ') && - document.activeElement?.getAttribute?.('target') === '_blank') { - e.preventDefault(); - const url = urlFrom(document.activeElement); - if (url) location.href = url; - } - }, { capture: true }); - - // Handle form submissions - document.addEventListener('submit', e => { - if (e.target?.target === '_blank') { - e.preventDefault(); - e.target.target = '_self'; - e.target.submit(); - } - }, { capture: true }); - - // Helper to attach listeners to shadow roots - const attach = root => - ['pointerdown', 'click', 'auxclick'].forEach(ev => - root.addEventListener(ev, intercept, { capture: true }) - ); - - // MutationObserver for shadow DOM - try { - const observeTarget = document.documentElement || document; - if (observeTarget) { - new MutationObserver(muts => { - muts.forEach(m => - m.addedNodes.forEach(n => n && n.shadowRoot && attach(n.shadowRoot)) - ); - }).observe(observeTarget, { subtree: true, childList: true }); - } - } catch (e) { - console.warn("BrowserAutomation: Failed to set up MutationObserver for tab blocking", e); - } - })(); - "#; - - let params = AddScriptToEvaluateOnNewDocumentParams::new(script); - cdp_page.execute(params).await?; - debug!("Tab interception script injected successfully."); - Ok(()) - } - - /// Injects a unified bootstrap for each new document: tab blocking + cursor bootstrap + SPA hooks - /// and early console capture so tools like `browser_console` can read logs reliably. - async fn inject_bootstrap_script(cdp_page: &Arc) -> Result<()> { - // This script installs the full virtual cursor on DOM ready for each new document. - // It also prevents _blank tabs, hooks SPA history changes, and installs - // console/error capture early so logs accumulate from the start of the page. - let script = r#" -(function(){ - // 1) Tab blocking: override window.open + intercept target="_blank" - try { - const originalOpen = window.open; - const openProxy = new Proxy(originalOpen, { - apply(_t, _this, args) { - const url = args[0]; - if (url) location.href = url; - return null; - } - }); - Object.defineProperty(window, 'open', { value: openProxy, writable: false, configurable: false }); - - const urlFrom = n => n?.href ?? n?.getAttribute?.('href') ?? n?.getAttribute?.('post-outbound-link') ?? n?.dataset?.url ?? n?.dataset?.href ?? null; - const intercept = e => { - const path = e.composedPath?.() ?? []; - for (const n of path) { - if (!n?.getAttribute) continue; - if (n.getAttribute('target') === '_blank') { - const url = urlFrom(n); - if (url) { e.preventDefault(); e.stopImmediatePropagation(); location.href = url; } - return; - } - } - }; - ['pointerdown','click','auxclick'].forEach(ev => document.addEventListener(ev, intercept, { capture: true })); - document.addEventListener('keydown', e => { - if ((e.key === 'Enter' || e.key === ' ') && document.activeElement?.getAttribute?.('target') === '_blank') { - e.preventDefault(); const url = urlFrom(document.activeElement); if (url) location.href = url; - } - }, { capture: true }); - document.addEventListener('submit', e => { - if (e.target?.target === '_blank') { e.preventDefault(); e.target.target = '_self'; e.target.submit(); } - }, { capture: true }); - try { - const observeTarget = document.documentElement || document; - if (observeTarget) { - new MutationObserver(muts => muts.forEach(m => m.addedNodes.forEach(n => n && n.shadowRoot && ['pointerdown','click','auxclick'].forEach(ev => n.shadowRoot.addEventListener(ev, intercept, { capture: true })) ))).observe(observeTarget, { subtree: true, childList: true }); - } - } catch (e) { console.warn('Tab block MO failed', e); } - } catch (e) { console.warn('Tab blocking failed', e); } - - // 2) SPA history hooks - try { - const dispatch = () => { - try { - const ev = new Event('codex:locationchange'); - window.dispatchEvent(ev); - window.__code_last_url = location.href; - } catch {} - }; - const push = history.pushState.bind(history); - const repl = history.replaceState.bind(history); - history.pushState = function(...a){ const r = push(...a); dispatch(); return r; }; - history.replaceState = function(...a){ const r = repl(...a); dispatch(); return r; }; - window.addEventListener('popstate', dispatch, { passive: true }); - dispatch(); - } catch (e) { console.warn('SPA hook failed', e); } - - // 3) Console capture: install once and persist for the lifetime of the document - try { - if (!window.__code_console_logs) { - window.__code_console_logs = []; - const push = (level, message) => { - try { - window.__code_console_logs.push({ timestamp: new Date().toISOString(), level, message }); - if (window.__code_console_logs.length > 2000) window.__code_console_logs.shift(); - } catch (_) {} - }; - - // Override console methods once - ['log','warn','error','info','debug'].forEach(function(method) { - try { - const orig = console[method]; - console[method] = function() { - try { - var args = Array.prototype.slice.call(arguments); - var msg = args.map(function(a) { - try { - if (a && typeof a === 'object') return JSON.stringify(a); - return String(a); - } catch (_) { return String(a); } - }).join(' '); - push(method, msg); - } catch(_) {} - if (orig) return orig.apply(console, arguments); - }; - } catch(_) {} - }); - - // Capture uncaught errors - window.addEventListener('error', function(e) { - try { - var msg = e && e.message ? e.message : 'Script error'; - var stack = e && e.error && e.error.stack ? ('\n' + e.error.stack) : ''; - push('exception', msg + stack); - } catch(_) {} - }); - // Capture unhandled promise rejections - window.addEventListener('unhandledrejection', function(e) { - try { - var reason = e && e.reason; - if (reason && typeof reason === 'object') { - try { reason = JSON.stringify(reason); } catch(_) {} - } - push('unhandledrejection', String(reason)); - } catch(_) {} - }); - } - } catch (e) { /* swallow */ } - - // 5) Stealth: reduce headless/automation signals for basic anti-bot checks - try { - // webdriver: undefined - try { Object.defineProperty(Navigator.prototype, 'webdriver', { get: () => undefined }); } catch(_) {} - - // languages - try { - const langs = ['en-US','en']; - Object.defineProperty(Navigator.prototype, 'languages', { get: () => langs.slice() }); - Object.defineProperty(Navigator.prototype, 'language', { get: () => 'en-US' }); - } catch(_) {} - - // plugins & mimeTypes - try { - const fakePlugin = { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }; - const arrLike = (len) => ({ length: len, item(i){ return this[i]; } }); - const plugins = arrLike(1); plugins[0] = fakePlugin; - const mimes = arrLike(2); mimes[0] = { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }; mimes[1] = { type: 'application/x-google-chrome-pdf', suffixes: 'pdf', description: 'Portable Document Format' }; - Object.defineProperty(Navigator.prototype, 'plugins', { get: () => plugins }); - Object.defineProperty(Navigator.prototype, 'mimeTypes', { get: () => mimes }); - } catch(_) {} - - // hardwareConcurrency & deviceMemory - try { Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', { get: () => 8 }); } catch(_) {} - try { Object.defineProperty(Navigator.prototype, 'deviceMemory', { get: () => 8 }); } catch(_) {} - - // permissions.query - try { - const orig = navigator.permissions && navigator.permissions.query ? navigator.permissions.query.bind(navigator.permissions) : null; - if (orig) { - navigator.permissions.query = function(p){ - if (p && p.name === 'notifications') { return Promise.resolve({ state: 'granted' }); } - return orig(p); - } - } - } catch(_) {} - - // WebGL vendor/renderer - try { - const spoof = (proto) => { - const orig = proto.getParameter; - Object.defineProperty(proto, 'getParameter', { value: function(p){ - const UNMASKED_VENDOR_WEBGL = 0x9245; // WEBGL_debug_renderer_info - const UNMASKED_RENDERER_WEBGL = 0x9246; - if (p === UNMASKED_VENDOR_WEBGL) return 'Apple Inc.'; - if (p === UNMASKED_RENDERER_WEBGL) return 'Apple M2'; - return orig.apply(this, arguments); - }}); - }; - if (window.WebGLRenderingContext) spoof(WebGLRenderingContext.prototype); - if (window.WebGL2RenderingContext) spoof(WebGL2RenderingContext.prototype); - } catch(_) {} - - // userAgentData (hints) - try { - if (!('userAgentData' in navigator)) { - Object.defineProperty(Navigator.prototype, 'userAgentData', { get: () => ({ - brands: [ { brand: 'Chromium', version: '128' }, { brand: 'Google Chrome', version: '128' } ], - mobile: false, - platform: navigator.platform || 'macOS' - })}); - } - } catch(_) {} - } catch(_) { /* ignore */ } - - // 4) No cursor bootstrap here; full cursor is injected by runtime ensure_virtual_cursor -})(); -"#; - - let params = AddScriptToEvaluateOnNewDocumentParams::new(script); - cdp_page.execute(params).await?; - Ok(()) - } - - /// Helper function to capture screenshot with retry logic. - /// Strategy summary (critical to UX and reliability): - /// - Visible pages: Start with from_surface(false) (no-flash path). If it fails once, retry false quickly. - /// Only as a last resort use from_surface(true), because it can flash a visible window. - /// - Non-visible pages: Use a fast 8×8 preflight (false) to decide. If compositor is unavailable, start - /// with from_surface(true) immediately (safe when not visible). Fallbacks stay conservative. - /// - Final fallback: If two attempts with false fail even while visible, we try true once rather than - /// failing entirely. This prevents chronic timeouts; the flash trade-off is acceptable as a last resort. - /// Do not loosen these guarantees casually; they were tuned to balance reliability and no-flash UX. - async fn capture_screenshot_with_retry( - &self, - params_builder: chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParamsBuilder, - ) -> Result { - // Determine page visibility once to decide if from_surface(true) is safe/necessary. - // If this check fails, assume visible to avoid accidentally picking the flashing path. - let is_visible = { - let eval = self - .cdp_page - .evaluate( - "(() => { try { return { hidden: !!document.hidden, vs: String(document.visibilityState||'unknown') }; } catch (e) { return { hidden: null, vs: 'error' }; } })()", - ) - .await; - match eval { - Ok(v) => { - let obj = v.value().cloned().unwrap_or(serde_json::Value::Null); - let hidden = obj - .get("hidden") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - let vs = obj.get("vs").and_then(|x| x.as_str()).unwrap_or("visible"); - !(hidden || vs != "visible") - } - Err(_) => true, // assume visible to avoid risky from_surface(true) - } - }; - - // Preflight probe (non-visible only): - // - A very fast 8×8 clip via from_surface(false) predicts if the compositor path is currently viable. - // - Only run when page is not visible (no flash risk) and cache the result for ~5s. - // This avoids long timeouts on minimized windows without reintroducing flash for visible ones. - let mut prefer_false = true; - if !is_visible { - let now = Instant::now(); - { - let mut cache = self.preflight_cache.lock().await; - if let Some((ts, ok)) = *cache { - if now.duration_since(ts) < Duration::from_secs(5) { - prefer_false = ok; - } else { - *cache = None; - } - } - } - - if prefer_false { - let cached = { - let cache = self.preflight_cache.lock().await; - cache.is_some() - }; - if !cached { - let probe_params = params_builder - .clone() - .from_surface(false) - .capture_beyond_viewport(true) - .clip(chromiumoxide::cdp::browser_protocol::page::Viewport { - x: 0.0, - y: 0.0, - width: 8.0, - height: 8.0, - scale: 1.0, - }) - .build(); - let probe = tokio::time::timeout(Duration::from_millis(350), self.cdp_page.execute(probe_params)).await; - let ok = matches!(probe, Ok(Ok(_))); - let mut cache = self.preflight_cache.lock().await; - *cache = Some((Instant::now(), ok)); - prefer_false = ok; - if !prefer_false { - debug!("Preflight suggests compositor path unavailable; non-visible context will use from_surface(true)"); - } - } - } - } - - // First attempt policy: - // - Visible: Always start with from_surface(false) and a short timeout to minimize flash risk. - // - Not visible: Use preflight outcome; allow from_surface(true) immediately when compositor is unavailable. - let (first_params, first_timeout, first_is_false) = if is_visible { - (params_builder.clone().from_surface(false).build(), Duration::from_secs(3), true) - } else if prefer_false { - (params_builder.clone().from_surface(false).build(), Duration::from_secs(6), true) - } else { - (params_builder.clone().from_surface(true).build(), Duration::from_secs(6), false) - }; - let first_attempt = tokio::time::timeout(first_timeout, self.cdp_page.execute(first_params)).await; - - match first_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(e)) => { - debug!( - "Screenshot first attempt failed (used_false={}): {} (visible={})", - first_is_false, e, is_visible - ); - if !is_visible || !first_is_false { - // Non-visible or already tried true path: retry with from_surface(true). - // Safe for minimized/hidden windows and avoids repeated long timeouts. - let retry_params = params_builder.from_surface(true).build(); - let retry_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(8), - self.cdp_page.execute(retry_params), - ) - .await; - - match retry_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(retry_err)) => Err(retry_err.into()), - Err(_) => Err(BrowserError::ScreenshotError( - "Screenshot retry (from_surface=true) timed out".to_string(), - )), - } - } else { - // Visible: avoid from_surface(true) if at all possible. Brief wait and retry once with false. - tokio::time::sleep(tokio::time::Duration::from_millis(120)).await; - let retry_params = params_builder.clone().from_surface(false).build(); - let retry_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(4), - self.cdp_page.execute(retry_params), - ) - .await; - match retry_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(_)) => { - // Last resort for visible pages: try from_surface(true). - // This can flash; we only do it after exhausting the safer path to prevent permanent failures. - debug!( - "Retry with from_surface(false) failed while visible; attempting from_surface(true) as fallback" - ); - let final_params = params_builder.from_surface(true).build(); - let final_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(4), - self.cdp_page.execute(final_params), - ) - .await; - match final_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(e3)) => Err(e3.into()), - Err(_) => Err(BrowserError::ScreenshotError( - "Screenshot timed out (final from_surface=true fallback)".to_string(), - )), - } - } - Err(_) => { - // Timeout on second false attempt; try true once as last resort (may flash) - debug!( - "Retry with from_surface(false) timed out while visible; attempting from_surface(true) as fallback" - ); - let final_params = params_builder.from_surface(true).build(); - let final_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(4), - self.cdp_page.execute(final_params), - ) - .await; - match final_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(e3)) => Err(e3.into()), - Err(_) => Err(BrowserError::ScreenshotError( - "Screenshot timed out after retries (from_surface=true fallback)".to_string(), - )), - } - } - } - } - } - Err(_) => { - debug!( - "Screenshot first attempt timed out (used_false={}, visible={})", - first_is_false, is_visible - ); - if !is_visible || !first_is_false { - // Not visible (safe) or already tried false: try from_surface(true) - let retry_params = params_builder.from_surface(true).build(); - let retry_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(8), - self.cdp_page.execute(retry_params), - ) - .await; - match retry_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(e)) => Err(e.into()), - Err(_) => Err(BrowserError::ScreenshotError( - "Screenshot timed out with from_surface(true)".to_string(), - )), - } - } else { - // Visible: avoid from_surface(true) if possible; retry quickly with false - let retry_params = params_builder.clone().from_surface(false).build(); - let retry_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(4), - self.cdp_page.execute(retry_params), - ) - .await; - match retry_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(_)) => { - // Final fallback with from_surface(true) even though visible (see doc rationale) - debug!( - "Second attempt with from_surface(false) failed while visible; attempting final from_surface(true)" - ); - let final_params = params_builder.from_surface(true).build(); - let final_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(4), - self.cdp_page.execute(final_params), - ) - .await; - match final_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(e3)) => Err(e3.into()), - Err(_) => Err(BrowserError::ScreenshotError( - "Screenshot timed out after retries (final from_surface=true)".to_string(), - )), - } - } - Err(_) => { - // Timeout on second false attempt, try true once (final) - debug!( - "Second attempt with from_surface(false) timed out while visible; attempting final from_surface(true)" - ); - let final_params = params_builder.from_surface(true).build(); - let final_attempt = tokio::time::timeout( - tokio::time::Duration::from_secs(4), - self.cdp_page.execute(final_params), - ) - .await; - match final_attempt { - Ok(Ok(resp)) => Ok(resp.result), - Ok(Err(e3)) => Err(e3.into()), - Err(_) => Err(BrowserError::ScreenshotError( - "Screenshot timed out after retries (from_surface=true fallback)".to_string(), - )), - } - } - } - } - } - } - } - - /// Returns the current page title, if available. - pub async fn get_title(&self) -> Option { - self.cdp_page.get_title().await.ok().flatten() - } - - /// Check and fix viewport scaling issues before taking screenshots - #[allow(dead_code)] - async fn check_and_fix_scaling(&self) -> Result<()> { - // Never touch viewport metrics for external Chrome connections. - // Changing device metrics on a user's Chrome causes a visible flash - // and slows down screenshots. We only verify/correct for internally - // launched Chrome where we control the window. - if self.config.connect_port.is_some() || self.config.connect_ws.is_some() { - return Ok(()); - } - // Check current viewport and scaling - let check_script = r#" - (() => { - const vw = window.innerWidth; - const vh = window.innerHeight; - const dpr = window.devicePixelRatio || 1; - const zoom = Math.round(window.outerWidth / window.innerWidth * 100) / 100; - - // Check if viewport matches expected dimensions - const expectedWidth = %EXPECTED_WIDTH%; - const expectedHeight = %EXPECTED_HEIGHT%; - const expectedDpr = %EXPECTED_DPR%; - - return { - currentWidth: vw, - currentHeight: vh, - currentDpr: dpr, - currentZoom: zoom, - expectedWidth: expectedWidth, - expectedHeight: expectedHeight, - expectedDpr: expectedDpr, - // Only correct when there's a meaningful mismatch in size/DPR. - // Ignore zoom heuristics which can be noisy on some platforms. - needsCorrection: ( - Math.abs(vw - expectedWidth) > 5 || - Math.abs(vh - expectedHeight) > 5 || - Math.abs(dpr - expectedDpr) > 0.05 - ) - }; - })() - "#; - - // Replace placeholders with actual expected values - let script = check_script - .replace("%EXPECTED_WIDTH%", &self.config.viewport.width.to_string()) - .replace( - "%EXPECTED_HEIGHT%", - &self.config.viewport.height.to_string(), - ) - .replace( - "%EXPECTED_DPR%", - &self.config.viewport.device_scale_factor.to_string(), - ); - - let result = self.cdp_page.evaluate(script).await?; - - if let Some(obj) = result.value() { - let needs_correction = obj - .get("needsCorrection") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if needs_correction { - let current_width = obj - .get("currentWidth") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let current_height = obj - .get("currentHeight") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let current_dpr = obj - .get("currentDpr") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0); - let current_zoom = obj - .get("currentZoom") - .and_then(|v| v.as_f64()) - .unwrap_or(1.0); - - debug!( - "Viewport needs correction: {}x{} @ {}x DPR (zoom: {}) -> {}x{} @ {}x DPR", - current_width, - current_height, - current_dpr, - current_zoom, - self.config.viewport.width, - self.config.viewport.height, - self.config.viewport.device_scale_factor - ); - - // Use CDP to set the correct viewport metrics - let params = SetDeviceMetricsOverrideParams::builder() - .width(self.config.viewport.width as i64) - .height(self.config.viewport.height as i64) - .device_scale_factor(self.config.viewport.device_scale_factor) - .mobile(self.config.viewport.mobile) - .build() - .map_err(|e| { - BrowserError::CdpError(format!("Failed to build viewport params: {}", e)) - })?; - - self.cdp_page.execute(params).await?; - - // Avoid aggressive zoom resets to reduce reflow/flash. - // If internal zoom is off, leave it unless size/DPR corrected above isn't sufficient. - - info!("Viewport scaling corrected"); - } - } - - Ok(()) - } - - /// (NEW) Injects a virtual cursor element into the page at the current coordinates. - pub async fn inject_virtual_cursor(&self) -> Result<()> { - let cursor = self.cursor_state.lock().await.clone(); - let cursor_x = cursor.x; - let cursor_y = cursor.y; - - // First try the externalized installer for easier iteration. - // The JS must define `window.__vcInstall(x,y)` and create window.__vc with __version=11. - let external = format!( - "{}\n;(()=>{{ try {{ return (window.__vcInstall ? window.__vcInstall : function(x,y){{}})({:.0},{:.0}); }} catch (e) {{ return String(e && e.message || e); }} }})()", - VIRTUAL_CURSOR_JS, - cursor_x, - cursor_y - ); - if let Ok(res) = self.cdp_page.evaluate(external).await { - let status = res - .value() - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if status == "ok" { - return Ok(()); - } else { - warn!("Virtual cursor injection reported: {}", status); - return Err(BrowserError::CdpError(format!( - "Virtual cursor injection failed: {}", - status - ))); - } - } - warn!("Virtual cursor injection failed: no response"); - Err(BrowserError::CdpError("Virtual cursor injection failed: no response".into())) - } - - /// Ensures an editable element is focused before typing without stealing focus. - /// Rules: - /// - If the deeply focused element (piercing shadow DOM and same-origin iframes) is editable, do nothing. - /// - Otherwise, try to focus the editable element directly under the virtual cursor location. - /// - Never fall back to any other candidate (prevents unexpected focus steals). - async fn ensure_editable_focused(&self) -> Result { - let cursor = self.cursor_state.lock().await.clone(); - let cursor_x = cursor.x; - let cursor_y = cursor.y; - - let script = format!( - r#" - (function(cursorX, cursorY) {{ - const isEditableInputType = (t) => !/^(checkbox|radio|button|submit|reset|file|image|color|hidden|range)$/i.test(t || ''); - const isEditable = (el) => !!el && ( - (el.tagName === 'INPUT' && isEditableInputType(el.type)) || - el.tagName === 'TEXTAREA' || - el.isContentEditable === true - ); - - const deepActiveElement = () => {{ - try {{ - let ae = document.activeElement; - // Pierce shadow roots - while (ae && ae.shadowRoot && ae.shadowRoot.activeElement) {{ - ae = ae.shadowRoot.activeElement; - }} - // Pierce same-origin iframes - while (ae && ae.tagName === 'IFRAME') {{ - try {{ - const doc = ae.contentWindow && ae.contentWindow.document; - if (!doc) break; - let inner = doc.activeElement; - if (!inner) break; - while (inner && inner.shadowRoot && inner.shadowRoot.activeElement) {{ - inner = inner.shadowRoot.activeElement; - }} - ae = inner; - }} catch (_) {{ break; }} - }} - return ae || null; - }} catch (_) {{ return null; }} - }}; - - const deepElementFromPoint = (x, y) => {{ - // Walk composed tree using elementsFromPoint, then descend into open shadow roots and same-origin iframes - const walk = (root, gx, gy) => {{ - let list = []; - try {{ - list = (root.elementsFromPoint ? root.elementsFromPoint(gx, gy) : [root.elementFromPoint(gx, gy)].filter(Boolean)) || []; - }} catch (_) {{ list = []; }} - for (const el of list) {{ - // Descend into shadow root if present - if (el && el.shadowRoot) {{ - const deep = walk(el.shadowRoot, gx, gy); - if (deep) return deep; - }} - // Descend into same-origin iframe - if (el && el.tagName === 'IFRAME') {{ - try {{ - const rect = el.getBoundingClientRect(); - const lx = gx - rect.left; // local X inside iframe viewport - const ly = gy - rect.top; // local Y inside iframe viewport - const doc = el.contentWindow && el.contentWindow.document; - if (doc) {{ - const deep = walk(doc, lx, ly); - if (deep) return deep; - }} - }} catch(_) {{ /* cross-origin: skip */ }} - }} - if (el) return el; - }} - return null; - }}; - return walk(document, x, y); - }}; - - // 1) If something is already focused and is editable (deeply), keep it. - const current = deepActiveElement(); - if (isEditable(current)) return true; - - // 2) Otherwise, only try to focus the editable element under the cursor. - if (Number.isFinite(cursorX) && Number.isFinite(cursorY)) {{ - let el = deepElementFromPoint(cursorX, cursorY); - // climb up to an editable ancestor if needed within same composed tree - const canFocus = (n) => n && typeof n.focus === 'function'; - let walker = el; - while (walker && !isEditable(walker)) {{ - walker = walker.parentElement || (walker.getRootNode && (walker.getRootNode().host || null)) || null; - }} - if (isEditable(walker) && canFocus(walker)) {{ - walker.focus(); - const after = deepActiveElement(); - return after === walker; - }} - }} - return false; // Do not steal focus by picking arbitrary candidates. - }})({cursor_x}, {cursor_y}) - "#, - cursor_x = cursor_x, - cursor_y = cursor_y - ); - - let result = self.cdp_page.evaluate(script).await?; - let focused = result.value().and_then(|v| v.as_bool()).unwrap_or(false); - Ok(focused) - } - - pub async fn goto(&self, url: &str, wait: Option) -> Result { - info!("Navigating to {}", url); - - let wait_strategy = wait.unwrap_or_else(|| self.config.wait.clone()); - - // Navigate to the URL with retry on timeout. If Chrome reports timeouts - // but the page URL actually updates to a real http(s) page, treat it as success. - let max_retries = 3; - let mut last_error = None; - let mut fallback_navigated = false; - - for attempt in 1..=max_retries { - // Wrap CDP navigation with a short timeout so we don't block ~30s - // for sites that load but don't signal expected events. - let nav_attempt = - tokio::time::timeout(tokio::time::Duration::from_secs(5), self.cdp_page.goto(url)) - .await; - - match nav_attempt { - Ok(Ok(_)) => { - // Navigation reported success - last_error = None; - break; - } - Ok(Err(e)) => { - let error_str = e.to_string(); - if error_str.contains("Request timed out") || error_str.contains("timeout") { - warn!( - "Navigation timeout on attempt {}/{}: {}", - attempt, max_retries, error_str - ); - last_error = Some(e); - - // Check if the page actually navigated despite the timeout - if let Ok(cur_opt) = self.cdp_page.url().await { - if let Some(cur) = cur_opt { - let looks_loaded = - cur.starts_with("http://") || cur.starts_with("https://"); - if looks_loaded && cur != "about:blank" { - info!( - "Navigation reported timeout, but page URL is now {} — treating as success", - cur - ); - fallback_navigated = true; - last_error = None; - break; - } - } - } - - if attempt < max_retries { - // Wait before retry, increasing delay each time - let delay_ms = 1000 * attempt as u64; - info!("Retrying navigation after {}ms...", delay_ms); - tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; - continue; - } - } else { - // Non-timeout error, fail immediately - return Err(e.into()); - } - } - Err(_) => { - // Our outer timeout fired; fallback to URL check, same as above. - warn!( - "Navigation attempt {}/{} exceeded 5s timeout; checking current URL...", - attempt, max_retries - ); - // Check if the page actually navigated despite the timeout - if let Ok(cur_opt) = self.cdp_page.url().await { - if let Some(cur) = cur_opt { - let looks_loaded = - cur.starts_with("http://") || cur.starts_with("https://"); - if looks_loaded && cur != "about:blank" { - info!( - "Navigation exceeded timeout, but page URL is now {} — treating as success", - cur - ); - fallback_navigated = true; - last_error = None; - break; - } - } - } - - if attempt < max_retries { - let delay_ms = 1000 * attempt as u64; - info!("Retrying navigation after {}ms...", delay_ms); - tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; - continue; - } - // If this was the last attempt, return a synthetic timeout error - return Err(BrowserError::CdpError("Navigation timed out".to_string())); - } - } - } - - // If we exhausted retries and still have an error, bail out - if let Some(e) = last_error { - return Err(BrowserError::CdpError(format!( - "Navigation failed after {} retries: {}", - max_retries, e - ))); - } - - // Wait according to the strategy - match wait_strategy { - WaitStrategy::Event(event) => match event.as_str() { - "domcontentloaded" => { - if fallback_navigated { - // Poll document.readyState instead of wait_for_navigation() - wait_ready_state( - &self.cdp_page, - ReadyStateTarget::InteractiveOrComplete, - Duration::from_secs(3), - Duration::from_millis(100), - ) - .await; - } else { - // Wait for DOMContentLoaded event - let wait_timeout = Duration::from_secs(4); - match tokio::time::timeout( - wait_timeout, - self.cdp_page.wait_for_navigation(), - ) - .await - { - Ok(Ok(_)) => {} - Ok(Err(e)) => { - warn!( - "DOMContentLoaded wait failed after {:?}: {}", - wait_timeout, e - ); - wait_ready_state( - &self.cdp_page, - ReadyStateTarget::InteractiveOrComplete, - Duration::from_secs(3), - Duration::from_millis(100), - ) - .await; - if !url_looks_loaded(&self.cdp_page, Duration::from_millis(400)).await - { - return Err(BrowserError::CdpError(e.to_string())); - } - } - Err(_) => { - warn!("DOMContentLoaded wait timed out after {:?}", wait_timeout); - wait_ready_state( - &self.cdp_page, - ReadyStateTarget::InteractiveOrComplete, - Duration::from_secs(3), - Duration::from_millis(100), - ) - .await; - if !url_looks_loaded(&self.cdp_page, Duration::from_millis(400)).await - { - return Err(BrowserError::CdpError(format!( - "DOMContentLoaded wait timed out after {wait_timeout:?}" - ))); - } - } - } - } - } - "networkidle" | "networkidle0" => { - // Wait for network to be idle - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - } - "networkidle2" => { - // Wait for network to be mostly idle - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - } - "load" => { - if fallback_navigated { - // Poll for complete state - wait_ready_state( - &self.cdp_page, - ReadyStateTarget::Complete, - Duration::from_secs(4), - Duration::from_millis(120), - ) - .await; - // Small cushion after load - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; - } else { - // Wait for load event - let wait_timeout = Duration::from_secs(5); - match tokio::time::timeout( - wait_timeout, - self.cdp_page.wait_for_navigation(), - ) - .await - { - Ok(Ok(_)) => {} - Ok(Err(e)) => { - warn!("Load wait failed after {:?}: {}", wait_timeout, e); - wait_ready_state( - &self.cdp_page, - ReadyStateTarget::Complete, - Duration::from_secs(4), - Duration::from_millis(120), - ) - .await; - if !url_looks_loaded(&self.cdp_page, Duration::from_millis(400)).await - { - return Err(BrowserError::CdpError(e.to_string())); - } - } - Err(_) => { - warn!("Load wait timed out after {:?}", wait_timeout); - wait_ready_state( - &self.cdp_page, - ReadyStateTarget::Complete, - Duration::from_secs(4), - Duration::from_millis(120), - ) - .await; - if !url_looks_loaded(&self.cdp_page, Duration::from_millis(400)).await - { - return Err(BrowserError::CdpError(format!( - "Load wait timed out after {wait_timeout:?}" - ))); - } - } - } - // Add extra delay to ensure page is fully loaded - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - } - } - _ => { - return Err(BrowserError::ConfigError(format!( - "Unknown wait event: {}", - event - ))); - } - }, - WaitStrategy::Delay { delay_ms } => { - tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; - } - } - - // Get the final URL and title after navigation completes - let title = self.cdp_page.get_title().await.ok().flatten(); - - // Try to get the URL multiple times in case it's not immediately available - let mut final_url = None; - for _ in 0..3 { - if let Ok(Some(url)) = self.cdp_page.url().await { - final_url = Some(url); - break; - } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - let final_url = final_url.unwrap_or_else(|| url.to_string()); - - let mut current_url = self.current_url.write().await; - *current_url = Some(final_url.clone()); - drop(current_url); // Release lock before injecting cursor - - // Ensure the virtual cursor after navigation - debug!("Ensuring virtual cursor after navigation"); - if let Err(e) = self.ensure_virtual_cursor().await { - warn!("Failed to inject virtual cursor after navigation: {}", e); - // Continue even if cursor injection fails - } - - Ok(GotoResult { - url: final_url, - title, - }) - } - - // (UPDATED) Inject cursor before taking screenshot - pub async fn screenshot(&self, mode: ScreenshotMode) -> Result> { - // Do not adjust device metrics before screenshots; this causes flashing on - // external Chrome and adds latency. Rely on connect-time configuration. - - // Fast path: ensure the virtual cursor exists before capturing - let injected = match self.ensure_virtual_cursor().await { - Ok(injected) => injected, - Err(e) => { - warn!("Failed to inject virtual cursor: {}", e); - // Continue with screenshot even if cursor injection fails - false - } - }; - - // Do not wait for animations to settle; capture current frame to preserve visible motion - // Small render delay only on fresh injection to avoid empty frame - if injected { - tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; - } - - match mode { - ScreenshotMode::Viewport => self.screenshot_viewport().await, - ScreenshotMode::FullPage { segments_max } => { - self.screenshot_fullpage(segments_max.unwrap_or(self.config.segments_max)) - .await - } - ScreenshotMode::Region(region) => self.screenshot_region(region).await, - } - } - - pub async fn screenshot_viewport(&self) -> Result> { - // Safe viewport capture: do not change device metrics or viewport. - // Measure CSS viewport size via JS and capture a clipped image - // using the compositor without affecting focus. - debug!("Taking viewport screenshot (safe clip, no resize)"); - - let format = match self.config.format { - ImageFormat::Png => CaptureScreenshotFormat::Png, - ImageFormat::Webp => CaptureScreenshotFormat::Webp, - }; - - // Probe CSS viewport using Runtime.evaluate to avoid layout_metrics - let probe = self - .inject_js( - "(() => ({ w: (document.documentElement.clientWidth|0), h: (document.documentElement.clientHeight|0) }))()", - ) - .await - .unwrap_or(serde_json::Value::Null); - - let doc_w = probe.get("w").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - let doc_h = probe.get("h").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - - // Fall back to configured viewport if probe failed - let vw = if doc_w > 0 { - doc_w - } else { - self.config.viewport.width - }; - let vh = if doc_h > 0 { - doc_h - } else { - self.config.viewport.height - }; - - // Clamp to configured maximums to keep images small for the LLM - let target_w = vw.min(self.config.viewport.width); - let target_h = vh.min(self.config.viewport.height); - - let params_builder = CaptureScreenshotParams::builder() - .format(format) - .capture_beyond_viewport(true) - .clip(chromiumoxide::cdp::browser_protocol::page::Viewport { - x: 0.0, - y: 0.0, - width: target_w as f64, - height: target_h as f64, - scale: 1.0, - }); - - // Use our retry logic to handle cases where window is not visible - let resp = self.capture_screenshot_with_retry(params_builder).await?; - let data_b64: &str = resp.data.as_ref(); - let data = base64::engine::general_purpose::STANDARD - .decode(data_b64.as_bytes()) - .map_err(|e| BrowserError::ScreenshotError(format!("base64 decode failed: {}", e)))?; - - Ok(vec![Screenshot { - data, - width: target_w, - height: target_h, - format: self.config.format, - }]) - } - - pub async fn screenshot_fullpage(&self, segments_max: usize) -> Result> { - let format = match self.config.format { - ImageFormat::Png => CaptureScreenshotFormat::Png, - ImageFormat::Webp => CaptureScreenshotFormat::Webp, - }; - - // 1) Get document dimensions (CSS px) - let lm = self.cdp_page.layout_metrics().await?; - let content = lm.css_content_size; // Rect (not Option) - let doc_w = content.width.ceil() as u32; - let doc_h = content.height.ceil() as u32; - - // Use your configured viewport width, but never exceed doc width - let vw = self.config.viewport.width.min(doc_w); - let vh = self.config.viewport.height; - - // 2) Slice the page by y-offsets WITHOUT scrolling the page - let mut shots = Vec::new(); - let mut y: u32 = 0; - let mut taken = 0usize; - - while y < doc_h && taken < segments_max { - let h = vh.min(doc_h - y); // last slice may be shorter - let params_builder = CaptureScreenshotParams::builder() - .format(format.clone()) - .capture_beyond_viewport(true) // key to avoid scrolling/flash - .clip(chromiumoxide::cdp::browser_protocol::page::Viewport { - x: 0.0, - y: y as f64, - width: vw as f64, - height: h as f64, - scale: 1.0, - }); - - let resp = self.capture_screenshot_with_retry(params_builder).await?; - let data_b64: &str = resp.data.as_ref(); - let data = base64::engine::general_purpose::STANDARD - .decode(data_b64.as_bytes()) - .map_err(|e| { - BrowserError::ScreenshotError(format!("base64 decode failed: {}", e)) - })?; - shots.push(Screenshot { - data, - width: vw, - height: h, - format: self.config.format, - }); - - y += h; - taken += 1; - } - - if taken == segments_max && y < doc_h { - info!("[full page truncated at {} segments]", segments_max); - } - - Ok(shots) - } - - pub async fn screenshot_region(&self, region: ScreenshotRegion) -> Result> { - debug!( - "Taking region screenshot: {}x{} at ({}, {})", - region.width, region.height, region.x, region.y - ); - - let format = match self.config.format { - ImageFormat::Png => CaptureScreenshotFormat::Png, - ImageFormat::Webp => CaptureScreenshotFormat::Webp, - }; - - let params_builder = CaptureScreenshotParams::builder().format(format).clip( - chromiumoxide::cdp::browser_protocol::page::Viewport { - x: region.x as f64, - y: region.y as f64, - width: region.width as f64, - height: region.height as f64, - scale: 1.0, - }, - ); - - let resp = self.capture_screenshot_with_retry(params_builder).await?; - let data_b64: &str = resp.data.as_ref(); - let data = base64::engine::general_purpose::STANDARD - .decode(data_b64.as_bytes()) - .map_err(|e| BrowserError::ScreenshotError(format!("base64 decode failed: {}", e)))?; - - let final_width = if region.width > 1024 { - 1024 - } else { - region.width - }; - - Ok(vec![Screenshot { - data, - width: final_width, - height: region.height, - format: self.config.format, - }]) - } - - pub async fn set_viewport(&self, viewport: SetViewportParams) -> Result { - // Apply CDP device metrics override once on demand - let params = SetDeviceMetricsOverrideParams::builder() - .width(viewport.width as i64) - .height(viewport.height as i64) - .device_scale_factor(viewport.device_scale_factor.unwrap_or(1.0)) - .mobile(viewport.mobile.unwrap_or(false)) - .build() - .map_err(|e| BrowserError::CdpError(format!("Failed to build viewport params: {}", e)))?; - self.cdp_page.execute(params).await?; - - Ok(ViewportResult { - width: viewport.width, - height: viewport.height, - dpr: viewport.device_scale_factor.unwrap_or(1.0), - }) - } - - pub async fn inject_js(&self, script: &str) -> Result { - let result = self.cdp_page.evaluate(script).await?; - Ok(result.value().cloned().unwrap_or(serde_json::Value::Null)) - } - - pub async fn close(&self) -> Result<()> { - // Note: chromiumoxide's close() takes ownership, so we can't call it on Arc - // The page will be closed when the Arc is dropped - Ok(()) - } - - /// Return a snapshot (tail) of the CDP-captured console buffer. - pub async fn get_console_logs_tail(&self, lines: Option) -> serde_json::Value { - let buf = self.console_logs.lock().await; - if buf.is_empty() { - return serde_json::Value::Array(vec![]); - } - let n = lines.unwrap_or(0); - let slice: Vec = if n > 0 && n < buf.len() { - buf[buf.len() - n..].to_vec() - } else { - buf.clone() - }; - serde_json::Value::Array(slice) - } - - pub async fn get_url(&self) -> Result { - let url_guard = self.current_url.read().await; - url_guard.clone().ok_or(BrowserError::PageNotLoaded) - } - - /// Get the current URL directly from the browser (not cached) - pub async fn get_current_url(&self) -> Result { - match self.cdp_page.url().await? { - Some(url) => Ok(url), - None => Err(BrowserError::PageNotLoaded), - } - } - - pub fn target_id_debug(&self) -> String { - let target_id = self.cdp_page.target_id(); - format!("{target_id:?}") - } - - pub fn session_id_debug(&self) -> String { - let session_id = self.cdp_page.session_id(); - format!("{session_id:?}") - } - - pub fn opener_id_debug(&self) -> Option { - self.cdp_page - .opener_id() - .as_ref() - .map(|opener_id| format!("{opener_id:?}")) - } - - pub async fn update_viewport(&self, _viewport: ViewportConfig) -> Result<()> { - Ok(()) - } - - // Move the mouse by relative offset from current position - pub async fn move_mouse_relative(&self, dx: f64, dy: f64) -> Result<(f64, f64)> { - // Get current position - let cursor = self.cursor_state.lock().await; - let current_x = cursor.x; - let current_y = cursor.y; - drop(cursor); - - // Calculate new position - let new_x = current_x + dx; - let new_y = current_y + dy; - - debug!( - "Moving mouse relatively by ({}, {}) from ({}, {}) to ({}, {})", - dx, dy, current_x, current_y, new_x, new_y - ); - - // Use absolute move with the calculated position - self.move_mouse(new_x, new_y).await?; - Ok((new_x, new_y)) - } - - // (NEW) Move the mouse to the specified coordinates - pub async fn move_mouse(&self, x: f64, y: f64) -> Result<()> { - debug!("Moving mouse to ({}, {})", x, y); - - // Clamp and floor coordinates - let move_x = x.floor().max(0.0); - let move_y = y.floor().max(0.0); - - let mut cursor = self.cursor_state.lock().await; - - // If target is effectively the same as current, avoid dispatching/animating - if (cursor.x - move_x).abs() < 0.5 && (cursor.y - move_y).abs() < 0.5 { - drop(cursor); - // Ensure cursor is present/updated even if no move - let _ = self.ensure_virtual_cursor().await; - return Ok(()); - } - - // Dispatch the mouse move event, including the current button state - let move_params = DispatchMouseEventParams::builder() - .r#type(DispatchMouseEventType::MouseMoved) - .x(move_x) - .y(move_y) - .button(cursor.button.clone()) // Pass the button state - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(move_params).await?; - - // Update cursor position - cursor.x = move_x; - cursor.y = move_y; - drop(cursor); // Release lock before JavaScript evaluation - - // First check if cursor exists, if not inject it - let check_script = "typeof window.__vc !== 'undefined'"; - let cursor_exists = self - .cdp_page - .evaluate(check_script) - .await - .ok() - .and_then(|result| result.value().and_then(|v| v.as_bool())) - .unwrap_or(false); - - if !cursor_exists { - debug!("Virtual cursor not found, injecting it now"); - if let Err(e) = self.inject_virtual_cursor().await { - warn!("Failed to inject virtual cursor: {}", e); - } - } - - // For internal browser, snap instantly without animation. For external, animate and respect duration. - let is_external = self.config.connect_port.is_some() || self.config.connect_ws.is_some(); - let dur_ms = if is_external { - self - .cdp_page - .evaluate(format!( - "(function(x,y){{ try {{ if(window.__vc && window.__vc.update) return window.__vc.update(x,y)|0; }} catch(_e){{}} return 0; }})({:.0},{:.0})", - move_x, move_y - )) - .await - .ok() - .and_then(|r| r.value().and_then(|v| v.as_u64())) - .unwrap_or(0) as u64 - } else { - // Internal browser: snap immediately and report zero duration - let _ = self - .cdp_page - .evaluate(format!( - "(function(x,y){{ try {{ if(window.__vc && window.__vc.snapTo) {{ window.__vc.snapTo(x,y); return 0; }} }} catch(_e){{}} return 0; }})({:.0},{:.0})", - move_x, move_y - )) - .await; - 0 - }; - - // Only wait when connected to an external browser and there is a non-zero duration - if is_external && dur_ms > 0 { - tokio::time::sleep(tokio::time::Duration::from_millis(dur_ms)).await; - } - - Ok(()) - } - - /// (UPDATED) Click at the specified coordinates with visual animation - pub async fn click(&self, x: f64, y: f64) -> Result<()> { - debug!("Clicking at ({}, {})", x, y); - - // Use move_mouse to handle movement, clamping, and state update - self.move_mouse(x, y).await?; - - // Get the final coordinates after potential clamping - let cursor = self.cursor_state.lock().await; - let click_x = cursor.x; - let click_y = cursor.y; - drop(cursor); // Release lock before async calls - - // Trigger click pulse animation via virtual cursor API and wait briefly - let click_ms_val = self - .cdp_page - .evaluate( - "(function(){ if(window.__vc && window.__vc.clickPulse){ return window.__vc.clickPulse(); } return 0; })()", - ) - .await - .ok() - .and_then(|r| r.value().and_then(|v| v.as_u64())) - .unwrap_or(0) as u64; - - // Mouse down - let down_params = DispatchMouseEventParams::builder() - .r#type(DispatchMouseEventType::MousePressed) - .x(click_x) - .y(click_y) - .button(MouseButton::Left) - .click_count(1) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(down_params).await?; - - // Add a small delay between press and release - tokio::time::sleep(tokio::time::Duration::from_millis(40)).await; - - // Mouse up - let up_params = DispatchMouseEventParams::builder() - .r#type(DispatchMouseEventType::MouseReleased) - .x(click_x) - .y(click_y) - .button(MouseButton::Left) - .click_count(1) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(up_params).await?; - - // Wait briefly so the page processes the click; avoid long animation waits - let is_external = self.config.connect_port.is_some() || self.config.connect_ws.is_some(); - let settle_ms = if is_external { click_ms_val.min(240) } else { 40 }; - if settle_ms > 0 { - tokio::time::sleep(tokio::time::Duration::from_millis(settle_ms)).await; - } - - Ok(()) - } - - /// Perform mouse down at the current position - pub async fn mouse_down_at_current(&self) -> Result<(f64, f64)> { - let cursor = self.cursor_state.lock().await; - let x = cursor.x; - let y = cursor.y; - let is_down = cursor.is_mouse_down; - drop(cursor); - - if is_down { - debug!("Mouse is already down at ({}, {})", x, y); - return Ok((x, y)); - } - - debug!("Mouse down at current position ({}, {})", x, y); - - let down_params = DispatchMouseEventParams::builder() - .r#type(DispatchMouseEventType::MousePressed) - .x(x) - .y(y) - .button(MouseButton::Left) - .click_count(1) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(down_params).await?; - - // Update mouse state (track button for drag moves) - let mut cursor = self.cursor_state.lock().await; - cursor.is_mouse_down = true; - cursor.button = MouseButton::Left; - drop(cursor); - - Ok((x, y)) - } - - /// Perform mouse up at the current position - pub async fn mouse_up_at_current(&self) -> Result<(f64, f64)> { - let cursor = self.cursor_state.lock().await; - let x = cursor.x; - let y = cursor.y; - let is_down = cursor.is_mouse_down; - drop(cursor); - - if !is_down { - debug!("Mouse is already up at ({}, {})", x, y); - return Ok((x, y)); - } - - debug!("Mouse up at current position ({}, {})", x, y); - - let up_params = DispatchMouseEventParams::builder() - .r#type(DispatchMouseEventType::MouseReleased) - .x(x) - .y(y) - .button(MouseButton::Left) - .click_count(1) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(up_params).await?; - - // Update mouse state - let mut cursor = self.cursor_state.lock().await; - cursor.is_mouse_down = false; - cursor.button = MouseButton::None; - drop(cursor); - - Ok((x, y)) - } - - /// Click at the current mouse position without moving the cursor - pub async fn click_at_current(&self) -> Result<(f64, f64)> { - // Get the current cursor position and check if mouse is down - let cursor = self.cursor_state.lock().await; - let click_x = cursor.x; - let click_y = cursor.y; - let was_down = cursor.is_mouse_down; - debug!( - "Clicking at current position ({}, {}), mouse was_down: {}", - click_x, click_y, was_down - ); - drop(cursor); // Release lock before async calls - - // If mouse is already down, release it first - if was_down { - debug!("Mouse was down, releasing first before click"); - self.mouse_up_at_current().await?; - tokio::time::sleep(tokio::time::Duration::from_millis(40)).await; - } - - // Trigger click animation through virtual cursor API - let click_ms_val = self - .cdp_page - .evaluate( - "(function(){ if(window.__vc && window.__vc.clickPulse){ return window.__vc.clickPulse(); } return 0; })()", - ) - .await - .ok() - .and_then(|r| r.value().and_then(|v| v.as_u64())) - .unwrap_or(0) as u64; - - // Mouse down - let down_params = DispatchMouseEventParams::builder() - .r#type(DispatchMouseEventType::MousePressed) - .x(click_x) - .y(click_y) - .button(MouseButton::Left) - .click_count(1) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(down_params).await?; - - // Add a small delay between press and release - tokio::time::sleep(tokio::time::Duration::from_millis(40)).await; - - // Mouse up - let up_params = DispatchMouseEventParams::builder() - .r#type(DispatchMouseEventType::MouseReleased) - .x(click_x) - .y(click_y) - .button(MouseButton::Left) - .click_count(1) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(up_params).await?; - - // Wait briefly so the page processes the click; avoid long animation waits - let is_external = self.config.connect_port.is_some() || self.config.connect_ws.is_some(); - let settle_ms = if is_external { click_ms_val.min(240) } else { 40 }; - if settle_ms > 0 { - tokio::time::sleep(tokio::time::Duration::from_millis(settle_ms)).await; - } - - Ok((click_x, click_y)) - } - - /// Type text into the currently focused element with optimized typing strategies - pub async fn type_text(&self, text: &str) -> Result<()> { - // Replace em dashes with regular dashes - let processed_text = text.replace('—', " - "); - debug!("Typing text: {}", processed_text); - - // Ensure an editable element is focused first. If we cannot ensure focus - // on an editable field, bail to avoid sending keystrokes to the wrong place. - let ensured = self.ensure_editable_focused().await?; - if !ensured { - debug!("No editable focus ensured; skipping typing to avoid stealing focus"); - return Ok(()); - } - - // Install a temporary focus guard that keeps focus anchored on the - // currently focused editable unless the user intentionally sends Tab/Enter. - let _ = self.execute_javascript( - r#"(() => { - try { - const isEditableInputType = (t) => !/^(checkbox|radio|button|submit|reset|file|image|color|hidden|range)$/i.test(t || ''); - const isEditable = (el) => !!el && ( - (el.tagName === 'INPUT' && isEditableInputType(el.type)) || - el.tagName === 'TEXTAREA' || - el.isContentEditable === true - ); - const deepActiveElement = (rootDoc) => { - let ae = (rootDoc || document).activeElement; - // Shadow roots - while (ae && ae.shadowRoot && ae.shadowRoot.activeElement) { - ae = ae.shadowRoot.activeElement; - } - // Same-origin iframes - while (ae && ae.tagName === 'IFRAME') { - try { - const doc = ae.contentWindow && ae.contentWindow.document; - if (!doc) break; - let inner = doc.activeElement; - if (!inner) break; - while (inner && inner.shadowRoot && inner.shadowRoot.activeElement) { - inner = inner.shadowRoot.activeElement; - } - ae = inner; - } catch (_) { break; } - } - return ae || null; - }; - - const w = window; - if (!w.__codeFG) { - w.__codeFG = { - active: false, - lastKey: null, - anchor: null, - onKeyDown: null, - onFocusIn: null, - onBlur: null, - install() { - const anchor = deepActiveElement(); - if (!isEditable(anchor)) return false; - this.anchor = anchor; - this.active = true; - this.lastKey = null; - this.onKeyDown = (e) => { this.lastKey = e && e.key; }; - this.onFocusIn = (e) => { - if (!this.active) return; - const a = this.anchor; - const curr = deepActiveElement(); - if (!a || a === curr) return; - // Allow intentional navigations - if (this.lastKey === 'Tab' || this.lastKey === 'Enter') return; - // If anchor was detached or hidden, stop guarding - try { - const cs = a.ownerDocument && a.ownerDocument.defaultView && a.ownerDocument.defaultView.getComputedStyle(a); - const hidden = !a.isConnected || (cs && (cs.display === 'none' || cs.visibility === 'hidden')); - if (hidden) { this.active = false; return; } - } catch(_){} - // Restore focus asynchronously to override app-level auto-tabbing - setTimeout(() => { try { a.focus && a.focus(); } catch(_){} }, 0); - }; - this.onBlur = () => { /* ignore */ }; - document.addEventListener('keydown', this.onKeyDown, true); - document.addEventListener('focusin', this.onFocusIn, true); - document.addEventListener('blur', this.onBlur, true); - return true; - }, - uninstall() { - try { - document.removeEventListener('keydown', this.onKeyDown, true); - document.removeEventListener('focusin', this.onFocusIn, true); - document.removeEventListener('blur', this.onBlur, true); - } catch(_){} - this.active = false; - this.anchor = null; - this.lastKey = null; - return true; - } - }; - } - return window.__codeFG.install(); - } catch(_) { return false; } - })()"# - ).await; - - let text_len = processed_text.len(); - - if text_len >= 100 { - // Large text: paste-style insertion with no per-char delay - // Try to insert at caret for input/textarea and contenteditable; fall back to raw key events without delay. - let js = format!( - r#"(() => {{ - try {{ - const T = {text_json}; - const isEditableInputType = (t) => !/^(checkbox|radio|button|submit|reset|file|image|color|hidden|range)$/i.test(t || ''); - const isEditable = (el) => !!el && ((el.tagName === 'INPUT' && isEditableInputType(el.type)) || el.tagName === 'TEXTAREA' || el.isContentEditable === true); - const deepActiveElement = (rootDoc) => {{ - let ae = (rootDoc || document).activeElement; - while (ae && ae.shadowRoot && ae.shadowRoot.activeElement) {{ ae = ae.shadowRoot.activeElement; }} - while (ae && ae.tagName === 'IFRAME') {{ - try {{ - const doc = ae.contentWindow && ae.contentWindow.document; if (!doc) break; - let inner = doc.activeElement; if (!inner) break; - while (inner && inner.shadowRoot && inner.shadowRoot.activeElement) {{ inner = inner.shadowRoot.activeElement; }} - ae = inner; - }} catch (_) {{ break; }} - }} - return ae || null; - }}; - const ae = deepActiveElement(); - if (!isEditable(ae)) return {{ success: false, reason: 'no-editable' }}; - - if (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA') {{ - const start = ae.selectionStart|0, end = ae.selectionEnd|0; - const val = String(ae.value || ''); - const before = val.slice(0, start), after = val.slice(end); - ae.value = before + T + after; - const pos = (before + T).length; - ae.selectionStart = ae.selectionEnd = pos; - try {{ ae.dispatchEvent(new InputEvent('input', {{ bubbles: true, inputType: 'insertText', data: T }})); }} catch (_e) {{}} - return {{ success: true, inserted: T.length, caret: pos }}; - }} else if (ae.isContentEditable === true) {{ - try {{ if (document.execCommand) {{ document.execCommand('insertText', false, T); return {{ success: true, inserted: T.length }}; }} }} catch (_e) {{}} - try {{ - const sel = window.getSelection(); - if (sel && sel.rangeCount) {{ - const r = sel.getRangeAt(0); - r.deleteContents(); - r.insertNode(document.createTextNode(T)); - r.collapse(false); - return {{ success: true, inserted: T.length }}; - }} - }} catch (_e) {{}} - return {{ success: false, reason: 'contenteditable-failed' }}; - }} - return {{ success: false, reason: 'unsupported' }}; - }} catch (e) {{ return {{ success: false, error: String(e) }}; }} -}})()"#, - text_json = serde_json::to_string(&processed_text).unwrap_or_else(|_| "".to_string()) - ); - - let _ = self.execute_javascript(&js).await; - } else { - // Short/medium text: per-character with reduced delay 30–60ms - for ch in processed_text.chars() { - if ch == '\n' { - self.press_key("Enter").await?; - } else if ch == '\t' { - self.press_key("Tab").await?; - } else { - let params = DispatchKeyEventParams::builder() - .r#type(DispatchKeyEventType::Char) - .text(ch.to_string()) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(params).await?; - } - - // Reduced natural typing delay - let delay = 30 + (rand::random::() % 31); // 30–60ms - tokio::time::sleep(Duration::from_millis(delay)).await; - } - } - - // Remove the focus guard shortly after typing to cover post-typing side effects - let _ = self.execute_javascript( - r#"(() => { try { if (window.__codeFG && window.__codeFG.uninstall) { setTimeout(() => { try { window.__codeFG.uninstall(); } catch(_){} }, 500); return true; } return false; } catch(_) { return false; } })()"# - ).await; - - Ok(()) - } - - /// Press a key (e.g., "Enter", "Tab", "Escape", "ArrowDown") - pub async fn press_key(&self, key: &str) -> Result<()> { - debug!("Pressing key: {}", key); - - // Map key names to their proper codes and virtual key codes - let (code, text, windows_virtual_key_code, native_virtual_key_code) = match key { - "Enter" => ("Enter", Some("\r"), Some(13), Some(13)), - "Tab" => ("Tab", Some("\t"), Some(9), Some(9)), - "Escape" => ("Escape", None, Some(27), Some(27)), - "Backspace" => ("Backspace", None, Some(8), Some(8)), - "Delete" => ("Delete", None, Some(46), Some(46)), - "ArrowUp" => ("ArrowUp", None, Some(38), Some(38)), - "ArrowDown" => ("ArrowDown", None, Some(40), Some(40)), - "ArrowLeft" => ("ArrowLeft", None, Some(37), Some(37)), - "ArrowRight" => ("ArrowRight", None, Some(39), Some(39)), - "Home" => ("Home", None, Some(36), Some(36)), - "End" => ("End", None, Some(35), Some(35)), - "PageUp" => ("PageUp", None, Some(33), Some(33)), - "PageDown" => ("PageDown", None, Some(34), Some(34)), - "Space" => ("Space", Some(" "), Some(32), Some(32)), - _ => (key, None, None, None), // Default fallback - }; - - // Key down - let mut down_builder = DispatchKeyEventParams::builder() - .r#type(DispatchKeyEventType::KeyDown) - .key(key.to_string()) - .code(code.to_string()); - - if let Some(vk) = windows_virtual_key_code { - down_builder = down_builder.windows_virtual_key_code(vk); - } - if let Some(nvk) = native_virtual_key_code { - down_builder = down_builder.native_virtual_key_code(nvk); - } - - let down_params = down_builder.build().map_err(BrowserError::CdpError)?; - self.cdp_page.execute(down_params).await?; - - // Send char event for keys that produce text - if let Some(text_str) = text { - let char_params = DispatchKeyEventParams::builder() - .r#type(DispatchKeyEventType::Char) - .key(key.to_string()) - .code(code.to_string()) - .text(text_str.to_string()) - .build() - .map_err(BrowserError::CdpError)?; - self.cdp_page.execute(char_params).await?; - } - - // Key up - let mut up_builder = DispatchKeyEventParams::builder() - .r#type(DispatchKeyEventType::KeyUp) - .key(key.to_string()) - .code(code.to_string()); - - if let Some(vk) = windows_virtual_key_code { - up_builder = up_builder.windows_virtual_key_code(vk); - } - if let Some(nvk) = native_virtual_key_code { - up_builder = up_builder.native_virtual_key_code(nvk); - } - - let up_params = up_builder.build().map_err(BrowserError::CdpError)?; - self.cdp_page.execute(up_params).await?; - - Ok(()) - } - - /// Execute JavaScript code with enhanced return value handling - pub async fn execute_javascript(&self, code: &str) -> Result { - debug!( - "Executing JavaScript: {}...", - &code.chars().take(100).collect::() - ); - - // Create the user code with sourceURL for better debugging - let user_code_with_source = format!("{code}\n//# sourceURL=browser_js_user_code.js"); - - // Use the improved JavaScript harness - let wrapped = format!( - r#"(async () => {{ - const __meta = {{ startTs: Date.now(), urlBefore: location.href }}; - const __logs = []; - const __errs = []; - - const __orig = {{ - log: console.log, warn: console.warn, error: console.error, debug: console.debug - }}; - - function __normalize(v, d = 0) {{ - const MAX_DEPTH = 3, MAX_STR = 4000; - if (d > MAX_DEPTH) return {{ __type: 'truncated' }}; - if (v === undefined) return {{ __type: 'undefined' }}; - if (v === null || typeof v === 'number' || typeof v === 'boolean') return v; - if (typeof v === 'string') return v.length > MAX_STR ? v.slice(0, MAX_STR) + '…' : v; - if (typeof v === 'bigint') return {{ __type: 'bigint', value: v.toString() + 'n' }}; - if (typeof v === 'symbol') return {{ __type: 'symbol', value: String(v) }}; - if (typeof v === 'function') return {{ __type: 'function', name: v.name || '' }}; - - if (typeof Element !== 'undefined' && v instanceof Element) {{ - return {{ - __type: 'element', - tag: v.tagName, id: v.id || null, class: v.className || null, - text: (v.textContent || '').trim().slice(0, 200) - }}; - }} - try {{ return JSON.parse(JSON.stringify(v)); }} catch {{}} - - if (Array.isArray(v)) return v.slice(0, 50).map(x => __normalize(x, d + 1)); - - const out = Object.create(null); - let n = 0; - for (const k in v) {{ - if (!Object.prototype.hasOwnProperty.call(v, k)) continue; - out[k] = __normalize(v[k], d + 1); - if (++n >= 50) {{ out.__truncated = true; break; }} - }} - return out; - }} - - const __push = (level, args) => {{ - __logs.push({{ level, args: args.map(a => __normalize(a)) }}); - }}; - console.log = (...a) => {{ __push('log', a); __orig.log(...a); }}; - console.warn = (...a) => {{ __push('warn', a); __orig.warn(...a); }}; - console.error= (...a) => {{ __push('error',a); __orig.error(...a); }}; - console.debug= (...a) => {{ __push('debug',a); __orig.debug(...a); }}; - - window.addEventListener('error', e => {{ - try {{ __errs.push(String(e.error || e.message || e)); }} catch {{ __errs.push('window.error'); }} - }}); - window.addEventListener('unhandledrejection', e => {{ - try {{ __errs.push('unhandledrejection: ' + String(e.reason)); }} catch {{ __errs.push('unhandledrejection'); }} - }}); - - try {{ - const AsyncFunction = Object.getPrototypeOf(async function(){{}}).constructor; - const __userCode = {0}; - const evaluator = new AsyncFunction('__code', '"use strict"; return eval(__code);'); - const raw = await evaluator(__userCode); - const value = (raw === undefined ? null : __normalize(raw)); - - return {{ - success: true, - value, - logs: __logs, - errors: __errs, - meta: {{ - urlBefore: __meta.urlBefore, - urlAfter: location.href, - durationMs: Date.now() - __meta.startTs - }} - }}; - }} catch (err) {{ - return {{ - success: false, - value: null, - error: String(err), - stack: (err && err.stack) ? String(err.stack) : '', - logs: __logs, - errors: __errs - }}; - }} finally {{ - console.log = __orig.log; - console.warn = __orig.warn; - console.error = __orig.error; - console.debug = __orig.debug; - }} -}})()"#, - serde_json::to_string(&user_code_with_source).expect("Failed to serialize user code") - ); - - tracing::debug!("Executing JavaScript code: {}", code); - tracing::debug!("Wrapped code: {}", wrapped); - - // Execute the wrapped code - chromiumoxide's evaluate method handles async functions - let result = self.cdp_page.evaluate(wrapped).await?; - let result_value = result.value().cloned().unwrap_or(serde_json::Value::Null); - - tracing::debug!("JavaScript execution result: {}", result_value); - - // Give a very brief moment for potential navigation or DOM updates triggered - // by the script. Keep this low to avoid inflating tool latency. - let is_external = self.config.connect_port.is_some() || self.config.connect_ws.is_some(); - let settle_ms = if is_external { 120 } else { 40 }; - tokio::time::sleep(tokio::time::Duration::from_millis(settle_ms)).await; - - Ok(result_value) - } - - /// Scroll the page by the given delta in pixels - pub async fn scroll_by(&self, dx: f64, dy: f64) -> Result<()> { - debug!("Scrolling by ({}, {})", dx, dy); - let js = format!( - "(function() {{ window.scrollBy({dx}, {dy}); return {{ x: window.scrollX, y: window.scrollY }}; }})()" - ); - let _ = self.execute_javascript(&js).await?; - Ok(()) - } - - /// Navigate browser history backward one entry - pub async fn go_back(&self) -> Result<()> { - debug!("History back"); - let _ = self.execute_javascript("history.back();").await?; - Ok(()) - } - - /// Navigate browser history forward one entry - pub async fn go_forward(&self) -> Result<()> { - debug!("History forward"); - let _ = self.execute_javascript("history.forward();").await?; - Ok(()) - } - - /// Get the current cursor position - pub async fn get_cursor_position(&self) -> Result<(f64, f64)> { - let cursor = self.cursor_state.lock().await; - Ok((cursor.x, cursor.y)) - } -} - -// Raw CDP command wrapper to allow executing arbitrary methods with JSON params -#[derive(Debug, Clone)] -struct RawCdpCommand { - method: String, - params: serde_json::Value, -} - -impl RawCdpCommand { - fn new(method: impl Into, params: serde_json::Value) -> Self { - Self { - method: method.into(), - params, - } - } -} - -impl serde::Serialize for RawCdpCommand { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - // Serialize only the params as the Command payload - self.params.serialize(serializer) - } -} - -impl chromiumoxide_types::Method for RawCdpCommand { - fn identifier(&self) -> chromiumoxide_types::MethodId { - self.method.clone().into() - } -} - -impl chromiumoxide_types::Command for RawCdpCommand { - type Response = serde_json::Value; -} - -impl Page { - /// Execute an arbitrary CDP method with the provided JSON params against this page's session - pub async fn execute_cdp_raw( - &self, - method: &str, - params: serde_json::Value, - ) -> Result { - let cmd = RawCdpCommand::new(method, params); - let resp = self.cdp_page.execute(cmd).await?; - Ok(resp.result) - } -} - -#[derive(Debug, Clone)] -pub enum ScreenshotMode { - Viewport, - FullPage { segments_max: Option }, - Region(ScreenshotRegion), -} - -#[derive(Debug, Clone)] -pub struct ScreenshotRegion { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, -} - -#[derive(Debug)] -pub struct Screenshot { - pub data: Vec, - pub width: u32, - pub height: u32, - pub format: ImageFormat, -} - -#[derive(Debug, serde::Serialize)] -pub struct GotoResult { - pub url: String, - pub title: Option, -} - -#[derive(Debug, serde::Deserialize)] -pub struct SetViewportParams { - pub width: u32, - pub height: u32, - pub device_scale_factor: Option, - pub mobile: Option, -} - -#[derive(Debug, serde::Serialize)] -pub struct ViewportResult { - pub width: u32, - pub height: u32, - pub dpr: f64, -} diff --git a/code-rs/browser/src/tools/browser_tools.rs b/code-rs/browser/src/tools/browser_tools.rs deleted file mode 100644 index 382427de8e6..00000000000 --- a/code-rs/browser/src/tools/browser_tools.rs +++ /dev/null @@ -1,180 +0,0 @@ -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, - asset_manager: Arc, -} - -impl BrowserTools { - pub fn new(manager: Arc, asset_manager: Arc) -> Self { - Self { - manager, - asset_manager, - } - } - - pub async fn handle_tool_call(&self, call: BrowserToolCall) -> Result { - 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 = 86_400_000; // 24 hours - 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, - }, - - #[serde(rename = "browser.screenshot")] - Screenshot { - mode: Option, - segments_max: Option, - region: Option, - inject_js: Option, - format: Option, - }, - - #[serde(rename = "browser.setViewport")] - SetViewport { - width: u32, - height: u32, - device_scale_factor: Option, - mobile: Option, - }, - - #[serde(rename = "browser.close")] - Close { what: Option }, -} - -#[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, -} - -#[derive(Debug, Serialize)] -pub struct CloseResult { - pub closed: String, -} diff --git a/code-rs/browser/src/tools/mod.rs b/code-rs/browser/src/tools/mod.rs deleted file mode 100644 index 4b377a3e231..00000000000 --- a/code-rs/browser/src/tools/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -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; diff --git a/code-rs/browser/src/tools/schema.rs b/code-rs/browser/src/tools/schema.rs deleted file mode 100644 index 144cefe299f..00000000000 --- a/code-rs/browser/src/tools/schema.rs +++ /dev/null @@ -1,127 +0,0 @@ -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 { - 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)" - } - } - }), - }, - ] -} diff --git a/code-rs/browser/tests/local_navigation.rs b/code-rs/browser/tests/local_navigation.rs deleted file mode 100644 index bb2657c3b8e..00000000000 --- a/code-rs/browser/tests/local_navigation.rs +++ /dev/null @@ -1,107 +0,0 @@ -use code_browser::BrowserConfig; -use code_browser::BrowserManager; -use std::env; -use std::io::Read; -use std::io::Write; -use std::net::TcpListener; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::thread; -use std::time::Duration; - -fn spawn_http_server() -> (String, Arc, thread::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); - listener - .set_nonblocking(true) - .expect("set non-blocking listener"); - let addr = listener.local_addr().expect("listener addr"); - let stop = Arc::new(AtomicBool::new(false)); - let stop_thread = Arc::clone(&stop); - - let handle = thread::spawn(move || { - while !stop_thread.load(Ordering::Relaxed) { - match listener.accept() { - Ok((mut stream, _)) => { - let mut buf = [0_u8; 2048]; - let _ = stream.read(&mut buf); - let body = "Code Browser Local Test

browser-ok

"; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), body - ); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(25)); - } - Err(_) => break, - } - } - }); - - (format!("http://127.0.0.1:{}", addr.port()), stop, handle) -} - -async fn assert_manager_can_open_local_http_server(headless: bool) { - let (url, stop, handle) = spawn_http_server(); - - let mut config = BrowserConfig::default(); - config.enabled = true; - config.headless = headless; - config.idle_timeout_ms = 300_000; - - let manager = BrowserManager::new(config); - manager.goto(&url).await.expect("navigate to local server"); - - let current_url = manager - .get_current_url() - .await - .expect("manager current url after goto"); - assert!(current_url == url || current_url == format!("{url}/")); - - let page = manager.get_or_create_page().await.expect("page after goto"); - let href = page.inject_js("location.href").await.expect("raw href"); - let href_text = href.as_str().unwrap_or_default(); - assert!(href_text == url || href_text == format!("{url}/")); - - let body = page - .execute_javascript("document.body && document.body.innerText") - .await - .expect("read page body"); - let body_text = body - .get("value") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - assert!(body_text.contains("browser-ok"), "unexpected page body: {body_text}"); - - stop.store(true, Ordering::Relaxed); - let _ = handle.join(); - let _ = manager.stop().await; -} - -fn can_run_headed_browser_test() -> bool { - if !cfg!(target_os = "linux") { - return true; - } - - env::var_os("DISPLAY").is_some() || env::var_os("WAYLAND_DISPLAY").is_some() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn internal_browser_can_open_local_http_server() { - assert_manager_can_open_local_http_server(true).await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn headed_internal_browser_can_open_local_http_server() { - if !can_run_headed_browser_test() { - eprintln!( - "skipping headed browser regression test: no DISPLAY or WAYLAND_DISPLAY available" - ); - return; - } - - assert_manager_can_open_local_http_server(false).await; -} diff --git a/code-rs/builtin-mcps/BUILD.bazel b/code-rs/builtin-mcps/BUILD.bazel new file mode 100644 index 00000000000..9c738d636b4 --- /dev/null +++ b/code-rs/builtin-mcps/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "builtin-mcps", + crate_name = "codex_builtin_mcps", +) diff --git a/code-rs/builtin-mcps/Cargo.toml b/code-rs/builtin-mcps/Cargo.toml new file mode 100644 index 00000000000..9eb2123329e --- /dev/null +++ b/code-rs/builtin-mcps/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-builtin-mcps" +version.workspace = true + +[lib] +name = "codex_builtin_mcps" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-memories-mcp = { workspace = true } +codex-utils-absolute-path = { workspace = true } +tokio = { workspace = true, features = ["io-util"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/code-rs/builtin-mcps/src/lib.rs b/code-rs/builtin-mcps/src/lib.rs new file mode 100644 index 00000000000..cf5cb748827 --- /dev/null +++ b/code-rs/builtin-mcps/src/lib.rs @@ -0,0 +1,101 @@ +//! Built-in MCP servers shipped with Codex. +//! +//! This crate owns the catalog of product-owned MCP servers and the small +//! amount of server-specific dispatch needed to run them. Runtime placement is +//! chosen by `codex-mcp`; built-ins should not be flattened into user-facing +//! MCP server config just to make them launchable. + +use std::path::Path; + +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; + +pub const MEMORIES_MCP_SERVER_NAME: &str = "memories"; + +/// Product-owned MCP servers that Codex can provide without user config. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BuiltinMcpServer { + Memories, +} + +#[derive(Debug, Clone, Copy)] +struct BuiltinMcpServerMetadata { + name: &'static str, + supports_parallel_tool_calls: bool, + pollutes_memory: bool, +} + +impl BuiltinMcpServer { + const fn metadata(self) -> BuiltinMcpServerMetadata { + match self { + Self::Memories => BuiltinMcpServerMetadata { + name: MEMORIES_MCP_SERVER_NAME, + supports_parallel_tool_calls: true, + pollutes_memory: false, + }, + } + } + + pub const fn name(self) -> &'static str { + self.metadata().name + } + + pub const fn supports_parallel_tool_calls(self) -> bool { + self.metadata().supports_parallel_tool_calls + } + + pub const fn pollutes_memory(self) -> bool { + self.metadata().pollutes_memory + } + + pub async fn serve(self, codex_home: &Path, transport: T) -> anyhow::Result<()> + where + T: AsyncRead + AsyncWrite + Send + 'static, + { + match self { + Self::Memories => { + let codex_home = codex_utils_absolute_path::AbsolutePathBuf::try_from(codex_home)?; + codex_memories_mcp::run_server(&codex_home, transport).await + } + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinMcpServerOptions { + pub memories_enabled: bool, +} + +pub fn enabled_builtin_mcp_servers(options: BuiltinMcpServerOptions) -> Vec { + let mut servers = Vec::new(); + if options.memories_enabled { + servers.push(BuiltinMcpServer::Memories); + } + servers +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn enabled_builtin_mcp_servers_adds_memories_when_enabled() { + assert_eq!( + enabled_builtin_mcp_servers(BuiltinMcpServerOptions { + memories_enabled: true, + }), + vec![BuiltinMcpServer::Memories] + ); + } + + #[test] + fn enabled_builtin_mcp_servers_omits_memories_when_disabled() { + assert_eq!( + enabled_builtin_mcp_servers(BuiltinMcpServerOptions { + memories_enabled: false, + }), + Vec::::new() + ); + } +} diff --git a/code-rs/bwrap/BUILD.bazel b/code-rs/bwrap/BUILD.bazel new file mode 100644 index 00000000000..3d0b89b9667 --- /dev/null +++ b/code-rs/bwrap/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "bwrap", + crate_name = "codex_bwrap", + # Bazel wires vendored bubblewrap + libcap via :bwrap-ffi below and sets + # bwrap_available explicitly, so we skip Cargo's build.rs in Bazel builds. + build_script_enabled = False, + deps_extra = select({ + "@platforms//os:linux": [":bwrap-ffi"], + "//conditions:default": [], + }), + rustc_flags_extra = select({ + "@platforms//os:linux": ["--cfg=bwrap_available"], + "//conditions:default": [], + }), +) + +cc_library( + name = "bwrap-ffi", + srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], + hdrs = [ + "config.h", + "//codex-rs/vendor:bubblewrap_headers", + ], + copts = [ + "-D_GNU_SOURCE", + "-Dmain=bwrap_main", + ], + includes = ["."], + deps = ["@libcap//:libcap"], + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], +) diff --git a/code-rs/bwrap/Cargo.toml b/code-rs/bwrap/Cargo.toml new file mode 100644 index 00000000000..ed7010c8fda --- /dev/null +++ b/code-rs/bwrap/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-bwrap" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "bwrap" +path = "src/main.rs" + +[lints] +workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/code-rs/bwrap/build.rs b/code-rs/bwrap/build.rs new file mode 100644 index 00000000000..d9d87932b2d --- /dev/null +++ b/code-rs/bwrap/build.rs @@ -0,0 +1,106 @@ +use std::env; +use std::path::Path; +use std::path::PathBuf; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(bwrap_available)"); + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); + println!("cargo:rerun-if-env-changed=CODEX_SKIP_BWRAP_BUILD"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + for source in ["bubblewrap.c", "bind-mount.c", "network.c", "utils.c"] { + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join(source).display() + ); + } + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "linux" || env::var_os("CODEX_SKIP_BWRAP_BUILD").is_some() { + return; + } + + if let Err(err) = try_build_bwrap() { + panic!("failed to compile bubblewrap for Linux target: {err}"); + } +} + +fn try_build_bwrap() -> Result<(), String> { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); + let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; + let libcap = pkg_config::Config::new() + .cargo_metadata(false) + .probe("libcap") + .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; + + let config_h = out_dir.join("config.h"); + std::fs::write( + &config_h, + r#"#pragma once +#define PACKAGE_STRING "bubblewrap built for Codex" +"#, + ) + .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; + + let mut build = cc::Build::new(); + build + .file(src_dir.join("bubblewrap.c")) + .file(src_dir.join("bind-mount.c")) + .file(src_dir.join("network.c")) + .file(src_dir.join("utils.c")) + .include(&out_dir) + .include(&src_dir) + .define("_GNU_SOURCE", None) + // Rename `main` so the Rust wrapper can expose the Cargo-built binary. + .define("main", Some("bwrap_main")); + for include_path in libcap.include_paths { + // Use -idirafter so target sysroot headers win (musl cross builds), + // while still allowing libcap headers from the host toolchain. + build.flag(format!("-idirafter{}", include_path.display())); + } + + build.compile("standalone_bwrap"); + for link_path in libcap.link_paths { + println!("cargo:rustc-link-search=native={}", link_path.display()); + } + for lib in libcap.libs { + println!("cargo:rustc-link-lib={lib}"); + } + println!("cargo:rustc-cfg=bwrap_available"); + Ok(()) +} + +/// Resolve the bubblewrap source directory used for build-time compilation. +/// +/// Priority: +/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. +/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. +fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { + if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { + let src_dir = PathBuf::from(path); + if src_dir.exists() { + return Ok(src_dir); + } + return Err(format!( + "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", + src_dir.display() + )); + } + + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + if vendor_dir.exists() { + return Ok(vendor_dir); + } + + Err(format!( + "expected vendored bubblewrap at {}, but it was not found.\n\ +Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", + vendor_dir.display() + )) +} diff --git a/code-rs/bwrap/config.h b/code-rs/bwrap/config.h new file mode 100644 index 00000000000..f73932a0f89 --- /dev/null +++ b/code-rs/bwrap/config.h @@ -0,0 +1 @@ +#define PACKAGE_STRING "bubblewrap built for Codex" diff --git a/code-rs/bwrap/src/main.rs b/code-rs/bwrap/src/main.rs new file mode 100644 index 00000000000..09c624aa9e5 --- /dev/null +++ b/code-rs/bwrap/src/main.rs @@ -0,0 +1,45 @@ +#[cfg(all(target_os = "linux", bwrap_available))] +fn main() { + use std::ffi::CStr; + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + + unsafe extern "C" { + fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; + } + + let cstrings = std::env::args_os() + .map(|arg| { + CString::new(arg.as_os_str().as_bytes()) + .unwrap_or_else(|err| panic!("failed to convert argv to CString: {err}")) + }) + .collect::>(); + let mut argv_ptrs = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect::>(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: We provide a null-terminated argv vector whose pointers remain + // valid for the duration of the call. + let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) }; + std::process::exit(exit_code); +} + +#[cfg(all(target_os = "linux", not(bwrap_available)))] +fn main() { + panic!( + r#"bubblewrap is not available in this build. +Notes: +- ensure the target OS is Linux +- libcap headers must be available via pkg-config +- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# + ); +} + +#[cfg(not(target_os = "linux"))] +fn main() { + panic!("bwrap is only supported on Linux"); +} diff --git a/code-rs/chatgpt/BUILD.bazel b/code-rs/chatgpt/BUILD.bazel new file mode 100644 index 00000000000..78900d8a450 --- /dev/null +++ b/code-rs/chatgpt/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "chatgpt", + crate_name = "codex_chatgpt", +) diff --git a/code-rs/chatgpt/Cargo.toml b/code-rs/chatgpt/Cargo.toml index c71266ceb39..6b0e0109648 100644 --- a/code-rs/chatgpt/Cargo.toml +++ b/code-rs/chatgpt/Cargo.toml @@ -1,7 +1,8 @@ [package] -edition = "2024" -name = "code-chatgpt" -version = { workspace = true } +name = "codex-chatgpt" +version.workspace = true +edition.workspace = true +license.workspace = true [lints] workspace = true @@ -9,14 +10,23 @@ 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 } +codex-app-server-protocol = { workspace = true } +codex-connectors = { workspace = true } +codex-core = { workspace = true } +codex-core-plugins = { workspace = true } +codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-plugin = { workspace = true } +codex-utils-cli = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } -code-git-apply = { path = "../git-apply" } [dev-dependencies] +codex-utils-cargo-bin = { workspace = true } +pretty_assertions = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } + +[lib] +doctest = false diff --git a/code-rs/chatgpt/src/apply_command.rs b/code-rs/chatgpt/src/apply_command.rs index f31bc674886..70fe4481db7 100644 --- a/code-rs/chatgpt/src/apply_command.rs +++ b/code-rs/chatgpt/src/apply_command.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; use clap::Parser; -use code_common::CliConfigOverrides; -use code_core::config::Config; -use code_core::config::ConfigOverrides; +use codex_core::config::Config; +use codex_git_utils::ApplyGitRequest; +use codex_git_utils::apply_git_patch; +use codex_utils_cli::CliConfigOverrides; + use crate::get_task::GetTaskResponse; use crate::get_task::OutputItem; use crate::get_task::PrOutputItem; @@ -26,8 +28,8 @@ pub async fn run_apply_command( .config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?, - ConfigOverrides::default(), - )?; + ) + .await?; let task_response = get_task(&config, apply_cli.task_id).await?; apply_diff_from_task(task_response, cwd).await @@ -53,13 +55,13 @@ pub async fn apply_diff_from_task( async fn apply_diff(diff: &str, cwd: Option) -> anyhow::Result<()> { let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir())); - let req = code_git_apply::ApplyGitRequest { + let req = ApplyGitRequest { cwd, diff: diff.to_string(), revert: false, preflight: false, }; - let res = code_git_apply::apply_git_patch(&req)?; + let res = apply_git_patch(&req)?; if res.exit_code != 0 { anyhow::bail!( "Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}", diff --git a/code-rs/chatgpt/src/chatgpt_client.rs b/code-rs/chatgpt/src/chatgpt_client.rs index 5280d44a564..05d8186686b 100644 --- a/code-rs/chatgpt/src/chatgpt_client.rs +++ b/code-rs/chatgpt/src/chatgpt_client.rs @@ -1,47 +1,55 @@ -use code_core::config::Config; -use code_core::CodexAuth; -use code_app_server_protocol::AuthMode; +use codex_core::config::Config; +use codex_login::AuthManager; +use codex_login::default_client::create_client; use anyhow::Context; use serde::de::DeserializeOwned; +use std::time::Duration; /// Make a GET request to the ChatGPT backend API. pub(crate) async fn chatgpt_get_request( config: &Config, path: String, +) -> anyhow::Result { + chatgpt_get_request_with_timeout(config, path, /*timeout*/ None).await +} + +pub(crate) async fn chatgpt_get_request_with_timeout( + config: &Config, + path: String, + timeout: Option, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; - let auth = CodexAuth::from_code_home( - &config.code_home, - AuthMode::ChatGPT, - &config.responses_originator_header, - )? - .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?; + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + let auth = auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?; anyhow::ensure!( auth.uses_codex_backend(), "ChatGPT backend requests require Codex backend auth" ); + anyhow::ensure!( + auth.get_account_id().is_some(), + "ChatGPT account ID not available, please re-run `codex login`" + ); // Make direct HTTP request to ChatGPT backend API with the token - let client = code_core::http_client::build_http_client(); + let client = create_client(); let url = format!( "{}/{}", chatgpt_base_url.trim_end_matches('/'), path.trim_start_matches('/') ); - let token = auth.get_token_data().await?; - let account_id = token.account_id.ok_or_else(|| { - anyhow::anyhow!("ChatGPT account ID not available, please re-run `code login`") - })?; - let mut request = client .get(&url) - .bearer_auth(&token.access_token) - .header("chatgpt-account-id", account_id) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) .header("Content-Type", "application/json"); - if token.id_token.is_fedramp_account() { - request = request.header("X-OpenAI-Fedramp", "true"); + + if let Some(timeout) = timeout { + request = request.timeout(timeout); } let response = request.send().await.context("Failed to send request")?; diff --git a/code-rs/chatgpt/src/connectors.rs b/code-rs/chatgpt/src/connectors.rs new file mode 100644 index 00000000000..cbeb4fd1b79 --- /dev/null +++ b/code-rs/chatgpt/src/connectors.rs @@ -0,0 +1,282 @@ +use std::collections::HashSet; +use std::time::Duration; + +use crate::chatgpt_client::chatgpt_get_request_with_timeout; + +use codex_app_server_protocol::AppInfo; +use codex_connectors::AllConnectorsCacheKey; +use codex_connectors::DirectoryListResponse; +use codex_connectors::filter::filter_disallowed_connectors; +use codex_connectors::merge::merge_connectors; +use codex_connectors::merge::merge_plugin_connectors; +use codex_core::config::Config; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; +pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools; +pub use codex_core::connectors::with_app_enabled_state; +use codex_core_plugins::PluginsManager; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::default_client::originator; +use codex_plugin::AppConnectorId; + +const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); + +async fn apps_enabled(config: &Config) -> bool { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + let auth = auth_manager.auth().await; + config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) +} + +async fn connector_auth(config: &Config) -> anyhow::Result { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + let auth = auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?; + anyhow::ensure!( + auth.uses_codex_backend(), + "ChatGPT connectors require Codex backend auth" + ); + Ok(auth) +} + +pub async fn list_connectors(config: &Config) -> anyhow::Result> { + if !apps_enabled(config).await { + return Ok(Vec::new()); + } + let (connectors_result, accessible_result) = tokio::join!( + list_all_connectors(config), + list_accessible_connectors_from_mcp_tools(config), + ); + let connectors = connectors_result?; + let accessible = accessible_result?; + Ok(with_app_enabled_state( + merge_connectors_with_accessible( + connectors, accessible, /*all_connectors_loaded*/ true, + ), + config, + )) +} + +pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { + list_all_connectors_with_options(config, /*force_refetch*/ false).await +} + +pub async fn list_cached_all_connectors(config: &Config) -> Option> { + if !apps_enabled(config).await { + return Some(Vec::new()); + } + + let auth = connector_auth(config).await.ok()?; + let cache_key = all_connectors_cache_key(config, &auth); + let connectors = codex_connectors::cached_all_connectors(&cache_key)?; + let connectors = merge_plugin_connectors( + connectors, + plugin_apps_for_config(config) + .await + .into_iter() + .map(|connector_id| connector_id.0), + ); + Some(filter_disallowed_connectors( + connectors, + originator().value.as_str(), + )) +} + +pub async fn list_all_connectors_with_options( + config: &Config, + force_refetch: bool, +) -> anyhow::Result> { + if !apps_enabled(config).await { + return Ok(Vec::new()); + } + let auth = connector_auth(config).await?; + let cache_key = all_connectors_cache_key(config, &auth); + let connectors = codex_connectors::list_all_connectors_with_options( + cache_key, + auth.is_workspace_account(), + force_refetch, + |path| async move { + chatgpt_get_request_with_timeout::( + config, + path, + Some(DIRECTORY_CONNECTORS_TIMEOUT), + ) + .await + }, + ) + .await?; + let connectors = merge_plugin_connectors( + connectors, + plugin_apps_for_config(config) + .await + .into_iter() + .map(|connector_id| connector_id.0), + ); + Ok(filter_disallowed_connectors( + connectors, + originator().value.as_str(), + )) +} + +fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsCacheKey { + AllConnectorsCacheKey::new( + config.chatgpt_base_url.clone(), + auth.get_account_id(), + auth.get_chatgpt_user_id(), + auth.is_workspace_account(), + ) +} + +async fn plugin_apps_for_config(config: &Config) -> Vec { + let plugins_input = config.plugins_config_input(); + PluginsManager::new(config.codex_home.to_path_buf()) + .plugins_for_config(&plugins_input) + .await + .effective_apps() +} + +pub fn connectors_for_plugin_apps( + connectors: Vec, + plugin_apps: &[AppConnectorId], +) -> Vec { + let plugin_app_ids = plugin_apps + .iter() + .map(|connector_id| connector_id.0.as_str()) + .collect::>(); + + let connectors = merge_plugin_connectors( + connectors, + plugin_apps + .iter() + .map(|connector_id| connector_id.0.clone()), + ); + filter_disallowed_connectors(connectors, originator().value.as_str()) + .into_iter() + .filter(|connector| plugin_app_ids.contains(connector.id.as_str())) + .collect() +} + +pub fn merge_connectors_with_accessible( + connectors: Vec, + accessible_connectors: Vec, + all_connectors_loaded: bool, +) -> Vec { + let accessible_connectors = if all_connectors_loaded { + let connector_ids: HashSet<&str> = connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect(); + accessible_connectors + .into_iter() + .filter(|connector| connector_ids.contains(connector.id.as_str())) + .collect() + } else { + accessible_connectors + }; + let merged = merge_connectors(connectors, accessible_connectors); + filter_disallowed_connectors(merged, originator().value.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_connectors::metadata::connector_install_url; + use codex_plugin::AppConnectorId; + use pretty_assertions::assert_eq; + + fn app(id: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } + } + + fn merged_app(id: &str, is_accessible: bool) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url(id, id)), + is_accessible, + is_enabled: true, + plugin_display_names: Vec::new(), + } + } + + #[test] + fn excludes_accessible_connectors_not_in_all_when_all_loaded() { + let merged = merge_connectors_with_accessible( + vec![app("alpha")], + vec![app("alpha"), app("beta")], + /*all_connectors_loaded*/ true, + ); + assert_eq!(merged, vec![merged_app("alpha", /*is_accessible*/ true)]); + } + + #[test] + fn keeps_accessible_connectors_not_in_all_while_all_loading() { + let merged = merge_connectors_with_accessible( + vec![app("alpha")], + vec![app("alpha"), app("beta")], + /*all_connectors_loaded*/ false, + ); + assert_eq!( + merged, + vec![ + merged_app("alpha", /*is_accessible*/ true), + merged_app("beta", /*is_accessible*/ true) + ] + ); + } + + #[test] + fn connectors_for_plugin_apps_returns_only_requested_plugin_apps() { + let connectors = connectors_for_plugin_apps( + vec![app("alpha"), app("beta")], + &[ + AppConnectorId("alpha".to_string()), + AppConnectorId("gmail".to_string()), + ], + ); + assert_eq!( + connectors, + vec![app("alpha"), merged_app("gmail", /*is_accessible*/ false)] + ); + } + + #[test] + fn connectors_for_plugin_apps_filters_disallowed_plugin_apps() { + let connectors = connectors_for_plugin_apps( + Vec::new(), + &[AppConnectorId( + "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + )], + ); + assert_eq!(connectors, Vec::::new()); + } +} diff --git a/code-rs/chatgpt/src/get_task.rs b/code-rs/chatgpt/src/get_task.rs index 42f929c20de..9301ffc38d3 100644 --- a/code-rs/chatgpt/src/get_task.rs +++ b/code-rs/chatgpt/src/get_task.rs @@ -1,4 +1,4 @@ -use code_core::config::Config; +use codex_core::config::Config; use serde::Deserialize; use crate::chatgpt_client::chatgpt_get_request; diff --git a/code-rs/chatgpt/src/lib.rs b/code-rs/chatgpt/src/lib.rs index 1bf019a33ba..a245265d944 100644 --- a/code-rs/chatgpt/src/lib.rs +++ b/code-rs/chatgpt/src/lib.rs @@ -1,3 +1,5 @@ pub mod apply_command; mod chatgpt_client; +pub mod connectors; pub mod get_task; +pub mod workspace_settings; diff --git a/code-rs/chatgpt/src/workspace_settings.rs b/code-rs/chatgpt/src/workspace_settings.rs new file mode 100644 index 00000000000..a1772155182 --- /dev/null +++ b/code-rs/chatgpt/src/workspace_settings.rs @@ -0,0 +1,152 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use codex_core::config::Config; +use codex_login::CodexAuth; +use serde::Deserialize; + +use crate::chatgpt_client::chatgpt_get_request_with_timeout; + +const WORKSPACE_SETTINGS_TIMEOUT: Duration = Duration::from_secs(10); +const WORKSPACE_SETTINGS_CACHE_TTL: Duration = Duration::from_secs(15 * 60); +const CODEX_PLUGINS_BETA_SETTING: &str = "enable_plugins"; + +#[derive(Debug, Deserialize)] +struct WorkspaceSettingsResponse { + #[serde(default)] + beta_settings: HashMap, +} + +#[derive(Debug, Default)] +pub struct WorkspaceSettingsCache { + entry: RwLock>, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WorkspaceSettingsCacheKey { + chatgpt_base_url: String, + account_id: String, +} + +#[derive(Clone, Debug)] +struct CachedWorkspaceSettings { + key: WorkspaceSettingsCacheKey, + expires_at: Instant, + codex_plugins_enabled: bool, +} + +impl WorkspaceSettingsCache { + fn get_codex_plugins_enabled(&self, key: &WorkspaceSettingsCacheKey) -> Option { + { + let entry = match self.entry.read() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if let Some(cached) = entry.as_ref() + && now < cached.expires_at + && cached.key == *key + { + return Some(cached.codex_plugins_enabled); + } + } + + let mut entry = match self.entry.write() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if entry + .as_ref() + .is_some_and(|cached| now >= cached.expires_at || cached.key != *key) + { + *entry = None; + } + None + } + + fn set_codex_plugins_enabled(&self, key: WorkspaceSettingsCacheKey, enabled: bool) { + let mut entry = match self.entry.write() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + *entry = Some(CachedWorkspaceSettings { + key, + expires_at: Instant::now() + WORKSPACE_SETTINGS_CACHE_TTL, + codex_plugins_enabled: enabled, + }); + } +} + +pub async fn codex_plugins_enabled_for_workspace( + config: &Config, + auth: Option<&CodexAuth>, + cache: Option<&WorkspaceSettingsCache>, +) -> anyhow::Result { + let Some(auth) = auth else { + return Ok(true); + }; + if !auth.is_chatgpt_auth() { + return Ok(true); + } + + let token_data = auth + .get_token_data() + .context("ChatGPT token data is not available")?; + if !token_data.id_token.is_workspace_account() { + return Ok(true); + } + + let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else { + return Ok(true); + }; + + let cache_key = WorkspaceSettingsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id: account_id.to_string(), + }; + if let Some(cache) = cache + && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) + { + return Ok(enabled); + } + + let encoded_account_id = encode_path_segment(account_id); + let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout( + config, + format!("/accounts/{encoded_account_id}/settings"), + Some(WORKSPACE_SETTINGS_TIMEOUT), + ) + .await?; + + let codex_plugins_enabled = settings + .beta_settings + .get(CODEX_PLUGINS_BETA_SETTING) + .copied() + .unwrap_or(true); + + if let Some(cache) = cache { + cache.set_codex_plugins_enabled(cache_key, codex_plugins_enabled); + } + + Ok(codex_plugins_enabled) +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +#[cfg(test)] +#[path = "workspace_settings_tests.rs"] +mod tests; diff --git a/code-rs/chatgpt/src/workspace_settings_tests.rs b/code-rs/chatgpt/src/workspace_settings_tests.rs new file mode 100644 index 00000000000..d84cc4c3a22 --- /dev/null +++ b/code-rs/chatgpt/src/workspace_settings_tests.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn encode_path_segment_leaves_unreserved_ascii_unchanged() { + assert_eq!( + encode_path_segment("account-123_ABC.~"), + "account-123_ABC.~" + ); +} + +#[test] +fn encode_path_segment_escapes_path_separators_and_spaces() { + assert_eq!( + encode_path_segment("account/123 with space"), + "account%2F123%20with%20space" + ); +} diff --git a/code-rs/mcp-types/tests/all.rs b/code-rs/chatgpt/tests/all.rs similarity index 100% rename from code-rs/mcp-types/tests/all.rs rename to code-rs/chatgpt/tests/all.rs diff --git a/code-rs/chatgpt/tests/suite/apply_command_e2e.rs b/code-rs/chatgpt/tests/suite/apply_command_e2e.rs new file mode 100644 index 00000000000..c2d570528ce --- /dev/null +++ b/code-rs/chatgpt/tests/suite/apply_command_e2e.rs @@ -0,0 +1,188 @@ +use codex_chatgpt::apply_command::apply_diff_from_task; +use codex_chatgpt::get_task::GetTaskResponse; +use codex_utils_cargo_bin::find_resource; +use tempfile::TempDir; +use tokio::process::Command; + +/// Creates a temporary git repository with initial commit +async fn create_temp_git_repo() -> anyhow::Result { + 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", "test@example.com"]) + .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 { + let fixture_path = find_resource!("tests/task_turn_fixture.json")?; + let fixture_content = tokio::fs::read_to_string(fixture_path).await?; + 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}", + ); +} diff --git a/code-rs/chatgpt/tests/suite/mod.rs b/code-rs/chatgpt/tests/suite/mod.rs new file mode 100644 index 00000000000..40b4a59a0d1 --- /dev/null +++ b/code-rs/chatgpt/tests/suite/mod.rs @@ -0,0 +1,2 @@ +// Aggregates all former standalone integration tests as modules. +mod apply_command_e2e; diff --git a/code-rs/chatgpt/tests/task_turn_fixture.json b/code-rs/chatgpt/tests/task_turn_fixture.json new file mode 100644 index 00000000000..3750f550395 --- /dev/null +++ b/code-rs/chatgpt/tests/task_turn_fixture.json @@ -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+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." + } + ] + } + ] + } +} diff --git a/code-rs/cli/BUILD.bazel b/code-rs/cli/BUILD.bazel new file mode 100644 index 00000000000..a8a97cef004 --- /dev/null +++ b/code-rs/cli/BUILD.bazel @@ -0,0 +1,11 @@ +load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate", "multiplatform_binaries") + +codex_rust_crate( + name = "cli", + crate_name = "codex_cli", + rustc_flags_extra = MACOS_WEBRTC_RUSTC_LINK_FLAGS, +) + +multiplatform_binaries( + name = "codex", +) diff --git a/code-rs/cli/Cargo.toml b/code-rs/cli/Cargo.toml index 9625f6c6faf..16708d42697 100644 --- a/code-rs/cli/Cargo.toml +++ b/code-rs/cli/Cargo.toml @@ -1,55 +1,62 @@ [package] -edition = "2024" -name = "code-cli" -version = { workspace = true } +name = "codex-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +build = "build.rs" [[bin]] name = "code" path = "src/main.rs" [lib] -name = "code_cli" +name = "codex_cli" path = "src/lib.rs" +doctest = false [lints] workspace = true [dependencies] anyhow = { workspace = true } -chrono = { 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 } +codex-app-server = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-app-server-test-client = { workspace = true } +codex-arg0 = { workspace = true } +codex-chatgpt = { workspace = true } +codex-cloud-tasks = { path = "../cloud-tasks" } +codex-utils-cli = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-core-plugins = { workspace = true } +codex-exec = { workspace = true } +codex-exec-server = { workspace = true } +codex-execpolicy = { workspace = true } +codex-features = { workspace = true } +codex-login = { workspace = true } +codex-memories-write = { workspace = true } +codex-mcp = { workspace = true } +codex-mcp-server = { workspace = true } +codex-models-manager = { workspace = true } +codex-protocol = { workspace = true } +codex-responses-api-proxy = { workspace = true } +codex-rmcp-client = { workspace = true } +codex-rollout-trace = { workspace = true } +codex-sandboxing = { workspace = true } +codex-state = { workspace = true } +codex-stdio-to-uds = { workspace = true } +codex-terminal-detection = { workspace = true } +codex-tui = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-path = { workspace = true } +libc = { workspace = true } owo-colors = { workspace = true } +regex-lite = { workspace = true } serde_json = { workspace = true } -serde = { workspace = true, features = ["derive"] } -sha2 = { workspace = true } supports-color = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", @@ -57,9 +64,18 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } -tokio-tungstenite = { version = "0.23", default-features = true, features = ["rustls-tls-webpki-roots"] } -tracing = { workspace = true, features = ["log"] } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +toml = { workspace = true } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } [dev-dependencies] -filetime = { workspace = true } +assert_cmd = { workspace = true } +assert_matches = { workspace = true } +codex-utils-cargo-bin = { workspace = true } +predicates = { workspace = true } +pretty_assertions = { workspace = true } +sqlx = { workspace = true } diff --git a/code-rs/cli/build.rs b/code-rs/cli/build.rs new file mode 100644 index 00000000000..abccc48bb6a --- /dev/null +++ b/code-rs/cli/build.rs @@ -0,0 +1,5 @@ +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + println!("cargo:rustc-link-arg=-ObjC"); + } +} diff --git a/code-rs/cli/src/app_cmd.rs b/code-rs/cli/src/app_cmd.rs new file mode 100644 index 00000000000..c28182b4c5e --- /dev/null +++ b/code-rs/cli/src/app_cmd.rs @@ -0,0 +1,25 @@ +use clap::Parser; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +pub struct AppCommand { + /// Workspace path to open in Codex Desktop. + #[arg(value_name = "PATH", default_value = ".")] + pub path: PathBuf, + + /// Override the app installer download URL (advanced). + #[arg(long = "download-url")] + pub download_url_override: Option, +} + +pub async fn run_app(cmd: AppCommand) -> anyhow::Result<()> { + let workspace = std::fs::canonicalize(&cmd.path).unwrap_or(cmd.path); + #[cfg(target_os = "macos")] + { + crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url_override).await + } + #[cfg(target_os = "windows")] + { + crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url_override).await + } +} diff --git a/code-rs/cli/src/bin/order_replay.rs b/code-rs/cli/src/bin/order_replay.rs deleted file mode 100644 index 2514bae736d..00000000000 --- a/code-rs/cli/src/bin/order_replay.rs +++ /dev/null @@ -1,96 +0,0 @@ -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> { - // 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> { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let re = Regex::new(r"insert window: seq=(?P\d+) \((?P[OU]):(?:req=(?P\d+) out=(?P\d+) seq=(?P\d+)|(?P\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 "); - 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(()) -} diff --git a/code-rs/cli/src/bridge.rs b/code-rs/cli/src/bridge.rs deleted file mode 100644 index 668049f025d..00000000000 --- a/code-rs/cli/src/bridge.rs +++ /dev/null @@ -1,560 +0,0 @@ -use anyhow::{bail, Context, Result}; -use chrono::{DateTime, Utc}; -use futures::stream::{SplitSink, SplitStream}; -use futures::{SinkExt, StreamExt}; -use serde::Deserialize; -use serde_json::Value; -use std::collections::HashSet; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use tokio::time::timeout; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::error::ProtocolError; -use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; -use tokio_tungstenite::tungstenite::protocol::CloseFrame; -use tokio_tungstenite::tungstenite::Error as WsError; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -type WsStream = - tokio_tungstenite::WebSocketStream>; - -const META_FILE: &str = "code-bridge.json"; -const HEARTBEAT_STALE_MS: i64 = 20_000; -const DEFAULT_CAPABILITIES: &[&str] = &[ - "error", - "console", - "pageview", - "navigation", - "network", - "screenshot", - "control", -]; - -#[derive(Debug, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct BridgeMeta { - pub url: String, - pub secret: String, - #[allow(dead_code)] - pub port: Option, - pub workspace_path: Option, - #[allow(dead_code)] - pub started_at: Option, - pub heartbeat_at: Option, - pub pid: Option, -} - -#[derive(Debug, Clone)] -pub struct BridgeTarget { - pub meta_path: PathBuf, - pub meta: BridgeMeta, - pub stale: bool, - pub heartbeat_age_ms: Option, -} - -#[derive(Debug)] -pub struct ControlOutcome { - pub delivered: usize, - pub result: Option, - pub screenshot_bytes: Option, - pub screenshot_mime: Option, -} - -pub fn discover_bridge_targets(cwd: &Path) -> Result> { - let mut seen = HashSet::new(); - let mut targets = Vec::new(); - let mut current = Some(cwd); - - while let Some(dir) = current { - let candidate = dir.join(".code").join(META_FILE); - if candidate.exists() && seen.insert(candidate.clone()) { - let raw = fs::read_to_string(&candidate).context("read bridge metadata")?; - let meta: BridgeMeta = serde_json::from_str(&raw).context("parse bridge metadata")?; - let (stale, heartbeat_age_ms) = compute_staleness(&meta, &candidate); - - targets.push(BridgeTarget { - meta_path: candidate, - meta, - stale, - heartbeat_age_ms, - }); - } - - current = dir.parent(); - } - - Ok(targets) -} - -pub async fn list_control_capable(target: &BridgeTarget) -> Result { - let (mut tx, mut rx) = connect_and_subscribe(target, &default_levels(), &[]).await?; - let id = format!("code-cli-list-{}", Uuid::new_v4()); - let payload = serde_json::json!({ - "type": "control_request", - "id": id, - "action": "ping", - "expectResult": false, - }); - tx.send(Message::Text(payload.to_string())) - .await - .context("send ping control")?; - - let delivered = wait_for_forwarded(&mut rx, &id, Duration::from_secs(2)).await?; - // Close politely so the host drops the consumer quickly - let _ = tx - .send(Message::Close(Some(CloseFrame { - code: CloseCode::Normal, - reason: "ok".into(), - }))) - .await; - Ok(delivered.unwrap_or(0)) -} - -pub async fn tail_events(target: &BridgeTarget, level: &str, raw: bool) -> Result<()> { - let levels = vec![normalise_level(level)?]; - let caps = DEFAULT_CAPABILITIES - .iter() - .map(|s| s.to_string()) - .collect::>(); - let (mut tx, mut rx) = connect_and_subscribe(target, &levels, &caps).await?; - - println!( - "Connected to bridge {}{}", - target.meta.url, - if target.stale { - " (metadata stale, awaiting live data)" - } else { - "" - } - ); - println!("Subscribed to levels: {}", levels.join(", ")); - println!("Press Ctrl+C to stop.\n"); - - loop { - tokio::select! { - msg = rx.next() => { - match msg { - Some(Ok(Message::Text(text))) => { - if raw { - println!("{}", text); - continue; - } - if let Ok(val) = serde_json::from_str::(&text) { - if let Some(line) = format_bridge_message(&val) { - println!("{}", line); - } - } - } - Some(Ok(Message::Binary(_))) => {} - Some(Ok(Message::Close(_))) => break, - Some(Ok(_)) => {} - Some(Err(err)) => { - eprintln!("Bridge stream error: {err:?}"); - break; - } - None => break, - } - } - _ = tokio::signal::ctrl_c() => { - let _ = tx - .send(Message::Close(Some(CloseFrame { code: CloseCode::Normal, reason: "interrupt".into() }))) - .await; - break; - } - } - } - - Ok(()) -} - -pub async fn request_screenshot( - target: &BridgeTarget, - timeout_secs: u64, -) -> Result { - // Subscribe at info so screenshot events (level=info) are delivered - let levels = vec!["info".to_string()]; - let caps = vec!["screenshot".to_string(), "control".to_string()]; - let (mut tx, mut rx) = connect_and_subscribe(target, &levels, &caps).await?; - - let id = format!("code-cli-screenshot-{}", Uuid::new_v4()); - let payload = serde_json::json!({ - "type": "control_request", - "id": id, - "action": "screenshot", - "args": {}, - "timeoutMs": timeout_secs * 1000, - }); - tx.send(Message::Text(payload.to_string())) - .await - .context("send screenshot control")?; - - let delivered = wait_for_forwarded(&mut rx, &id, Duration::from_secs(2)).await?; - if delivered == Some(0) { - bail!("No control-capable bridges are connected (advertising screenshot)"); - } - - let (result, screenshot_meta) = - wait_for_control_and_screenshot(&mut rx, &id, Duration::from_secs(timeout_secs)).await?; - let screenshot_bytes = screenshot_meta.as_ref().map(|m| m.0); - let screenshot_mime = screenshot_meta.map(|m| m.1); - - let _ = tx - .send(Message::Close(Some(CloseFrame { - code: CloseCode::Normal, - reason: "done".into(), - }))) - .await; - - Ok(ControlOutcome { - delivered: delivered.unwrap_or(0), - result, - screenshot_bytes, - screenshot_mime, - }) -} - -pub async fn run_javascript( - target: &BridgeTarget, - code: &str, - timeout_secs: u64, -) -> Result { - let levels = vec!["errors".to_string()]; - let caps = vec!["control".to_string()]; - let (mut tx, mut rx) = connect_and_subscribe(target, &levels, &caps).await?; - - let id = format!("code-cli-js-{}", Uuid::new_v4()); - let payload = serde_json::json!({ - "type": "control_request", - "id": id, - // The bridge library handles `eval` by default; keep action aligned with the tool naming. - "action": "eval", - "code": code, - "timeoutMs": timeout_secs * 1000, - "expectResult": true, - }); - tx.send(Message::Text(payload.to_string())) - .await - .context("send javascript control")?; - - let delivered = wait_for_forwarded(&mut rx, &id, Duration::from_secs(2)).await?; - if delivered == Some(0) { - bail!("No control-capable bridges are connected"); - } - - let (result, _shot_meta) = - wait_for_control_and_screenshot(&mut rx, &id, Duration::from_secs(timeout_secs)).await?; - - let _ = tx - .send(Message::Close(Some(CloseFrame { - code: CloseCode::Normal, - reason: "done".into(), - }))) - .await; - - Ok(ControlOutcome { - delivered: delivered.unwrap_or(0), - result, - screenshot_bytes: None, - screenshot_mime: None, - }) -} - -fn default_levels() -> Vec { - vec!["errors".to_string(), "warn".to_string(), "info".to_string()] -} - -fn compute_staleness(meta: &BridgeMeta, path: &Path) -> (bool, Option) { - if let Some(hb) = &meta.heartbeat_at { - if let Ok(ts) = DateTime::parse_from_rfc3339(hb) { - let age = Utc::now().signed_duration_since(ts.with_timezone(&Utc)); - return (age.num_milliseconds() > HEARTBEAT_STALE_MS, Some(age.num_milliseconds())); - } - } - - if let Ok(stat) = std::fs::metadata(path) { - if let Ok(modified) = stat.modified() { - let modified: DateTime = modified.into(); - let age = Utc::now().signed_duration_since(modified); - return (age.num_milliseconds() > HEARTBEAT_STALE_MS, Some(age.num_milliseconds())); - } - } - - (false, None) -} - -async fn connect_and_subscribe( - target: &BridgeTarget, - levels: &[String], - capabilities: &[String], -) -> Result<(SplitSink, SplitStream)> { - // connect_async returns owned WebSocketStream; splitting after auth allows reuse of the stream for waits - let (ws, _) = connect_async(&target.meta.url) - .await - .with_context(|| format!("connect to {}", target.meta.url))?; - let (mut tx, mut rx) = ws.split(); - - let client_id = format!( - "code-cli-{}", - target - .meta - .workspace_path - .as_deref() - .unwrap_or("workspace") - .rsplit_once('/') - .map(|(_, tail)| tail) - .unwrap_or("workspace"), - ); - - let auth = serde_json::json!({ - "type": "auth", - "role": "consumer", - "secret": target.meta.secret, - "clientId": client_id, - }); - tx.send(Message::Text(auth.to_string())) - .await - .context("send auth")?; - if wait_for_type(&mut rx, &["auth_success"], Duration::from_secs(5)) - .await? - .is_none() - { - bail!("bridge authentication timed out"); - } - - let subscribe = serde_json::json!({ - "type": "subscribe", - "levels": levels, - "capabilities": capabilities, - "llm_filter": "off", - }); - tx.send(Message::Text(subscribe.to_string())) - .await - .context("send subscribe")?; - if wait_for_type(&mut rx, &["subscribe_ack"], Duration::from_secs(5)) - .await? - .is_none() - { - bail!("bridge subscribe timed out"); - } - - Ok((tx, rx)) -} - -async fn wait_for_type( - rx: &mut SplitStream, - expected: &[&str], - dur: Duration, -) -> Result> { - let expected_lower: Vec = expected.iter().map(|s| s.to_string()).collect(); - let found = timeout(dur, async { - while let Some(msg) = rx.next().await { - match msg { - Ok(Message::Text(text)) => { - if let Ok(val) = serde_json::from_str::(&text) { - if val - .get("type") - .and_then(|t| t.as_str()) - .map(|t| expected_lower.contains(&t.to_string())) - .unwrap_or(false) - { - return Some(val); - } - } - } - Ok(Message::Binary(_)) => {} - Ok(Message::Close(frame)) => { - let reason = frame.map(|f| f.reason.to_string()).unwrap_or_default(); - return Some(serde_json::json!({"type":"close","reason":reason})); - } - Ok(_) => {} - Err(WsError::Protocol(ProtocolError::ResetWithoutClosingHandshake)) => return None, - Err(err) => { - eprintln!("bridge socket error: {err:?}"); - return None; - } - } - } - None - }) - .await - .unwrap_or(None); - - Ok(found) -} - -async fn wait_for_forwarded( - rx: &mut SplitStream, - id: &str, - dur: Duration, -) -> Result> { - let found = timeout(dur, async { - while let Some(msg) = rx.next().await { - match msg { - Ok(Message::Text(text)) => { - if let Ok(val) = serde_json::from_str::(&text) { - if val.get("type").and_then(|t| t.as_str()) == Some("control_forwarded") - && val.get("id").and_then(|v| v.as_str()) == Some(id) - { - return val.get("delivered").and_then(|v| v.as_u64()).map(|v| v as usize); - } - } - } - Ok(Message::Binary(_)) => {} - Ok(_) => {} - Err(_) => return None, - } - } - None - }) - .await - .unwrap_or(None); - - Ok(found) -} - -async fn wait_for_control_and_screenshot( - rx: &mut SplitStream, - id: &str, - dur: Duration, -) -> Result<(Option, Option<(usize, String)>)> { - let mut result: Option = None; - let mut screenshot: Option<(usize, String)> = None; - - let _ = timeout(dur, async { - while let Some(msg) = rx.next().await { - match msg { - Ok(Message::Text(text)) => { - if let Ok(val) = serde_json::from_str::(&text) { - match val.get("type").and_then(|t| t.as_str()) { - Some("control_result") if val.get("id").and_then(|v| v.as_str()) == Some(id) => { - result = Some(val.clone()); - if screenshot.is_some() { - break; - } - } - Some("screenshot") if val.get("id").and_then(|v| v.as_str()) == Some(id) => { - let data_len = val - .get("data") - .and_then(|v| v.as_str()) - .map(|s| s.len()); - let mime = val - .get("mime") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if let Some(len) = data_len { - screenshot = Some((len, mime)); - if result.is_some() { - break; - } - } - } - _ => {} - } - } - } - Ok(Message::Binary(_)) => {} - Ok(_) => {} - Err(_) => break, - } - } - }) - .await; - - Ok((result, screenshot)) -} - -fn format_bridge_message(val: &Value) -> Option { - let t = val.get("type").and_then(|v| v.as_str())?; - match t { - "subscribe_ack" | "control_forwarded" => None, - "rate_limit_notice" => { - let reason = val - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("rate_limit"); - let msg = val - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or(reason); - Some(format!("⚠ drop/rate-limit: {msg}")) - } - "control_result" => { - let ok = val.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); - let id = val.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let summary = if ok { - val.get("result") - .map(|r| format_result(r)) - .unwrap_or_else(|| "ok".to_string()) - } else { - val.get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .unwrap_or("error") - .to_string() - }; - Some(format!("[control:{id}] {summary}")) - } - _ => { - let level = val.get("level").and_then(|v| v.as_str()).unwrap_or("info"); - let ts = val - .get("timestamp") - .and_then(|v| v.as_i64()) - .map(|ms| format_ts(ms)) - .unwrap_or_else(|| "--:--:--".to_string()); - - let body = match t { - "screenshot" => { - let mime = val.get("mime").and_then(|v| v.as_str()).unwrap_or("?"); - let bytes = val - .get("data") - .and_then(|v| v.as_str()) - .map(|s| s.len()) - .unwrap_or(0); - format!("screenshot {mime} ({} KB)", bytes / 1024) - } - "navigation" => { - let to = val - .get("navigation") - .and_then(|n| n.get("to")) - .and_then(|v| v.as_str()) - .or_else(|| val.get("route").and_then(|v| v.as_str())) - .unwrap_or(""); - format!("navigation -> {to}") - } - _ => val - .get("message") - .and_then(|m| m.as_str()) - .map(|m| m.to_string()) - .unwrap_or_else(|| t.to_string()), - }; - - Some(format!("{ts} [{level}/{t}] {body}")) - } - } -} - -fn format_result(val: &Value) -> String { - if let Some(s) = val.as_str() { - return s.to_string(); - } - if val.is_object() || val.is_array() { - return serde_json::to_string(val).unwrap_or_else(|_| "ok".to_string()); - } - val.to_string() -} - -fn format_ts(ms: i64) -> String { - let dt = DateTime::from_timestamp_millis(ms).unwrap_or_else(|| Utc::now()); - dt.format("%H:%M:%S").to_string() -} - -fn normalise_level(raw: &str) -> Result { - let lvl = raw.trim().to_lowercase(); - match lvl.as_str() { - "errors" | "warn" | "info" | "trace" => Ok(lvl), - _ => bail!("invalid level (use errors|warn|info|trace)"), - } -} diff --git a/code-rs/cli/src/debug_sandbox.rs b/code-rs/cli/src/debug_sandbox.rs index 7a3c8cf3e92..e9bc6a046eb 100644 --- a/code-rs/cli/src/debug_sandbox.rs +++ b/code-rs/cli/src/debug_sandbox.rs @@ -1,81 +1,197 @@ +#[cfg(target_os = "macos")] +mod pid_tracker; +#[cfg(target_os = "macos")] +mod seatbelt; + use std::path::PathBuf; +use std::process::Stdio; -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 codex_config::LoaderOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; +use codex_core::config::NetworkProxyAuditMetadata; +use codex_core::exec_env::create_env; +#[cfg(target_os = "macos")] +use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; +use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::landlock::allow_network_for_proxy; +use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::create_seatbelt_command_args; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use tokio::process::Child; +use tokio::process::Command as TokioCommand; +use toml::Value as TomlValue; use crate::LandlockCommand; use crate::SeatbeltCommand; +use crate::WindowsCommand; use crate::exit_status::handle_exit_status; +#[cfg(target_os = "macos")] +use seatbelt::DenialLogger; + +#[cfg(target_os = "macos")] pub async fn run_command_under_seatbelt( command: SeatbeltCommand, - code_linux_sandbox_exe: Option, + codex_linux_sandbox_exe: Option, ) -> anyhow::Result<()> { let SeatbeltCommand { - full_auto, + permissions_profile, + cwd, + include_managed_config, + allow_unix_sockets, + log_denials, config_overrides, command, } = command; + let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation( + &permissions_profile, + include_managed_config, + ); run_command_under_sandbox( - full_auto, + DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + }, command, config_overrides, - code_linux_sandbox_exe, + codex_linux_sandbox_exe, SandboxType::Seatbelt, + log_denials, + &allow_unix_sockets, ) .await } +#[cfg(not(target_os = "macos"))] +pub async fn run_command_under_seatbelt( + _command: SeatbeltCommand, + _codex_linux_sandbox_exe: Option, +) -> anyhow::Result<()> { + anyhow::bail!("Seatbelt sandbox is only available on macOS"); +} + pub async fn run_command_under_landlock( command: LandlockCommand, - code_linux_sandbox_exe: Option, + codex_linux_sandbox_exe: Option, ) -> anyhow::Result<()> { let LandlockCommand { - full_auto, + permissions_profile, + cwd, + include_managed_config, config_overrides, command, } = command; + let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation( + &permissions_profile, + include_managed_config, + ); run_command_under_sandbox( - full_auto, + DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + }, command, config_overrides, - code_linux_sandbox_exe, + codex_linux_sandbox_exe, SandboxType::Landlock, + /*log_denials*/ false, + &[], + ) + .await +} + +pub async fn run_command_under_windows( + command: WindowsCommand, + codex_linux_sandbox_exe: Option, +) -> anyhow::Result<()> { + let WindowsCommand { + permissions_profile, + cwd, + include_managed_config, + config_overrides, + command, + } = command; + let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation( + &permissions_profile, + include_managed_config, + ); + run_command_under_sandbox( + DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + }, + command, + config_overrides, + codex_linux_sandbox_exe, + SandboxType::Windows, + /*log_denials*/ false, + &[], ) .await } enum SandboxType { + #[cfg(target_os = "macos")] Seatbelt, Landlock, + Windows, +} + +#[derive(Debug)] +struct DebugSandboxConfigOptions { + permissions_profile: Option, + cwd: Option, + managed_requirements_mode: ManagedRequirementsMode, +} + +#[derive(Debug, Clone, Copy)] +enum ManagedRequirementsMode { + Include, + Ignore, +} + +impl ManagedRequirementsMode { + fn for_profile_invocation( + permissions_profile: &Option, + include_managed_config: bool, + ) -> Self { + if permissions_profile.is_some() && !include_managed_config { + Self::Ignore + } else { + Self::Include + } + } } async fn run_command_under_sandbox( - full_auto: bool, + config_options: DebugSandboxConfigOptions, command: Vec, config_overrides: CliConfigOverrides, - code_linux_sandbox_exe: Option, + codex_linux_sandbox_exe: Option, sandbox_type: SandboxType, + log_denials: bool, + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] + allow_unix_sockets: &[AbsolutePathBuf], ) -> anyhow::Result<()> { - let sandbox_mode = create_sandbox_mode(full_auto); - let config = Config::load_with_cli_overrides( + let config = load_debug_sandbox_config( config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?, - ConfigOverrides { - sandbox_mode: Some(sandbox_mode), - code_linux_sandbox_exe, - compact_prompt_override: None, - compact_prompt_override_file: None, - ..Default::default() - }, - )?; + codex_linux_sandbox_exe, + config_options, + ) + .await?; // 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. @@ -85,47 +201,843 @@ async fn run_command_under_sandbox( // separately. let sandbox_policy_cwd = cwd.clone(); - let stdio_policy = StdioPolicy::Inherit; - let env = create_env(&config.shell_environment_policy); + let env = create_env( + &config.permissions.shell_environment_policy, + /*thread_id*/ None, + ); + + // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. + if let SandboxType::Windows = sandbox_type { + #[cfg(target_os = "windows")] + { + run_command_under_windows_session(&config, command, cwd, sandbox_policy_cwd, env).await; + } + #[cfg(not(target_os = "windows"))] + { + anyhow::bail!("Windows sandbox is only available on Windows"); + } + } + + #[cfg(target_os = "macos")] + let mut denial_logger = log_denials.then(DenialLogger::new).flatten(); + #[cfg(not(target_os = "macos"))] + let _ = log_denials; + + let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + + // This proxy should only live for the lifetime of the child process. + let network_proxy = match config.permissions.network.as_ref() { + Some(spec) => Some( + spec.start_proxy( + config.permissions.permission_profile.get(), + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + managed_network_requirements_enabled, + NetworkProxyAuditMetadata::default(), + ) + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, + ), + None => None, + }; + let network = network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy); let mut child = match sandbox_type { + #[cfg(target_os = "macos")] SandboxType::Seatbelt => { - spawn_command_under_seatbelt( + let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); + let network_sandbox_policy = config.permissions.network_sandbox_policy(); + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command, - cwd, - &config.sandbox_policy, - sandbox_policy_cwd.as_path(), - stdio_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_policy_cwd: sandbox_policy_cwd.as_path(), + enforce_managed_network: false, + network: network.as_ref(), + extra_allow_unix_sockets: allow_unix_sockets, + }); + spawn_debug_sandbox_child( + PathBuf::from("/usr/bin/sandbox-exec"), + args, + /*arg0*/ None, + cwd.to_path_buf(), + network_sandbox_policy, env, + |env_map| { + env_map.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); + if let Some(network) = network.as_ref() { + network.apply_to_env(env_map); + } + }, ) .await? } SandboxType::Landlock => { #[expect(clippy::expect_used)] - let code_linux_sandbox_exe = config - .code_linux_sandbox_exe + let codex_linux_sandbox_exe = config + .codex_linux_sandbox_exe .expect("codex-linux-sandbox executable not found"); - spawn_command_under_linux_sandbox( - code_linux_sandbox_exe, + let use_legacy_landlock = config.features.use_legacy_landlock(); + let network_sandbox_policy = config.permissions.network_sandbox_policy(); + let args = create_linux_sandbox_command_args_for_permission_profile( command, - cwd, - &config.sandbox_policy, + cwd.as_path(), + &config.permissions.permission_profile(), sandbox_policy_cwd.as_path(), - stdio_policy, + use_legacy_landlock, + allow_network_for_proxy(managed_network_requirements_enabled), + ); + spawn_debug_sandbox_child( + codex_linux_sandbox_exe, + args, + Some("codex-linux-sandbox"), + cwd.to_path_buf(), + network_sandbox_policy, env, + |env_map| { + if let Some(network) = network.as_ref() { + network.apply_to_env(env_map); + } + }, ) .await? } + SandboxType::Windows => { + unreachable!("Windows sandbox should have been handled above"); + } }; + + #[cfg(target_os = "macos")] + if let Some(denial_logger) = &mut denial_logger { + denial_logger.on_child_spawn(&child); + } + let status = child.wait().await?; + #[cfg(target_os = "macos")] + if let Some(denial_logger) = denial_logger { + let denials = denial_logger.finish().await; + eprintln!("\n=== Sandbox denials ==="); + if denials.is_empty() { + eprintln!("None found."); + } else { + for seatbelt::SandboxDenial { name, capability } in denials { + eprintln!("({name}) {capability}"); + } + } + } + handle_exit_status(status); } -pub fn create_sandbox_mode(full_auto: bool) -> SandboxMode { - if full_auto { - SandboxMode::WorkspaceWrite +#[cfg(target_os = "windows")] +async fn run_command_under_windows_session( + config: &Config, + command: Vec, + cwd: AbsolutePathBuf, + sandbox_policy_cwd: AbsolutePathBuf, + env: std::collections::HashMap, +) -> ! { + use codex_core::windows_sandbox::WindowsSandboxLevelExt; + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_windows_sandbox::spawn_windows_sandbox_session_elevated; + use codex_windows_sandbox::spawn_windows_sandbox_session_legacy; + + let sandbox_policy = config + .permissions + .legacy_sandbox_policy(sandbox_policy_cwd.as_path()); + let policy_str = match serde_json::to_string(&sandbox_policy) { + Ok(policy_str) => policy_str, + Err(err) => { + eprintln!("windows sandbox failed to serialize policy: {err}"); + std::process::exit(1); + } + }; + + let use_elevated = matches!( + WindowsSandboxLevel::from_config(config), + WindowsSandboxLevel::Elevated + ); + + let spawned = if use_elevated { + spawn_windows_sandbox_session_elevated( + policy_str.as_str(), + sandbox_policy_cwd.as_path(), + config.codex_home.as_path(), + command, + cwd.as_path(), + env, + None, + /*tty*/ false, + /*stdin_open*/ true, + config.permissions.windows_sandbox_private_desktop, + ) + .await } else { - SandboxMode::ReadOnly + spawn_windows_sandbox_session_legacy( + policy_str.as_str(), + sandbox_policy_cwd.as_path(), + config.codex_home.as_path(), + command, + cwd.as_path(), + env, + None, + /*tty*/ false, + /*stdin_open*/ true, + config.permissions.windows_sandbox_private_desktop, + ) + .await + }; + + let spawned = match spawned { + Ok(spawned) => spawned, + Err(err) => { + eprintln!("windows sandbox failed: {err}"); + std::process::exit(1); + } + }; + + let session = std::sync::Arc::new(spawned.session); + let tokio_runtime = tokio::runtime::Handle::current(); + // Give large or slow tail output a better chance to finish draining + // without letting rare EOF issues hang the wrapper indefinitely. + let output_drain_timeout = std::time::Duration::from_secs(5); + // A helper thread watches our stdin. When the input source closes it, + // the thread tells the main async code so we can also close stdin for + // the sandboxed child process. + let (stdin_eof_tx, stdin_eof_rx) = tokio::sync::oneshot::channel(); + + // Start background threads that copy stdin/stdout/stderr. We + // intentionally do not keep their JoinHandles; dropping the handle does + // not stop the thread, it just means we are not going to wait on it + // later. + drop(windows_stdio_bridge::spawn_input_forwarder( + std::io::stdin(), + session.writer_sender(), + stdin_eof_tx, + )); + let (stdout_forwarder, stdout_forwarder_done_rx) = windows_stdio_bridge::spawn_output_forwarder( + tokio_runtime.clone(), + spawned.stdout_rx, + std::io::stdout(), + ); + drop(stdout_forwarder); + let (stderr_forwarder, stderr_forwarder_done_rx) = windows_stdio_bridge::spawn_output_forwarder( + tokio_runtime.clone(), + spawned.stderr_rx, + std::io::stderr(), + ); + drop(stderr_forwarder); + + let stdin_close_task = tokio::spawn({ + let session = std::sync::Arc::clone(&session); + async move { + let _ = stdin_eof_rx.await; + session.close_stdin(); + } + }); + + let mut exit_rx = spawned.exit_rx; + let exit_code = tokio::select! { + res = &mut exit_rx => res.unwrap_or(-1), + res = tokio::signal::ctrl_c() => { + if let Ok(()) = res { + session.request_terminate(); + } + exit_rx.await.unwrap_or(-1) + } + }; + + stdin_close_task.abort(); + let _ = tokio::time::timeout(output_drain_timeout, async { + let _ = stdout_forwarder_done_rx.await; + let _ = stderr_forwarder_done_rx.await; + }) + .await; + std::process::exit(exit_code); +} + +async fn spawn_debug_sandbox_child( + program: PathBuf, + args: Vec, + arg0: Option<&str>, + cwd: PathBuf, + network_sandbox_policy: NetworkSandboxPolicy, + mut env: std::collections::HashMap, + apply_env: impl FnOnce(&mut std::collections::HashMap), +) -> std::io::Result { + let mut cmd = TokioCommand::new(&program); + #[cfg(unix)] + cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from)); + #[cfg(not(unix))] + let _ = arg0; + cmd.args(args); + cmd.current_dir(cwd); + apply_env(&mut env); + cmd.env_clear(); + cmd.envs(env); + + if !network_sandbox_policy.is_enabled() { + cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); + } + + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() +} + +#[cfg(target_os = "windows")] +mod windows_stdio_bridge { + use std::io::Read; + use std::io::Write; + + use tokio::sync::mpsc; + use tokio::sync::oneshot; + + const STDIN_FORWARD_CHUNK_SIZE: usize = 8 * 1024; + + pub(super) fn spawn_input_forwarder( + mut input: R, + writer_tx: mpsc::Sender>, + stdin_eof_tx: oneshot::Sender<()>, + ) -> std::thread::JoinHandle<()> + where + R: Read + Send + 'static, + { + std::thread::spawn(move || { + let mut buffer = [0_u8; STDIN_FORWARD_CHUNK_SIZE]; + loop { + match input.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + if writer_tx.blocking_send(buffer[..n].to_vec()).is_err() { + break; + } + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(err) => { + eprintln!("windows sandbox stdin forwarder failed: {err}"); + break; + } + } + } + let _ = stdin_eof_tx.send(()); + }) + } + + pub(super) fn spawn_output_forwarder( + tokio_runtime: tokio::runtime::Handle, + output_rx: mpsc::Receiver>, + mut writer: W, + ) -> (std::thread::JoinHandle<()>, oneshot::Receiver<()>) + where + W: Write + Send + 'static, + { + let (done_tx, done_rx) = oneshot::channel(); + // The sandbox session emits output on Tokio channels, but writing to the + // caller's stdio is simplest from a dedicated blocking thread. + let handle = std::thread::spawn(move || { + let mut output_rx = output_rx; + while let Some(chunk) = tokio_runtime.block_on(output_rx.recv()) { + if let Err(err) = writer.write_all(&chunk) { + eprintln!("windows sandbox output forwarder failed to write: {err}"); + break; + } + if let Err(err) = writer.flush() { + eprintln!("windows sandbox output forwarder failed to flush: {err}"); + break; + } + } + let _ = done_tx.send(()); + }); + (handle, done_rx) + } + + #[cfg(test)] + mod tests { + use std::sync::Mutex; + + use pretty_assertions::assert_eq; + + use super::*; + + #[tokio::test] + async fn input_forwarder_sends_chunks_and_reports_eof() -> anyhow::Result<()> { + let (writer_tx, mut writer_rx) = tokio::sync::mpsc::channel::>(4); + let (stdin_closed_tx, stdin_closed_rx) = tokio::sync::oneshot::channel(); + let input = std::io::Cursor::new(b"first\nsecond\n".to_vec()); + + let forwarder = spawn_input_forwarder(input, writer_tx, stdin_closed_tx); + let mut received = Vec::new(); + while let Some(chunk) = writer_rx.recv().await { + received.extend_from_slice(&chunk); + } + stdin_closed_rx.await?; + forwarder.join().expect("stdin forwarder should finish"); + + assert_eq!(received, b"first\nsecond\n".to_vec()); + Ok(()) + } + + #[tokio::test] + async fn output_forwarder_writes_all_chunks() -> anyhow::Result<()> { + #[derive(Clone, Default)] + struct SharedWriter(std::sync::Arc>>); + + impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut guard = self + .0 + .lock() + .map_err(|_| std::io::Error::other("writer poisoned"))?; + guard.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let runtime = tokio::runtime::Handle::current(); + let (output_tx, output_rx) = tokio::sync::mpsc::channel::>(4); + let writer = SharedWriter::default(); + let sink = std::sync::Arc::clone(&writer.0); + + let (forwarder, done_rx) = spawn_output_forwarder(runtime, output_rx, writer); + output_tx.send(b"alpha".to_vec()).await?; + output_tx.send(b"beta".to_vec()).await?; + drop(output_tx); + forwarder.join().expect("output forwarder should finish"); + done_rx.await?; + + let output = sink + .lock() + .map_err(|_| anyhow::anyhow!("writer poisoned"))? + .clone(); + assert_eq!(output, b"alphabeta".to_vec()); + Ok(()) + } + } +} + +async fn load_debug_sandbox_config( + cli_overrides: Vec<(String, TomlValue)>, + codex_linux_sandbox_exe: Option, + options: DebugSandboxConfigOptions, +) -> anyhow::Result { + load_debug_sandbox_config_with_codex_home( + cli_overrides, + codex_linux_sandbox_exe, + options, + /*codex_home*/ None, + ) + .await +} + +async fn load_debug_sandbox_config_with_codex_home( + mut cli_overrides: Vec<(String, TomlValue)>, + codex_linux_sandbox_exe: Option, + options: DebugSandboxConfigOptions, + codex_home: Option, +) -> anyhow::Result { + let DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + } = options; + + if let Some(permissions_profile) = permissions_profile { + cli_overrides.push(( + "default_permissions".to_string(), + TomlValue::String(permissions_profile), + )); + } + + // For legacy configs, `codex sandbox` historically defaulted to read-only + // instead of inheriting ambient `sandbox_mode` settings from user/system + // config. Keep that behavior unless this invocation explicitly passes a + // legacy `sandbox_mode` CLI override, which is now the documented writable + // replacement for the removed `--full-auto` flag. + let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides); + let config = build_debug_sandbox_config( + cli_overrides.clone(), + ConfigOverrides { + cwd: cwd.clone(), + codex_linux_sandbox_exe: codex_linux_sandbox_exe.clone(), + ..Default::default() + }, + codex_home.clone(), + managed_requirements_mode, + ) + .await?; + + if config_uses_permission_profiles(&config) || uses_legacy_sandbox_mode_override { + return Ok(config); + } + + build_debug_sandbox_config( + cli_overrides, + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + cwd, + codex_linux_sandbox_exe, + ..Default::default() + }, + codex_home, + managed_requirements_mode, + ) + .await + .map_err(Into::into) +} + +async fn build_debug_sandbox_config( + cli_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + codex_home: Option, + managed_requirements_mode: ManagedRequirementsMode, +) -> std::io::Result { + let mut builder = ConfigBuilder::default() + .cli_overrides(cli_overrides) + .harness_overrides(harness_overrides); + if let ManagedRequirementsMode::Ignore = managed_requirements_mode { + builder = builder.loader_overrides(LoaderOverrides { + ignore_managed_requirements: true, + ..Default::default() + }); + } + if let Some(codex_home) = codex_home { + builder = builder + .codex_home(codex_home.clone()) + .fallback_cwd(Some(codex_home)); + } + builder.build().await +} + +fn config_uses_permission_profiles(config: &Config) -> bool { + config + .config_layer_stack + .effective_config() + .get("default_permissions") + .is_some() +} + +fn cli_overrides_use_legacy_sandbox_mode(cli_overrides: &[(String, TomlValue)]) -> bool { + cli_overrides.iter().any(|(key, _)| key == "sandbox_mode") +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn escape_toml_path(path: &std::path::Path) -> String { + path.display().to_string().replace('\\', "\\\\") + } + + fn write_permissions_profile_config( + codex_home: &TempDir, + docs: &std::path::Path, + private: &std::path::Path, + ) -> std::io::Result<()> { + std::fs::create_dir_all(private)?; + let config = format!( + "default_permissions = \"limited-read-test\"\n\ + [permissions.limited-read-test.filesystem]\n\ + \":minimal\" = \"read\"\n\ + \"{}\" = \"read\"\n\ + \"{}\" = \"none\"\n\ + \n\ + [permissions.limited-read-test.network]\n\ + enabled = true\n", + escape_toml_path(docs), + escape_toml_path(private), + ); + std::fs::write(codex_home.path().join("config.toml"), config)?; + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_honors_active_permission_profiles() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let sandbox_paths = TempDir::new()?; + let docs = sandbox_paths.path().join("docs"); + let private = docs.join("private"); + write_permissions_profile_config(&codex_home, &docs, &private)?; + let codex_home_path = codex_home.path().to_path_buf(); + + let profile_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides::default(), + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + ) + .await?; + let legacy_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }, + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + ) + .await?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + }, + Some(codex_home_path), + ) + .await?; + + assert!(config_uses_permission_profiles(&config)); + assert!( + profile_config.permissions.file_system_sandbox_policy() + != legacy_config.permissions.file_system_sandbox_policy(), + "test fixture should distinguish profile syntax from legacy sandbox_mode" + ); + assert_eq!( + config.permissions.file_system_sandbox_policy(), + profile_config.permissions.file_system_sandbox_policy(), + ); + assert_ne!( + config.permissions.file_system_sandbox_policy(), + legacy_config.permissions.file_system_sandbox_policy(), + ); + + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let codex_home_path = codex_home.path().to_path_buf(); + let cli_overrides = vec![( + "sandbox_mode".to_string(), + TomlValue::String("workspace-write".to_string()), + )]; + + let workspace_write_config = build_debug_sandbox_config( + cli_overrides.clone(), + ConfigOverrides::default(), + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + ) + .await?; + let read_only_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }, + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + ) + .await?; + + let config = load_debug_sandbox_config_with_codex_home( + cli_overrides, + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + }, + Some(codex_home_path), + ) + .await?; + + if cfg!(target_os = "windows") { + assert_eq!( + workspace_write_config + .permissions + .file_system_sandbox_policy(), + read_only_config.permissions.file_system_sandbox_policy(), + "workspace-write downgrades to read-only when the Windows sandbox is disabled" + ); + } else { + assert_ne!( + workspace_write_config + .permissions + .file_system_sandbox_policy(), + read_only_config.permissions.file_system_sandbox_policy(), + "test fixture should distinguish explicit workspace-write from read-only" + ); + } + assert_eq!( + config.permissions.file_system_sandbox_policy(), + workspace_write_config + .permissions + .file_system_sandbox_policy(), + ); + + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_defaults_legacy_configs_to_read_only() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let codex_home_path = codex_home.path().to_path_buf(); + + let read_only_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }, + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + ) + .await?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + }, + Some(codex_home_path), + ) + .await?; + + assert!(!config_uses_permission_profiles(&config)); + assert_eq!( + config.permissions.file_system_sandbox_policy(), + read_only_config.permissions.file_system_sandbox_policy(), + ); + + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_honors_explicit_builtin_permission_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: Some(":workspace".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!( + config.permissions.file_system_sandbox_policy(), + codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy() + ); + + Ok(()) + } + + #[tokio::test] + async fn explicit_permission_profile_overrides_active_profile_sandbox_mode() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "profile = \"legacy\"\n\ + \n\ + [profiles.legacy]\n\ + sandbox_mode = \"danger-full-access\"\n", + )?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: Some(":workspace".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!( + config.permissions.file_system_sandbox_policy(), + codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy() + ); + + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let sandbox_paths = TempDir::new()?; + let docs = sandbox_paths.path().join("docs"); + let private = docs.join("private"); + write_permissions_profile_config(&codex_home, &docs, &private)?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: Some("limited-read-test".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, + Some(codex_home.path().to_path_buf()), + ) + .await?; + + let expected = build_debug_sandbox_config( + vec![( + "default_permissions".to_string(), + TomlValue::String("limited-read-test".to_string()), + )], + ConfigOverrides::default(), + Some(codex_home.path().to_path_buf()), + ManagedRequirementsMode::Include, + ) + .await?; + + assert_eq!( + config.permissions.file_system_sandbox_policy(), + expected.permissions.file_system_sandbox_policy() + ); + + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_uses_explicit_profile_cwd() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: Some(":workspace".to_string()), + cwd: Some(cwd.path().to_path_buf()), + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!(config.cwd.as_path(), cwd.path()); + + Ok(()) } } diff --git a/code-rs/cli/src/debug_sandbox/pid_tracker.rs b/code-rs/cli/src/debug_sandbox/pid_tracker.rs new file mode 100644 index 00000000000..07bd2fd046c --- /dev/null +++ b/code-rs/cli/src/debug_sandbox/pid_tracker.rs @@ -0,0 +1,372 @@ +use std::collections::HashSet; +use tokio::task::JoinHandle; +use tracing::warn; + +/// Tracks the (recursive) descendants of a process by using `kqueue` to watch for fork events, and +/// `proc_listchildpids` to list the children of a process. +pub(crate) struct PidTracker { + kq: libc::c_int, + handle: JoinHandle>, +} + +impl PidTracker { + pub(crate) fn new(root_pid: i32) -> Option { + if root_pid <= 0 { + return None; + } + + let kq = unsafe { libc::kqueue() }; + let handle = tokio::task::spawn_blocking(move || track_descendants(kq, root_pid)); + + Some(Self { kq, handle }) + } + + pub(crate) async fn stop(self) -> HashSet { + trigger_stop_event(self.kq); + self.handle.await.unwrap_or_default() + } +} + +unsafe extern "C" { + fn proc_listchildpids( + ppid: libc::c_int, + buffer: *mut libc::c_void, + buffersize: libc::c_int, + ) -> libc::c_int; +} + +/// Wrap proc_listchildpids. +fn list_child_pids(parent: i32) -> Vec { + unsafe { + let mut capacity: usize = 16; + loop { + let mut buf: Vec = vec![0; capacity]; + let count = proc_listchildpids( + parent as libc::c_int, + buf.as_mut_ptr() as *mut libc::c_void, + (buf.len() * std::mem::size_of::()) as libc::c_int, + ); + if count <= 0 { + return Vec::new(); + } + let returned = count as usize; + if returned < capacity { + buf.truncate(returned); + return buf; + } + capacity = capacity.saturating_mul(2).max(returned + 16); + } + } +} + +fn pid_is_alive(pid: i32) -> bool { + if pid <= 0 { + return false; + } + let res = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if res == 0 { + true + } else { + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) + } +} + +enum WatchPidError { + ProcessGone, + Other(std::io::Error), +} + +/// Add `pid` to the watch list in `kq`. +fn watch_pid(kq: libc::c_int, pid: i32) -> Result<(), WatchPidError> { + if pid <= 0 { + return Err(WatchPidError::ProcessGone); + } + + let kev = libc::kevent { + ident: pid as libc::uintptr_t, + filter: libc::EVFILT_PROC, + flags: libc::EV_ADD | libc::EV_CLEAR, + fflags: libc::NOTE_FORK | libc::NOTE_EXEC | libc::NOTE_EXIT, + data: 0, + udata: std::ptr::null_mut(), + }; + + let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) }; + if res < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + Err(WatchPidError::ProcessGone) + } else { + Err(WatchPidError::Other(err)) + } + } else { + Ok(()) + } +} + +fn watch_children( + kq: libc::c_int, + parent: i32, + seen: &mut HashSet, + active: &mut HashSet, +) { + for child_pid in list_child_pids(parent) { + add_pid_watch(kq, child_pid, seen, active); + } +} + +/// Watch `pid` and its children, updating `seen` and `active` sets. +fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet, active: &mut HashSet) { + if pid <= 0 { + return; + } + + let newly_seen = seen.insert(pid); + let mut should_recurse = newly_seen; + + if active.insert(pid) { + match watch_pid(kq, pid) { + Ok(()) => { + should_recurse = true; + } + Err(WatchPidError::ProcessGone) => { + active.remove(&pid); + return; + } + Err(WatchPidError::Other(err)) => { + warn!("failed to watch pid {pid}: {err}"); + active.remove(&pid); + return; + } + } + } + + if should_recurse { + watch_children(kq, pid, seen, active); + } +} +const STOP_IDENT: libc::uintptr_t = 1; + +fn register_stop_event(kq: libc::c_int) -> bool { + let kev = libc::kevent { + ident: STOP_IDENT, + filter: libc::EVFILT_USER, + flags: libc::EV_ADD | libc::EV_CLEAR, + fflags: 0, + data: 0, + udata: std::ptr::null_mut(), + }; + + let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) }; + res >= 0 +} + +fn trigger_stop_event(kq: libc::c_int) { + if kq < 0 { + return; + } + + let kev = libc::kevent { + ident: STOP_IDENT, + filter: libc::EVFILT_USER, + flags: 0, + fflags: libc::NOTE_TRIGGER, + data: 0, + udata: std::ptr::null_mut(), + }; + + let _ = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) }; +} + +/// Put all of the above together to track all the descendants of `root_pid`. +fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet { + if kq < 0 { + let mut seen = HashSet::new(); + seen.insert(root_pid); + return seen; + } + + if !register_stop_event(kq) { + let mut seen = HashSet::new(); + seen.insert(root_pid); + let _ = unsafe { libc::close(kq) }; + return seen; + } + + let mut seen: HashSet = HashSet::new(); + let mut active: HashSet = HashSet::new(); + + add_pid_watch(kq, root_pid, &mut seen, &mut active); + + const EVENTS_CAP: usize = 32; + let mut events: [libc::kevent; EVENTS_CAP] = + unsafe { std::mem::MaybeUninit::zeroed().assume_init() }; + + let mut stop_requested = false; + loop { + if active.is_empty() { + if !pid_is_alive(root_pid) { + break; + } + add_pid_watch(kq, root_pid, &mut seen, &mut active); + if active.is_empty() { + continue; + } + } + + let nev = unsafe { + libc::kevent( + kq, + std::ptr::null::(), + 0, + events.as_mut_ptr(), + EVENTS_CAP as libc::c_int, + std::ptr::null(), + ) + }; + + if nev < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + break; + } + + if nev == 0 { + continue; + } + + for ev in events.iter().take(nev as usize) { + let pid = ev.ident as i32; + + if ev.filter == libc::EVFILT_USER && ev.ident == STOP_IDENT { + stop_requested = true; + break; + } + + if (ev.flags & libc::EV_ERROR) != 0 { + if ev.data == libc::ESRCH as isize { + active.remove(&pid); + } + continue; + } + + if (ev.fflags & libc::NOTE_FORK) != 0 { + watch_children(kq, pid, &mut seen, &mut active); + } + + if (ev.fflags & libc::NOTE_EXIT) != 0 { + active.remove(&pid); + } + } + + if stop_requested { + break; + } + } + + let _ = unsafe { libc::close(kq) }; + + seen +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::process::Stdio; + use std::time::Duration; + + #[test] + fn pid_is_alive_detects_current_process() { + let pid = std::process::id() as i32; + assert!(pid_is_alive(pid)); + } + + #[cfg(target_os = "macos")] + #[test] + fn list_child_pids_includes_spawned_child() { + let mut child = Command::new("/bin/sleep") + .arg("5") + .stdin(Stdio::null()) + .spawn() + .expect("failed to spawn child process"); + + let child_pid = child.id() as i32; + let parent_pid = std::process::id() as i32; + + let mut found = false; + for _ in 0..100 { + if list_child_pids(parent_pid).contains(&child_pid) { + found = true; + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + let _ = child.wait(); + + assert!(found, "expected to find child pid {child_pid} in list"); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn pid_tracker_collects_spawned_children() { + let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker"); + + let mut child = Command::new("/bin/sleep") + .arg("0.1") + .stdin(Stdio::null()) + .spawn() + .expect("failed to spawn child process"); + + let child_pid = child.id() as i32; + let parent_pid = std::process::id() as i32; + + let _ = child.wait(); + + let seen = tracker.stop().await; + + assert!( + seen.contains(&parent_pid), + "expected tracker to include parent pid {parent_pid}" + ); + assert!( + seen.contains(&child_pid), + "expected tracker to include child pid {child_pid}" + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn pid_tracker_collects_bash_subshell_descendants() { + let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker"); + + let child = Command::new("/bin/bash") + .arg("-c") + .arg("(sleep 0.1 & echo $!; wait)") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn bash"); + + let output = child.wait_with_output().unwrap().stdout; + let subshell_pid = String::from_utf8_lossy(&output) + .trim() + .parse::() + .expect("failed to parse subshell pid"); + + let seen = tracker.stop().await; + + assert!( + seen.contains(&subshell_pid), + "expected tracker to include subshell pid {subshell_pid}" + ); + } +} diff --git a/code-rs/cli/src/debug_sandbox/seatbelt.rs b/code-rs/cli/src/debug_sandbox/seatbelt.rs new file mode 100644 index 00000000000..a1d6435b510 --- /dev/null +++ b/code-rs/cli/src/debug_sandbox/seatbelt.rs @@ -0,0 +1,114 @@ +use std::collections::HashSet; +use tokio::io::AsyncBufReadExt; +use tokio::process::Child; +use tokio::task::JoinHandle; + +use super::pid_tracker::PidTracker; + +pub struct SandboxDenial { + pub name: String, + pub capability: String, +} + +pub struct DenialLogger { + log_stream: Child, + pid_tracker: Option, + log_reader: Option>>, +} + +impl DenialLogger { + pub(crate) fn new() -> Option { + let mut log_stream = start_log_stream()?; + let stdout = log_stream.stdout.take()?; + let log_reader = tokio::spawn(async move { + let mut reader = tokio::io::BufReader::new(stdout); + let mut logs = Vec::new(); + let mut chunk = Vec::new(); + loop { + match reader.read_until(b'\n', &mut chunk).await { + Ok(0) | Err(_) => break, + Ok(_) => { + logs.extend_from_slice(&chunk); + chunk.clear(); + } + } + } + logs + }); + + Some(Self { + log_stream, + pid_tracker: None, + log_reader: Some(log_reader), + }) + } + + pub(crate) fn on_child_spawn(&mut self, child: &Child) { + if let Some(root_pid) = child.id() { + self.pid_tracker = PidTracker::new(root_pid as i32); + } + } + + pub(crate) async fn finish(mut self) -> Vec { + let pid_set = match self.pid_tracker { + Some(tracker) => tracker.stop().await, + None => Default::default(), + }; + + if pid_set.is_empty() { + return Vec::new(); + } + + let _ = self.log_stream.kill().await; + let _ = self.log_stream.wait().await; + + let logs_bytes = match self.log_reader.take() { + Some(handle) => handle.await.unwrap_or_default(), + None => Vec::new(), + }; + let logs = String::from_utf8_lossy(&logs_bytes); + + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut denials: Vec = Vec::new(); + for line in logs.lines() { + if let Ok(json) = serde_json::from_str::(line) + && let Some(msg) = json.get("eventMessage").and_then(|v| v.as_str()) + && let Some((pid, name, capability)) = parse_message(msg) + && pid_set.contains(&pid) + && seen.insert((name.clone(), capability.clone())) + { + denials.push(SandboxDenial { name, capability }); + } + } + denials + } +} + +fn start_log_stream() -> Option { + use std::process::Stdio; + + const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#; + + tokio::process::Command::new("log") + .args(["stream", "--style", "ndjson", "--predicate", PREDICATE]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .ok() +} + +fn parse_message(msg: &str) -> Option<(i32, String, String)> { + // Example message: + // Sandbox: processname(1234) deny(1) capability-name args... + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| { + #[expect(clippy::unwrap_used)] + regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap() + }); + + let (_, [name, pid_str, capability]) = re.captures(msg)?.extract(); + let pid = pid_str.trim().parse::().ok()?; + Some((pid, name.to_string(), capability.to_string())) +} diff --git a/code-rs/cli/src/desktop_app/mac.rs b/code-rs/cli/src/desktop_app/mac.rs new file mode 100644 index 00000000000..10a47806b14 --- /dev/null +++ b/code-rs/cli/src/desktop_app/mac.rs @@ -0,0 +1,316 @@ +use anyhow::Context as _; +use std::ffi::CString; +use std::path::Path; +use std::path::PathBuf; +use tempfile::Builder; +use tokio::process::Command; + +const CODEX_DMG_URL_ARM64: &str = "https://persistent.oaistatic.com/codex-app-prod/Codex.dmg"; +const CODEX_DMG_URL_X64: &str = + "https://persistent.oaistatic.com/codex-app-prod/Codex-latest-x64.dmg"; + +pub async fn run_mac_app_open_or_install( + workspace: PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + if let Some(app_path) = find_existing_codex_app_path() { + eprintln!( + "Opening Codex Desktop at {app_path}...", + app_path = app_path.display() + ); + open_codex_app(&app_path, &workspace).await?; + return Ok(()); + } + eprintln!("Codex Desktop not found; downloading installer..."); + let download_url = download_url_override.unwrap_or_else(|| { + let default_url = if is_apple_silicon_mac() { + CODEX_DMG_URL_ARM64 + } else { + CODEX_DMG_URL_X64 + }; + default_url.to_string() + }); + let installed_app = download_and_install_codex_to_user_applications(&download_url) + .await + .context("failed to download/install Codex Desktop")?; + eprintln!( + "Launching Codex Desktop from {installed_app}...", + installed_app = installed_app.display() + ); + open_codex_app(&installed_app, &workspace).await?; + Ok(()) +} + +fn is_apple_silicon_mac() -> bool { + fn macos_sysctl_flag(name: &str) -> Option { + let name = CString::new(name).ok()?; + let mut value: libc::c_int = 0; + let mut size = std::mem::size_of_val(&value); + let result = unsafe { + libc::sysctlbyname( + name.as_ptr(), + (&mut value as *mut libc::c_int).cast::(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + (result == 0).then_some(value != 0) + } + + std::env::consts::ARCH == "aarch64" + || macos_sysctl_flag("sysctl.proc_translated").unwrap_or(false) + || macos_sysctl_flag("hw.optional.arm64").unwrap_or(false) +} + +fn find_existing_codex_app_path() -> Option { + candidate_codex_app_paths() + .into_iter() + .find(|candidate| candidate.is_dir()) +} + +fn candidate_codex_app_paths() -> Vec { + let mut paths = vec![PathBuf::from("/Applications/Codex.app")]; + if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join("Applications").join("Codex.app")); + } + paths +} + +async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> { + eprintln!( + "Opening workspace {workspace}...", + workspace = workspace.display() + ); + let status = Command::new("open") + .arg("-a") + .arg(app_path) + .arg(workspace) + .status() + .await + .context("failed to invoke `open`")?; + + if status.success() { + return Ok(()); + } + + anyhow::bail!( + "`open -a {app_path} {workspace}` exited with {status}", + app_path = app_path.display(), + workspace = workspace.display() + ); +} + +async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyhow::Result { + let temp_dir = Builder::new() + .prefix("codex-app-installer-") + .tempdir() + .context("failed to create temp dir")?; + let tmp_root = temp_dir.path().to_path_buf(); + let _temp_dir = temp_dir; + + let dmg_path = tmp_root.join("Codex.dmg"); + download_dmg(dmg_url, &dmg_path).await?; + + eprintln!("Mounting Codex Desktop installer..."); + let mount_point = mount_dmg(&dmg_path).await?; + eprintln!( + "Installer mounted at {mount_point}.", + mount_point = mount_point.display() + ); + let result = async { + let app_in_volume = find_codex_app_in_mount(&mount_point) + .context("failed to locate Codex.app in mounted dmg")?; + install_codex_app_bundle(&app_in_volume).await + } + .await; + + let detach_result = detach_dmg(&mount_point).await; + if let Err(err) = detach_result { + eprintln!( + "warning: failed to detach dmg at {mount_point}: {err}", + mount_point = mount_point.display() + ); + } + + result +} + +async fn install_codex_app_bundle(app_in_volume: &Path) -> anyhow::Result { + for applications_dir in candidate_applications_dirs()? { + eprintln!( + "Installing Codex Desktop into {applications_dir}...", + applications_dir = applications_dir.display() + ); + std::fs::create_dir_all(&applications_dir).with_context(|| { + format!( + "failed to create applications dir {applications_dir}", + applications_dir = applications_dir.display() + ) + })?; + + let dest_app = applications_dir.join("Codex.app"); + if dest_app.is_dir() { + return Ok(dest_app); + } + + match copy_app_bundle(app_in_volume, &dest_app).await { + Ok(()) => return Ok(dest_app), + Err(err) => { + eprintln!( + "warning: failed to install Codex.app to {applications_dir}: {err}", + applications_dir = applications_dir.display() + ); + } + } + } + + anyhow::bail!("failed to install Codex.app to any applications directory"); +} + +fn candidate_applications_dirs() -> anyhow::Result> { + let mut dirs = vec![PathBuf::from("/Applications")]; + dirs.push(user_applications_dir()?); + Ok(dirs) +} + +async fn download_dmg(url: &str, dest: &Path) -> anyhow::Result<()> { + eprintln!("Downloading installer..."); + let status = Command::new("curl") + .arg("-fL") + .arg("--retry") + .arg("3") + .arg("--retry-delay") + .arg("1") + .arg("-o") + .arg(dest) + .arg(url) + .status() + .await + .context("failed to invoke `curl`")?; + + if status.success() { + return Ok(()); + } + anyhow::bail!("curl download failed with {status}"); +} + +async fn mount_dmg(dmg_path: &Path) -> anyhow::Result { + let output = Command::new("hdiutil") + .arg("attach") + .arg("-nobrowse") + .arg("-readonly") + .arg(dmg_path) + .output() + .await + .context("failed to invoke `hdiutil attach`")?; + + if !output.status.success() { + anyhow::bail!( + "`hdiutil attach` failed with {status}: {stderr}", + status = output.status, + stderr = String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_hdiutil_attach_mount_point(&stdout) + .map(PathBuf::from) + .with_context(|| format!("failed to parse mount point from hdiutil output:\n{stdout}")) +} + +async fn detach_dmg(mount_point: &Path) -> anyhow::Result<()> { + let status = Command::new("hdiutil") + .arg("detach") + .arg(mount_point) + .status() + .await + .context("failed to invoke `hdiutil detach`")?; + + if status.success() { + return Ok(()); + } + anyhow::bail!("hdiutil detach failed with {status}"); +} + +fn find_codex_app_in_mount(mount_point: &Path) -> anyhow::Result { + let direct = mount_point.join("Codex.app"); + if direct.is_dir() { + return Ok(direct); + } + + for entry in std::fs::read_dir(mount_point).with_context(|| { + format!( + "failed to read {mount_point}", + mount_point = mount_point.display() + ) + })? { + let entry = entry.context("failed to read mount directory entry")?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "app") && path.is_dir() { + return Ok(path); + } + } + + anyhow::bail!( + "no .app bundle found at {mount_point}", + mount_point = mount_point.display() + ); +} + +async fn copy_app_bundle(src_app: &Path, dest_app: &Path) -> anyhow::Result<()> { + let status = Command::new("ditto") + .arg(src_app) + .arg(dest_app) + .status() + .await + .context("failed to invoke `ditto`")?; + + if status.success() { + return Ok(()); + } + anyhow::bail!("ditto copy failed with {status}"); +} + +fn user_applications_dir() -> anyhow::Result { + let home = std::env::var_os("HOME").context("HOME is not set")?; + Ok(PathBuf::from(home).join("Applications")) +} + +fn parse_hdiutil_attach_mount_point(output: &str) -> Option { + output.lines().find_map(|line| { + if !line.contains("/Volumes/") { + return None; + } + if let Some((_, mount)) = line.rsplit_once('\t') { + return Some(mount.trim().to_string()); + } + line.split_whitespace() + .find(|field| field.starts_with("/Volumes/")) + .map(str::to_string) + }) +} + +#[cfg(test)] +mod tests { + use super::parse_hdiutil_attach_mount_point; + use pretty_assertions::assert_eq; + + #[test] + fn parses_mount_point_from_tab_separated_hdiutil_output() { + let output = "/dev/disk2s1\tApple_HFS\tCodex\t/Volumes/Codex\n"; + assert_eq!( + parse_hdiutil_attach_mount_point(output).as_deref(), + Some("/Volumes/Codex") + ); + } + + #[test] + fn parses_mount_point_with_spaces() { + let output = "/dev/disk2s1\tApple_HFS\tCodex Installer\t/Volumes/Codex Installer\n"; + assert_eq!( + parse_hdiutil_attach_mount_point(output).as_deref(), + Some("/Volumes/Codex Installer") + ); + } +} diff --git a/code-rs/cli/src/desktop_app/mod.rs b/code-rs/cli/src/desktop_app/mod.rs new file mode 100644 index 00000000000..5a78341c078 --- /dev/null +++ b/code-rs/cli/src/desktop_app/mod.rs @@ -0,0 +1,22 @@ +#[cfg(target_os = "macos")] +mod mac; +#[cfg(target_os = "windows")] +mod windows; + +/// Run the app install/open logic for the current OS. +#[cfg(target_os = "macos")] +pub async fn run_app_open_or_install( + workspace: std::path::PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + mac::run_mac_app_open_or_install(workspace, download_url_override).await +} + +/// Run the app install/open logic for the current OS. +#[cfg(target_os = "windows")] +pub async fn run_app_open_or_install( + workspace: std::path::PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + windows::run_windows_app_open_or_install(workspace, download_url_override).await +} diff --git a/code-rs/cli/src/desktop_app/windows.rs b/code-rs/cli/src/desktop_app/windows.rs new file mode 100644 index 00000000000..932ca00cf2b --- /dev/null +++ b/code-rs/cli/src/desktop_app/windows.rs @@ -0,0 +1,132 @@ +use anyhow::Context as _; +use std::path::Path; +use std::path::PathBuf; +use tokio::process::Command; + +const CODEX_WINDOWS_INSTALLER_URL: &str = + "https://get.microsoft.com/installer/download/9PLM9XGG6VKS?cid=website_cta_psi"; +const CODEX_MICROSOFT_STORE_WEB_URL: &str = "https://apps.microsoft.com/detail/9plm9xgg6vks"; + +pub async fn run_windows_app_open_or_install( + workspace: PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + if let Some(app_id) = find_codex_app_id().await? { + eprintln!("Opening Codex Desktop..."); + open_installed_codex_app(&app_id).await?; + eprintln!( + "In Codex Desktop, open workspace {workspace}.", + workspace = display_workspace_path(&workspace) + ); + return Ok(()); + } + + eprintln!("Codex Desktop not found; opening Windows installer..."); + let download_url = download_url_override + .as_deref() + .unwrap_or(CODEX_WINDOWS_INSTALLER_URL); + if open_url(download_url).await.is_err() && download_url_override.is_none() { + open_url(CODEX_MICROSOFT_STORE_WEB_URL).await?; + } + eprintln!( + "After installing Codex Desktop, open workspace {workspace}.", + workspace = display_workspace_path(&workspace) + ); + Ok(()) +} + +async fn find_codex_app_id() -> anyhow::Result> { + let output = Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-Command") + .arg("Get-StartApps -Name 'Codex' | Select-Object -First 1 -ExpandProperty AppID") + .output() + .await + .context("failed to invoke `powershell.exe`")?; + + if !output.status.success() { + return Ok(None); + } + + let app_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if app_id.is_empty() { + Ok(None) + } else { + Ok(Some(app_id)) + } +} + +async fn open_installed_codex_app(app_id: &str) -> anyhow::Result<()> { + let target = format!("shell:AppsFolder\\{app_id}"); + open_shell_target(&target).await +} + +async fn open_url(url: &str) -> anyhow::Result<()> { + let status = Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-Command") + .arg("& { param($target) Start-Process -FilePath $target }") + .arg(url) + .status() + .await + .with_context(|| format!("failed to open {url}"))?; + + if status.success() { + Ok(()) + } else { + anyhow::bail!("failed to open {url} with {status}"); + } +} + +async fn open_shell_target(target: &str) -> anyhow::Result<()> { + // Explorer can successfully hand off shell targets and still return exit code 1. + let _status = Command::new("explorer.exe") + .arg(target) + .status() + .await + .with_context(|| format!("failed to open {target}"))?; + + Ok(()) +} + +fn display_workspace_path(workspace: &Path) -> String { + let path = workspace.display().to_string(); + if let Some(path) = path.strip_prefix(r"\\?\UNC\") { + format!(r"\\{path}") + } else if let Some(path) = path.strip_prefix(r"\\?\") { + path.to_string() + } else { + path + } +} + +#[cfg(test)] +mod tests { + use super::display_workspace_path; + use pretty_assertions::assert_eq; + use std::path::Path; + + #[test] + fn display_workspace_path_removes_windows_extended_prefix() { + assert_eq!( + display_workspace_path(Path::new(r"\\?\C:\Users\fcoury\code\codex")), + r"C:\Users\fcoury\code\codex" + ); + } + + #[test] + fn display_workspace_path_preserves_unc_prefix() { + assert_eq!( + display_workspace_path(Path::new(r"\\?\UNC\server\share\codex")), + r"\\server\share\codex" + ); + } + + #[test] + fn display_workspace_path_leaves_regular_paths_unchanged() { + assert_eq!( + display_workspace_path(Path::new(r"C:\Users\fcoury\code\codex")), + r"C:\Users\fcoury\code\codex" + ); + } +} diff --git a/code-rs/cli/src/lib.rs b/code-rs/cli/src/lib.rs index 48233971034..5bea8ce78dc 100644 --- a/code-rs/cli/src/lib.rs +++ b/code-rs/cli/src/lib.rs @@ -1,16 +1,57 @@ -pub mod debug_sandbox; +pub(crate) mod debug_sandbox; mod exit_status; -pub mod login; -pub mod proto; +pub(crate) mod login; use clap::Parser; -use code_common::CliConfigOverrides; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use std::path::PathBuf; +pub use debug_sandbox::run_command_under_landlock; +pub use debug_sandbox::run_command_under_seatbelt; +pub use debug_sandbox::run_command_under_windows; +pub use login::read_access_token_from_stdin; +pub use login::read_api_key_from_stdin; +pub use login::run_login_status; +pub use login::run_login_with_access_token; +pub use login::run_login_with_api_key; +pub use login::run_login_with_chatgpt; +pub use login::run_login_with_device_code; +pub use login::run_login_with_device_code_fallback_to_browser; +pub use login::run_logout; + +// TODO: Deduplicate these shared sandbox options if we remove the explicit +// `codex sandbox ` platform subcommands. #[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, + /// Named permissions profile to apply from the active configuration stack. + #[arg(long = "permissions-profile", value_name = "NAME")] + pub permissions_profile: Option, + + /// Working directory used for profile resolution and command execution. + #[arg( + short = 'C', + long = "cd", + value_name = "DIR", + requires = "permissions_profile" + )] + pub cwd: Option, + + /// Include managed requirements while resolving an explicit permissions profile. + #[arg( + long = "include-managed-config", + default_value_t = false, + requires = "permissions_profile" + )] + pub include_managed_config: bool, + + /// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths. + #[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)] + pub allow_unix_sockets: Vec, + + /// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit + #[arg(long = "log-denials", default_value_t = false)] + pub log_denials: bool, #[clap(skip)] pub config_overrides: CliConfigOverrides, @@ -20,16 +61,69 @@ pub struct SeatbeltCommand { pub command: Vec, } +fn parse_allow_unix_socket_path(raw: &str) -> Result { + AbsolutePathBuf::relative_to_current_dir(raw) + .map_err(|err| format!("invalid path {raw}: {err}")) +} + #[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, + /// Named permissions profile to apply from the active configuration stack. + #[arg(long = "permissions-profile", value_name = "NAME")] + pub permissions_profile: Option, + + /// Working directory used for profile resolution and command execution. + #[arg( + short = 'C', + long = "cd", + value_name = "DIR", + requires = "permissions_profile" + )] + pub cwd: Option, + + /// Include managed requirements while resolving an explicit permissions profile. + #[arg( + long = "include-managed-config", + default_value_t = false, + requires = "permissions_profile" + )] + pub include_managed_config: bool, + + #[clap(skip)] + pub config_overrides: CliConfigOverrides, + + /// Full command args to run under the Linux sandbox. + #[arg(trailing_var_arg = true)] + pub command: Vec, +} + +#[derive(Debug, Parser)] +pub struct WindowsCommand { + /// Named permissions profile to apply from the active configuration stack. + #[arg(long = "permissions-profile", value_name = "NAME")] + pub permissions_profile: Option, + + /// Working directory used for profile resolution and command execution. + #[arg( + short = 'C', + long = "cd", + value_name = "DIR", + requires = "permissions_profile" + )] + pub cwd: Option, + + /// Include managed requirements while resolving an explicit permissions profile. + #[arg( + long = "include-managed-config", + default_value_t = false, + requires = "permissions_profile" + )] + pub include_managed_config: bool, #[clap(skip)] pub config_overrides: CliConfigOverrides, - /// Full command args to run under landlock. + /// Full command args to run under Windows restricted token sandbox. #[arg(trailing_var_arg = true)] pub command: Vec, } diff --git a/code-rs/cli/src/llm.rs b/code-rs/cli/src/llm.rs deleted file mode 100644 index 872bd966812..00000000000 --- a/code-rs/cli/src/llm.rs +++ /dev/null @@ -1,375 +0,0 @@ -use std::io::Read; -use std::path::Path; -use std::path::PathBuf; - -use anyhow::Context; -use clap::Parser; -use code_common::CliConfigOverrides; -use code_core::config::Config; -use code_core::config::ConfigOverrides; -use code_core::ResponseEvent; -use code_core::ModelClient; -use code_core::ModelProviderInfo; -use code_core::agent_defaults::model_guide_markdown_with_custom; -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)] -#[command(group( - clap::ArgGroup::new("message_input") - .required(true) - .args(["message", "message_file"]) -))] -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: Option, - - /// Read primary user message/content from a UTF-8 file - #[arg(long = "message-file", value_name = "PATH")] - pub message_file: Option, - - /// `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, - - /// 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, - - /// Path to a JSON schema file (mutually exclusive with --schema-json) - #[arg(long = "schema-file")] - pub schema_file: Option, - - /// Optional model override (e.g. gpt-4.1, gpt-5.1) - #[arg(long)] - pub model: Option, -} - -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()), - compact_prompt_override: None, - compact_prompt_override_file: None, - ..ConfigOverrides::default() - } - } else { - ConfigOverrides::default() - }; - - let config = Config::load_with_cli_overrides(overrides_vec, overrides)?; - let message = read_request_message(&args)?; - - // Build Prompt with custom developer + user messages, no extra tools - let mut input: Vec = Vec::new(); - input.push(ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { text: args.developer.clone() }], - end_turn: None, - phase: None, - }); - input.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: message }], - end_turn: None, - phase: None, - }); - - // Resolve schema - let schema_val: Option = if let Some(s) = &args.schema_json { - Some(serde_json::from_str::(s)?) - } else if let Some(p) = &args.schema_file { - let data = std::fs::read_to_string(p)?; - Some(serde_json::from_str::(&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); - if let Some(custom) = model_guide_markdown_with_custom(&config.agents) { - prompt.model_descriptions = Some(custom); - } - prompt.set_log_tag("cli/manual_prompt"); - - // 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(); - let mut saw_output_text_delta = false; - tracing::info!("LLM: created"); - while let Some(ev) = stream.next().await { - let ev = ev?; - match ev { - ResponseEvent::ReasoningSummaryDelta { delta, .. } => { tracing::info!(target: "llm", "thinking: {}", delta); } - ResponseEvent::ReasoningContentDelta { delta, .. } => { tracing::info!(target: "llm", "reasoning: {}", delta); } - ResponseEvent::OutputItemDone { item, .. } => { - append_output_item_done(&mut final_text, &mut saw_output_text_delta, item); - } - ResponseEvent::OutputTextDelta { delta, .. } => { - tracing::info!(target: "llm", "delta: {}", delta); - append_output_text_delta(&mut final_text, &mut saw_output_text_delta, delta); - } - ResponseEvent::Completed { .. } => { tracing::info!("LLM: completed"); break; } - _ => {} - } - } - - println!("{}", final_text); - Ok(()) -} - -fn read_request_message(args: &RequestArgs) -> anyhow::Result { - read_request_message_from(args, || { - let mut input = String::new(); - std::io::stdin() - .read_to_string(&mut input) - .context("failed to read --message - from stdin")?; - Ok(input) - }) -} - -fn read_request_message_from(args: &RequestArgs, read_stdin: F) -> anyhow::Result -where - F: FnOnce() -> anyhow::Result, -{ - match (&args.message, &args.message_file) { - (Some(_), Some(_)) => anyhow::bail!("--message and --message-file are mutually exclusive"), - (Some(message), None) if message == "-" => read_stdin(), - (Some(message), None) => Ok(message.clone()), - (None, Some(path)) => read_message_file(path), - (None, None) => anyhow::bail!("one of --message or --message-file is required"), - } -} - -fn read_message_file(path: &Path) -> anyhow::Result { - std::fs::read_to_string(path) - .with_context(|| format!("failed to read --message-file {}", path.display())) -} - -fn append_output_text_delta( - final_text: &mut String, - saw_output_text_delta: &mut bool, - delta: String, -) { - *saw_output_text_delta = true; - final_text.push_str(&delta); -} - -fn append_output_item_done( - final_text: &mut String, - saw_output_text_delta: &mut bool, - item: ResponseItem, -) { - if *saw_output_text_delta { - return; - } - - if let ResponseItem::Message { content, .. } = item { - for c in content { - if let ContentItem::OutputText { text } = c { - final_text.push_str(&text); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn args_with_message(message: Option<&str>, message_file: Option) -> RequestArgs { - RequestArgs { - developer: "developer".to_string(), - message: message.map(str::to_string), - message_file, - format_type: "json_schema".to_string(), - format_name: None, - format_strict: true, - schema_json: None, - schema_file: None, - model: None, - } - } - - #[test] - fn request_message_uses_inline_message() { - let args = args_with_message(Some("hello"), None); - - let message = read_request_message_from(&args, || anyhow::bail!("stdin should not be read")) - .expect("inline message should resolve"); - - assert_eq!(message, "hello"); - } - - #[test] - fn request_message_dash_reads_stdin() { - let args = args_with_message(Some("-"), None); - - let message = read_request_message_from(&args, || Ok("from stdin".to_string())) - .expect("stdin message should resolve"); - - assert_eq!(message, "from stdin"); - } - - #[test] - fn request_message_reads_message_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("prompt.txt"); - std::fs::write(&path, "from file").expect("write prompt file"); - let args = args_with_message(None, Some(path)); - - let message = read_request_message_from(&args, || anyhow::bail!("stdin should not be read")) - .expect("file message should resolve"); - - assert_eq!(message, "from file"); - } - - #[test] - fn request_message_rejects_multiple_sources() { - let args = args_with_message(Some("hello"), Some(PathBuf::from("prompt.txt"))); - - let err = read_request_message_from(&args, || anyhow::bail!("stdin should not be read")) - .expect_err("multiple message sources should fail"); - - assert!( - err.to_string() - .contains("--message and --message-file are mutually exclusive") - ); - } - - #[test] - fn request_cli_requires_a_message_source() { - let err = LlmCli::try_parse_from(["code", "request", "--developer", "developer"]) - .expect_err("missing message source should fail"); - - assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } - - #[test] - fn request_cli_rejects_multiple_message_sources() { - let err = LlmCli::try_parse_from([ - "code", - "request", - "--developer", - "developer", - "--message", - "hello", - "--message-file", - "prompt.txt", - ]) - .expect_err("multiple message sources should fail"); - - assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); - } - - #[test] - fn output_item_done_is_used_when_no_deltas_arrive() { - let mut final_text = String::new(); - let mut saw_delta = false; - - append_output_item_done(&mut final_text, &mut saw_delta, output_message("complete")); - - assert_eq!(final_text, "complete"); - assert!(!saw_delta); - } - - #[test] - fn output_item_done_does_not_duplicate_streamed_deltas() { - let mut final_text = String::new(); - let mut saw_delta = false; - - append_output_text_delta(&mut final_text, &mut saw_delta, "partial".to_string()); - append_output_item_done(&mut final_text, &mut saw_delta, output_message("partial")); - - assert_eq!(final_text, "partial"); - assert!(saw_delta); - } - - fn output_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } -} diff --git a/code-rs/cli/src/login.rs b/code-rs/cli/src/login.rs index 2897294b20d..16add7ac90f 100644 --- a/code-rs/cli/src/login.rs +++ b/code-rs/cli/src/login.rs @@ -1,43 +1,157 @@ -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; +//! CLI login commands and their direct-user observability surfaces. +//! +//! The TUI path already installs a broader tracing stack with feedback, OpenTelemetry, and other +//! interactive-session layers. Direct `codex login` intentionally does less: it preserves the +//! existing stderr/browser UX and adds only a small file-backed tracing layer for login-specific +//! targets. Keeping that setup local avoids pulling the TUI's session-oriented logging machinery +//! into a one-shot CLI command while still producing a durable `codex-login.log` artifact that +//! support can request from users. + +use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::Config; +use codex_login::CLIENT_ID; +use codex_login::CodexAuth; +use codex_login::ServerOptions; +use codex_login::login_with_access_token; +use codex_login::login_with_api_key; +use codex_login::logout_with_revoke; +use codex_login::run_device_code_login; +use codex_login::run_login_server; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_utils_cli::CliConfigOverrides; +use std::fs::OpenOptions; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; +use tracing_appender::non_blocking; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; -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)?; +const CHATGPT_LOGIN_DISABLED_MESSAGE: &str = + "ChatGPT login is disabled. Use API key login instead."; +const API_KEY_LOGIN_DISABLED_MESSAGE: &str = + "API key login is disabled. Use ChatGPT login instead."; +const ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE: &str = + "Access token login is disabled. Use API key login instead."; +const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in"; + +/// Installs a small file-backed tracing layer for direct `codex login` flows. +/// +/// This deliberately duplicates a narrow slice of the TUI logging setup instead of reusing it +/// wholesale. The TUI stack includes session-oriented layers that are valuable for interactive +/// runs but unnecessary for a one-shot login command. Keeping the direct CLI path local lets this +/// command produce a durable `codex-login.log` artifact without coupling it to the TUI's broader +/// telemetry and feedback initialization. +fn init_login_file_logging(config: &Config) -> Option { + let log_dir = match codex_core::config::log_dir(config) { + Ok(log_dir) => log_dir, + Err(err) => { + eprintln!("Warning: failed to resolve login log directory: {err}"); + return None; + } + }; + + if let Err(err) = std::fs::create_dir_all(&log_dir) { + eprintln!( + "Warning: failed to create login log directory {}: {err}", + log_dir.display() + ); + return None; + } + + let mut log_file_opts = OpenOptions::new(); + log_file_opts.create(true).append(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + log_file_opts.mode(0o600); + } + let log_path = log_dir.join("codex-login.log"); + let log_file = match log_file_opts.open(&log_path) { + Ok(log_file) => log_file, + Err(err) => { + eprintln!( + "Warning: failed to open login log file {}: {err}", + log_path.display() + ); + return None; + } + }; + + let (non_blocking, guard) = non_blocking(log_file); + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("codex_cli=info,codex_core=info,codex_login=info")); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_target(true) + .with_ansi(false) + .with_filter(env_filter); + + // Direct `codex login` otherwise relies on ephemeral stderr and browser output. + // Persist the same login targets to a file so support can inspect auth failures + // without reproducing them through TUI or app-server. + if let Err(err) = tracing_subscriber::registry().with(file_layer).try_init() { + eprintln!( + "Warning: failed to initialize login log file {}: {err}", + log_path.display() + ); + return None; + } + + Some(guard) +} + +fn print_login_server_start(actual_port: u16, auth_url: &str) { 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, + "Starting local login server on http://localhost:{actual_port}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}\n\nOn a remote or headless machine? Use `codex login --device-auth` instead." + ); +} + +pub async fn login_with_chatgpt( + codex_home: PathBuf, + forced_chatgpt_workspace_id: Option, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let opts = ServerOptions::new( + codex_home, + CLIENT_ID.to_string(), + forced_chatgpt_workspace_id, + cli_auth_credentials_store_mode, ); + let server = run_login_server(opts)?; + + print_login_server_start(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); + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + tracing::info!("starting browser login flow"); + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); match login_with_chatgpt( - config.code_home, - config.responses_originator_header.clone(), + config.codex_home.to_path_buf(), + forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, ) .await { Ok(_) => { - eprintln!("Successfully logged in"); + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } Err(e) => { @@ -51,11 +165,22 @@ pub async fn run_login_with_api_key( cli_config_overrides: CliConfigOverrides, api_key: String, ) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + tracing::info!("starting api key login flow"); - match login_with_api_key(&config.code_home, &api_key) { + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { + eprintln!("{API_KEY_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + + match login_with_api_key( + &config.codex_home, + &api_key, + config.cli_auth_credentials_store_mode, + ) { Ok(_) => { - eprintln!("Successfully logged in"); + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } Err(e) => { @@ -65,31 +190,77 @@ pub async fn run_login_with_api_key( } } +pub async fn run_login_with_access_token( + cli_config_overrides: CliConfigOverrides, + access_token: String, +) -> ! { + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + tracing::info!("starting access token login flow"); + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("{ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + + match login_with_access_token( + &config.codex_home, + &access_token, + config.cli_auth_credentials_store_mode, + Some(&config.chatgpt_base_url), + ) + .await + { + Ok(_) => { + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error logging in with access token: {e}"); + std::process::exit(1); + } + } +} + pub fn read_api_key_from_stdin() -> String { + read_stdin_secret( + "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`.", + "Reading API key from stdin...", + "No API key provided via stdin.", + ) +} + +pub fn read_access_token_from_stdin() -> String { + read_stdin_secret( + "--with-access-token expects the access token on stdin. Try piping it, e.g. `printenv CODEX_ACCESS_TOKEN | codex login --with-access-token`.", + "Reading access token from stdin...", + "No access token provided via stdin.", + ) +} + +fn read_stdin_secret(terminal_message: &str, reading_message: &str, empty_message: &str) -> 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`." - ); + eprintln!("{terminal_message}"); std::process::exit(1); } - eprintln!("Reading API key from stdin..."); + eprintln!("{reading_message}"); let mut buffer = String::new(); if let Err(err) = stdin.read_to_string(&mut buffer) { - eprintln!("Failed to read API key from stdin: {err}"); + eprintln!("Failed to read 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."); + let secret = buffer.trim().to_string(); + if secret.is_empty() { + eprintln!("{empty_message}"); std::process::exit(1); } - api_key + secret } /// Login using the OAuth device code flow. @@ -98,18 +269,26 @@ pub async fn run_login_with_device_code( issuer_base_url: Option, client_id: Option, ) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + tracing::info!("starting device code login flow"); + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( - config.code_home, + config.codex_home.to_path_buf(), client_id.unwrap_or(CLIENT_ID.to_string()), - config.responses_originator_header.clone(), + forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; } match run_device_code_login(opts).await { Ok(()) => { - eprintln!("Successfully logged in"); + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } Err(e) => { @@ -119,26 +298,84 @@ pub async fn run_login_with_device_code( } } -pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { - let config = load_config_or_exit(cli_config_overrides); +/// Prefers device-code login (with `open_browser = false`) when headless environment is detected, but keeps +/// `codex login` working in environments where device-code may be disabled/feature-gated. +/// If `run_device_code_login` returns `ErrorKind::NotFound` ("device-code unsupported"), this +/// falls back to starting the local browser login server. +pub async fn run_login_with_device_code_fallback_to_browser( + cli_config_overrides: CliConfigOverrides, + issuer_base_url: Option, + client_id: Option, +) -> ! { + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + tracing::info!("starting login flow with device code fallback"); + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } - 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)); + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let mut opts = ServerOptions::new( + config.codex_home.to_path_buf(), + client_id.unwrap_or(CLIENT_ID.to_string()), + forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, + ); + if let Some(iss) = issuer_base_url { + opts.issuer = iss; + } + opts.open_browser = false; - 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" - ); + match run_device_code_login(opts.clone()).await { + Ok(()) => { + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); + std::process::exit(0); + } + Err(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + eprintln!("Device code login is not enabled; falling back to browser login."); + match run_login_server(opts) { + Ok(server) => { + print_login_server_start(server.actual_port, &server.auth_url); + match server.block_until_done().await { + Ok(()) => { + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error logging in: {e}"); + std::process::exit(1); + } + } } + Err(e) => { + eprintln!("Error logging in: {e}"); + std::process::exit(1); } + } + } else { + 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).await; + + match CodexAuth::from_auth_storage( + &config.codex_home, + config.cli_auth_credentials_store_mode, + Some(&config.chatgpt_base_url), + ) + .await + { + Ok(Some(auth)) => match auth.auth_mode() { + AuthMode::ApiKey => match auth.get_token() { + Ok(api_key) => { + eprintln!("Logged in using an API key - {}", safe_format_key(&api_key)); std::process::exit(0); } Err(e) => { @@ -146,10 +383,14 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { std::process::exit(1); } }, - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { + AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens => { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::AgentIdentity => { + eprintln!("Logged in using access token"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); @@ -163,9 +404,9 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { } pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; - match logout(&config.code_home) { + match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); @@ -181,7 +422,7 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { } } -fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { +async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { let cli_overrides = match cli_config_overrides.parse_overrides() { Ok(v) => v, Err(e) => { @@ -190,8 +431,7 @@ fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { } }; - let config_overrides = ConfigOverrides::default(); - match Config::load_with_cli_overrides(cli_overrides, config_overrides) { + match Config::load_with_cli_overrides(cli_overrides).await { Ok(config) => config, Err(e) => { eprintln!("Error loading configuration: {e}"); diff --git a/code-rs/cli/src/main.rs b/code-rs/cli/src/main.rs index 0eb3403691e..9bda6769085 100644 --- a/code-rs/cli/src/main.rs +++ b/code-rs/cli/src/main.rs @@ -1,69 +1,71 @@ -use anyhow::anyhow; -use anyhow::Context; +use clap::Args; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; -use code_arg0::arg0_dispatch_or_else; -use code_chatgpt::apply_command::ApplyCommand; -use code_chatgpt::apply_command::run_apply_command; -use code_cli::LandlockCommand; -use code_cli::SeatbeltCommand; -use code_cli::login::read_api_key_from_stdin; -use code_cli::login::run_login_status; -use code_cli::login::run_login_with_api_key; -use code_cli::login::run_login_with_chatgpt; -use code_cli::login::run_login_with_device_code; -use code_cli::login::run_logout; -mod bridge; -mod llm; -mod update; -use llm::{LlmCli, run_llm}; -use update::{UpdateCheckCommand, UpdateCommand, run_update, run_update_check}; -use code_app_server::AppServerTransport; -use code_common::CliConfigOverrides; -use code_core::{entry_to_rollout_path, SessionCatalog, SessionQuery}; -use code_core::spawn::spawn_std_command_with_retry; -use code_protocol::protocol::SessionSource; -use code_cloud_tasks::Cli as CloudTasksCli; -use code_exec::Cli as ExecCli; -use code_responses_api_proxy::Args as ResponsesApiProxyArgs; -use code_tui::Cli as TuiCli; -use code_tui::ExitSummary; -use code_tui::resume_command_name; -use serde::{Deserialize, Serialize}; -use serde_json; -use std::fs; -use std::path::Path; +use codex_arg0::Arg0DispatchPaths; +use codex_arg0::arg0_dispatch_or_else; +use codex_chatgpt::apply_command::ApplyCommand; +use codex_chatgpt::apply_command::run_apply_command; +use codex_cli::LandlockCommand; +use codex_cli::SeatbeltCommand; +use codex_cli::WindowsCommand; +use codex_cli::read_access_token_from_stdin; +use codex_cli::read_api_key_from_stdin; +use codex_cli::run_login_status; +use codex_cli::run_login_with_access_token; +use codex_cli::run_login_with_api_key; +use codex_cli::run_login_with_chatgpt; +use codex_cli::run_login_with_device_code; +use codex_cli::run_logout; +use codex_cloud_tasks::Cli as CloudTasksCli; +use codex_exec::Cli as ExecCli; +use codex_exec::Command as ExecCommand; +use codex_exec::ReviewArgs; +use codex_execpolicy::ExecPolicyCheckCommand; +use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; +use codex_rollout_trace::REDUCED_STATE_FILE_NAME; +use codex_rollout_trace::replay_bundle; +use codex_state::StateRuntime; +use codex_state::state_db_path; +use codex_tui::AppExitInfo; +use codex_tui::Cli as TuiCli; +use codex_tui::ExitReason; +use codex_tui::UpdateAction; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use owo_colors::OwoColorize; +use std::io::IsTerminal; use std::path::PathBuf; -use std::process; -use tokio::runtime::{Builder as TokioRuntimeBuilder, Handle as TokioHandle}; +use supports_color::Stream; +#[cfg(any(target_os = "macos", target_os = "windows"))] +mod app_cmd; +#[cfg(any(target_os = "macos", target_os = "windows"))] +mod desktop_app; +mod marketplace_cmd; mod mcp_cmd; +#[cfg(not(windows))] +mod wsl_paths; +use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; -const CLI_COMMAND_NAME: &str = "code"; -pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE"; - -/// As early as possible in the process lifecycle, apply hardening measures -/// if the CODEX_SECURE_MODE environment variable is set to "1". -#[ctor::ctor] -fn pre_main_hardening() { - let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) { - Ok(value) => value, - Err(_) => return, - }; - - if secure_mode == "1" { - code_process_hardening::pre_main_hardening(); - } - - // Always clear this env var so child processes don't inherit it. - unsafe { - std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR); - } -} +use codex_core::build_models_manager; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::find_codex_home; +use codex_features::FEATURES; +use codex_features::Stage; +use codex_features::is_known_feature_key; +use codex_login::AuthManager; +use codex_memories_write::clear_memory_roots_contents; +use codex_models_manager::bundled_models_response; +use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::user_input::UserInput; +use codex_terminal_detection::TerminalName; /// Codex CLI /// @@ -71,29 +73,27 @@ fn pre_main_hardening() { #[derive(Debug, Parser)] #[clap( author, - name = "code", - version = code_version::version(), + version, // If a sub‑command is given, ignore requirements of the default args. subcommand_negates_reqs = true, - // The executable is sometimes invoked via a platform‑specific name like - // `codex-x86_64-unknown-linux-musl`, but the help output should always use - // the generic `code` command name that users run. - bin_name = "code" + // The executable is sometimes invoked via a platform-specific name, but the + // help output should always use the generic `code` command name that Every + // Code users run. + bin_name = "code", + override_usage = "code [OPTIONS] [PROMPT]\n code [OPTIONS] [ARGS]" )] struct MultitoolCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, #[clap(flatten)] - interactive: TuiCli, + pub feature_toggles: FeatureToggles, - /// Run Auto Drive when executing non-interactive sessions. - #[clap(long = "auto", global = true, default_value_t = false)] - auto_drive: bool, + #[clap(flatten)] + remote: InteractiveRemoteOptions, - /// Developer-role message to prepend to every turn for demos. - #[clap(long = "demo", global = true, value_name = "TEXT")] - demo_developer_message: Option, + #[clap(flatten)] + interactive: TuiCli, #[clap(subcommand)] subcommand: Option, @@ -105,9 +105,8 @@ enum Subcommand { #[clap(visible_alias = "e")] Exec(ExecCli), - /// Run Auto Drive in headless mode (alias for `exec --auto --full-auto`). - #[clap(name = "auto")] - Auto(ExecCli), + /// Run a code review non-interactively. + Review(ReviewArgs), /// Manage login. Login(LoginCommand), @@ -115,25 +114,40 @@ enum Subcommand { /// Remove stored authentication credentials. Logout(LogoutCommand), - /// [experimental] Run Codex as an MCP server and manage MCP servers. - #[clap(visible_alias = "acp")] + /// Manage external MCP servers for Codex. Mcp(McpCli), - /// [experimental] Run the Codex MCP server (stdio transport). + /// Manage Codex plugins. + Plugin(PluginCli), + + /// Start Codex as an MCP server (stdio). McpServer, - /// [experimental] Run the app server. - AppServer(AppServerArgs), + /// [experimental] Run the app server or related tooling. + AppServer(AppServerCommand), + + /// [experimental] Start a headless app-server with remote control enabled. + RemoteControl, + + /// Launch the Codex desktop app (opens the app installer if missing). + #[cfg(any(target_os = "macos", target_os = "windows"))] + App(app_cmd::AppCommand), /// Generate shell completion scripts. Completion(CompletionCommand), - /// Internal debugging commands. - Debug(DebugArgs), + /// Update Codex to the latest version. + Update, + + /// Run commands within a Codex-provided sandbox. + Sandbox(SandboxArgs), + + /// Debugging tools. + Debug(DebugCommand), - /// Debug: replay ordering from response.json and codex-tui.log - #[clap(hide = false)] - OrderReplay(OrderReplayArgs), + /// Execpolicy tooling. + #[clap(hide = true)] + Execpolicy(ExecpolicyCommand), /// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree. #[clap(visible_alias = "a")] @@ -142,9 +156,9 @@ enum Subcommand { /// Resume a previous interactive session (picker by default; use --last to continue the most recent). Resume(ResumeCommand), - /// Internal: generate TypeScript protocol bindings. - #[clap(hide = true)] - GenerateTs(GenerateTsCommand), + /// Fork a previous interactive session (picker by default; use --last to fork the most recent). + Fork(ForkCommand), + /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. #[clap(name = "cloud", alias = "cloud-tasks")] Cloud(CloudTasksCli), @@ -153,42 +167,31 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), - /// Diagnose PATH, binary collisions, and versions. - Doctor, + /// Internal: relay stdio to a Unix domain socket. + #[clap(hide = true, name = "stdio-to-uds")] + StdioToUds(StdioToUdsCommand), - /// Download and run preview artifact by slug. - Preview(PreviewArgs), + /// [EXPERIMENTAL] Run the standalone exec-server service. + ExecServer(ExecServerCommand), - /// Check the GitHub Release update manifest for a newer build. - #[clap(name = "update-check")] - UpdateCheck(UpdateCheckCommand), - - /// Update a directly managed dogfood binary after checksum verification. - Update(UpdateCommand), - - /// Side-channel LLM utilities (no TUI events). - Llm(LlmCli), - - /// Manage Code Bridge subscription for this workspace. - Bridge(BridgeCommand), + /// Inspect feature flags. + Features(FeaturesCli), } #[derive(Debug, Parser)] -struct AppServerArgs { - /// Accepted for Codex Desktop compatibility. Every Code handles analytics - /// policy through its normal config path, so this flag is intentionally a - /// no-op for the app-server process. - #[arg(long = "analytics-default-enabled", default_value_t = false)] - _analytics_default_enabled: bool, +#[command(bin_name = "codex plugin")] +struct PluginCli { + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, - /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`. - #[arg( - long = "listen", - value_name = "URL", - default_value = AppServerTransport::DEFAULT_LISTEN_URL - )] - listen: AppServerTransport, + #[command(subcommand)] + subcommand: PluginSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +enum PluginSubcommand { + /// Manage plugin marketplaces for Codex. + Marketplace(MarketplaceCli), } #[derive(Debug, Parser)] @@ -199,146 +202,157 @@ struct CompletionCommand { } #[derive(Debug, Parser)] -struct ResumeCommand { - /// Conversation/session id (UUID). When provided, resumes this session. - /// If omitted, use --last to pick the most recent recorded session. - #[arg(value_name = "SESSION_ID")] - session_id: Option, +struct DebugCommand { + #[command(subcommand)] + subcommand: DebugSubcommand, +} - /// Continue the most recent session without showing the picker. - #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] - last: bool, +#[derive(Debug, clap::Subcommand)] +enum DebugSubcommand { + /// Render the raw model catalog as JSON. + Models(DebugModelsCommand), - #[clap(flatten)] - config_overrides: TuiCli, -} + /// Tooling: helps debug the app server. + AppServer(DebugAppServerCommand), -#[derive(Debug, Parser)] -struct DebugArgs { - #[command(subcommand)] - cmd: DebugCommand, + /// Render the model-visible prompt input list as JSON. + PromptInput(DebugPromptInputCommand), + + /// Replay a rollout trace bundle and write reduced state JSON. + #[clap(hide = true)] + TraceReduce(DebugTraceReduceCommand), + + /// Internal: reset local memory state for a fresh start. + #[clap(hide = true)] + ClearMemories, } #[derive(Debug, Parser)] -struct BridgeCommand { +struct DebugAppServerCommand { #[command(subcommand)] - action: BridgeAction, + subcommand: DebugAppServerSubcommand, } #[derive(Debug, clap::Subcommand)] -enum BridgeAction { - /// View or change the bridge subscription for the current workspace. - Subscription(BridgeSubscriptionCommand), +enum DebugAppServerSubcommand { + // Send message to app server V2. + SendMessageV2(DebugAppServerSendMessageV2Command), +} - /// Show bridge metadata for the current workspace. - #[clap(alias = "ls")] - List(BridgeListCommand), +#[derive(Debug, Parser)] +struct DebugAppServerSendMessageV2Command { + #[arg(value_name = "USER_MESSAGE", required = true)] + user_message: String, +} - /// Stream live bridge events. - Tail(BridgeTailCommand), +#[derive(Debug, Parser)] +struct DebugPromptInputCommand { + /// Optional user prompt to append after session context. + #[arg(value_name = "PROMPT")] + prompt: Option, + + /// Optional image(s) to attach to the user prompt. + #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] + images: Vec, +} - /// Request a screenshot from control-capable bridge clients. - Screenshot(BridgeScreenshotCommand), +#[derive(Debug, Parser)] +struct DebugModelsCommand { + /// Skip refresh and dump only the bundled catalog shipped with this binary. + #[arg(long = "bundled", default_value_t = false)] + bundled: bool, +} - /// Execute JavaScript via the bridge control channel (eval). - #[clap(alias = "js")] - Javascript(BridgeJavascriptCommand), +#[derive(Debug, Parser)] +struct DebugTraceReduceCommand { + /// Trace bundle directory containing manifest.json and trace.jsonl. + #[arg(value_name = "TRACE_BUNDLE")] + trace_bundle: PathBuf, + + /// Output path for reduced RolloutTrace JSON. Defaults to TRACE_BUNDLE/state.json. + #[arg(long = "output", short = 'o', value_name = "FILE")] + output: Option, } #[derive(Debug, Parser)] -struct BridgeSubscriptionCommand { - /// Show the current desired subscription (defaults if no override file). - #[arg(long, default_value_t = false)] - show: bool, +struct ResumeCommand { + /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. + /// If omitted, use --last to pick the most recent recorded session. + #[arg(value_name = "SESSION_ID")] + session_id: Option, - /// CSV list of levels: errors,warn,info,trace (default: errors). - #[arg(long, value_delimiter = ',')] - levels: Option>, + /// Continue the most recent session without showing the picker. + #[arg(long = "last", default_value_t = false)] + last: bool, - /// CSV list of capabilities: screenshot,pageview,control,console,error. - #[arg(long, value_delimiter = ',')] - capabilities: Option>, + /// Show all sessions (disables cwd filtering and shows CWD column). + #[arg(long = "all", default_value_t = false)] + all: bool, - /// LLM overload filter: off|minimal|aggressive. - #[arg(long, value_name = "FILTER")] - filter: Option, + /// Include non-interactive sessions in the resume picker and --last selection. + #[arg(long = "include-non-interactive", default_value_t = false)] + include_non_interactive: bool, - /// Remove the override file and revert to defaults (errors only). - #[arg(long, default_value_t = false)] - clear: bool, -} + #[clap(flatten)] + remote: InteractiveRemoteOptions, -#[derive(Debug, Parser)] -struct BridgeListCommand {} + #[clap(flatten)] + config_overrides: TuiCli, +} #[derive(Debug, Parser)] -struct BridgeTailCommand { - /// Minimum level to subscribe to (errors|warn|info|trace). - #[arg(long, default_value = "info", value_parser = ["errors", "warn", "info", "trace"])] - level: String, +struct ForkCommand { + /// Conversation/session id (UUID). When provided, forks this session. + /// If omitted, use --last to pick the most recent recorded session. + #[arg(value_name = "SESSION_ID")] + session_id: Option, - /// Bridge target to use (index from `code bridge list` or metadata path). - #[arg(long = "bridge", value_name = "PATH|INDEX")] - bridge: Option, + /// Fork the most recent session without showing the picker. + #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] + last: bool, - /// Print raw JSON frames instead of summaries. - #[arg(long, default_value_t = false)] - raw: bool, -} + /// Show all sessions (disables cwd filtering and shows CWD column). + #[arg(long = "all", default_value_t = false)] + all: bool, -#[derive(Debug, Parser)] -struct BridgeScreenshotCommand { - /// Seconds to wait for a control response/screenshot. - #[arg(long, default_value_t = 10)] - timeout: u64, + #[clap(flatten)] + remote: InteractiveRemoteOptions, - /// Bridge target to use (index from `code bridge list` or metadata path). - #[arg(long = "bridge", value_name = "PATH|INDEX")] - bridge: Option, + #[clap(flatten)] + config_overrides: TuiCli, } #[derive(Debug, Parser)] -struct BridgeJavascriptCommand { - /// JavaScript to run inside the bridge client (eval). - code: String, - - /// Seconds to wait for a control response. - #[arg(long, default_value_t = 10)] - timeout: u64, - - /// Bridge target to use (index from `code bridge list` or metadata path). - #[arg(long = "bridge", value_name = "PATH|INDEX")] - bridge: Option, +struct SandboxArgs { + #[command(subcommand)] + cmd: SandboxCommand, } -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SubscriptionOverride { - #[serde(default = "default_levels")] - levels: Vec, - #[serde(default)] - capabilities: Vec, - #[serde(default = "default_filter", alias = "llm_filter")] - llm_filter: String, -} +#[derive(Debug, clap::Subcommand)] +enum SandboxCommand { + /// Run a command under Seatbelt (macOS only). + #[clap(visible_alias = "seatbelt")] + Macos(SeatbeltCommand), -const SUBSCRIPTION_OVERRIDE_FILE: &str = "code-bridge.subscription.json"; + /// Run a command under the Linux sandbox (bubblewrap by default). + #[clap(visible_alias = "landlock")] + Linux(LandlockCommand), -fn default_levels() -> Vec { - vec!["errors".to_string()] + /// Run a command under Windows restricted token (Windows only). + Windows(WindowsCommand), } -fn default_filter() -> String { - "off".to_string() +#[derive(Debug, Parser)] +struct ExecpolicyCommand { + #[command(subcommand)] + sub: ExecpolicySubcommand, } #[derive(Debug, clap::Subcommand)] -enum DebugCommand { - /// Run a command under Seatbelt (macOS only). - Seatbelt(SeatbeltCommand), - - /// Run a command under Landlock+seccomp (Linux only). - Landlock(LandlockCommand), +enum ExecpolicySubcommand { + /// Check execpolicy files against a command. + #[clap(name = "check")] + Check(ExecPolicyCheckCommand), } #[derive(Debug, Parser)] @@ -352,17 +366,23 @@ struct LoginCommand { )] with_api_key: bool, + #[arg( + long = "with-access-token", + help = "Read the access token from stdin (e.g. `printenv CODEX_ACCESS_TOKEN | codex login --with-access-token`)" + )] + with_access_token: bool, + #[arg( long = "api-key", + num_args = 0..=1, + default_missing_value = "", value_name = "API_KEY", help = "(deprecated) Previously accepted the API key directly; now exits with guidance to use --with-api-key", hide = true )] api_key: Option, - /// EXPERIMENTAL: Use device code flow (not yet supported) - /// This feature is experimental and may changed in future releases. - #[arg(long = "experimental_use-device-code", hide = true)] + #[arg(long = "device-auth")] use_device_code: bool, /// EXPERIMENTAL: Use custom OAuth issuer base URL (advanced) @@ -391,6 +411,86 @@ struct LogoutCommand { } #[derive(Debug, Parser)] +struct AppServerCommand { + /// Omit to run the app server; specify a subcommand for tooling. + #[command(subcommand)] + subcommand: Option, + + /// Transport endpoint URL. Supported values: `stdio://` (default), + /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. + #[arg( + long = "listen", + value_name = "URL", + default_value = codex_app_server::AppServerTransport::DEFAULT_LISTEN_URL + )] + listen: codex_app_server::AppServerTransport, + + /// Controls whether analytics are enabled by default. + /// + /// Analytics are disabled by default for app-server. Users have to explicitly opt in + /// via the `analytics` section in the config.toml file. + /// + /// However, for first-party use cases like the VSCode IDE extension, we default analytics + /// to be enabled by default by setting this flag. Users can still opt out by setting this + /// in their config.toml: + /// + /// ```toml + /// [analytics] + /// enabled = false + /// ``` + /// + /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. + #[arg(long = "analytics-default-enabled")] + analytics_default_enabled: bool, + + #[command(flatten)] + auth: codex_app_server::AppServerWebsocketAuthArgs, +} + +#[derive(Debug, Parser)] +struct ExecServerCommand { + /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`. + #[arg(long = "listen", value_name = "URL", conflicts_with = "remote")] + listen: Option, + + /// Register this exec-server as a remote executor using the given base URL. + #[arg(long = "remote", value_name = "URL", requires = "executor_id")] + remote: Option, + + /// Executor id to attach to when registering remotely. + #[arg(long = "executor-id", value_name = "ID")] + executor_id: Option, + + /// Human-readable executor name. + #[arg(long = "name", value_name = "NAME")] + name: Option, +} + +#[derive(Debug, clap::Subcommand)] +#[allow(clippy::enum_variant_names)] +enum AppServerSubcommand { + /// Proxy stdio bytes to the running app-server control socket. + Proxy(AppServerProxyCommand), + + /// [experimental] Generate TypeScript bindings for the app server protocol. + GenerateTs(GenerateTsCommand), + + /// [experimental] Generate JSON Schema for the app server protocol. + GenerateJsonSchema(GenerateJsonSchemaCommand), + + /// [internal] Generate internal JSON Schema artifacts for Codex tooling. + #[clap(hide = true)] + GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand), +} + +#[derive(Debug, Args)] +struct AppServerProxyCommand { + /// Path to the app-server Unix domain socket to connect to. + #[arg(long = "sock", value_name = "SOCKET_PATH", value_parser = parse_socket_path)] + socket_path: Option, +} + +#[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written #[arg(short = 'o', long = "out", value_name = "DIR")] @@ -399,69 +499,274 @@ struct GenerateTsCommand { /// Optional path to the Prettier executable to format generated files #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] prettier: Option, + + /// Include experimental methods and fields in the generated output + #[arg(long = "experimental", default_value_t = false)] + experimental: bool, +} + +#[derive(Debug, Args)] +struct GenerateJsonSchemaCommand { + /// Output directory where the schema bundle will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, + + /// Include experimental methods and fields in the generated output + #[arg(long = "experimental", default_value_t = false)] + experimental: bool, +} + +#[derive(Debug, Args)] +struct GenerateInternalJsonSchemaCommand { + /// Output directory where internal JSON Schema artifacts will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, } #[derive(Debug, Parser)] -struct OrderReplayArgs { - /// Path to a response.json captured under ~/.code/debug_logs/*_response.json - /// (legacy ~/.codex/debug_logs/ is still read). - response_json: std::path::PathBuf, - /// Path to codex-tui.log (typically ~/.code/debug_logs/codex-tui.log). - tui_log: std::path::PathBuf, +struct StdioToUdsCommand { + /// Path to the Unix domain socket to connect to. + #[arg(value_name = "SOCKET_PATH", value_parser = parse_socket_path)] + socket_path: AbsolutePathBuf, +} + +fn parse_socket_path(raw: &str) -> Result { + AbsolutePathBuf::relative_to_current_dir(raw) + .map_err(|err| format!("failed to resolve socket path `{raw}`: {err}")) +} + +fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { + let AppExitInfo { + token_usage, + thread_id: conversation_id, + .. + } = exit_info; + + let mut lines = Vec::new(); + if !token_usage.is_zero() { + lines.push(token_usage.to_string()); + } + + if let Some(resume_cmd) = + codex_core::util::resume_command(/*thread_name*/ None, conversation_id) + { + let command = if color_enabled { + resume_cmd.cyan().to_string() + } else { + resume_cmd + }; + lines.push(format!("To continue this session, run {command}")); + } + + lines +} + +/// Handle the app exit and print the results. Optionally run the update action. +fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { + match exit_info.exit_reason { + ExitReason::Fatal(message) => { + eprintln!("ERROR: {message}"); + std::process::exit(1); + } + ExitReason::UserRequested => { /* normal exit */ } + } + + let update_action = exit_info.update_action; + let color_enabled = supports_color::on(Stream::Stdout).is_some(); + for line in format_exit_messages(exit_info, color_enabled) { + println!("{line}"); + } + if let Some(action) = update_action { + run_update_action(action)?; + } + Ok(()) +} + +/// Run the update action and print the result. +fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { + println!(); + let cmd_str = action.command_str(); + println!("Updating Codex via `{cmd_str}`..."); + + let status = { + #[cfg(windows)] + { + if action == UpdateAction::StandaloneWindows { + let (cmd, args) = action.command_args(); + // Run the standalone PowerShell installer with PowerShell + // itself. Routing this through `cmd.exe /C` would parse + // PowerShell metacharacters like `|` before PowerShell sees + // the installer command. + std::process::Command::new(cmd).args(args).status()? + } else { + // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). + std::process::Command::new("cmd") + .args(["/C", &cmd_str]) + .status()? + } + } + #[cfg(not(windows))] + { + let (cmd, args) = action.command_args(); + let command_path = crate::wsl_paths::normalize_for_wsl(cmd); + let normalized_args: Vec = args + .iter() + .map(crate::wsl_paths::normalize_for_wsl) + .collect(); + std::process::Command::new(&command_path) + .args(&normalized_args) + .status()? + } + }; + if !status.success() { + anyhow::bail!("`{cmd_str}` failed with status {status}"); + } + println!("\n🎉 Update ran successfully! Please restart Codex."); + Ok(()) +} + +fn run_update_command() -> anyhow::Result<()> { + #[cfg(debug_assertions)] + { + anyhow::bail!( + "`codex update` is not available in debug builds. Install a release build of Codex to use this command." + ); + } + + #[cfg(not(debug_assertions))] + { + let Some(action) = codex_tui::get_update_action() else { + anyhow::bail!( + "Could not detect the Codex installation method. Please update manually: https://developers.openai.com/codex/cli/" + ); + }; + run_update_action(action) + } +} + +fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> { + cmd.run() +} + +async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> { + match cmd.subcommand { + DebugAppServerSubcommand::SendMessageV2(cmd) => { + let codex_bin = std::env::current_exe()?; + codex_app_server_test_client::send_message_v2(&codex_bin, &[], cmd.user_message, &None) + .await + } + } +} + +#[derive(Debug, Default, Parser, Clone)] +struct FeatureToggles { + /// Enable a feature (repeatable). Equivalent to `-c features.=true`. + #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] + enable: Vec, + + /// Disable a feature (repeatable). Equivalent to `-c features.=false`. + #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] + disable: Vec, +} + +#[derive(Debug, Default, Parser, Clone)] +struct InteractiveRemoteOptions { + /// Connect the TUI to a remote app server websocket endpoint. + /// + /// Accepted forms: `ws://host:port` or `wss://host:port`. + #[arg(long = "remote", value_name = "ADDR")] + remote: Option, + + /// Name of the environment variable containing the bearer token to send to + /// a remote app server websocket. + #[arg(long = "remote-auth-token-env", value_name = "ENV_VAR")] + remote_auth_token_env: Option, +} + +impl FeatureToggles { + fn to_overrides(&self) -> anyhow::Result> { + let mut v = Vec::new(); + for feature in &self.enable { + Self::validate_feature(feature)?; + v.push(format!("features.{feature}=true")); + } + for feature in &self.disable { + Self::validate_feature(feature)?; + v.push(format!("features.{feature}=false")); + } + Ok(v) + } + + fn validate_feature(feature: &str) -> anyhow::Result<()> { + if is_known_feature_key(feature) { + Ok(()) + } else { + anyhow::bail!("Unknown feature flag: {feature}") + } + } } #[derive(Debug, Parser)] -struct PreviewArgs { - /// Slug identifier (e.g., faster-downloads) - slug: String, - /// Optional owner/repo to override (defaults to just-every/code or $GITHUB_REPOSITORY) - #[arg(long = "repo", value_name = "OWNER/REPO")] - repo: Option, - /// Output directory where the binary will be extracted - #[arg(short = 'o', long = "out", value_name = "DIR")] - out_dir: Option, - /// Additional args to pass to the downloaded binary - #[arg(trailing_var_arg = true)] - extra: Vec, +struct FeaturesCli { + #[command(subcommand)] + sub: FeaturesSubcommand, +} + +#[derive(Debug, Parser)] +enum FeaturesSubcommand { + /// List known features with their stage and effective state. + List, + /// Enable a feature in config.toml. + Enable(FeatureSetArgs), + /// Disable a feature in config.toml. + Disable(FeatureSetArgs), +} + +#[derive(Debug, Parser)] +struct FeatureSetArgs { + /// Feature key to update (for example: unified_exec). + feature: String, +} + +const REMOTE_CONTROL_FEATURE_OVERRIDE: &str = "features.remote_control=true"; + +fn enable_remote_control_for_invocation(config_overrides: &mut CliConfigOverrides) { + config_overrides + .raw_overrides + .push(REMOTE_CONTROL_FEATURE_OVERRIDE.to_string()); +} + +fn stage_str(stage: Stage) -> &'static str { + match stage { + Stage::UnderDevelopment => "under development", + Stage::Experimental { .. } => "experimental", + Stage::Stable => "stable", + Stage::Deprecated => "deprecated", + Stage::Removed => "removed", + } } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|code_linux_sandbox_exe| async move { - cli_main(code_linux_sandbox_exe).await?; + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { + cli_main(arg0_paths).await?; Ok(()) }) } -async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> { +async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let MultitoolCli { - config_overrides: root_config_overrides, + config_overrides: mut root_config_overrides, + feature_toggles, + remote, mut interactive, - auto_drive, - demo_developer_message, subcommand, } = MultitoolCli::parse(); - interactive.finalize_defaults(); - interactive.demo_developer_message = demo_developer_message.clone(); - - // The TUI already runs housekeeping. For headless `exec` sessions, kick off - // housekeeping early so stale worktrees/branches don't accumulate. - let housekeeping_handle = match &subcommand { - Some(Subcommand::Exec(_)) | Some(Subcommand::Auto(_)) => { - match code_core::config::find_code_home() { - Ok(code_home) => Some(std::thread::spawn(move || { - if let Err(err) = code_core::run_housekeeping_if_due(&code_home) { - tracing::warn!("code home housekeeping failed: {err}"); - } - })), - Err(err) => { - tracing::warn!("failed to resolve code home for housekeeping: {err}"); - None - } - } - } - _ => None, - }; + // Fold --enable/--disable into config overrides so they flow to all subcommands. + let toggle_overrides = feature_toggles.to_overrides()?; + root_config_overrides.raw_overrides.extend(toggle_overrides); + let root_remote = remote.remote; + let root_remote_auth_token_env = remote.remote_auth_token_env; match subcommand { None => { @@ -469,95 +774,226 @@ async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> &mut interactive.config_overrides, root_config_overrides.clone(), ); - let ExitSummary { - token_usage, - session_id, - } = code_tui::run_main(interactive, code_linux_sandbox_exe).await?; - if !token_usage.is_zero() { - println!( - "{}", - code_core::protocol::FinalOutput::from(token_usage.clone()) - ); - } - if let Some(session_id) = session_id { - println!( - "To continue this session, run {} resume {}", - resume_command_name(), - session_id - ); - } + let exit_info = run_interactive_tui( + interactive, + root_remote.clone(), + root_remote_auth_token_env.clone(), + arg0_paths.clone(), + ) + .await?; + handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { - if auto_drive { - exec_cli.auto_drive = true; - } - exec_cli.demo_developer_message = demo_developer_message.clone(); + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "exec", + )?; + exec_cli + .shared + .inherit_exec_root_options(&interactive.shared); prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); - code_exec::run_main(exec_cli, code_linux_sandbox_exe).await?; + codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } - Some(Subcommand::Auto(mut exec_cli)) => { - exec_cli.auto_drive = true; - if !exec_cli.full_auto && !exec_cli.dangerously_bypass_approvals_and_sandbox { - exec_cli.full_auto = true; - } - exec_cli.demo_developer_message = demo_developer_message.clone(); + Some(Subcommand::Review(review_args)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "review", + )?; + let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; + exec_cli.command = Some(ExecCommand::Review(review_args)); prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); - code_exec::run_main(exec_cli, code_linux_sandbox_exe).await?; + codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::McpServer) => { - code_mcp_server::run_main(code_linux_sandbox_exe, root_config_overrides).await?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp-server", + )?; + codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp", + )?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; } - Some(Subcommand::AppServer(args)) => { - code_app_server::run_main_with_transport( - code_linux_sandbox_exe, + Some(Subcommand::Plugin(plugin_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "plugin", + )?; + let PluginCli { + mut config_overrides, + subcommand, + } = plugin_cli; + prepend_config_flags(&mut config_overrides, root_config_overrides.clone()); + match subcommand { + PluginSubcommand::Marketplace(mut marketplace_cli) => { + prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides); + marketplace_cli.run().await?; + } + } + } + Some(Subcommand::AppServer(app_server_cli)) => { + let AppServerCommand { + subcommand, + listen, + analytics_default_enabled, + auth, + } = app_server_cli; + reject_remote_mode_for_app_server_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + subcommand.as_ref(), + )?; + match subcommand { + None => { + let transport = listen; + let auth = auth.try_into_settings()?; + codex_app_server::run_main_with_transport( + arg0_paths.clone(), + root_config_overrides, + codex_config::LoaderOverrides::default(), + analytics_default_enabled, + transport, + codex_protocol::protocol::SessionSource::VSCode, + auth, + ) + .await?; + } + Some(AppServerSubcommand::Proxy(proxy_cli)) => { + let socket_path = match proxy_cli.socket_path { + Some(socket_path) => socket_path, + None => { + let codex_home = find_codex_home()?; + codex_app_server::app_server_control_socket_path(&codex_home)? + } + }; + codex_stdio_to_uds::run(socket_path.as_path()).await?; + } + Some(AppServerSubcommand::GenerateTs(gen_cli)) => { + let options = codex_app_server_protocol::GenerateTsOptions { + experimental_api: gen_cli.experimental, + ..Default::default() + }; + codex_app_server_protocol::generate_ts_with_options( + &gen_cli.out_dir, + gen_cli.prettier.as_deref(), + options, + )?; + } + Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { + codex_app_server_protocol::generate_json_with_experimental( + &gen_cli.out_dir, + gen_cli.experimental, + )?; + } + Some(AppServerSubcommand::GenerateInternalJsonSchema(gen_cli)) => { + codex_app_server_protocol::generate_internal_json_schema(&gen_cli.out_dir)?; + } + } + } + Some(Subcommand::RemoteControl) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "remote-control", + )?; + enable_remote_control_for_invocation(&mut root_config_overrides); + codex_app_server::run_main_with_transport( + arg0_paths.clone(), root_config_overrides, - args.listen, + codex_config::LoaderOverrides::default(), + /*default_analytics_enabled*/ false, + codex_app_server::AppServerTransport::Off, + codex_protocol::protocol::SessionSource::Cli, + codex_app_server::AppServerWebsocketAuthSettings::default(), ) .await?; } + #[cfg(any(target_os = "macos", target_os = "windows"))] + Some(Subcommand::App(app_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "app", + )?; + app_cmd::run_app(app_cli).await?; + } Some(Subcommand::Resume(ResumeCommand { session_id, last, - mut config_overrides, + all, + include_non_interactive, + remote, + config_overrides, })) => { - config_overrides.finalize_defaults(); interactive = finalize_resume_interactive( interactive, root_config_overrides.clone(), session_id, last, + all, + include_non_interactive, config_overrides, ); - let ExitSummary { - token_usage, + let exit_info = run_interactive_tui( + interactive, + remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), + arg0_paths.clone(), + ) + .await?; + handle_app_exit(exit_info)?; + } + Some(Subcommand::Fork(ForkCommand { + session_id, + last, + all, + remote, + config_overrides, + })) => { + interactive = finalize_fork_interactive( + interactive, + root_config_overrides.clone(), session_id, - } = code_tui::run_main(interactive, code_linux_sandbox_exe).await?; - if !token_usage.is_zero() { - println!( - "{}", - code_core::protocol::FinalOutput::from(token_usage.clone()) - ); - } - if let Some(session_id) = session_id { - println!( - "To continue this session, run {} resume {}", - resume_command_name(), - session_id - ); - } + last, + all, + config_overrides, + ); + let exit_info = run_interactive_tui( + interactive, + remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), + arg0_paths.clone(), + ) + .await?; + handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "login", + )?; prepend_config_flags( &mut login_cli.config_overrides, root_config_overrides.clone(), @@ -567,7 +1003,12 @@ async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> run_login_status(login_cli.config_overrides).await; } None => { - if login_cli.use_device_code { + if login_cli.with_api_key && login_cli.with_access_token { + eprintln!( + "Choose one login credential source: --with-api-key or --with-access-token." + ); + std::process::exit(1); + } else if login_cli.use_device_code { run_login_with_device_code( login_cli.config_overrides, login_cli.issuer_base_url, @@ -582,6 +1023,9 @@ async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; + } else if login_cli.with_access_token { + let access_token = read_access_token_from_stdin(); + run_login_with_access_token(login_cli.config_overrides, access_token).await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; } @@ -589,6 +1033,11 @@ async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> } } Some(Subcommand::Logout(mut logout_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "logout", + )?; prepend_config_flags( &mut logout_cli.config_overrides, root_config_overrides.clone(), @@ -596,420 +1045,599 @@ async fn cli_main(code_linux_sandbox_exe: Option) -> anyhow::Result<()> run_logout(logout_cli.config_overrides).await; } Some(Subcommand::Completion(completion_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "completion", + )?; print_completion(completion_cli); } + Some(Subcommand::Update) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "update", + )?; + run_update_command()?; + } Some(Subcommand::Cloud(mut cloud_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "cloud", + )?; prepend_config_flags( &mut cloud_cli.config_overrides, root_config_overrides.clone(), ); - code_cloud_tasks::run_main(cloud_cli, code_linux_sandbox_exe).await?; + codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone()) + .await?; } - Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { - DebugCommand::Seatbelt(mut seatbelt_cli) => { + Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { + SandboxCommand::Macos(mut seatbelt_cli) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox macos", + )?; prepend_config_flags( &mut seatbelt_cli.config_overrides, root_config_overrides.clone(), ); - code_cli::debug_sandbox::run_command_under_seatbelt( + codex_cli::run_command_under_seatbelt( seatbelt_cli, - code_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe.clone(), ) .await?; } - DebugCommand::Landlock(mut landlock_cli) => { + SandboxCommand::Linux(mut landlock_cli) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox linux", + )?; prepend_config_flags( &mut landlock_cli.config_overrides, root_config_overrides.clone(), ); - code_cli::debug_sandbox::run_command_under_landlock( + codex_cli::run_command_under_landlock( landlock_cli, - code_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe.clone(), ) .await?; } - }, - Some(Subcommand::Apply(mut apply_cli)) => { - prepend_config_flags( - &mut apply_cli.config_overrides, + SandboxCommand::Windows(mut windows_cli) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox windows", + )?; + prepend_config_flags( + &mut windows_cli.config_overrides, + root_config_overrides.clone(), + ); + codex_cli::run_command_under_windows( + windows_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + ) + .await?; + } + }, + Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { + DebugSubcommand::Models(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug models", + )?; + run_debug_models_command(cmd, root_config_overrides).await?; + } + DebugSubcommand::AppServer(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug app-server", + )?; + run_debug_app_server_command(cmd).await?; + } + DebugSubcommand::PromptInput(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug prompt-input", + )?; + run_debug_prompt_input_command( + cmd, + root_config_overrides, + interactive, + arg0_paths.clone(), + ) + .await?; + } + DebugSubcommand::TraceReduce(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug trace-reduce", + )?; + run_debug_trace_reduce_command(cmd).await?; + } + DebugSubcommand::ClearMemories => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug clear-memories", + )?; + run_debug_clear_memories_command(&root_config_overrides, &interactive).await?; + } + }, + Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { + ExecpolicySubcommand::Check(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "execpolicy check", + )?; + run_execpolicycheck(cmd)? + } + }, + Some(Subcommand::Apply(mut apply_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "apply", + )?; + prepend_config_flags( + &mut apply_cli.config_overrides, root_config_overrides.clone(), ); - run_apply_command(apply_cli, None).await?; + run_apply_command(apply_cli, /*cwd*/ None).await?; } Some(Subcommand::ResponsesApiProxy(args)) => { - tokio::task::spawn_blocking(move || code_responses_api_proxy::run_main(args)) + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "responses-api-proxy", + )?; + tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } - Some(Subcommand::GenerateTs(gen_cli)) => { - code_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; - } - Some(Subcommand::OrderReplay(args)) => { - order_replay_main(args)?; - } - Some(Subcommand::Doctor) => { - doctor_main().await?; - } - Some(Subcommand::Preview(args)) => { - preview_main(args).await?; - } - Some(Subcommand::UpdateCheck(args)) => { - run_update_check(args).await?; + Some(Subcommand::StdioToUds(cmd)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "stdio-to-uds", + )?; + let socket_path = cmd.socket_path; + codex_stdio_to_uds::run(socket_path.as_path()).await?; } - Some(Subcommand::Update(args)) => { - run_update(args).await?; + Some(Subcommand::ExecServer(cmd)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "exec-server", + )?; + run_exec_server_command(cmd, &arg0_paths).await?; } - Some(Subcommand::Bridge(bridge_cli)) => { - run_bridge_command(bridge_cli).await?; - } - Some(Subcommand::Llm(mut llm_cli)) => { - prepend_config_flags( - &mut llm_cli.config_overrides, - root_config_overrides.clone(), - ); - run_llm(llm_cli).await?; - } - } - - if let Some(handle) = housekeeping_handle { - let _ = handle.join(); - } + Some(Subcommand::Features(FeaturesCli { sub })) => match sub { + FeaturesSubcommand::List => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features list", + )?; + // Respect root-level `-c` overrides plus top-level flags like `--profile`. + let mut cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + + // Honor `--search` via the canonical web_search mode. + if interactive.web_search { + cli_kv_overrides.push(( + "web_search".to_string(), + toml::Value::String("live".to_string()), + )); + } - Ok(()) -} + // Thread through relevant top-level flags (at minimum, `--profile`). + let overrides = ConfigOverrides { + config_profile: interactive.config_profile.clone(), + ..Default::default() + }; -/// Prepend root-level overrides so they have lower precedence than -/// CLI-specific ones specified after the subcommand (if any). -fn prepend_config_flags( - subcommand_config_overrides: &mut CliConfigOverrides, - cli_config_overrides: CliConfigOverrides, -) { - subcommand_config_overrides - .raw_overrides - .splice(0..0, cli_config_overrides.raw_overrides); -} + let config = Config::load_with_cli_overrides_and_harness_overrides( + cli_kv_overrides, + overrides, + ) + .await?; + let mut rows = Vec::with_capacity(FEATURES.len()); + let mut name_width = 0; + let mut stage_width = 0; + for def in FEATURES { + let name = def.key; + let stage = stage_str(def.stage); + let enabled = config.features.enabled(def.id); + name_width = name_width.max(name.len()); + stage_width = stage_width.max(stage.len()); + rows.push((name, stage, enabled)); + } + rows.sort_unstable_by_key(|(name, _, _)| *name); -async fn run_bridge_command(cmd: BridgeCommand) -> anyhow::Result<()> { - match cmd.action { - BridgeAction::Subscription(sub_cmd) => run_bridge_subscription(sub_cmd), - BridgeAction::List(list_cmd) => run_bridge_list(list_cmd).await, - BridgeAction::Tail(tail_cmd) => run_bridge_tail(tail_cmd).await, - BridgeAction::Screenshot(shot_cmd) => run_bridge_screenshot(shot_cmd).await, - BridgeAction::Javascript(js_cmd) => run_bridge_javascript(js_cmd).await, + for (name, stage, enabled) in rows { + println!("{name: { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features enable", + )?; + enable_feature_in_config(&interactive, &feature).await?; + } + FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features disable", + )?; + disable_feature_in_config(&interactive, &feature).await?; + } + }, } -} -fn run_bridge_subscription(cmd: BridgeSubscriptionCommand) -> anyhow::Result<()> { - let cwd = std::env::current_dir().context("cannot read current dir")?; - let override_path = resolve_subscription_override_path(&cwd); + Ok(()) +} - if cmd.clear { - if override_path.exists() { - fs::remove_file(&override_path).context("failed to remove subscription override")?; - println!( - "Removed {}. The running Code session will revert to defaults (errors only) within a few seconds.", - override_path.display() - ); - } else { - println!("No override file to remove at {}", override_path.display()); +async fn run_exec_server_command( + cmd: ExecServerCommand, + arg0_paths: &Arg0DispatchPaths, +) -> anyhow::Result<()> { + let codex_self_exe = arg0_paths + .codex_self_exe + .clone() + .ok_or_else(|| anyhow::anyhow!("Codex executable path is not configured"))?; + let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new( + codex_self_exe, + arg0_paths.codex_linux_sandbox_exe.clone(), + )?; + if let Some(base_url) = cmd.remote { + let executor_id = cmd + .executor_id + .ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?; + let mut remote_config = + codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?; + if let Some(name) = cmd.name { + remote_config.name = name; } + codex_exec_server::run_remote_executor(remote_config, runtime_paths).await?; return Ok(()); } + let listen_url = cmd + .listen + .as_deref() + .unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL); + codex_exec_server::run_main(listen_url, runtime_paths) + .await + .map_err(anyhow::Error::from_boxed) +} - if cmd.show && cmd.levels.is_none() && cmd.capabilities.is_none() && cmd.filter.is_none() { - let sub = read_subscription_file(&override_path)?; - println!("Subscription override path: {}", override_path.display()); - println!("levels : {}", sub.levels.join(", ")); - println!("capabilities : {}", sub.capabilities.join(", ")); - println!("llm_filter : {}", sub.llm_filter); - println!("(Running Code picks up changes every ~5s.)"); - return Ok(()); - } - - let mut sub = read_subscription_file(&override_path)?; - - if let Some(levels) = cmd.levels { - sub.levels = normalise_cli_vec(levels, default_levels()); - } - if let Some(caps) = cmd.capabilities { - sub.capabilities = normalise_cli_vec(caps, Vec::new()); - } - if let Some(filter) = cmd.filter { - sub.llm_filter = filter.trim().to_lowercase(); - } - - if let Some(parent) = override_path.parent() { - fs::create_dir_all(parent).context("failed to create .code dir")?; - } - let data = serde_json::to_string_pretty(&sub).context("serialize subscription")?; - fs::write(&override_path, data).context("write subscription override")?; +async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { + FeatureToggles::validate_feature(feature)?; + let codex_home = find_codex_home()?; + ConfigEditsBuilder::new(&codex_home) + .with_profile(interactive.config_profile.as_deref()) + .set_feature_enabled(feature, /*enabled*/ true) + .apply() + .await?; + println!("Enabled feature `{feature}` in config.toml."); + maybe_print_under_development_feature_warning(&codex_home, interactive, feature); + Ok(()) +} - println!("Updated {}", override_path.display()); - println!("levels : {}", sub.levels.join(", ")); - println!("capabilities : {}", sub.capabilities.join(", ")); - println!("llm_filter : {}", sub.llm_filter); - println!("Running Code session will resubscribe within ~5s."); +async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { + FeatureToggles::validate_feature(feature)?; + let codex_home = find_codex_home()?; + ConfigEditsBuilder::new(&codex_home) + .with_profile(interactive.config_profile.as_deref()) + .set_feature_enabled(feature, /*enabled*/ false) + .apply() + .await?; + println!("Disabled feature `{feature}` in config.toml."); Ok(()) } -async fn run_bridge_list(_cmd: BridgeListCommand) -> anyhow::Result<()> { - let cwd = std::env::current_dir().context("cannot read current dir")?; - let targets = bridge::discover_bridge_targets(&cwd)?; - if targets.is_empty() { - println!( - "No Code Bridge metadata found. Start `code-bridge-host` in this workspace and try again." - ); - return Ok(()); +fn maybe_print_under_development_feature_warning( + codex_home: &std::path::Path, + interactive: &TuiCli, + feature: &str, +) { + if interactive.config_profile.is_some() { + return; } - for (idx, target) in targets.iter().enumerate() { - let prefix = if targets.len() > 1 { - format!("#{} ", idx + 1) - } else { - String::new() - }; - let indent = if targets.len() > 1 { " " } else { "" }; - - println!("{}Bridge metadata : {}", prefix, target.meta_path.display()); - println!("{}url : {}", indent, target.meta.url); - if let Some(ws) = target.meta.workspace_path.as_deref() { - println!("{}workspace : {ws}", indent); - } - if let Some(pid) = target.meta.pid { - println!("{}host pid : {pid}", indent); - } - - let hb = match target.heartbeat_age_ms { - Some(ms) => { - let secs = ms as f64 / 1000.0; - if target.stale { - format!("{secs:.1}s ago (stale)") - } else { - format!("{secs:.1}s ago") - } - } - None => "unknown".to_string(), - }; - println!("{}heartbeat : {hb}", indent); - println!("{}stale : {}", indent, if target.stale { "yes" } else { "no" }); - if target.stale { - println!("{}⚠ metadata looks stale; restart code-bridge-host if this persists.", indent); - } - - match bridge::list_control_capable(target).await { - Ok(count) => println!("{}control-capable : {count} bridge client(s)", indent), - Err(err) => println!("{}control-capable : unknown ({err})", indent), - } - - if idx + 1 < targets.len() { - println!(); - } + let Some(spec) = FEATURES.iter().find(|spec| spec.key == feature) else { + return; + }; + if !matches!(spec.stage, Stage::UnderDevelopment) { + return; } - Ok(()) + let config_path = codex_home.join(codex_config::CONFIG_TOML_FILE); + eprintln!( + "Under-development features enabled: {feature}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {}.", + config_path.display() + ); } -async fn run_bridge_tail(cmd: BridgeTailCommand) -> anyhow::Result<()> { - let target = select_bridge_target(cmd.bridge.as_deref())?; - bridge::tail_events(&target, &cmd.level, cmd.raw).await -} +async fn run_debug_trace_reduce_command(cmd: DebugTraceReduceCommand) -> anyhow::Result<()> { + let output = cmd + .output + .unwrap_or_else(|| cmd.trace_bundle.join(REDUCED_STATE_FILE_NAME)); -async fn run_bridge_screenshot(cmd: BridgeScreenshotCommand) -> anyhow::Result<()> { - let target = select_bridge_target(cmd.bridge.as_deref())?; - let outcome = bridge::request_screenshot(&target, cmd.timeout).await?; + let trace = replay_bundle(&cmd.trace_bundle)?; + let reduced_json = serde_json::to_vec_pretty(&trace)?; + tokio::fs::write(&output, reduced_json).await?; + println!("{}", output.display()); - println!( - "Forwarded to {} control-capable bridge(s).", - outcome.delivered - ); + Ok(()) +} - if let Some(res) = outcome.result.as_ref() { - let ok = res.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); - if ok { - let payload = res - .get("result") - .map(|v| v.to_string()) - .unwrap_or_else(|| "ok".to_string()); - println!("Control result : {payload}"); - } else { - let msg = res - .get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .unwrap_or("control failed"); - println!("Control result : {msg}"); - } - } else { - println!("Control result : (no response)"); +async fn run_debug_prompt_input_command( + cmd: DebugPromptInputCommand, + root_config_overrides: CliConfigOverrides, + interactive: TuiCli, + arg0_paths: Arg0DispatchPaths, +) -> anyhow::Result<()> { + let shared = interactive.shared.into_inner(); + let mut cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + if interactive.web_search { + cli_kv_overrides.push(( + "web_search".to_string(), + toml::Value::String("live".to_string()), + )); } - if let Some(bytes) = outcome.screenshot_bytes { - let kb = bytes / 1024; - let mime = outcome.screenshot_mime.unwrap_or_else(|| "unknown".to_string()); - println!("Screenshot : {kb} KB ({mime})"); + let approval_policy = if shared.dangerously_bypass_approvals_and_sandbox { + Some(AskForApproval::Never) + } else { + interactive.approval_policy.map(Into::into) + }; + let sandbox_mode = if shared.dangerously_bypass_approvals_and_sandbox { + Some(codex_protocol::config_types::SandboxMode::DangerFullAccess) } else { - println!("Screenshot : no screenshot event received"); + shared.sandbox_mode.map(Into::into) + }; + let overrides = ConfigOverrides { + model: shared.model, + config_profile: shared.config_profile, + approval_policy, + sandbox_mode, + cwd: shared.cwd, + codex_self_exe: arg0_paths.codex_self_exe, + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, + show_raw_agent_reasoning: shared.oss.then_some(true), + ephemeral: Some(true), + additional_writable_roots: shared.add_dir, + ..Default::default() + }; + let config = + Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + + let mut input = shared + .images + .into_iter() + .chain(cmd.images) + .map(|path| UserInput::LocalImage { path }) + .collect::>(); + if let Some(prompt) = cmd.prompt.or(interactive.prompt) { + input.push(UserInput::Text { + text: prompt.replace("\r\n", "\n").replace('\r', "\n"), + text_elements: Vec::new(), + }); } + let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?; + println!("{}", serde_json::to_string_pretty(&prompt_input)?); + Ok(()) } -async fn run_bridge_javascript(cmd: BridgeJavascriptCommand) -> anyhow::Result<()> { - let target = select_bridge_target(cmd.bridge.as_deref())?; - let outcome = bridge::run_javascript(&target, &cmd.code, cmd.timeout).await?; - - println!( - "Forwarded to {} control-capable bridge(s).", - outcome.delivered - ); - - if let Some(res) = outcome.result.as_ref() { - let ok = res.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); - if ok { - let payload = res - .get("result") - .map(|v| v.to_string()) - .unwrap_or_else(|| "ok".to_string()); - println!("Result : {payload}"); - } else { - let msg = res - .get("error") - .and_then(|e| e.get("message")) - .and_then(|m| m.as_str()) - .unwrap_or("control failed"); - println!("Result : {msg}"); - } +async fn run_debug_models_command( + cmd: DebugModelsCommand, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + let catalog = if cmd.bundled { + bundled_models_response()? } else { - println!("Result : (no response)"); - } + let cli_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let config = Config::load_with_cli_overrides(cli_overrides).await?; + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; + let models_manager = build_models_manager(&config, auth_manager); + models_manager + .raw_model_catalog(RefreshStrategy::OnlineIfUncached) + .await + }; + serde_json::to_writer(std::io::stdout(), &catalog)?; + println!(); Ok(()) } -fn select_bridge_target(selector: Option<&str>) -> anyhow::Result { - let cwd = std::env::current_dir().context("cannot read current dir")?; - let targets = bridge::discover_bridge_targets(&cwd)?; - if targets.is_empty() { - return Err(anyhow!( - "No Code Bridge metadata found. Start `code-bridge-host` in this workspace and try again." - )); +async fn run_debug_clear_memories_command( + root_config_overrides: &CliConfigOverrides, + interactive: &TuiCli, +) -> anyhow::Result<()> { + let cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let overrides = ConfigOverrides { + config_profile: interactive.config_profile.clone(), + ..Default::default() + }; + let config = + Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + + let state_path = state_db_path(config.sqlite_home.as_path()); + let mut cleared_state_db = false; + if tokio::fs::try_exists(&state_path).await? { + let state_db = + StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) + .await?; + state_db.clear_memory_data().await?; + cleared_state_db = true; } - let Some(selector) = selector.map(str::trim).filter(|s| !s.is_empty()) else { - return Ok(targets[0].clone()); + clear_memory_roots_contents(&config.codex_home).await?; + + let mut message = if cleared_state_db { + format!("Cleared memory state from {}.", state_path.display()) + } else { + format!("No state db found at {}.", state_path.display()) }; + message.push_str(&format!( + " Cleared memory directories under {}.", + config.codex_home.display() + )); - if let Ok(idx) = selector.parse::() { - if idx == 0 || idx > targets.len() { - anyhow::bail!("Bridge index out of range (found {}).", targets.len()); - } - return Ok(targets[idx - 1].clone()); - } + println!("{message}"); - let path = PathBuf::from(selector); - for target in &targets { - if paths_match(&target.meta_path, &path) { - return Ok(target.clone()); - } - if let Some(ws) = target.meta.workspace_path.as_deref() { - if ws == selector || ws.ends_with(selector) || ws.contains(selector) { - return Ok(target.clone()); - } - } - } + Ok(()) +} - anyhow::bail!( - "No bridge matched '{selector}'. Use `code bridge list` to see available bridges." - ); +/// Prepend root-level overrides so they have lower precedence than +/// CLI-specific ones specified after the subcommand (if any). +fn prepend_config_flags( + subcommand_config_overrides: &mut CliConfigOverrides, + cli_config_overrides: CliConfigOverrides, +) { + subcommand_config_overrides + .raw_overrides + .splice(0..0, cli_config_overrides.raw_overrides); } -fn paths_match(left: &Path, right: &Path) -> bool { - if left == right { - return true; +fn reject_remote_mode_for_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: &str, +) -> anyhow::Result<()> { + if let Some(remote) = remote { + anyhow::bail!( + "`--remote {remote}` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); } - match (left.canonicalize(), right.canonicalize()) { - (Ok(l), Ok(r)) => l == r, - _ => false, + if remote_auth_token_env.is_some() { + anyhow::bail!( + "`--remote-auth-token-env` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); } + Ok(()) } -fn read_subscription_file(path: &Path) -> anyhow::Result { - if path.exists() { - let data = fs::read_to_string(path).context("read subscription override")?; - let sub: SubscriptionOverride = serde_json::from_str(&data).context("parse subscription override")?; - Ok(sub) - } else { - Ok(SubscriptionOverride { - levels: default_levels(), - capabilities: Vec::new(), - llm_filter: "off".to_string(), - }) - } +fn reject_remote_mode_for_app_server_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: Option<&AppServerSubcommand>, +) -> anyhow::Result<()> { + let subcommand_name = match subcommand { + None => "app-server", + Some(AppServerSubcommand::Proxy(_)) => "app-server proxy", + Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts", + Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema", + Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { + "app-server generate-internal-json-schema" + } + }; + reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) } -fn normalise_cli_vec(values: Vec, fallback: Vec) -> Vec { - let mut vals: Vec = values - .into_iter() - .map(|v| v.trim().to_lowercase()) - .filter(|v| !v.is_empty()) - .collect(); - if vals.is_empty() { - return fallback; - } - vals.sort(); - vals.dedup(); - vals -} - -fn find_subscription_override_path(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join(".code").join(SUBSCRIPTION_OVERRIDE_FILE); - if candidate.exists() { - return Some(candidate); - } - current = dir.parent(); +fn read_remote_auth_token_from_env_var_with( + env_var_name: &str, + get_var: F, +) -> anyhow::Result +where + F: FnOnce(&str) -> Result, +{ + let auth_token = get_var(env_var_name) + .map_err(|_| anyhow::anyhow!("environment variable `{env_var_name}` is not set"))?; + let auth_token = auth_token.trim().to_string(); + if auth_token.is_empty() { + anyhow::bail!("environment variable `{env_var_name}` is empty"); } - None + Ok(auth_token) } -fn resolve_subscription_override_path(start: &Path) -> PathBuf { - if let Some(path) = find_subscription_override_path(start) { - return path; - } +fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result { + read_remote_auth_token_from_env_var_with(env_var_name, |name| std::env::var(name)) +} - if let Some(dir) = find_meta_dir(start) { - return dir.join(SUBSCRIPTION_OVERRIDE_FILE); - } +async fn run_interactive_tui( + mut interactive: TuiCli, + remote: Option, + remote_auth_token_env: Option, + arg0_paths: Arg0DispatchPaths, +) -> std::io::Result { + if let Some(prompt) = interactive.prompt.take() { + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); + } + + let terminal_info = codex_terminal_detection::terminal_info(); + if terminal_info.name == TerminalName::Dumb { + if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { + return Ok(AppExitInfo::fatal( + "TERM is set to \"dumb\". Refusing to start the interactive TUI because no terminal is available for a confirmation prompt (stdin/stderr is not a TTY). Run in a supported terminal or unset TERM.", + )); + } - if let Some(dir) = find_code_dir(start) { - return dir.join(SUBSCRIPTION_OVERRIDE_FILE); + eprintln!( + "WARNING: TERM is set to \"dumb\". Codex's interactive TUI may not work in this terminal." + ); + if !confirm("Continue anyway? [y/N]: ")? { + return Ok(AppExitInfo::fatal( + "Refusing to start the interactive TUI because TERM is set to \"dumb\". Run in a supported terminal or unset TERM.", + )); + } } - start.join(".code").join(SUBSCRIPTION_OVERRIDE_FILE) -} - -fn find_meta_dir(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join(".code").join("code-bridge.json"); - if candidate.exists() { - return candidate.parent().map(Path::to_path_buf); - } - current = dir.parent(); + let normalized_remote = remote + .as_deref() + .map(codex_tui::normalize_remote_addr) + .transpose() + .map_err(std::io::Error::other)?; + if remote_auth_token_env.is_some() && normalized_remote.is_none() { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires `--remote`.", + )); } - None + let remote_auth_token = remote_auth_token_env + .as_deref() + .map(read_remote_auth_token_from_env_var) + .transpose() + .map_err(std::io::Error::other)?; + codex_tui::run_main( + interactive, + arg0_paths, + codex_config::LoaderOverrides::default(), + normalized_remote, + remote_auth_token, + ) + .await } -fn find_code_dir(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join(".code"); - if candidate.is_dir() { - return Some(candidate); - } - current = dir.parent(); - } - None +fn confirm(prompt: &str) -> std::io::Result { + eprintln!("{prompt}"); + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let answer = input.trim(); + Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")) } /// Build the final `TuiCli` for a `codex resume` invocation. @@ -1018,21 +1646,47 @@ fn finalize_resume_interactive( root_config_overrides: CliConfigOverrides, session_id: Option, last: bool, - mut resume_cli: TuiCli, + show_all: bool, + include_non_interactive: bool, + resume_cli: TuiCli, ) -> TuiCli { - // Our fork does not expose explicit resume fields on the TUI CLI. - // We simply merge resume-scoped flags and root overrides and run the TUI. - - interactive.finalize_defaults(); - resume_cli.finalize_defaults(); + // Start with the parsed interactive CLI so resume shares the same + // configuration surface area as `codex` without additional flags. + let resume_session_id = session_id; + interactive.resume_picker = resume_session_id.is_none() && !last; + interactive.resume_last = last; + interactive.resume_session_id = resume_session_id; + interactive.resume_show_all = show_all; + interactive.resume_include_non_interactive = include_non_interactive; // Merge resume-scoped flags and overrides with highest precedence. - merge_resume_cli_flags(&mut interactive, resume_cli); + merge_interactive_cli_flags(&mut interactive, resume_cli); - if let Err(err) = apply_resume_directives(&mut interactive, session_id, last) { - eprintln!("{}", err); - process::exit(1); - } + // Propagate any root-level config overrides (e.g. `-c key=value`). + prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); + + interactive +} + +/// Build the final `TuiCli` for a `codex fork` invocation. +fn finalize_fork_interactive( + mut interactive: TuiCli, + root_config_overrides: CliConfigOverrides, + session_id: Option, + last: bool, + show_all: bool, + fork_cli: TuiCli, +) -> TuiCli { + // Start with the parsed interactive CLI so fork shares the same + // configuration surface area as `codex` without additional flags. + let fork_session_id = session_id; + interactive.fork_picker = fork_session_id.is_none() && !last; + interactive.fork_last = last; + interactive.fork_session_id = fork_session_id; + interactive.fork_show_all = show_all; + + // Merge fork-scoped flags and overrides with highest precedence. + merge_interactive_cli_flags(&mut interactive, fork_cli); // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); @@ -1040,576 +1694,445 @@ fn finalize_resume_interactive( interactive } -/// Merge flags provided to `codex resume` so they take precedence over any -/// root-level flags. Only overrides fields explicitly set on the resume-scoped +/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any +/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped /// CLI. Also appends `-c key=value` overrides with highest precedence. -fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) { - if let Some(model) = resume_cli.model { - interactive.model = Some(model); - } - if resume_cli.oss { - interactive.oss = true; - } - if let Some(profile) = resume_cli.config_profile { - interactive.config_profile = Some(profile); - } - if let Some(sandbox) = resume_cli.sandbox_mode { - interactive.sandbox_mode = Some(sandbox); - } - if let Some(approval) = resume_cli.approval_policy { +fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { + let TuiCli { + shared, + approval_policy, + web_search, + prompt, + config_overrides, + .. + } = subcommand_cli; + interactive + .shared + .apply_subcommand_overrides(shared.into_inner()); + if let Some(approval) = approval_policy { interactive.approval_policy = Some(approval); } - if resume_cli.full_auto { - interactive.full_auto = true; - } - if resume_cli.dangerously_bypass_approvals_and_sandbox { - interactive.dangerously_bypass_approvals_and_sandbox = true; - } - if let Some(cwd) = resume_cli.cwd { - interactive.cwd = Some(cwd); - } - if !resume_cli.images.is_empty() { - interactive.images = resume_cli.images; + if web_search { + interactive.web_search = true; } - if let Some(prompt) = resume_cli.prompt { - interactive.prompt = Some(prompt); - } - - if resume_cli.enable_web_search || resume_cli.disable_web_search { - interactive.enable_web_search = resume_cli.enable_web_search; - interactive.disable_web_search = resume_cli.disable_web_search; - interactive.web_search = resume_cli.web_search; + if let Some(prompt) = prompt { + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } interactive .config_overrides .raw_overrides - .extend(resume_cli.config_overrides.raw_overrides); + .extend(config_overrides.raw_overrides); } -fn apply_resume_directives( - interactive: &mut TuiCli, - session_id: Option, - last: bool, -) -> anyhow::Result<()> { - interactive.resume_picker = false; - interactive.resume_last = false; - interactive.resume_session_id = None; - - match (session_id, last) { - (Some(id), _) => { - let path = resolve_resume_path(Some(id.as_str()), false)? - .ok_or_else(|| anyhow!("No recorded session found with id {id}"))?; - interactive.resume_session_id = Some(id); - push_experimental_resume_override(interactive, &path); - } - (None, true) => { - let path = resolve_resume_path(None, true)? - .ok_or_else(|| anyhow!("No recent sessions found to resume. Start a session with `code` first."))?; - interactive.resume_last = true; - push_experimental_resume_override(interactive, &path); - } - (None, false) => { - interactive.resume_picker = true; - } +fn print_completion(cmd: CompletionCommand) { + let mut app = MultitoolCli::command(); + let name = "code"; + generate(cmd.shell, &mut app, name, &mut std::io::stdout()); +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use codex_protocol::ThreadId; + use codex_tui::TokenUsage; + use pretty_assertions::assert_eq; + + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let MultitoolCli { + interactive, + config_overrides: root_overrides, + subcommand, + feature_toggles: _, + remote: _, + } = cli; + + let Subcommand::Resume(ResumeCommand { + session_id, + last, + all, + include_non_interactive, + remote: _, + config_overrides: resume_cli, + }) = subcommand.expect("resume present") + else { + unreachable!() + }; + + finalize_resume_interactive( + interactive, + root_overrides, + session_id, + last, + all, + include_non_interactive, + resume_cli, + ) } - Ok(()) -} + fn finalize_fork_from_args(args: &[&str]) -> TuiCli { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let MultitoolCli { + interactive, + config_overrides: root_overrides, + subcommand, + feature_toggles: _, + remote: _, + } = cli; -fn resolve_resume_path(session_id: Option<&str>, last: bool) -> anyhow::Result> { - if session_id.is_none() && !last { - return Ok(None); + let Subcommand::Fork(ForkCommand { + session_id, + last, + all, + remote: _, + config_overrides: fork_cli, + }) = subcommand.expect("fork present") + else { + unreachable!() + }; + + finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) } - let code_home = code_core::config::find_code_home() - .context("failed to locate Codex home directory")?; + #[test] + fn exec_resume_last_accepts_prompt_positional() { + let cli = + MultitoolCli::try_parse_from(["codex", "exec", "--json", "resume", "--last", "2+2"]) + .expect("parse should succeed"); - let sess = session_id.map(|s| s.to_string()); - let fetch = async move { - let catalog = SessionCatalog::new(code_home.clone()); - if let Some(id) = sess.as_deref() { - let entry = catalog - .find_by_id(id) - .await - .context("failed to look up session by id")?; - Ok(entry.map(|entry| entry_to_rollout_path(&code_home, &entry))) - } else if last { - let query = SessionQuery { - cwd: None, - git_root: None, - sources: vec![SessionSource::Cli, SessionSource::VSCode, SessionSource::Exec], - min_user_messages: 1, - include_archived: false, - include_deleted: false, - limit: Some(1), + let Some(Subcommand::Exec(exec)) = cli.subcommand else { + panic!("expected exec subcommand"); }; - let entry = catalog - .get_latest(&query) - .await - .context("failed to get latest session from catalog")?; - Ok(entry.map(|entry| entry_to_rollout_path(&code_home, &entry))) - } else { - Ok(None) - } + let Some(codex_exec::Command::Resume(args)) = exec.command else { + panic!("expected exec resume"); }; - match TokioHandle::try_current() { - Ok(handle) => { - let handle = handle.clone(); - std::thread::Builder::new() - .name("resume-lookup".to_string()) - .spawn(move || handle.block_on(fetch)) - .map_err(|err| anyhow!("resume lookup thread spawn failed: {err}"))? - .join() - .map_err(|_| anyhow!("resume lookup thread panicked"))? - } - Err(_) => TokioRuntimeBuilder::new_current_thread() - .enable_all() - .build() - .context("failed to create async runtime for resume lookup")? - .block_on(fetch), + assert!(args.last); + assert_eq!(args.session_id, None); + assert_eq!(args.prompt.as_deref(), Some("2+2")); } -} -fn push_experimental_resume_override(interactive: &mut TuiCli, path: &Path) { - let raw = path.to_string_lossy(); - let escaped = raw.replace('\\', "\\\\").replace('"', "\\\""); - interactive - .config_overrides - .raw_overrides - .push(format!("experimental_resume=\"{escaped}\"")); -} + #[test] + fn exec_resume_accepts_output_last_message_flag_after_subcommand() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "exec", + "resume", + "session-123", + "-o", + "/tmp/resume-output.md", + "re-review", + ]) + .expect("parse should succeed"); + + let Some(Subcommand::Exec(exec)) = cli.subcommand else { + panic!("expected exec subcommand"); + }; + let Some(codex_exec::Command::Resume(args)) = exec.command else { + panic!("expected exec resume"); + }; -fn write_completion(shell: Shell, out: &mut W) { - let mut app = MultitoolCli::command(); - generate(shell, &mut app, CLI_COMMAND_NAME, out); -} + assert_eq!( + exec.last_message_file, + Some(std::path::PathBuf::from("/tmp/resume-output.md")) + ); + assert_eq!(args.session_id.as_deref(), Some("session-123")); + assert_eq!(args.prompt.as_deref(), Some("re-review")); + } -fn print_completion(cmd: CompletionCommand) { - write_completion(cmd.shell, &mut std::io::stdout()); -} - -fn order_replay_main(args: OrderReplayArgs) -> anyhow::Result<()> { - use anyhow::{Context, Result}; - use regex::Regex; - use serde_json::Value; - use std::fs; - - fn parse_response_expected(path: &std::path::Path) -> Result> { - 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)] - struct InsertLog { ordered: bool, req: u64, out: u64, item_seq: u64, raw: u64 } - - fn parse_tui_inserts(path: &std::path::Path) -> Result> { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let re = Regex::new(r"insert window: seq=(?P\d+) \((?P[OU]):(?:req=(?P\d+) out=(?P\d+) seq=(?P\d+)|(?P\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 { ordered, req, out: out_idx, item_seq, raw: seq }); - } - } - Ok(out) + #[test] + fn dangerous_bypass_conflicts_with_approval_policy() { + let err = MultitoolCli::try_parse_from([ + "codex", + "--dangerously-bypass-approvals-and-sandbox", + "--ask-for-approval", + "on-request", + ]) + .expect_err("conflicting permission flags should be rejected"); + + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); } - let expected = parse_response_expected(&args.response_json)?; - let actual = parse_tui_inserts(&args.tui_log)?; + fn app_server_from_args(args: &[&str]) -> AppServerCommand { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else { + unreachable!() + }; + app_server + } - println!("Expected (first 20 sorted by out,seq):"); - for (i, (out, seq)) in expected.iter().take(20).enumerate() { - println!(" {:>3}: out={} seq={}", i, out, seq); + fn default_app_server_socket_path() -> AbsolutePathBuf { + let codex_home = find_codex_home().expect("codex home"); + codex_app_server::app_server_control_socket_path(&codex_home) + .expect("default app-server socket path") } - 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.raw); - } else { - println!(" {:>3}: U:{}", i, log.item_seq); - } + #[test] + fn debug_prompt_input_parses_prompt_and_images() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "debug", + "prompt-input", + "hello", + "--image", + "/tmp/a.png,/tmp/b.png", + ]) + .expect("parse"); + + let Some(Subcommand::Debug(DebugCommand { + subcommand: DebugSubcommand::PromptInput(cmd), + })) = cli.subcommand + else { + panic!("expected debug prompt-input subcommand"); + }; + + assert_eq!(cmd.prompt.as_deref(), Some("hello")); + assert_eq!( + cmd.images, + vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")] + ); } - // Simple check: assistant (out=1) should appear before tool (out=2) within same req - 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); - println!("\nCheck (req=1): first out=1 at {:?}, first out=2 at {:?}", pos_out1, pos_out2); - if let (Some(p1), Some(p2)) = (pos_out1, pos_out2) { - if p1 < p2 { println!("Result: OK (assistant precedes tool)"); } else { println!("Result: WRONG (tool precedes assistant)"); } + #[test] + fn debug_models_parses_bundled_flag() { + let cli = + MultitoolCli::try_parse_from(["codex", "debug", "models", "--bundled"]).expect("parse"); + + let Some(Subcommand::Debug(DebugCommand { + subcommand: DebugSubcommand::Models(cmd), + })) = cli.subcommand + else { + panic!("expected debug models subcommand"); + }; + + assert!(cmd.bundled); } - Ok(()) -} + #[test] + fn responses_subcommand_is_not_registered() { + let command = MultitoolCli::command(); + assert!( + command + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "responses") + ); + } -async fn preview_main(args: PreviewArgs) -> anyhow::Result<()> { - use anyhow::{bail, Context}; - use flate2::read::GzDecoder; - use std::env; - use std::fs; - use std::path::Path; - use tempfile::tempdir; - use zip::ZipArchive; - - let repo = args - .repo - .or_else(|| env::var("GITHUB_REPOSITORY").ok()) - .unwrap_or_else(|| "just-every/code".to_string()); - let (owner, name) = repo - .split_once('/') - .map(|(o, n)| (o.to_string(), n.to_string())) - .ok_or_else(|| anyhow::anyhow!(format!("Invalid repo format: {}", repo)))?; - - let os = env::consts::OS; - let arch = env::consts::ARCH; - let target = match (os, arch) { - ("linux", "x86_64") => "x86_64-unknown-linux-musl", - ("linux", "aarch64") => "aarch64-unknown-linux-musl", - ("macos", "x86_64") => "x86_64-apple-darwin", - ("macos", "aarch64") => "aarch64-apple-darwin", - ("windows", _) => "x86_64-pc-windows-msvc", - _ => bail!(format!("Unsupported platform: {}/{}", os, arch)), - }; + fn help_from_args(args: &[&str]) -> String { + let err = MultitoolCli::try_parse_from(args).expect_err("help should short-circuit"); + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + err.to_string() + } - let client = reqwest::Client::builder().user_agent("codex-preview/1").build()?; - - // Resolve slug/tag from id - let id = args.slug.trim().to_string(); - async fn fetch_json(client: &reqwest::Client, url: &str) -> anyhow::Result { - let r = client.get(url).send().await?; - let s = r.status(); - let t = r.text().await?; - if !s.is_success() { anyhow::bail!(format!("GET {} -> {} {}", url, s.as_u16(), t)); } - Ok(serde_json::from_str(&t).unwrap_or(serde_json::Value::Null)) - } - async fn latest_tag_for_slug(client: &reqwest::Client, owner: &str, name: &str, slug: &str) -> anyhow::Result { - let base = format!("preview-{}", slug); - let url = format!("https://api.github.com/repos/{owner}/{name}/releases?per_page=100"); - let v = fetch_json(client, &url).await?; - let mut latest = base.clone(); - let mut max_n: u64 = 0; - if let Some(arr) = v.as_array() { - let re = regex::Regex::new(&format!(r"^{}-(\\d+)$", regex::escape(&base))).unwrap(); - for it in arr { - if let Some(tag) = it.get("tag_name").and_then(|x| x.as_str()) { - if tag == base { if max_n < 1 { max_n = 1; latest = base.clone(); } } - else if let Some(c) = re.captures(tag) { - let n: u64 = c.get(1).unwrap().as_str().parse().unwrap_or(0); - if n > max_n { max_n = n; latest = tag.to_string(); } - } - } - } + #[test] + fn plugin_marketplace_help_uses_plugin_namespace() { + let help = help_from_args(&["codex", "plugin", "marketplace", "--help"]); + assert!( + help.contains("Usage: codex plugin marketplace [OPTIONS] "), + "{help}" + ); + + for (subcommand, usage) in [ + ("add", "Usage: codex plugin marketplace add"), + ("upgrade", "Usage: codex plugin marketplace upgrade"), + ("remove", "Usage: codex plugin marketplace remove"), + ] { + let help = help_from_args(&["codex", "plugin", "marketplace", subcommand, "--help"]); + assert!(help.contains(usage), "{help}"); } - Ok(latest) } - let slug = id.to_lowercase(); - let tag = latest_tag_for_slug(&client, &owner, &name, &slug).await?; - let (slug, tag) = (slug, tag); - let base = format!("https://github.com/{owner}/{name}/releases/download/{tag}"); - // Try to download the best asset for this platform; prefer .tar.gz on Unix and .zip on Windows; fallback to .zst. - let mut urls: Vec = vec![]; - if cfg!(windows) { - urls.push(format!("{base}/code-x86_64-pc-windows-msvc.exe.zip")); - } else { - // tar.gz first, then zst - urls.push(format!("{base}/code-{target}.tar.gz")); - urls.push(format!("{base}/code-{target}.zst")); - } - - let tmp = tempdir()?; - let mut downloaded: Option<(std::path::PathBuf, String)> = None; - for u in urls.iter() { - let resp = client.get(u).send().await?; - if resp.status().is_success() { - let data = resp.bytes().await?; - let filename = u.split('/').last().unwrap_or("download.bin"); - let p = tmp.path().join(filename); - fs::write(&p, &data)?; - downloaded = Some((p, u.clone())); - break; - } + #[test] + fn plugin_marketplace_add_parses_under_plugin() { + let cli = + MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "add", "owner/repo"]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); } - let (path, url_used) = downloaded.context("No matching preview asset found on the prerelease. It may still be uploading; try again shortly.")?; - // Find the easiest payload - fn first_match(dir: &Path, pat: &str) -> Option { - for entry in fs::read_dir(dir).ok()? { - let p = entry.ok()?.path(); - if let Some(name) = p.file_name().and_then(|s| s.to_str()) { - if name.starts_with(pat) { return Some(p); } - } - } - None + #[test] + fn plugin_marketplace_upgrade_parses_under_plugin() { + let cli = + MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "upgrade", "debug"]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); } - // Determine output directory - // Default: ~/.code/bin - let out_dir = if let Some(dir) = args.out_dir { - dir - } else { - let home = if cfg!(windows) { - env::var_os("USERPROFILE") - } else { - env::var_os("HOME") + #[test] + fn update_parses_as_update_subcommand() { + let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse"); + assert!(matches!(cli.subcommand, Some(Subcommand::Update))); + } + + #[test] + fn sandbox_macos_parses_permissions_profile() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "sandbox", + "macos", + "--permissions-profile", + ":workspace", + "--", + "echo", + ]) + .expect("parse"); + + let Some(Subcommand::Sandbox(SandboxArgs { + cmd: SandboxCommand::Macos(command), + })) = cli.subcommand + else { + panic!("expected sandbox macos command"); }; - let base = home - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - base.join(".code").join("bin") - }; - let _ = fs::create_dir_all(&out_dir); - - #[cfg(target_family = "unix")] - fn make_exec(p: &Path) { use std::os::unix::fs::PermissionsExt; let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o755)); } - #[cfg(target_family = "windows")] - fn make_exec(_p: &Path) { } - - if os != "windows" { - // If we downloaded a tar.gz, extract - if path.extension().and_then(|e| e.to_str()) == Some("gz") { - let tgz = path.clone(); - let file = fs::File::open(&tgz)?; - let gz = GzDecoder::new(file); - let mut ar = tar::Archive::new(gz); - ar.unpack(&out_dir)?; - // Find extracted binary - let bin = first_match(&out_dir, "code-").unwrap_or(out_dir.join("code")); - let dest_name = format!("{}-{}", bin.file_name().and_then(|s| s.to_str()).unwrap_or("code"), slug); - let dest = out_dir.join(dest_name); - // Rename/move to include PR number suffix - let _ = fs::rename(&bin, &dest).or_else(|_| { fs::copy(&bin, &dest).map(|_| () ) }); - make_exec(&dest); - println!("Downloaded preview to {}", dest.display()); - if !args.extra.is_empty() { let _ = std::process::Command::new(&dest).args(&args.extra).status(); } else { let _ = std::process::Command::new(&dest).status(); } - return Ok(()); - } - } else { - // Windows: expand zip - if path.extension().and_then(|e| e.to_str()) == Some("zip") { - let f = fs::File::open(&path)?; - let mut z = ZipArchive::new(f)?; - z.extract(&out_dir)?; - let exe = first_match(&out_dir, "code-").unwrap_or(out_dir.join("code.exe")); - // Append slug before extension if present - let dest = match exe.extension().and_then(|e| e.to_str()) { - Some(ext) => { - let stem = exe.file_stem().and_then(|s| s.to_str()).unwrap_or("code"); - out_dir.join(format!("{}-{}.{}", stem, slug, ext)) - } - None => out_dir.join(format!("{}-{}", exe.file_name().and_then(|s| s.to_str()).unwrap_or("code"), slug)), - }; - let _ = fs::rename(&exe, &dest).or_else(|_| { fs::copy(&exe, &dest).map(|_| () ) }); - println!("Downloaded preview to {}", dest.display()); - if !args.extra.is_empty() { - let mut cmd = std::process::Command::new(&dest); - cmd.args(&args.extra); - let _ = spawn_std_command_with_retry(&mut cmd); - } else { - let mut cmd = std::process::Command::new(&dest); - let _ = spawn_std_command_with_retry(&mut cmd); - } - return Ok(()); - } + + assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); + assert_eq!(command.command, vec!["echo"]); } - // Fallback: raw 'code' file (after .zst) if present - if path.file_name().and_then(|s| s.to_str()).map(|n| n.ends_with(".zst")).unwrap_or(false) { - // Try to decompress .zst to 'code' - if which::which("zstd").is_ok() { - // Derive base name from archive (e.g., code-aarch64-apple-darwin.zst -> code-aarch64-apple-darwin-.{exe?}) - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("code"); - let dest = if cfg!(windows) { out_dir.join(format!("{}-{}.exe", stem, slug)) } else { out_dir.join(format!("{}-{}", stem, slug)) }; - let status = std::process::Command::new("zstd").arg("-d").arg(&path).arg("-o").arg(&dest).status()?; - if status.success() { - make_exec(&dest); - println!("Downloaded preview from {} to {}", url_used, dest.display()); - if !args.extra.is_empty() { let _ = std::process::Command::new(&dest).args(&args.extra).status(); } else { let _ = std::process::Command::new(&dest).status(); } - return Ok(()); - } - } - // If zstd missing, tell the user - bail!("Downloaded .zst but 'zstd' is not installed. Install zstd or download the .tar.gz/.zip asset instead."); - } else if let Some(bin) = first_match(tmp.path(), "code") { - let dest = out_dir.join(bin.file_name().unwrap_or_default()); - fs::copy(&bin, &dest)?; - make_exec(&dest); - println!("Downloaded preview to {}", dest.display()); - if !args.extra.is_empty() { let _ = std::process::Command::new(&dest).args(&args.extra).status(); } else { let _ = std::process::Command::new(&dest).status(); } - return Ok(()); + #[test] + fn sandbox_macos_rejects_explicit_profile_controls_without_profile() { + let err = MultitoolCli::try_parse_from(["codex", "sandbox", "macos", "-C", "/tmp"]) + .expect_err("parse should fail"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); } - bail!("No recognized artifact content found.") -} + #[test] + fn plugin_marketplace_remove_parses_under_plugin() { + let cli = + MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "remove", "debug"]) + .expect("parse"); -async fn doctor_main() -> anyhow::Result<()> { - use std::env; - use std::process::Stdio; - use tokio::process::Command; + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } - // Print current executable and version - let exe = std::env::current_exe() - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| "".to_string()); - println!("code version: {}", code_version::version()); - println!("product: {}", code_version::LAB_BUILD_NAME); - println!("repository: {}", code_version::LAB_REPOSITORY); - println!("current_exe: {}", exe); + #[test] + fn marketplace_no_longer_parses_at_top_level() { + let add_result = + MultitoolCli::try_parse_from(["codex", "marketplace", "add", "owner/repo"]); + assert!(add_result.is_err()); - // PATH - let path = env::var("PATH").unwrap_or_default(); - println!("PATH: {}", path); + let upgrade_result = + MultitoolCli::try_parse_from(["codex", "marketplace", "upgrade", "debug"]); + assert!(upgrade_result.is_err()); - // Helper to run a shell command and capture stdout (best-effort) - async fn run_cmd(cmd: &str, args: &[&str]) -> String { - let mut c = Command::new(cmd); - c.args(args).stdin(Stdio::null()).stderr(Stdio::null()); - match c.output().await { - Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(), - Err(_) => String::new(), - } + let remove_result = + MultitoolCli::try_parse_from(["codex", "marketplace", "remove", "debug"]); + assert!(remove_result.is_err()); } - #[cfg(target_family = "unix")] - let which_all = |name: &str| { - let name = name.to_string(); - async move { - let out = run_cmd("/bin/bash", &["-lc", &format!("which -a {} 2>/dev/null || true", name)]).await; - out.split('\n').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect::>() - } - }; - #[cfg(target_family = "windows")] - let which_all = |name: &str| { - let name = name.to_string(); - async move { - let out = run_cmd("where", &[&name]).await; - out.split('\n').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect::>() - } - }; + #[test] + fn full_auto_no_longer_parses_at_top_level() { + let result = MultitoolCli::try_parse_from(["codex", "--full-auto"]); - // Gather candidates for code/coder - let code_paths = which_all("code").await; - let coder_paths = which_all("coder").await; + assert!(result.is_err()); + } - println!("\nFound 'code' on PATH (in order):"); - if code_paths.is_empty() { - println!(" "); - } else { - for p in &code_paths { println!(" {}", p); } + #[test] + fn exec_full_auto_reports_migration_path() { + let cli = MultitoolCli::try_parse_from(["codex", "exec", "--full-auto", "summarize"]) + .expect("exec should accept removed flag long enough to report a migration path"); + let Some(Subcommand::Exec(exec)) = cli.subcommand else { + panic!("expected exec subcommand"); + }; + + assert_eq!( + exec.removed_full_auto_warning(), + Some("warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.") + ); } - println!("\nFound 'coder' on PATH (in order):"); - if coder_paths.is_empty() { - println!(" "); - } else { - for p in &coder_paths { println!(" {}", p); } + + #[test] + fn sandbox_full_auto_no_longer_parses() { + let result = + MultitoolCli::try_parse_from(["codex", "sandbox", "linux", "--full-auto", "--"]); + + assert!(result.is_err()); } - // Try to run --version for each resolved binary to show where mismatches come from - async fn show_versions(caption: &str, paths: &[String]) { - println!("\n{}:", caption); - for p in paths { - let out = run_cmd(p, &["--version"]).await; - if out.is_empty() { - println!(" {} -> (no output)", p); - } else { - println!(" {} -> {}", p, out); - } + fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { + let token_usage = TokenUsage { + output_tokens: 2, + total_tokens: 2, + ..Default::default() + }; + AppExitInfo { + token_usage, + thread_id: conversation_id + .map(ThreadId::from_string) + .map(Result::unwrap), + thread_name: thread_name.map(str::to_string), + update_action: None, + exit_reason: ExitReason::UserRequested, } } - show_versions("code --version by path", &code_paths).await; - show_versions("coder --version by path", &coder_paths).await; - println!("\nIf versions differ, remove older PATH entries or reorder PATH so the intended Code binary appears first."); - println!("Run `code update-check` or ` update-check` from the intended binary to inspect the current GitHub Release update source."); + #[test] + fn format_exit_messages_skips_zero_usage() { + let exit_info = AppExitInfo { + token_usage: TokenUsage::default(), + thread_id: None, + thread_name: None, + update_action: None, + exit_reason: ExitReason::UserRequested, + }; + let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + assert!(lines.is_empty()); + } - Ok(()) -} -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use std::path::{Path, PathBuf}; - use std::sync::Mutex; - use std::time::{Duration, SystemTime}; - - use filetime::{set_file_mtime, FileTime}; - use tempfile::TempDir; - use uuid::Uuid; - - use code_protocol::models::{ContentItem, ResponseItem}; - use code_protocol::protocol::EventMsg as ProtoEventMsg; - use code_protocol::protocol::RecordedEvent; - use code_protocol::protocol::RolloutItem; - use code_protocol::protocol::RolloutLine; - use code_protocol::protocol::SessionMeta; - use code_protocol::protocol::SessionMetaLine; - use code_protocol::protocol::SessionSource; - use code_protocol::protocol::UserMessageEvent; - use code_protocol::ThreadId; - - #[test] - fn bash_completion_uses_code_command_name() { - let mut buf = Vec::new(); - write_completion(Shell::Bash, &mut buf); - let script = String::from_utf8(buf).expect("completion output should be valid UTF-8"); - assert!(script.contains("_code()"), "expected bash completion function to be named _code"); - assert!(!script.contains("_codex()"), "bash completion output should not use legacy codex prefix"); - } - - fn finalize_from_args(args: &[&str]) -> TuiCli { - let cli = MultitoolCli::try_parse_from(args).expect("parse"); - let MultitoolCli { - interactive, - config_overrides: root_overrides, - auto_drive: _, - subcommand, - .. - } = cli; + #[test] + fn format_exit_messages_includes_resume_hint_without_color() { + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + /*thread_name*/ None, + ); + let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + assert_eq!( + lines, + vec![ + "Token usage: total=2 input=0 output=2".to_string(), + "To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000" + .to_string(), + ] + ); + } - let Subcommand::Resume(ResumeCommand { - session_id, - last, - config_overrides: resume_cli, - }) = subcommand.expect("resume present") - else { - unreachable!() - }; + #[test] + fn format_exit_messages_applies_color_when_enabled() { + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + /*thread_name*/ None, + ); + let lines = format_exit_messages(exit_info, /*color_enabled*/ true); + assert_eq!(lines.len(), 2); + assert!(lines[1].contains("\u{1b}[36m")); + } - finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli) + #[test] + fn format_exit_messages_uses_id_even_when_thread_has_name() { + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + Some("my-thread"), + ); + let lines = format_exit_messages(exit_info, /*color_enabled*/ false); + assert_eq!( + lines, + vec![ + "Token usage: total=2 input=0 output=2".to_string(), + "To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000" + .to_string(), + ] + ); } #[test] fn resume_model_flag_applies_when_no_root_flags() { - let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); + let interactive = + finalize_resume_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); assert!(interactive.resume_picker); @@ -1619,377 +2142,558 @@ mod tests { #[test] fn resume_picker_logic_none_and_not_last() { - let interactive = finalize_from_args(["codex", "resume"].as_ref()); + let interactive = finalize_resume_from_args(["codex", "resume"].as_ref()); assert!(interactive.resume_picker); assert!(!interactive.resume_last); assert_eq!(interactive.resume_session_id, None); + assert!(!interactive.resume_show_all); } - static CODE_HOME_MUTEX: Mutex<()> = Mutex::new(()); + #[test] + fn resume_picker_logic_last() { + let interactive = finalize_resume_from_args(["codex", "resume", "--last"].as_ref()); + assert!(!interactive.resume_picker); + assert!(interactive.resume_last); + assert_eq!(interactive.resume_session_id, None); + assert!(!interactive.resume_show_all); + } -fn with_temp_code_home(f: F) -> R -where - F: FnOnce(&Path) -> R, -{ - let _guard = CODE_HOME_MUTEX - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - let temp_home = TempDir::new().expect("temp code home"); - let prev_code_home = std::env::var("CODE_HOME").ok(); - let prev_codex_home = std::env::var("CODEX_HOME").ok(); - set_env_var("CODE_HOME", temp_home.path()); - remove_env_var("CODEX_HOME"); - - let result = f(temp_home.path()); - - match prev_code_home { - Some(val) => set_env_var("CODE_HOME", val), - None => remove_env_var("CODE_HOME"), - } - match prev_codex_home { - Some(val) => set_env_var("CODEX_HOME", val), - None => remove_env_var("CODEX_HOME"), - } + #[test] + fn resume_picker_logic_with_session_id() { + let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref()); + assert!(!interactive.resume_picker); + assert!(!interactive.resume_last); + assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); + assert!(!interactive.resume_show_all); + } - result + #[test] + fn resume_all_flag_sets_show_all() { + let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref()); + assert!(interactive.resume_picker); + assert!(interactive.resume_show_all); } - fn set_env_var, V: AsRef>(key: K, value: V) { - unsafe { std::env::set_var(key, value) } + #[test] + fn resume_include_non_interactive_flag_sets_source_filter_override() { + let interactive = + finalize_resume_from_args(["codex", "resume", "--include-non-interactive"].as_ref()); + + assert!(interactive.resume_picker); + assert!(interactive.resume_include_non_interactive); } - fn remove_env_var>(key: K) { - unsafe { std::env::remove_var(key) } + #[test] + fn resume_merges_option_flags() { + let interactive = finalize_resume_from_args( + [ + "codex", + "resume", + "sid", + "--oss", + "--search", + "--sandbox", + "workspace-write", + "--ask-for-approval", + "on-request", + "-m", + "gpt-5.1-test", + "-p", + "my-profile", + "-C", + "/tmp", + "-i", + "/tmp/a.png,/tmp/b.png", + ] + .as_ref(), + ); + + assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); + assert!(interactive.oss); + assert_eq!(interactive.config_profile.as_deref(), Some("my-profile")); + assert_matches!( + interactive.sandbox_mode, + Some(codex_utils_cli::SandboxModeCliArg::WorkspaceWrite) + ); + assert_matches!( + interactive.approval_policy, + Some(codex_utils_cli::ApprovalModeCliArg::OnRequest) + ); + assert_eq!( + interactive.cwd.as_deref(), + Some(std::path::Path::new("/tmp")) + ); + assert!(interactive.web_search); + let has_a = interactive + .images + .iter() + .any(|p| p == std::path::Path::new("/tmp/a.png")); + let has_b = interactive + .images + .iter() + .any(|p| p == std::path::Path::new("/tmp/b.png")); + assert!(has_a && has_b); + assert!(!interactive.resume_picker); + assert!(!interactive.resume_last); + assert_eq!(interactive.resume_session_id.as_deref(), Some("sid")); } - fn create_session_fixture(code_home: &Path, id: &Uuid) -> PathBuf { - create_session_fixture_with_details( - code_home, - id, - "2025-10-06T12:00:00Z", - "2025-10-06T12:00:00Z", - Path::new("/project"), - SessionSource::Cli, - "Hello", - ) + #[test] + fn resume_merges_dangerously_bypass_flag() { + let interactive = finalize_resume_from_args( + [ + "codex", + "resume", + "--dangerously-bypass-approvals-and-sandbox", + ] + .as_ref(), + ); + assert!(interactive.dangerously_bypass_approvals_and_sandbox); + assert!(interactive.resume_picker); + assert!(!interactive.resume_last); + assert_eq!(interactive.resume_session_id, None); + } + + #[test] + fn fork_picker_logic_none_and_not_last() { + let interactive = finalize_fork_from_args(["codex", "fork"].as_ref()); + assert!(interactive.fork_picker); + assert!(!interactive.fork_last); + assert_eq!(interactive.fork_session_id, None); + assert!(!interactive.fork_show_all); + } + + #[test] + fn fork_picker_logic_last() { + let interactive = finalize_fork_from_args(["codex", "fork", "--last"].as_ref()); + assert!(!interactive.fork_picker); + assert!(interactive.fork_last); + assert_eq!(interactive.fork_session_id, None); + assert!(!interactive.fork_show_all); + } + + #[test] + fn fork_picker_logic_with_session_id() { + let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref()); + assert!(!interactive.fork_picker); + assert!(!interactive.fork_last); + assert_eq!(interactive.fork_session_id.as_deref(), Some("1234")); + assert!(!interactive.fork_show_all); } - fn create_session_fixture_with_details( - code_home: &Path, - id: &Uuid, - created_at: &str, - last_event_at: &str, - cwd: &Path, - source: SessionSource, - user_message: &str, - ) -> PathBuf { - let sessions_dir = code_home - .join("sessions") - .join("2025") - .join("10") - .join("06"); - std::fs::create_dir_all(&sessions_dir).expect("create sessions dir"); - - let filename = format!( - "rollout-{}-{}.jsonl", - created_at.replace(':', "-"), - id + #[test] + fn fork_all_flag_sets_show_all() { + let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref()); + assert!(interactive.fork_picker); + assert!(interactive.fork_show_all); + } + + #[test] + fn app_server_analytics_default_disabled_without_flag() { + let app_server = app_server_from_args(["codex", "app-server"].as_ref()); + assert!(!app_server.analytics_default_enabled); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::Stdio ); - let path = sessions_dir.join(filename); - - let session_meta = SessionMeta { - id: ThreadId::from_string(&id.to_string()).expect("valid thread id"), - timestamp: created_at.to_string(), - cwd: cwd.to_path_buf(), - originator: "test".to_string(), - cli_version: "0.0.0-test".to_string(), - source, - automation_origin: None, - model_provider: None, - base_instructions: None, - dynamic_tools: None, - forked_from_id: None, - }; + } - let session_line = RolloutLine { - timestamp: created_at.to_string(), - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta, - git: None, - }), - }; + #[test] + fn app_server_analytics_default_enabled_with_flag() { + let app_server = + app_server_from_args(["codex", "app-server", "--analytics-default-enabled"].as_ref()); + assert!(app_server.analytics_default_enabled); + } - let event_line = RolloutLine { - timestamp: last_event_at.to_string(), - item: RolloutItem::Event(RecordedEvent { - id: "event-0".to_string(), - event_seq: 0, - order: None, - msg: ProtoEventMsg::UserMessage(UserMessageEvent { - message: user_message.to_string(), - images: None, - local_images: vec![], - text_elements: vec![], - }), - }), - }; + #[test] + fn remote_control_override_is_appended_after_root_toggles() { + let mut config_overrides = CliConfigOverrides::default(); + config_overrides + .raw_overrides + .push("features.remote_control=false".to_string()); + + enable_remote_control_for_invocation(&mut config_overrides); + + assert_eq!( + config_overrides.raw_overrides, + vec![ + "features.remote_control=false".to_string(), + REMOTE_CONTROL_FEATURE_OVERRIDE.to_string(), + ] + ); + } - let user_line = RolloutLine { - timestamp: last_event_at.to_string(), - item: RolloutItem::ResponseItem(ResponseItem::Message { - id: Some(format!("user-{}", id)), - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: user_message.to_string(), - }], - end_turn: None, - phase: None, - }), - }; + #[test] + fn reject_remote_flag_for_remote_control() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--remote", + "ws://127.0.0.1:1234", + "remote-control", + ]) + .expect("parse"); + assert_matches!(cli.subcommand, Some(Subcommand::RemoteControl)); + + let err = reject_remote_mode_for_subcommand( + cli.remote.remote.as_deref(), + cli.remote.remote_auth_token_env.as_deref(), + "remote-control", + ) + .expect_err("remote-control should reject root --remote"); + + assert!(err.to_string().contains("remote-control")); + } + + #[test] + fn remote_flag_parses_for_interactive_root() { + let cli = MultitoolCli::try_parse_from(["codex", "--remote", "ws://127.0.0.1:4500"]) + .expect("parse"); + assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); + } + + #[test] + fn remote_auth_token_env_flag_parses_for_interactive_root() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--remote-auth-token-env", + "CODEX_REMOTE_AUTH_TOKEN", + "--remote", + "ws://127.0.0.1:4500", + ]) + .expect("parse"); + assert_eq!( + cli.remote.remote_auth_token_env.as_deref(), + Some("CODEX_REMOTE_AUTH_TOKEN") + ); + } - let response_line = RolloutLine { - timestamp: last_event_at.to_string(), - item: RolloutItem::ResponseItem(ResponseItem::Message { - id: Some(format!("msg-{}", id)), - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: format!("Ack: {}", user_message), - }], - end_turn: None, - phase: None, - }), + #[test] + fn remote_flag_parses_for_resume_subcommand() { + let cli = + MultitoolCli::try_parse_from(["codex", "resume", "--remote", "ws://127.0.0.1:4500"]) + .expect("parse"); + let Subcommand::Resume(ResumeCommand { remote, .. }) = + cli.subcommand.expect("resume present") + else { + panic!("expected resume subcommand"); }; + assert_eq!(remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); + } - let mut writer = std::io::BufWriter::new(std::fs::File::create(&path).expect("open session file")); - serde_json::to_writer(&mut writer, &session_line).expect("write session meta"); - writer.write_all(b"\n").expect("newline"); - serde_json::to_writer(&mut writer, &event_line).expect("write event"); - writer.write_all(b"\n").expect("newline"); - serde_json::to_writer(&mut writer, &user_line).expect("write user message"); - writer.write_all(b"\n").expect("newline"); - serde_json::to_writer(&mut writer, &response_line).expect("write response"); - writer.write_all(b"\n").expect("newline"); - writer.flush().expect("flush session file"); + #[test] + fn reject_remote_mode_for_non_interactive_subcommands() { + let err = reject_remote_mode_for_subcommand( + Some("127.0.0.1:4500"), + /*remote_auth_token_env*/ None, + "exec", + ) + .expect_err("non-interactive subcommands should reject --remote"); + assert!( + err.to_string() + .contains("only supported for interactive TUI commands") + ); + } - path + #[test] + fn reject_remote_auth_token_env_for_non_interactive_subcommands() { + let err = reject_remote_mode_for_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + "exec", + ) + .expect_err("non-interactive subcommands should reject --remote-auth-token-env"); + assert!( + err.to_string() + .contains("only supported for interactive TUI commands") + ); } #[test] - fn resume_picker_logic_last() { - with_temp_code_home(|code_home| { - let session_id = Uuid::new_v4(); - create_session_fixture(code_home, &session_id); - - let interactive = finalize_from_args(["codex", "resume", "--last"].as_ref()); - assert!(!interactive.resume_picker); - assert!(interactive.resume_last); - assert_eq!(interactive.resume_session_id, None); - }); + fn reject_remote_auth_token_env_for_app_server_generate_internal_json_schema() { + let subcommand = + AppServerSubcommand::GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand { + out_dir: PathBuf::from("/tmp/out"), + }); + let err = reject_remote_mode_for_app_server_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("non-interactive app-server subcommands should reject --remote-auth-token-env"); + assert!(err.to_string().contains("generate-internal-json-schema")); } #[test] - fn resume_picker_logic_with_session_id() { - with_temp_code_home(|code_home| { - let session_id = Uuid::new_v4(); - let session_id_str = session_id.to_string(); - create_session_fixture(code_home, &session_id); - - let args = vec![ - "codex".to_string(), - "resume".to_string(), - session_id_str.clone(), - ]; - let arg_refs = args.iter().map(String::as_str).collect::>(); - - let interactive = finalize_from_args(&arg_refs); - assert!(!interactive.resume_picker); - assert!(!interactive.resume_last); - assert_eq!(interactive.resume_session_id.as_deref(), Some(session_id_str.as_str())); - }); + fn read_remote_auth_token_from_env_var_reports_missing_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Err(std::env::VarError::NotPresent) + }) + .expect_err("missing env vars should be rejected"); + assert!(err.to_string().contains("is not set")); } #[test] - fn resolve_resume_path_uses_catalog_for_last() { - with_temp_code_home(|code_home| { - let cwd = Path::new("/project"); - let older_id = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(); - let newer_id = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(); + fn read_remote_auth_token_from_env_var_trims_values() { + let auth_token = + read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" bearer-token ".to_string()) + }) + .expect("env var should parse"); + assert_eq!(auth_token, "bearer-token"); + } - create_session_fixture_with_details( - code_home, - &older_id, - "2025-11-15T10:00:00Z", - "2025-11-15T10:00:10Z", - cwd, - SessionSource::Cli, - "older", - ); - create_session_fixture_with_details( - code_home, - &newer_id, - "2025-11-16T10:00:00Z", - "2025-11-16T10:00:10Z", - cwd, - SessionSource::Exec, - "newer", - ); + #[test] + fn read_remote_auth_token_from_env_var_rejects_empty_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" \n\t ".to_string()) + }) + .expect_err("empty env vars should be rejected"); + assert!(err.to_string().contains("is empty")); + } - let path = resolve_resume_path(None, true).expect("query").expect("path"); - let path_str = path.to_string_lossy(); - assert!( - path_str.contains("22222222-2222-4222-8222-222222222222"), - "path resolved to {}", - path_str - ); - }); + #[test] + fn app_server_listen_websocket_url_parses() { + let app_server = app_server_from_args( + ["codex", "app-server", "--listen", "ws://127.0.0.1:4500"].as_ref(), + ); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::WebSocket { + bind_address: "127.0.0.1:4500".parse().expect("valid socket address"), + } + ); } #[test] - fn resolve_resume_path_prefix_lookup() { - with_temp_code_home(|code_home| { - let cwd = Path::new("/project"); - let session_id = Uuid::parse_str("33333333-3333-4333-8333-333333333333").unwrap(); - create_session_fixture_with_details( - code_home, - &session_id, - "2025-11-16T12:00:00Z", - "2025-11-16T12:00:05Z", - cwd, - SessionSource::Cli, - "prefix", - ); + fn app_server_listen_stdio_url_parses() { + let app_server = + app_server_from_args(["codex", "app-server", "--listen", "stdio://"].as_ref()); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::Stdio + ); + } - let result = resolve_resume_path(Some("33333333"), false) - .expect("query") - .expect("path"); - let result_str = result.to_string_lossy(); - assert!( - result_str.contains("33333333-3333-4333-8333-333333333333"), - "path resolved to {}", - result_str - ); - }); + #[test] + fn app_server_listen_unix_socket_url_parses() { + let app_server = + app_server_from_args(["codex", "app-server", "--listen", "unix://"].as_ref()); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::UnixSocket { + socket_path: default_app_server_socket_path() + } + ); } #[test] - fn resolve_resume_path_handles_sync_like_mtime() { - with_temp_code_home(|code_home| { - let cwd = Path::new("/project"); - let older_id = Uuid::parse_str("44444444-4444-4444-8444-444444444444").unwrap(); - let newer_id = Uuid::parse_str("55555555-5555-4555-8555-555555555555").unwrap(); + fn app_server_listen_unix_socket_path_parses() { + let app_server = app_server_from_args( + ["codex", "app-server", "--listen", "unix:///tmp/codex.sock"].as_ref(), + ); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path("/tmp/codex.sock") + .expect("absolute path should parse") + } + ); + } - let older_path = create_session_fixture_with_details( - code_home, - &older_id, - "2025-11-10T10:00:00Z", - "2025-11-10T10:05:00Z", - cwd, - SessionSource::Cli, - "older", - ); - let newer_path = create_session_fixture_with_details( - code_home, - &newer_id, - "2025-11-16T10:00:00Z", - "2025-11-16T10:05:00Z", - cwd, - SessionSource::Exec, - "newer", - ); + #[test] + fn app_server_listen_off_parses() { + let app_server = app_server_from_args(["codex", "app-server", "--listen", "off"].as_ref()); + assert_eq!(app_server.listen, codex_app_server::AppServerTransport::Off); + } - let base = SystemTime::now(); - set_file_mtime(&older_path, FileTime::from_system_time(base + Duration::from_secs(300))).unwrap(); - set_file_mtime(&newer_path, FileTime::from_system_time(base + Duration::from_secs(60))).unwrap(); + #[test] + fn app_server_listen_invalid_url_fails_to_parse() { + let parse_result = + MultitoolCli::try_parse_from(["codex", "app-server", "--listen", "http://foo"]); + assert!(parse_result.is_err()); + } - let path = resolve_resume_path(None, true).expect("query").expect("path"); - let path_str = path.to_string_lossy(); - assert!( - path_str.contains("55555555-5555-4555-8555-555555555555"), - "path resolved to {}", - path_str - ); - }); + #[test] + fn app_server_proxy_subcommand_parses() { + let app_server = app_server_from_args(["codex", "app-server", "proxy"].as_ref()); + assert!(matches!( + app_server.subcommand, + Some(AppServerSubcommand::Proxy(AppServerProxyCommand { + socket_path: None + })) + )); } #[test] - fn resume_merges_option_flags_and_full_auto() { - with_temp_code_home(|code_home| { - let session_id = Uuid::new_v4(); - let session_id_str = session_id.to_string(); - create_session_fixture(code_home, &session_id); - - let args = vec![ - "codex".to_string(), - "resume".to_string(), - session_id_str.clone(), - "--oss".to_string(), - "--full-auto".to_string(), - "--search".to_string(), - "--sandbox".to_string(), - "workspace-write".to_string(), - "--ask-for-approval".to_string(), - "on-request".to_string(), - "-m".to_string(), - "gpt-5.1-test".to_string(), - "-p".to_string(), - "my-profile".to_string(), - "-C".to_string(), - "/tmp".to_string(), - "-i".to_string(), - "/tmp/a.png,/tmp/b.png".to_string(), - ]; - let arg_refs = args.iter().map(String::as_str).collect::>(); - - let interactive = finalize_from_args(&arg_refs); - - assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); - assert!(interactive.oss); - assert_eq!(interactive.config_profile.as_deref(), Some("my-profile")); - assert!(matches!( - interactive.sandbox_mode, - Some(code_common::SandboxModeCliArg::WorkspaceWrite) - )); - assert!(matches!( - interactive.approval_policy, - Some(code_common::ApprovalModeCliArg::OnRequest) - )); - assert!(interactive.full_auto); - assert_eq!( - interactive.cwd.as_deref(), - Some(std::path::Path::new("/tmp")) - ); - assert!(interactive.web_search); - let has_a = interactive - .images - .iter() - .any(|p| p == std::path::Path::new("/tmp/a.png")); - let has_b = interactive - .images - .iter() - .any(|p| p == std::path::Path::new("/tmp/b.png")); - assert!(has_a && has_b); - assert!(!interactive.resume_picker); - assert!(!interactive.resume_last); - assert_eq!( - interactive.resume_session_id.as_deref(), - Some(session_id_str.as_str()) - ); - }); + fn app_server_proxy_sock_path_parses() { + let app_server = + app_server_from_args(["codex", "app-server", "proxy", "--sock", "codex.sock"].as_ref()); + let Some(AppServerSubcommand::Proxy(proxy)) = app_server.subcommand else { + panic!("expected proxy subcommand"); + }; + assert_eq!( + proxy.socket_path, + Some( + AbsolutePathBuf::relative_to_current_dir("codex.sock") + .expect("relative path should resolve") + ) + ); } #[test] - fn resume_merges_dangerously_bypass_flag() { - let interactive = finalize_from_args( + fn reject_remote_auth_token_env_for_app_server_proxy() { + let subcommand = AppServerSubcommand::Proxy(AppServerProxyCommand { socket_path: None }); + let err = reject_remote_mode_for_app_server_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("app-server proxy should reject --remote-auth-token-env"); + assert!(err.to_string().contains("app-server proxy")); + } + + #[test] + fn app_server_capability_token_flags_parse() { + let app_server = app_server_from_args( [ "codex", - "resume", - "--dangerously-bypass-approvals-and-sandbox", + "app-server", + "--ws-auth", + "capability-token", + "--ws-token-file", + "/tmp/codex-token", ] .as_ref(), ); - assert!(interactive.dangerously_bypass_approvals_and_sandbox); - assert!(interactive.resume_picker); - assert!(!interactive.resume_last); - assert_eq!(interactive.resume_session_id, None); + assert_eq!( + app_server.auth.ws_auth, + Some(codex_app_server::WebsocketAuthCliMode::CapabilityToken) + ); + assert_eq!( + app_server.auth.ws_token_file, + Some(PathBuf::from("/tmp/codex-token")) + ); + } + + #[test] + fn app_server_signed_bearer_flags_parse() { + let app_server = app_server_from_args( + [ + "codex", + "app-server", + "--ws-auth", + "signed-bearer-token", + "--ws-shared-secret-file", + "/tmp/codex-secret", + "--ws-issuer", + "issuer", + "--ws-audience", + "audience", + "--ws-max-clock-skew-seconds", + "9", + ] + .as_ref(), + ); + assert_eq!( + app_server.auth.ws_auth, + Some(codex_app_server::WebsocketAuthCliMode::SignedBearerToken) + ); + assert_eq!( + app_server.auth.ws_shared_secret_file, + Some(PathBuf::from("/tmp/codex-secret")) + ); + assert_eq!(app_server.auth.ws_issuer.as_deref(), Some("issuer")); + assert_eq!(app_server.auth.ws_audience.as_deref(), Some("audience")); + assert_eq!(app_server.auth.ws_max_clock_skew_seconds, Some(9)); + } + + #[test] + fn app_server_rejects_removed_insecure_non_loopback_flag() { + let parse_result = MultitoolCli::try_parse_from([ + "codex", + "app-server", + "--allow-unauthenticated-non-loopback-ws", + ]); + assert!(parse_result.is_err()); + } + + #[test] + fn features_enable_parses_feature_name() { + let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"]) + .expect("parse should succeed"); + let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else { + panic!("expected features subcommand"); + }; + let FeaturesSubcommand::Enable(FeatureSetArgs { feature }) = sub else { + panic!("expected features enable"); + }; + assert_eq!(feature, "unified_exec"); + } + + #[test] + fn features_disable_parses_feature_name() { + let cli = MultitoolCli::try_parse_from(["codex", "features", "disable", "shell_tool"]) + .expect("parse should succeed"); + let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else { + panic!("expected features subcommand"); + }; + let FeaturesSubcommand::Disable(FeatureSetArgs { feature }) = sub else { + panic!("expected features disable"); + }; + assert_eq!(feature, "shell_tool"); + } + + #[test] + fn feature_toggles_known_features_generate_overrides() { + let toggles = FeatureToggles { + enable: vec!["web_search_request".to_string()], + disable: vec!["unified_exec".to_string()], + }; + let overrides = toggles.to_overrides().expect("valid features"); + assert_eq!( + overrides, + vec![ + "features.web_search_request=true".to_string(), + "features.unified_exec=false".to_string(), + ] + ); + } + + #[test] + fn feature_toggles_accept_legacy_linux_sandbox_flag() { + let toggles = FeatureToggles { + enable: vec!["use_linux_sandbox_bwrap".to_string()], + disable: Vec::new(), + }; + let overrides = toggles.to_overrides().expect("valid features"); + assert_eq!( + overrides, + vec!["features.use_linux_sandbox_bwrap=true".to_string(),] + ); + } + + #[test] + fn feature_toggles_accept_removed_image_detail_original_flag() { + let toggles = FeatureToggles { + enable: vec!["image_detail_original".to_string()], + disable: Vec::new(), + }; + let overrides = toggles.to_overrides().expect("valid features"); + assert_eq!( + overrides, + vec!["features.image_detail_original=true".to_string(),] + ); + } + + #[test] + fn feature_toggles_unknown_feature_errors() { + let toggles = FeatureToggles { + enable: vec!["does_not_exist".to_string()], + disable: Vec::new(), + }; + let err = toggles + .to_overrides() + .expect_err("feature should be rejected"); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); } } diff --git a/code-rs/cli/src/marketplace_cmd.rs b/code-rs/cli/src/marketplace_cmd.rs new file mode 100644 index 00000000000..fcd9049d59e --- /dev/null +++ b/code-rs/cli/src/marketplace_cmd.rs @@ -0,0 +1,245 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use clap::Parser; +use codex_core::config::Config; +use codex_core::config::find_codex_home; +use codex_core_plugins::PluginMarketplaceUpgradeOutcome; +use codex_core_plugins::PluginsManager; +use codex_core_plugins::marketplace_add::MarketplaceAddRequest; +use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; +use codex_core_plugins::marketplace_remove::remove_marketplace; +use codex_utils_cli::CliConfigOverrides; + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace")] +pub struct MarketplaceCli { + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, + + #[command(subcommand)] + subcommand: MarketplaceSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +enum MarketplaceSubcommand { + Add(AddMarketplaceArgs), + Upgrade(UpgradeMarketplaceArgs), + Remove(RemoveMarketplaceArgs), +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace add")] +struct AddMarketplaceArgs { + /// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, SSH URLs, + /// or local marketplace root directories. + source: String, + + #[arg(long = "ref", value_name = "REF")] + ref_name: Option, + + #[arg( + long = "sparse", + value_name = "PATH", + action = clap::ArgAction::Append + )] + sparse_paths: Vec, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace upgrade")] +struct UpgradeMarketplaceArgs { + marketplace_name: Option, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace remove")] +struct RemoveMarketplaceArgs { + /// Configured marketplace name to remove. + marketplace_name: String, +} + +impl MarketplaceCli { + pub async fn run(self) -> Result<()> { + let MarketplaceCli { + config_overrides, + subcommand, + } = self; + + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + + match subcommand { + MarketplaceSubcommand::Add(args) => run_add(args).await?, + MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, + MarketplaceSubcommand::Remove(args) => run_remove(args).await?, + } + + Ok(()) + } +} + +async fn run_add(args: AddMarketplaceArgs) -> Result<()> { + let AddMarketplaceArgs { + source, + ref_name, + sparse_paths, + } = args; + + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let outcome = add_marketplace( + codex_home.to_path_buf(), + MarketplaceAddRequest { + source, + ref_name, + sparse_paths, + }, + ) + .await?; + + if outcome.already_added { + println!( + "Marketplace `{}` is already added from {}.", + outcome.marketplace_name, outcome.source_display + ); + } else { + println!( + "Added marketplace `{}` from {}.", + outcome.marketplace_name, outcome.source_display + ); + } + println!( + "Installed marketplace root: {}", + outcome.installed_root.as_path().display() + ); + + Ok(()) +} + +async fn run_upgrade( + overrides: Vec<(String, toml::Value)>, + args: UpgradeMarketplaceArgs, +) -> Result<()> { + let UpgradeMarketplaceArgs { marketplace_name } = args; + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let manager = PluginsManager::new(codex_home.to_path_buf()); + let plugins_input = config.plugins_config_input(); + let outcome = manager + .upgrade_configured_marketplaces_for_config(&plugins_input, marketplace_name.as_deref()) + .map_err(anyhow::Error::msg)?; + print_upgrade_outcome(&outcome, marketplace_name.as_deref()) +} + +async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { + let RemoveMarketplaceArgs { marketplace_name } = args; + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let outcome = remove_marketplace( + codex_home.to_path_buf(), + MarketplaceRemoveRequest { marketplace_name }, + ) + .await?; + + println!("Removed marketplace `{}`.", outcome.marketplace_name); + if let Some(installed_root) = outcome.removed_installed_root { + println!( + "Removed installed marketplace root: {}", + installed_root.as_path().display() + ); + } + + Ok(()) +} + +fn print_upgrade_outcome( + outcome: &PluginMarketplaceUpgradeOutcome, + marketplace_name: Option<&str>, +) -> Result<()> { + for error in &outcome.errors { + eprintln!( + "Failed to upgrade marketplace `{}`: {}", + error.marketplace_name, error.message + ); + } + if !outcome.all_succeeded() { + bail!("{} upgrade failure(s) occurred.", outcome.errors.len()); + } + + let selection_label = marketplace_name.unwrap_or("all configured Git marketplaces"); + if outcome.selected_marketplaces.is_empty() { + println!("No configured Git marketplaces to upgrade."); + } else if outcome.upgraded_roots.is_empty() { + if marketplace_name.is_some() { + println!("Marketplace `{selection_label}` is already up to date."); + } else { + println!("All configured Git marketplaces are already up to date."); + } + } else if marketplace_name.is_some() { + println!("Upgraded marketplace `{selection_label}` to the latest configured revision."); + for root in &outcome.upgraded_roots { + println!("Installed marketplace root: {}", root.display()); + } + } else { + println!("Upgraded {} marketplace(s).", outcome.upgraded_roots.len()); + for root in &outcome.upgraded_roots { + println!("Installed marketplace root: {}", root.display()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn sparse_paths_parse_before_or_after_source() { + let sparse_before_source = + AddMarketplaceArgs::try_parse_from(["add", "--sparse", "plugins/foo", "owner/repo"]) + .unwrap(); + assert_eq!(sparse_before_source.source, "owner/repo"); + assert_eq!(sparse_before_source.sparse_paths, vec!["plugins/foo"]); + + let sparse_after_source = + AddMarketplaceArgs::try_parse_from(["add", "owner/repo", "--sparse", "plugins/foo"]) + .unwrap(); + assert_eq!(sparse_after_source.source, "owner/repo"); + assert_eq!(sparse_after_source.sparse_paths, vec!["plugins/foo"]); + + let repeated_sparse = AddMarketplaceArgs::try_parse_from([ + "add", + "--sparse", + "plugins/foo", + "--sparse", + "skills/bar", + "owner/repo", + ]) + .unwrap(); + assert_eq!(repeated_sparse.source, "owner/repo"); + assert_eq!( + repeated_sparse.sparse_paths, + vec!["plugins/foo", "skills/bar"] + ); + } + + #[test] + fn upgrade_subcommand_parses_optional_marketplace_name() { + let upgrade_all = UpgradeMarketplaceArgs::try_parse_from(["upgrade"]).unwrap(); + assert_eq!(upgrade_all.marketplace_name, None); + + let upgrade_one = UpgradeMarketplaceArgs::try_parse_from(["upgrade", "debug"]).unwrap(); + assert_eq!(upgrade_one.marketplace_name.as_deref(), Some("debug")); + } + + #[test] + fn remove_subcommand_parses_marketplace_name() { + let remove = RemoveMarketplaceArgs::try_parse_from(["remove", "debug"]).unwrap(); + assert_eq!(remove.marketplace_name, "debug"); + } +} diff --git a/code-rs/cli/src/mcp_cmd.rs b/code-rs/cli/src/mcp_cmd.rs index 12cbf8b2c55..af75999163c 100644 --- a/code-rs/cli/src/mcp_cmd.rs +++ b/code-rs/cli/src/mcp_cmd.rs @@ -1,24 +1,40 @@ use std::collections::HashMap; +use std::sync::Arc; 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; +use clap::ArgGroup; +use codex_config::types::AppToolApproval; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; +use codex_core::McpManager; +use codex_core::config::Config; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::find_codex_home; +use codex_core::config::load_global_mcp_servers; +use codex_core_plugins::PluginsManager; +use codex_mcp::McpOAuthLoginSupport; +use codex_mcp::ResolvedMcpOAuthScopes; +use codex_mcp::compute_auth_statuses; +use codex_mcp::discover_supported_scopes; +use codex_mcp::oauth_login_support; +use codex_mcp::resolve_oauth_scopes; +use codex_mcp::should_retry_without_scopes; +use codex_protocol::protocol::McpAuthStatus; +use codex_rmcp_client::delete_oauth_tokens; +use codex_rmcp_client::perform_oauth_login; +use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::format_env_display; /// 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`) +/// - `add` — add a server launcher entry to `~/.codex/config.toml` /// - `remove` — delete a server entry +/// - `login` — authenticate with MCP server using OAuth +/// - `logout` — remove OAuth credentials for MCP server #[derive(Debug, clap::Parser)] pub struct McpCli { #[clap(flatten)] @@ -31,12 +47,11 @@ pub struct McpCli { #[derive(Debug, clap::Subcommand)] pub enum McpSubcommand { List(ListArgs), - Get(GetArgs), - Add(AddArgs), - Remove(RemoveArgs), + Login(LoginArgs), + Logout(LogoutArgs), } #[derive(Debug, clap::Parser)] @@ -57,31 +72,66 @@ pub struct GetArgs { } #[derive(Debug, clap::Parser)] +#[command(override_usage = "codex mcp add [OPTIONS] (--url | -- ...)")] pub struct AddArgs { /// Name for the MCP server configuration. pub name: String, - /// URL of a remote MCP server. - /// - /// When `--bearer-token` is omitted, Code records the server as a stdio - /// launcher using `npx -y mcp-remote ` so the MCP server can handle - /// OAuth flows. - #[arg(long)] - pub url: Option, + #[command(flatten)] + pub transport_args: AddMcpTransportArgs, +} - /// Optional bearer token to use with `--url` for static authentication. - /// - /// When set, Code records the server as a `streamable_http` MCP server. - #[arg(long)] - pub bearer_token: Option, +#[derive(Debug, clap::Args)] +#[command( + group( + ArgGroup::new("transport") + .args(["command", "url"]) + .required(true) + .multiple(false) + ) +)] +pub struct AddMcpTransportArgs { + #[command(flatten)] + pub stdio: Option, + + #[command(flatten)] + pub streamable_http: Option, +} + +#[derive(Debug, clap::Args)] +pub struct AddMcpStdioArgs { + /// Command to launch the MCP server. + /// Use --url for a streamable HTTP server. + #[arg( + trailing_var_arg = true, + num_args = 0.., + )] + pub command: Vec, /// Environment variables to set when launching the server. - #[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")] + /// Only valid with stdio servers. + #[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 = 0..)] - pub command: Vec, +#[derive(Debug, clap::Args)] +pub struct AddMcpStreamableHttpArgs { + /// URL for a streamable HTTP MCP server. + #[arg(long)] + pub url: String, + + /// Optional environment variable to read for a bearer token. + /// Only valid with streamable HTTP servers. + #[arg( + long = "bearer-token-env-var", + value_name = "ENV_VAR", + requires = "url" + )] + pub bearer_token_env_var: Option, } #[derive(Debug, clap::Parser)] @@ -90,6 +140,22 @@ pub struct RemoveArgs { pub name: String, } +#[derive(Debug, clap::Parser)] +pub struct LoginArgs { + /// Name of the MCP server to authenticate with oauth. + pub name: String, + + /// Comma-separated list of OAuth scopes to request. + #[arg(long, value_delimiter = ',', value_name = "SCOPE,SCOPE")] + pub scopes: Vec, +} + +#[derive(Debug, clap::Parser)] +pub struct LogoutArgs { + /// Name of the MCP server to deauthenticate. + pub name: String, +} + impl McpCli { pub async fn run(self) -> Result<()> { let McpCli { @@ -99,16 +165,22 @@ impl McpCli { match subcommand { McpSubcommand::List(args) => { - run_list(&config_overrides, args)?; + run_list(&config_overrides, args).await?; } McpSubcommand::Get(args) => { - run_get(&config_overrides, args)?; + run_get(&config_overrides, args).await?; } McpSubcommand::Add(args) => { - run_add(&config_overrides, args)?; + run_add(&config_overrides, args).await?; } McpSubcommand::Remove(args) => { - run_remove(&config_overrides, args)?; + run_remove(&config_overrides, args).await?; + } + McpSubcommand::Login(args) => { + run_login(&config_overrides, args).await?; + } + McpSubcommand::Logout(args) => { + run_logout(&config_overrides, args).await?; } } @@ -116,167 +188,194 @@ impl McpCli { } } -fn build_mcp_transport_for_add( - url: Option, - bearer_token: Option, - env: Option>, - command: Vec, -) -> Result { - if let Some(url) = url { - if !command.is_empty() { - bail!("--url cannot be combined with a command"); - } - if let Some(bearer_token) = bearer_token { - return Ok(McpServerTransportConfig::StreamableHttp { +/// Preserve compatibility with servers that still expect the legacy empty-scope +/// OAuth request. If a discovered-scope request is rejected by the provider, +/// retry the login flow once without scopes. +#[allow(clippy::too_many_arguments)] +async fn perform_oauth_login_retry_without_scopes( + name: &str, + url: &str, + store_mode: codex_config::types::OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + resolved_scopes: &ResolvedMcpOAuthScopes, + oauth_resource: Option<&str>, + callback_port: Option, + callback_url: Option<&str>, +) -> Result<()> { + match perform_oauth_login( + name, + url, + store_mode, + http_headers.clone(), + env_http_headers.clone(), + &resolved_scopes.scopes, + oauth_resource, + callback_port, + callback_url, + ) + .await + { + Ok(()) => Ok(()), + Err(err) if should_retry_without_scopes(resolved_scopes, &err) => { + println!("OAuth provider rejected discovered scopes. Retrying without scopes…"); + perform_oauth_login( + name, url, - bearer_token: Some(bearer_token), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - oauth_resource: None, - }); + store_mode, + http_headers, + env_http_headers, + &[], + oauth_resource, + callback_port, + callback_url, + ) + .await } - return Ok(McpServerTransportConfig::Stdio { - command: "npx".to_string(), - args: vec!["-y".to_string(), "mcp-remote".to_string(), url], - env, - }); + Err(err) => Err(err), } - - if bearer_token.is_some() { - bail!("--bearer-token requires --url"); - } - - let mut command_parts = command.into_iter(); - let command_bin = command_parts - .next() - .ok_or_else(|| anyhow!("command is required"))?; - let command_args: Vec = command_parts.collect(); - Ok(McpServerTransportConfig::Stdio { - command: command_bin, - args: command_args, - env, - }) } -fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { +async 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 overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; let AddArgs { name, - url, - bearer_token, - env, - command, + transport_args, } = add_args; validate_server_name(&name)?; - let env_map = if env.is_empty() { - None - } else { - let mut map = HashMap::new(); - for (key, value) in env { - map.insert(key, value); + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let mut servers = load_global_mcp_servers(&codex_home) + .await + .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; + + let transport = match transport_args { + AddMcpTransportArgs { + stdio: Some(stdio), .. + } => { + let mut command_parts = stdio.command.into_iter(); + let command_bin = command_parts + .next() + .ok_or_else(|| anyhow!("command is required"))?; + let command_args: Vec = command_parts.collect(); + + let env_map = if stdio.env.is_empty() { + None + } else { + Some(stdio.env.into_iter().collect::>()) + }; + McpServerTransportConfig::Stdio { + command: command_bin, + args: command_args, + env: env_map, + env_vars: Vec::new(), + cwd: None, + } } - Some(map) + AddMcpTransportArgs { + streamable_http: + Some(AddMcpStreamableHttpArgs { + url, + bearer_token_env_var, + }), + .. + } => McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers: None, + env_http_headers: None, + }, + AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; - 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 transport = build_mcp_transport_for_add(url, bearer_token, env_map, command)?; - let new_entry = McpServerConfig { - transport, + transport: transport.clone(), + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, + default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), }; 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()))?; + ConfigEditsBuilder::new(&codex_home) + .replace_mcp_servers(&servers) + .apply() + .await + .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; println!("Added global MCP server '{name}'."); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn add_with_url_defaults_to_mcp_remote() { - let transport = build_mcp_transport_for_add( - Some("https://mcp.example.com/mcp".to_string()), - None, - None, - Vec::new(), - ) - .expect("transport"); - - match transport { - McpServerTransportConfig::Stdio { command, args, env } => { - assert_eq!(command, "npx"); - assert_eq!(args[0], "-y"); - assert_eq!(args[1], "mcp-remote"); - assert_eq!(args[2], "https://mcp.example.com/mcp"); - assert!(env.is_none()); - } - _ => panic!("expected stdio transport"), + match oauth_login_support(&transport).await { + McpOAuthLoginSupport::Supported(oauth_config) => { + println!("Detected OAuth support. Starting OAuth flow…"); + let resolved_scopes = resolve_oauth_scopes( + /*explicit_scopes*/ None, + /*configured_scopes*/ None, + oauth_config.discovered_scopes.clone(), + ); + perform_oauth_login_retry_without_scopes( + &name, + &oauth_config.url, + config.mcp_oauth_credentials_store_mode, + oauth_config.http_headers, + oauth_config.env_http_headers, + &resolved_scopes, + /*oauth_resource*/ None, + config.mcp_oauth_callback_port, + config.mcp_oauth_callback_url.as_deref(), + ) + .await?; + println!("Successfully logged in."); } + McpOAuthLoginSupport::Unsupported => {} + McpOAuthLoginSupport::Unknown(_) => println!( + "MCP server may or may not require login. Run `codex mcp login {name}` to login." + ), } - #[test] - fn add_with_url_and_bearer_token_uses_streamable_http() { - let transport = build_mcp_transport_for_add( - Some("https://mcp.example.com/mcp".to_string()), - Some("token".to_string()), - None, - Vec::new(), - ) - .expect("transport"); - - match transport { - McpServerTransportConfig::StreamableHttp { - url, - bearer_token, - bearer_token_env_var: _, - http_headers: _, - env_http_headers: _, - oauth_resource, - } => { - assert_eq!(url, "https://mcp.example.com/mcp"); - assert_eq!(bearer_token.as_deref(), Some("token")); - assert_eq!(oauth_resource, None); - } - _ => panic!("expected streamable http transport"), - } - } + Ok(()) } -fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { - config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; +async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { + config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; 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 codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let mut servers = load_global_mcp_servers(&codex_home) + .await + .with_context(|| format!("failed to load MCP servers from {}", codex_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()))?; + ConfigEditsBuilder::new(&codex_home) + .replace_mcp_servers(&servers) + .apply() + .await + .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; } if removed { @@ -288,50 +387,164 @@ fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> 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()) +async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; + let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( + config.codex_home.to_path_buf(), + ))); + let mcp_servers = mcp_manager.configured_servers(&config).await; + + let LoginArgs { name, scopes } = login_args; + + let Some(server) = mcp_servers.get(&name) else { + bail!("No MCP server named '{name}' found."); + }; + + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), + _ => bail!("OAuth login is only supported for streamable HTTP servers."), + }; + + let explicit_scopes = (!scopes.is_empty()).then_some(scopes); + let discovered_scopes = if explicit_scopes.is_none() && server.scopes.is_none() { + discover_supported_scopes(&server.transport).await + } else { + None + }; + let resolved_scopes = + resolve_oauth_scopes(explicit_scopes, server.scopes.clone(), discovered_scopes); + + perform_oauth_login_retry_without_scopes( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + &resolved_scopes, + server.oauth_resource.as_deref(), + config.mcp_oauth_callback_port, + config.mcp_oauth_callback_url.as_deref(), + ) + .await?; + println!("Successfully logged in to MCP server '{name}'."); + Ok(()) +} + +async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; + let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( + config.codex_home.to_path_buf(), + ))); + let mcp_servers = mcp_manager.configured_servers(&config).await; + + let LogoutArgs { name } = logout_args; + + let server = mcp_servers + .get(&name) + .ok_or_else(|| anyhow!("No MCP server named '{name}' found in configuration."))?; + + let url = match &server.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(), + _ => bail!("OAuth logout is only supported for streamable_http transports."), + }; + + match delete_oauth_tokens(&name, &url, config.mcp_oauth_credentials_store_mode) { + Ok(true) => println!("Removed OAuth credentials for '{name}'."), + Ok(false) => println!("No OAuth credentials stored for '{name}'."), + Err(err) => return Err(anyhow!("failed to delete OAuth credentials: {err}")), + } + + Ok(()) +} + +async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let config = Config::load_with_cli_overrides(overrides) + .await .context("failed to load configuration")?; + let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( + config.codex_home.to_path_buf(), + ))); + let mcp_servers = mcp_manager.configured_servers(&config).await; + let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; - let mut entries: Vec<_> = config.mcp_servers.iter().collect(); + let mut entries: Vec<_> = mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + let auth_statuses = compute_auth_statuses( + effective_mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + /*auth*/ None, + ) + .await; if list_args.json { let json_entries: Vec<_> = entries .into_iter() .map(|(name, cfg)| { + let auth_status = auth_statuses + .get(name.as_str()) + .map(|entry| entry.auth_status) + .unwrap_or(McpAuthStatus::Unsupported); let transport = match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, - bearer_token, bearer_token_env_var, http_headers, env_http_headers, - oauth_resource, } => { serde_json::json!({ "type": "streamable_http", "url": url, - "bearer_token": bearer_token, "bearer_token_env_var": bearer_token_env_var, "http_headers": http_headers, "env_http_headers": env_http_headers, - "oauth_resource": oauth_resource, }) } }; serde_json::json!({ "name": name, + "enabled": cfg.enabled, + "disabled_reason": cfg.disabled_reason.as_ref().map(ToString::to_string), "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()), + "startup_timeout_sec": cfg + .startup_timeout_sec + .map(|timeout| timeout.as_secs_f64()), + "tool_timeout_sec": cfg + .tool_timeout_sec + .map(|timeout| timeout.as_secs_f64()), + "auth_status": auth_status, }) }) .collect(); @@ -345,53 +558,79 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul return Ok(()); } - let mut stdio_rows: Vec<[String; 4]> = Vec::new(); - let mut http_rows: Vec<[String; 3]> = Vec::new(); + let mut stdio_rows: Vec<[String; 7]> = Vec::new(); + let mut http_rows: Vec<[String; 5]> = Vec::new(); for (name, cfg) in entries { match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { 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::>() - .join(", ") - } - }; - stdio_rows.push([name.clone(), command.clone(), args_display, env_display]); + let env_display = format_env_display(env.as_ref(), env_vars); + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); + let status = format_mcp_status(cfg); + let auth_status = auth_statuses + .get(name.as_str()) + .map(|entry| entry.auth_status) + .unwrap_or(McpAuthStatus::Unsupported) + .to_string(); + stdio_rows.push([ + name.clone(), + command.clone(), + args_display, + env_display, + cwd_display, + status, + auth_status, + ]); } McpServerTransportConfig::StreamableHttp { url, - bearer_token, bearer_token_env_var, - http_headers, - env_http_headers, - oauth_resource: _, + .. } => { - let has_bearer = if bearer_token.is_some() || bearer_token_env_var.is_some() { - "True" - } else { - "False" - }; - let _ = (http_headers, env_http_headers); - http_rows.push([name.clone(), url.clone(), has_bearer.into()]); + let status = format_mcp_status(cfg); + let auth_status = auth_statuses + .get(name.as_str()) + .map(|entry| entry.auth_status) + .unwrap_or(McpAuthStatus::Unsupported) + .to_string(); + let bearer_token_display = + bearer_token_env_var.as_deref().unwrap_or("-").to_string(); + http_rows.push([ + name.clone(), + url.clone(), + bearer_token_display, + status, + auth_status, + ]); } } } if !stdio_rows.is_empty() { - let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; + let mut widths = [ + "Name".len(), + "Command".len(), + "Args".len(), + "Env".len(), + "Cwd".len(), + "Status".len(), + "Auth".len(), + ]; for row in &stdio_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -399,28 +638,40 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul } println!( - "{: Resul } if !http_rows.is_empty() { - let mut widths = ["Name".len(), "Url".len(), "Has Bearer Token".len()]; + let mut widths = [ + "Name".len(), + "Url".len(), + "Bearer Token Env Var".len(), + "Status".len(), + "Auth".len(), + ]; for row in &http_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -438,24 +695,32 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul } println!( - "{: Resul 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()) +async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let config = Config::load_with_cli_overrides(overrides) + .await .context("failed to load configuration")?; + let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( + config.codex_home.to_path_buf(), + ))); + let mcp_servers = mcp_manager.configured_servers(&config).await; - let Some(server) = config.mcp_servers.get(&get_args.name) else { + let Some(server) = 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!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, - bearer_token, bearer_token_env_var, http_headers, env_http_headers, - oauth_resource, } => serde_json::json!({ "type": "streamable_http", "url": url, - "bearer_token": bearer_token, "bearer_token_env_var": bearer_token_env_var, "http_headers": http_headers, "env_http_headers": env_http_headers, - "oauth_resource": oauth_resource, }), }; let output = serde_json::to_string_pretty(&serde_json::json!({ "name": get_args.name, + "enabled": server.enabled, + "disabled_reason": server.disabled_reason.as_ref().map(ToString::to_string), "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()), + "enabled_tools": server.enabled_tools.clone(), + "disabled_tools": server.disabled_tools.clone(), + "startup_timeout_sec": server + .startup_timeout_sec + .map(|timeout| timeout.as_secs_f64()), + "tool_timeout_sec": server + .tool_timeout_sec + .map(|timeout| timeout.as_secs_f64()), }))?; println!("{output}"); return Ok(()); } + if !server.enabled { + if let Some(reason) = server.disabled_reason.as_ref() { + println!("{name} (disabled: {reason})", name = get_args.name); + } else { + println!("{name} (disabled)", name = get_args.name); + } + return Ok(()); + } + println!("{}", get_args.name); + println!(" enabled: {}", server.enabled); + let format_tool_list = |tools: &Option>| -> String { + match tools { + Some(list) if list.is_empty() => "[]".to_string(), + Some(list) => list.join(", "), + None => "-".to_string(), + } + }; + if server.enabled_tools.is_some() { + let enabled_tools_display = format_tool_list(&server.enabled_tools); + println!(" enabled_tools: {enabled_tools_display}"); + } + if server.disabled_tools.is_some() { + let disabled_tools_display = format_tool_list(&server.disabled_tools); + println!(" disabled_tools: {disabled_tools_display}"); + } match &server.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { println!(" transport: stdio"); println!(" command: {command}"); let args_display = if args.is_empty() { @@ -518,54 +833,66 @@ fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<( 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::>() - .join(", ") - } - }; + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); + println!(" cwd: {cwd_display}"); + let env_display = format_env_display(env.as_ref(), env_vars); println!(" env: {env_display}"); } McpServerTransportConfig::StreamableHttp { url, - bearer_token, bearer_token_env_var, http_headers, env_http_headers, - oauth_resource, } => { println!(" transport: streamable_http"); println!(" url: {url}"); - let token_display = bearer_token - .as_ref() - .map(|_| "".to_string()) - .or_else(|| bearer_token_env_var.as_ref().map(|value| format!("env:{value}"))) - .unwrap_or_else(|| "-".to_string()); - println!(" bearer_token: {token_display}"); - if let Some(headers) = http_headers { - println!(" http_headers: {}", serde_json::to_string(headers)?); - } - if let Some(headers) = env_http_headers { - println!(" env_http_headers: {}", serde_json::to_string(headers)?); - } - let resource_display = oauth_resource - .clone() - .unwrap_or_else(|| "-".to_string()); - println!(" oauth_resource: {resource_display}"); + let bearer_token_display = bearer_token_env_var.as_deref().unwrap_or("-"); + println!(" bearer_token_env_var: {bearer_token_display}"); + let headers_display = match http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, _)| format!("{k}=*****")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" http_headers: {headers_display}"); + let env_headers_display = match env_http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, var)| format!("{k}={var}")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" env_http_headers: {env_headers_display}"); } } if let Some(timeout) = server.startup_timeout_sec { - println!(" startup_timeout_sec: {:.3}", timeout.as_secs_f64()); + println!(" startup_timeout_sec: {}", timeout.as_secs_f64()); } if let Some(timeout) = server.tool_timeout_sec { - println!(" tool_timeout_sec: {:.3}", timeout.as_secs_f64()); + println!(" tool_timeout_sec: {}", timeout.as_secs_f64()); + } + if let Some(approval_mode) = server.default_tools_approval_mode { + let approval_mode = match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }; + println!(" default_tools_approval_mode: {approval_mode}"); } println!(" remove: codex mcp remove {}", get_args.name); @@ -599,3 +926,13 @@ fn validate_server_name(name: &str) -> Result<()> { bail!("invalid server name '{name}' (use letters, numbers, '-', '_')"); } } + +fn format_mcp_status(config: &McpServerConfig) -> String { + if config.enabled { + "enabled".to_string() + } else if let Some(reason) = config.disabled_reason.as_ref() { + format!("disabled: {reason}") + } else { + "disabled".to_string() + } +} diff --git a/code-rs/cli/src/proto.rs b/code-rs/cli/src/proto.rs deleted file mode 100644 index 5c346e812b9..00000000000 --- a/code-rs/cli/src/proto.rs +++ /dev/null @@ -1,142 +0,0 @@ -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::(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(()) -} diff --git a/code-rs/cli/src/update.rs b/code-rs/cli/src/update.rs deleted file mode 100644 index ce13a60dab2..00000000000 --- a/code-rs/cli/src/update.rs +++ /dev/null @@ -1,611 +0,0 @@ -use std::env; -use std::fs; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; - -use anyhow::Context; -use anyhow::bail; -use clap::Parser; -use serde::Deserialize; -use sha2::Digest; -use sha2::Sha256; - -const DEFAULT_REPOSITORY: &str = "cbusillo/code"; -const DEFAULT_CHANNEL: &str = "stable"; -const COMMAND_NAME_ENV: &str = "CODE_COMMAND_NAME"; - -#[derive(Debug, Parser)] -pub struct UpdateCheckCommand { - /// GitHub repository that owns the update manifest. - #[arg(long = "repo", value_name = "OWNER/REPO")] - pub repo: Option, - - /// Release tag to inspect. Defaults to the latest GitHub release. - #[arg(long = "tag", value_name = "TAG")] - pub tag: Option, -} - -#[derive(Debug, Parser)] -pub struct UpdateCommand { - /// GitHub repository that owns the update manifest. - #[arg(long = "repo", value_name = "OWNER/REPO")] - pub repo: Option, - - /// Release tag to install. Defaults to the latest GitHub release. - #[arg(long = "tag", value_name = "TAG")] - pub tag: Option, - - /// Confirm replacement of the current directly managed binary. - #[arg(long = "yes", short = 'y', default_value_t = false)] - pub yes: bool, -} - -#[derive(Debug, Deserialize)] -struct UpdateManifest { - schema_version: u64, - version: String, - channel: String, - commit: String, - published_at: String, - platforms: std::collections::BTreeMap, -} - -#[derive(Clone, Debug, Deserialize)] -struct PlatformAsset { - asset: String, - url: String, - sha256: String, - size: u64, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VersionOrdering { - Older, - Same, - Newer, - Unknown, -} - -pub async fn run_update_check(args: UpdateCheckCommand) -> anyhow::Result<()> { - let report = fetch_update_report(args.repo.as_deref(), args.tag.as_deref()).await?; - let identity = RuntimeIdentity::detect(None); - print_update_report(&report, &identity); - Ok(()) -} - -pub async fn run_update(args: UpdateCommand) -> anyhow::Result<()> { - let report = fetch_update_report(args.repo.as_deref(), args.tag.as_deref()).await?; - let exe = env::current_exe().context("failed to resolve current executable")?; - let identity = RuntimeIdentity::detect(Some(&exe)); - print_update_report(&report, &identity); - - if report.ordering != VersionOrdering::Newer { - println!("No update needed."); - return Ok(()); - } - - let install_target = resolve_install_target(&exe); - println!("command: {}", identity.command_name); - println!("install target: {}", install_target.display()); - println!("install mode: {}", install_mode_description(&install_target)); - if !is_direct_binary_install_path(&install_target) { - bail!( - "refusing self-update because this executable is not an Every Code direct binary; install the latest GitHub Release manually instead" - ); - } - - if !args.yes { - bail!("pass --yes to replace the current binary after checksum verification"); - } - - #[cfg(target_family = "windows")] - { - let _ = install_target; - bail!("self-update is not implemented for Windows yet"); - } - - #[cfg(target_family = "unix")] - { - install_direct_binary(&report.asset, &install_target).await?; - if install_target == exe { - println!("Updated {} to {}", install_target.display(), report.manifest.version); - } else { - println!( - "Updated {} to {} (launched via {})", - install_target.display(), - report.manifest.version, - exe.display() - ); - } - Ok(()) - } -} - -struct UpdateReport { - manifest: UpdateManifest, - asset: PlatformAsset, - current_version: String, - current_target: String, - ordering: VersionOrdering, -} - -async fn fetch_update_report(repo: Option<&str>, tag: Option<&str>) -> anyhow::Result { - let runtime_repo = env::var("GITHUB_REPOSITORY").ok(); - let repo = repo - .or(runtime_repo.as_deref()) - .unwrap_or(DEFAULT_REPOSITORY); - let tag = match tag { - Some(tag) => normalize_tag(tag), - None => latest_release_tag(repo).await?, - }; - let url = format!( - "https://github.com/{repo}/releases/download/{tag}/update-manifest.json" - ); - let manifest: UpdateManifest = http_client("code-update-check/1")? - .get(&url) - .send() - .await - .with_context(|| format!("failed to fetch {url}"))? - .error_for_status() - .with_context(|| format!("update manifest request failed: {url}"))? - .json() - .await - .context("failed to parse update manifest")?; - - if manifest.schema_version != 1 { - bail!("unsupported update manifest schema_version {}", manifest.schema_version); - } - if manifest.channel != DEFAULT_CHANNEL { - bail!("unsupported update channel '{}'; expected stable", manifest.channel); - } - - let target = current_target()?; - let asset = manifest - .platforms - .get(&target) - .cloned() - .with_context(|| format!("manifest has no asset for current target {target}"))?; - - let current_version = code_version::version().to_string(); - let ordering = compare_versions(¤t_version, &manifest.version); - - Ok(UpdateReport { - manifest, - asset, - current_version, - current_target: target, - ordering, - }) -} - -async fn latest_release_tag(repo: &str) -> anyhow::Result { - #[derive(Deserialize)] - struct LatestRelease { - tag_name: String, - } - - let url = format!("https://api.github.com/repos/{repo}/releases/latest"); - let release: LatestRelease = http_client("code-update-check/1")? - .get(&url) - .send() - .await - .with_context(|| format!("failed to fetch {url}"))? - .error_for_status() - .with_context(|| format!("latest release request failed: {url}"))? - .json() - .await - .context("failed to parse latest release response")?; - - Ok(release.tag_name) -} - -fn print_update_report(report: &UpdateReport, identity: &RuntimeIdentity) { - println!("product: {}", code_version::LAB_BUILD_NAME); - println!("repository: {}", code_version::LAB_REPOSITORY); - println!("command: {}", identity.command_name); - println!("current version: {}", report.current_version); - println!("latest version: {}", report.manifest.version); - println!("channel: {}", report.manifest.channel); - println!("published at: {}", report.manifest.published_at); - println!("commit: {}", report.manifest.commit); - println!("target: {}", report.current_target); - println!("asset: {}", report.asset.asset); - println!("url: {}", report.asset.url); - println!("sha256: {}", report.asset.sha256); - println!("size: {} bytes", report.asset.size); - - match report.ordering { - VersionOrdering::Newer => println!("status: update available"), - VersionOrdering::Same => println!("status: up to date"), - VersionOrdering::Older => println!("status: installed version is newer"), - VersionOrdering::Unknown => println!("status: unable to compare versions"), - } -} - -struct RuntimeIdentity { - command_name: String, -} - -impl RuntimeIdentity { - fn detect(exe: Option<&Path>) -> Self { - let command_name = env::var(COMMAND_NAME_ENV) - .ok() - .and_then(|name| valid_command_name(&name)) - .or_else(|| exe.and_then(command_name_from_path)) - .or_else(|| env::args_os().next().and_then(|arg| command_name_from_path(Path::new(&arg)))) - .unwrap_or_else(|| "code".to_string()); - - Self { command_name } - } -} - -fn command_name_from_path(path: &Path) -> Option { - path.file_name() - .and_then(|name| name.to_str()) - .and_then(valid_command_name) -} - -fn valid_command_name(name: &str) -> Option { - let trimmed = name.trim().trim_end_matches(".exe"); - if trimmed.is_empty() || trimmed.contains(std::path::MAIN_SEPARATOR) { - None - } else { - Some(trimmed.to_string()) - } -} - -fn http_client(user_agent: &str) -> anyhow::Result { - Ok(reqwest::Client::builder() - .user_agent(user_agent) - .timeout(Duration::from_secs(30)) - .build()?) -} - -#[cfg(target_family = "unix")] -async fn install_direct_binary(asset: &PlatformAsset, exe: &Path) -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt; - - let response = http_client("code-update/1")? - .get(&asset.url) - .send() - .await - .with_context(|| format!("failed to download {}", asset.url))? - .error_for_status() - .with_context(|| format!("asset download failed: {}", asset.url))?; - let bytes = response.bytes().await.context("failed to read asset bytes")?; - let actual = sha256_hex(&bytes); - if !actual.eq_ignore_ascii_case(&asset.sha256) { - bail!("asset checksum mismatch: expected {}, got {actual}", asset.sha256); - } - - let parent = exe - .parent() - .context("current executable has no parent directory")?; - let dir = tempfile::Builder::new() - .prefix(".code-update-") - .tempdir_in(parent) - .context("failed to create update temp dir")?; - let archive = dir.path().join(&asset.asset); - fs::write(&archive, &bytes).context("failed to write downloaded asset")?; - - let extracted = dir.path().join("code-new"); - if asset.asset.ends_with(".tar.gz") { - extract_tar_gz_binary(&asset.asset, &archive, &extracted)?; - } else { - bail!("self-update currently supports .tar.gz Unix assets only"); - } - - fs::set_permissions(&extracted, fs::Permissions::from_mode(0o755)) - .context("failed to mark staged binary executable")?; - fs::rename(&extracted, exe).context("failed to install staged binary") -} - -fn extract_tar_gz_binary(asset_name: &str, archive: &Path, extracted: &Path) -> anyhow::Result<()> { - use flate2::read::GzDecoder; - - let expected_name = asset_name - .strip_suffix(".tar.gz") - .context("tarball asset name did not end with .tar.gz")?; - let file = fs::File::open(archive).context("failed to open downloaded archive")?; - let gz = GzDecoder::new(file); - let mut archive = tar::Archive::new(gz); - for entry in archive.entries().context("failed to read downloaded archive")? { - let mut entry = entry.context("failed to read downloaded archive entry")?; - let path = entry - .path() - .context("failed to read downloaded archive entry path")? - .into_owned(); - let is_expected_binary = path.components().count() == 1 - && path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == expected_name) - && entry.header().entry_type().is_file(); - if is_expected_binary { - entry - .unpack(extracted) - .context("failed to stage extracted binary")?; - return Ok(()); - } - } - - bail!("downloaded archive did not contain expected binary {expected_name}") -} - -fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut out = String::with_capacity(digest.len() * 2); - for byte in digest { - use std::fmt::Write as _; - let _ = write!(out, "{byte:02x}"); - } - out -} - -fn normalize_tag(tag: &str) -> String { - if tag.starts_with('v') { - tag.to_string() - } else { - format!("v{tag}") - } -} - -fn current_target() -> anyhow::Result { - let os = env::consts::OS; - let arch = env::consts::ARCH; - match (os, arch) { - ("linux", "x86_64") => Ok("x86_64-unknown-linux-musl".to_string()), - ("linux", "aarch64") => Ok("aarch64-unknown-linux-musl".to_string()), - ("macos", "x86_64") => Ok("x86_64-apple-darwin".to_string()), - ("macos", "aarch64") => Ok("aarch64-apple-darwin".to_string()), - ("windows", _) => Ok("x86_64-pc-windows-msvc".to_string()), - _ => bail!("unsupported platform: {os}/{arch}"), - } -} - -fn resolve_install_target(exe: &Path) -> PathBuf { - fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf()) -} - -fn install_mode_description(exe: &Path) -> &'static str { - if is_direct_binary_install_path(exe) { - "Every Code direct binary" - } else { - "unsupported for self-update" - } -} - -fn is_direct_binary_install_path(exe: &Path) -> bool { - let path = exe.to_string_lossy().replace('\\', "/"); - path.contains("/.code/bin/") - || path.contains("/.local/bin/") - || path.contains("/usr/local/bin/") - || path.contains("/code-rs/target/release/") -} - -fn compare_versions(current: &str, latest: &str) -> VersionOrdering { - let Some(current) = parse_version_triplet(current) else { - return VersionOrdering::Unknown; - }; - let Some(latest) = parse_version_triplet(latest) else { - return VersionOrdering::Unknown; - }; - match current.cmp(&latest) { - std::cmp::Ordering::Less => VersionOrdering::Newer, - std::cmp::Ordering::Equal => VersionOrdering::Same, - std::cmp::Ordering::Greater => VersionOrdering::Older, - } -} - -fn parse_version_triplet(version: &str) -> Option<(u64, u64, u64)> { - let trimmed = version.trim().trim_start_matches('v'); - let core = trimmed - .split_once(['-', '+']) - .map_or(trimmed, |(version, _)| version); - let mut parts = core.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next()?.parse().ok()?; - if parts.next().is_some() { - return None; - } - Some((major, minor, patch)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn normalize_tag_adds_v_prefix_when_missing() { - assert_eq!(normalize_tag("0.6.101"), "v0.6.101"); - assert_eq!(normalize_tag("v0.6.101"), "v0.6.101"); - } - - #[test] - fn compare_versions_detects_update_states() { - assert_eq!(compare_versions("0.6.100", "v0.6.101"), VersionOrdering::Newer); - assert_eq!(compare_versions("v0.6.101", "0.6.101"), VersionOrdering::Same); - assert_eq!(compare_versions("0.6.102", "v0.6.101"), VersionOrdering::Older); - assert_eq!(compare_versions("0.6", "v0.6.101"), VersionOrdering::Unknown); - } - - #[test] - fn sha256_hex_matches_known_value() { - assert_eq!( - sha256_hex(b"code"), - "5694d08a2e53ffcae0c3103e5ad6f6076abd960eb1f8a56577040bc1028f702b" - ); - } - - #[test] - fn extract_tar_gz_binary_requires_exact_top_level_binary() -> anyhow::Result<()> { - use flate2::Compression; - use flate2::write::GzEncoder; - use std::io::Cursor; - - let dir = tempfile::tempdir()?; - let archive_path = dir.path().join("code-aarch64-apple-darwin.tar.gz"); - let archive_file = fs::File::create(&archive_path)?; - let encoder = GzEncoder::new(archive_file, Compression::default()); - let mut archive = tar::Builder::new(encoder); - let mut header = tar::Header::new_gnu(); - header.set_size(3); - header.set_mode(0o755); - header.set_cksum(); - archive.append_data( - &mut header, - "code-aarch64-apple-darwin", - Cursor::new(b"new"), - )?; - let encoder = archive.into_inner()?; - encoder.finish()?; - - let extracted = dir.path().join("code-new"); - extract_tar_gz_binary( - "code-aarch64-apple-darwin.tar.gz", - &archive_path, - &extracted, - )?; - - assert_eq!(fs::read(&extracted)?, b"new"); - Ok(()) - } - - #[test] - fn extract_tar_gz_binary_rejects_nested_binary() -> anyhow::Result<()> { - use flate2::Compression; - use flate2::write::GzEncoder; - use std::io::Cursor; - - let dir = tempfile::tempdir()?; - let archive_path = dir.path().join("code-aarch64-apple-darwin.tar.gz"); - let archive_file = fs::File::create(&archive_path)?; - let encoder = GzEncoder::new(archive_file, Compression::default()); - let mut archive = tar::Builder::new(encoder); - let mut header = tar::Header::new_gnu(); - header.set_size(3); - header.set_mode(0o755); - header.set_cksum(); - archive.append_data( - &mut header, - "nested/code-aarch64-apple-darwin", - Cursor::new(b"new"), - )?; - let encoder = archive.into_inner()?; - encoder.finish()?; - - let extracted = dir.path().join("code-new"); - let err = extract_tar_gz_binary( - "code-aarch64-apple-darwin.tar.gz", - &archive_path, - &extracted, - ) - .expect_err("nested payload should be rejected"); - - assert!(err.to_string().contains("expected binary")); - assert!(!extracted.exists()); - Ok(()) - } - - #[test] - fn direct_binary_install_path_allows_owned_locations_only() { - assert!(!is_direct_binary_install_path(Path::new( - "/opt/homebrew/Cellar/code/0.6.101/bin/code" - ))); - assert!(!is_direct_binary_install_path(Path::new( - "/Users/me/.npm-global/lib/node_modules/@just-every/code/bin/code" - ))); - assert!(!is_direct_binary_install_path(Path::new( - "/Users/me/.cargo/bin/code" - ))); - assert!(is_direct_binary_install_path(Path::new( - "/Users/me/.local/bin/code" - ))); - assert!(is_direct_binary_install_path(Path::new( - "/usr/local/bin/chris-code" - ))); - assert!(is_direct_binary_install_path(Path::new( - r"C:\Users\me\.code\bin\code.exe" - ))); - assert!(!is_direct_binary_install_path(Path::new( - r"C:\Users\me\AppData\Roaming\npm\code.exe" - ))); - } - - #[test] - fn runtime_identity_prefers_command_name_env() { - let _lock = ENV_TEST_LOCK.lock().unwrap(); - let _reset = EnvReset::capture(COMMAND_NAME_ENV); - unsafe { - env::set_var(COMMAND_NAME_ENV, "chris-code"); - } - - let identity = RuntimeIdentity::detect(Some(Path::new("/usr/local/bin/code"))); - - assert_eq!(identity.command_name, "chris-code"); - } - - #[test] - fn runtime_identity_falls_back_to_exe_name() { - let _lock = ENV_TEST_LOCK.lock().unwrap(); - let _reset = EnvReset::capture(COMMAND_NAME_ENV); - unsafe { - env::remove_var(COMMAND_NAME_ENV); - } - - let identity = RuntimeIdentity::detect(Some(Path::new("/usr/local/bin/chris-code"))); - - assert_eq!(identity.command_name, "chris-code"); - } - - struct EnvReset { - key: &'static str, - value: Option, - } - - impl EnvReset { - fn capture(key: &'static str) -> Self { - Self { - key, - value: env::var(key).ok(), - } - } - } - - impl Drop for EnvReset { - fn drop(&mut self) { - unsafe { - if let Some(value) = &self.value { - env::set_var(self.key, value); - } else { - env::remove_var(self.key); - } - } - } - } - - #[test] - fn direct_binary_install_path_refuses_build_cache_binaries() { - assert!(!is_direct_binary_install_path(Path::new( - "/Users/me/Developer/code/.code/working/_target-cache/code/main/code-rs/dev-fast/code" - ))); - } - - #[cfg(target_family = "unix")] - #[test] - fn resolve_install_target_follows_symlinks() -> anyhow::Result<()> { - let dir = tempfile::tempdir()?; - let target = dir.path().join("code-target"); - let link = dir.path().join("code-link"); - fs::write(&target, b"code")?; - std::os::unix::fs::symlink(&target, &link)?; - - assert_eq!(resolve_install_target(&link), fs::canonicalize(target)?); - Ok(()) - } -} diff --git a/code-rs/cli/src/wsl_paths.rs b/code-rs/cli/src/wsl_paths.rs new file mode 100644 index 00000000000..b90dc1e2836 --- /dev/null +++ b/code-rs/cli/src/wsl_paths.rs @@ -0,0 +1,59 @@ +use std::ffi::OsStr; + +/// Returns true if the current process is running under WSL. +pub use codex_utils_path::is_wsl; + +/// Convert a Windows absolute path (`C:\foo\bar` or `C:/foo/bar`) to a WSL mount path (`/mnt/c/foo/bar`). +/// Returns `None` if the input does not look like a Windows drive path. +pub fn win_path_to_wsl(path: &str) -> Option { + let bytes = path.as_bytes(); + if bytes.len() < 3 + || bytes[1] != b':' + || !(bytes[2] == b'\\' || bytes[2] == b'/') + || !bytes[0].is_ascii_alphabetic() + { + return None; + } + let drive = (bytes[0] as char).to_ascii_lowercase(); + let tail = path[3..].replace('\\', "/"); + if tail.is_empty() { + return Some(format!("/mnt/{drive}")); + } + Some(format!("/mnt/{drive}/{tail}")) +} + +/// If under WSL and given a Windows-style path, return the equivalent `/mnt//…` path. +/// Otherwise returns the input unchanged. +pub fn normalize_for_wsl>(path: P) -> String { + let value = path.as_ref().to_string_lossy().to_string(); + if !is_wsl() { + return value; + } + if let Some(mapped) = win_path_to_wsl(&value) { + return mapped; + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn win_to_wsl_basic() { + assert_eq!( + win_path_to_wsl(r"C:\Temp\codex.zip").as_deref(), + Some("/mnt/c/Temp/codex.zip") + ); + assert_eq!( + win_path_to_wsl("D:/Work/codex.tgz").as_deref(), + Some("/mnt/d/Work/codex.tgz") + ); + assert!(win_path_to_wsl("/home/user/codex").is_none()); + } + + #[test] + fn normalize_is_noop_on_unix_paths() { + assert_eq!(normalize_for_wsl("/home/u/x"), "/home/u/x"); + } +} diff --git a/code-rs/cli/tests/debug_clear_memories.rs b/code-rs/cli/tests/debug_clear_memories.rs new file mode 100644 index 00000000000..9d5e114dbfd --- /dev/null +++ b/code-rs/cli/tests/debug_clear_memories.rs @@ -0,0 +1,133 @@ +use std::path::Path; + +use anyhow::Result; +use codex_state::StateRuntime; +use codex_state::state_db_path; +use predicates::str::contains; +use sqlx::SqlitePool; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[tokio::test] +async fn debug_clear_memories_resets_state_and_removes_memory_dir() -> Result<()> { + let codex_home = TempDir::new()?; + let runtime = + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?; + drop(runtime); + + let thread_id = "00000000-0000-0000-0000-000000000123"; + let db_path = state_db_path(codex_home.path()); + let pool = SqlitePool::connect(&format!("sqlite://{}", db_path.display())).await?; + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + source, + agent_nickname, + agent_role, + model_provider, + cwd, + cli_version, + title, + sandbox_policy, + approval_mode, + tokens_used, + first_user_message, + archived, + archived_at, + git_sha, + git_branch, + git_origin_url, + memory_mode +) VALUES (?, ?, 1, 1, 'cli', NULL, NULL, 'test-provider', ?, '', '', 'read-only', 'on-request', 0, '', 0, NULL, NULL, NULL, NULL, 'enabled') + "#, + ) + .bind(thread_id) + .bind(codex_home.path().join("session.jsonl").display().to_string()) + .bind(codex_home.path().display().to_string()) + .execute(&pool) + .await?; + + sqlx::query( + r#" +INSERT INTO stage1_outputs ( + thread_id, + source_updated_at, + raw_memory, + rollout_summary, + generated_at, + rollout_slug, + usage_count, + last_usage, + selected_for_phase2, + selected_for_phase2_source_updated_at +) VALUES (?, 1, 'raw', 'summary', 1, NULL, 0, NULL, 0, NULL) + "#, + ) + .bind(thread_id) + .execute(&pool) + .await?; + + sqlx::query( + r#" +INSERT INTO jobs ( + kind, + job_key, + status, + worker_id, + ownership_token, + started_at, + finished_at, + lease_until, + retry_at, + retry_remaining, + last_error, + input_watermark, + last_success_watermark +) VALUES + ('memory_stage1', ?, 'completed', NULL, NULL, NULL, NULL, NULL, NULL, 3, NULL, NULL, 1), + ('memory_consolidate_global', 'global', 'completed', NULL, NULL, NULL, NULL, NULL, NULL, 3, NULL, NULL, 1) + "#, + ) + .bind(thread_id) + .execute(&pool) + .await?; + + let memory_root = codex_home.path().join("memories"); + std::fs::create_dir_all(&memory_root)?; + std::fs::write(memory_root.join("memory_summary.md"), "stale memory")?; + pool.close().await; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["debug", "clear-memories"]) + .assert() + .success() + .stdout(contains("Cleared memory state")); + + let pool = SqlitePool::connect(&format!("sqlite://{}", db_path.display())).await?; + let stage1_outputs_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stage1_outputs") + .fetch_one(&pool) + .await?; + assert_eq!(stage1_outputs_count, 0); + + let memory_jobs_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM jobs WHERE kind = 'memory_stage1' OR kind = 'memory_consolidate_global'", + ) + .fetch_one(&pool) + .await?; + assert_eq!(memory_jobs_count, 0); + assert!(memory_root.exists()); + assert_eq!(std::fs::read_dir(memory_root)?.count(), 0); + pool.close().await; + + Ok(()) +} diff --git a/code-rs/cli/tests/debug_models.rs b/code-rs/cli/tests/debug_models.rs new file mode 100644 index 00000000000..f927742a594 --- /dev/null +++ b/code-rs/cli/tests/debug_models.rs @@ -0,0 +1,40 @@ +use std::path::Path; + +use anyhow::Result; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn debug_models_bundled_prints_json() -> Result<()> { + let codex_home = TempDir::new()?; + let mut cmd = codex_command(codex_home.path())?; + let output = cmd.args(["debug", "models", "--bundled"]).output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + let value: serde_json::Value = serde_json::from_str(&stdout)?; + assert!(value["models"].is_array()); + assert!(!value["models"].as_array().unwrap_or(&Vec::new()).is_empty()); + + Ok(()) +} + +#[test] +fn debug_models_default_prints_json_without_auth() -> Result<()> { + let codex_home = TempDir::new()?; + let mut cmd = codex_command(codex_home.path())?; + let output = cmd.args(["debug", "models"]).output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + let value: serde_json::Value = serde_json::from_str(&stdout)?; + assert!(value["models"].is_array()); + assert!(!value["models"].as_array().unwrap_or(&Vec::new()).is_empty()); + + Ok(()) +} diff --git a/code-rs/cli/tests/execpolicy.rs b/code-rs/cli/tests/execpolicy.rs new file mode 100644 index 00000000000..30b5999c053 --- /dev/null +++ b/code-rs/cli/tests/execpolicy.rs @@ -0,0 +1,119 @@ +use std::fs; + +use assert_cmd::Command; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; + +#[test] +fn execpolicy_check_matches_expected_json() -> Result<(), Box> { + let codex_home = TempDir::new()?; + let policy_path = codex_home.path().join("rules").join("policy.rules"); + fs::create_dir_all( + policy_path + .parent() + .expect("policy path should have a parent"), + )?; + fs::write( + &policy_path, + r#" +prefix_rule( + pattern = ["git", "push"], + decision = "forbidden", +) +"#, + )?; + + let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?) + .env("CODEX_HOME", codex_home.path()) + .args([ + "execpolicy", + "check", + "--rules", + policy_path + .to_str() + .expect("policy path should be valid UTF-8"), + "git", + "push", + "origin", + "main", + ]) + .output()?; + + assert!(output.status.success()); + let result: serde_json::Value = serde_json::from_slice(&output.stdout)?; + assert_eq!( + result, + json!({ + "decision": "forbidden", + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "forbidden" + } + } + ] + }) + ); + + Ok(()) +} + +#[test] +fn execpolicy_check_includes_justification_when_present() -> Result<(), Box> +{ + let codex_home = TempDir::new()?; + let policy_path = codex_home.path().join("rules").join("policy.rules"); + fs::create_dir_all( + policy_path + .parent() + .expect("policy path should have a parent"), + )?; + fs::write( + &policy_path, + r#" +prefix_rule( + pattern = ["git", "push"], + decision = "forbidden", + justification = "pushing is blocked in this repo", +) +"#, + )?; + + let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?) + .env("CODEX_HOME", codex_home.path()) + .args([ + "execpolicy", + "check", + "--rules", + policy_path + .to_str() + .expect("policy path should be valid UTF-8"), + "git", + "push", + "origin", + "main", + ]) + .output()?; + + assert!(output.status.success()); + let result: serde_json::Value = serde_json::from_slice(&output.stdout)?; + assert_eq!( + result, + json!({ + "decision": "forbidden", + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "forbidden", + "justification": "pushing is blocked in this repo" + } + } + ] + }) + ); + + Ok(()) +} diff --git a/code-rs/cli/tests/features.rs b/code-rs/cli/tests/features.rs new file mode 100644 index 00000000000..17a7eff679c --- /dev/null +++ b/code-rs/cli/tests/features.rs @@ -0,0 +1,91 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[tokio::test] +async fn features_enable_writes_feature_flag_to_config() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["features", "enable", "unified_exec"]) + .assert() + .success() + .stdout(contains("Enabled feature `unified_exec` in config.toml.")); + + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains("[features]")); + assert!(config.contains("unified_exec = true")); + + Ok(()) +} + +#[tokio::test] +async fn features_disable_writes_feature_flag_to_config() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["features", "disable", "shell_tool"]) + .assert() + .success() + .stdout(contains("Disabled feature `shell_tool` in config.toml.")); + + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains("[features]")); + assert!(config.contains("shell_tool = false")); + + Ok(()) +} + +#[tokio::test] +async fn features_enable_under_development_feature_prints_warning() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["features", "enable", "runtime_metrics"]) + .assert() + .success() + .stderr(contains( + "Under-development features enabled: runtime_metrics.", + )); + + Ok(()) +} + +#[tokio::test] +async fn features_list_is_sorted_alphabetically_by_feature_name() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + let output = cmd + .args(["features", "list"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output)?; + + let actual_names = stdout + .lines() + .map(|line| { + line.split_once(" ") + .map(|(name, _)| name.trim_end().to_string()) + .expect("feature list output should contain aligned columns") + }) + .collect::>(); + let mut expected_names = actual_names.clone(); + expected_names.sort(); + + assert_eq!(actual_names, expected_names); + + Ok(()) +} diff --git a/code-rs/cli/tests/login.rs b/code-rs/cli/tests/login.rs new file mode 100644 index 00000000000..e290d059398 --- /dev/null +++ b/code-rs/cli/tests/login.rs @@ -0,0 +1,66 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn write_file_auth_config(codex_home: &Path) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + "cli_auth_credentials_store = \"file\"\n", + )?; + Ok(()) +} + +fn read_auth_json(codex_home: &Path) -> Result { + let auth_json = std::fs::read_to_string(codex_home.join("auth.json"))?; + Ok(serde_json::from_str(&auth_json)?) +} + +#[test] +fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args([ + "-c", + "forced_login_method=\"api\"", + "login", + "--with-api-key", + ]) + .write_stdin("sk-test\n") + .assert() + .success() + .stderr(contains("Successfully logged in")); + + let auth = read_auth_json(codex_home.path())?; + assert_eq!(auth["OPENAI_API_KEY"], "sk-test"); + assert!(auth.get("tokens").is_none()); + assert!(auth.get("agent_identity").is_none()); + + Ok(()) +} + +#[test] +fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["login", "--with-access-token"]) + .write_stdin("not-a-jwt\n") + .assert() + .failure() + .stderr(contains("Error logging in with access token")); + + Ok(()) +} diff --git a/code-rs/cli/tests/marketplace_add.rs b/code-rs/cli/tests/marketplace_add.rs new file mode 100644 index 00000000000..5ab18e24c48 --- /dev/null +++ b/code-rs/cli/tests/marketplace_add.rs @@ -0,0 +1,118 @@ +use anyhow::Result; +use codex_config::CONFIG_TOML_FILE; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn write_marketplace_source(source: &Path, marker: &str) -> Result<()> { + std::fs::create_dir_all(source.join(".agents/plugins"))?; + std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; + std::fs::write( + source.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + )?; + std::fs::write( + source.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write(source.join("plugins/sample/marker.txt"), marker)?; + Ok(()) +} + +#[tokio::test] +async fn marketplace_add_local_directory_source() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "local ref")?; + let source_parent = source.path().parent().unwrap(); + let source_arg = format!("./{}", source.path().file_name().unwrap().to_string_lossy()); + + codex_command(codex_home.path())? + .current_dir(source_parent) + .args(["plugin", "marketplace", "add", source_arg.as_str()]) + .assert() + .success(); + + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + assert!(!installed_root.exists()); + + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + let config: toml::Value = toml::from_str(&config)?; + let expected_source = source.path().canonicalize()?.display().to_string(); + assert_eq!( + config["marketplaces"]["debug"]["source_type"].as_str(), + Some("local") + ); + assert_eq!( + config["marketplaces"]["debug"]["source"].as_str(), + Some(expected_source.as_str()) + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "local ref")?; + let manifest_path = source.path().join(".agents/plugins/marketplace.json"); + + codex_command(codex_home.path())? + .args([ + "plugin", + "marketplace", + "add", + manifest_path.to_str().unwrap(), + ]) + .assert() + .failure() + .stderr(contains( + "local marketplace source must be a directory, not a file", + )); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_add_rejects_sparse_for_local_directory_source() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "local ref")?; + + codex_command(codex_home.path())? + .args([ + "plugin", + "marketplace", + "add", + "--sparse", + ".agents", + source.path().to_str().unwrap(), + ]) + .assert() + .failure() + .stderr(contains( + "--sparse is only supported for git marketplace sources", + )); + + Ok(()) +} diff --git a/code-rs/cli/tests/marketplace_remove.rs b/code-rs/cli/tests/marketplace_remove.rs new file mode 100644 index 00000000000..5c8c7a1f916 --- /dev/null +++ b/code-rs/cli/tests/marketplace_remove.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use predicates::str::contains; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn configured_marketplace_update() -> MarketplaceConfigUpdate<'static> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + } +} + +fn write_installed_marketplace(codex_home: &Path, marketplace_name: &str) -> Result<()> { + let root = marketplace_install_root(codex_home).join(marketplace_name); + std::fs::create_dir_all(root.join(".agents/plugins"))?; + std::fs::write(root.join(".agents/plugins/marketplace.json"), "{}")?; + std::fs::write(root.join("marker.txt"), "installed")?; + Ok(()) +} + +#[tokio::test] +async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .success() + .stdout(contains("Removed marketplace `debug`.")); + + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(config_path)?; + assert!(!config.contains("[marketplaces.debug]")); + assert!( + !marketplace_install_root(codex_home.path()) + .join("debug") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .failure() + .stderr(contains( + "marketplace `debug` is not configured or installed", + )); + + Ok(()) +} diff --git a/code-rs/cli/tests/marketplace_upgrade.rs b/code-rs/cli/tests/marketplace_upgrade.rs new file mode 100644 index 00000000000..268d75358e9 --- /dev/null +++ b/code-rs/cli/tests/marketplace_upgrade.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use predicates::str::contains; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[tokio::test] +async fn marketplace_upgrade_runs_under_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "upgrade"]) + .assert() + .success() + .stdout(contains("No configured Git marketplaces to upgrade.")); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .args(["marketplace", "upgrade"]) + .assert() + .failure() + .stderr(contains("unrecognized subcommand 'upgrade'")); + + Ok(()) +} diff --git a/code-rs/cli/tests/mcp_add_remove.rs b/code-rs/cli/tests/mcp_add_remove.rs new file mode 100644 index 00000000000..15afaf0828f --- /dev/null +++ b/code-rs/cli/tests/mcp_add_remove.rs @@ -0,0 +1,228 @@ +use std::path::Path; + +use anyhow::Result; +use codex_config::types::McpServerTransportConfig; +use codex_core::config::load_global_mcp_servers; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[tokio::test] +async fn add_and_remove_server_updates_global_config() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_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(codex_home.path()).await?; + assert_eq!(servers.len(), 1); + let docs = servers.get("docs").expect("server should exist"); + match &docs.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + assert_eq!(command, "echo"); + assert_eq!(args, &vec!["hello".to_string()]); + assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); + } + other => panic!("unexpected transport: {other:?}"), + } + assert!(docs.enabled); + + let mut remove_cmd = codex_command(codex_home.path())?; + remove_cmd + .args(["mcp", "remove", "docs"]) + .assert() + .success() + .stdout(contains("Removed global MCP server 'docs'.")); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + assert!(servers.is_empty()); + + let mut remove_again_cmd = codex_command(codex_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(codex_home.path()).await?; + assert!(servers.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn add_with_env_preserves_key_order_and_values() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_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(codex_home.path()).await?; + 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())); + assert!(envy.enabled); + + Ok(()) +} + +#[tokio::test] +async fn add_streamable_http_without_manual_token() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args(["mcp", "add", "github", "--url", "https://example.com/mcp"]) + .assert() + .success(); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + let github = servers.get("github").expect("github server should exist"); + match &github.transport { + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + assert_eq!(url, "https://example.com/mcp"); + assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); + } + other => panic!("unexpected transport: {other:?}"), + } + assert!(github.enabled); + + assert!(!codex_home.path().join(".credentials.json").exists()); + assert!(!codex_home.path().join(".env").exists()); + + Ok(()) +} + +#[tokio::test] +async fn add_streamable_http_with_custom_env_var() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "issues", + "--url", + "https://example.com/issues", + "--bearer-token-env-var", + "GITHUB_TOKEN", + ]) + .assert() + .success(); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + let issues = servers.get("issues").expect("issues server should exist"); + match &issues.transport { + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + assert_eq!(url, "https://example.com/issues"); + assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); + } + other => panic!("unexpected transport: {other:?}"), + } + assert!(issues.enabled); + Ok(()) +} + +#[tokio::test] +async fn add_streamable_http_rejects_removed_flag() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "github", + "--url", + "https://example.com/mcp", + "--with-bearer-token", + ]) + .assert() + .failure() + .stderr(contains("--with-bearer-token")); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + assert!(servers.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn add_cant_add_command_and_url() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "github", + "--url", + "https://example.com/mcp", + "--command", + "--", + "echo", + "hello", + ]) + .assert() + .failure() + .stderr(contains("unexpected argument '--command' found")); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + assert!(servers.is_empty()); + + Ok(()) +} diff --git a/code-rs/cli/tests/mcp_list.rs b/code-rs/cli/tests/mcp_list.rs new file mode 100644 index 00000000000..bed3505985f --- /dev/null +++ b/code-rs/cli/tests/mcp_list.rs @@ -0,0 +1,166 @@ +use std::path::Path; + +use anyhow::Result; +use codex_config::types::McpServerTransportConfig; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::load_global_mcp_servers; +use predicates::prelude::PredicateBooleanExt; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use serde_json::json; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn list_shows_empty_state() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_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(()) +} + +#[tokio::test] +async fn list_and_get_render_expected_output() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add = codex_command(codex_home.path())?; + add.args([ + "mcp", + "add", + "docs", + "--env", + "TOKEN=secret", + "--", + "docs-server", + "--port", + "4000", + ]) + .assert() + .success(); + + let mut servers = load_global_mcp_servers(codex_home.path()).await?; + let docs_entry = servers + .get_mut("docs") + .expect("docs server should exist after add"); + match &mut docs_entry.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + *env_vars = vec!["APP_TOKEN".into(), "WORKSPACE_ID".into()]; + } + other => panic!("unexpected transport: {other:?}"), + } + ConfigEditsBuilder::new(codex_home.path()) + .replace_mcp_servers(&servers) + .apply_blocking()?; + + let mut list_cmd = codex_command(codex_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=*****")); + assert!(stdout.contains("APP_TOKEN=*****")); + assert!(stdout.contains("WORKSPACE_ID=*****")); + assert!(stdout.contains("Status")); + assert!(stdout.contains("Auth")); + assert!(stdout.contains("enabled")); + assert!(stdout.contains("Unsupported")); + + let mut list_json_cmd = codex_command(codex_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", + "enabled": true, + "disabled_reason": null, + "transport": { + "type": "stdio", + "command": "docs-server", + "args": [ + "--port", + "4000" + ], + "env": { + "TOKEN": "secret" + }, + "env_vars": [ + "APP_TOKEN", + "WORKSPACE_ID" + ], + "cwd": null + }, + "startup_timeout_sec": null, + "tool_timeout_sec": null, + "auth_status": "unsupported" + } + ] + ) + ); + + let mut get_cmd = codex_command(codex_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=*****")); + assert!(stdout.contains("APP_TOKEN=*****")); + assert!(stdout.contains("WORKSPACE_ID=*****")); + assert!(stdout.contains("enabled: true")); + assert!(stdout.contains("remove: codex mcp remove docs")); + + let mut get_json_cmd = codex_command(codex_home.path())?; + get_json_cmd + .args(["mcp", "get", "docs", "--json"]) + .assert() + .success() + .stdout(contains("\"name\": \"docs\"").and(contains("\"enabled\": true"))); + + Ok(()) +} + +#[tokio::test] +async fn get_disabled_server_shows_single_line() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add = codex_command(codex_home.path())?; + add.args(["mcp", "add", "docs", "--", "docs-server"]) + .assert() + .success(); + + let mut servers = load_global_mcp_servers(codex_home.path()).await?; + let docs = servers + .get_mut("docs") + .expect("docs server should exist after add"); + docs.enabled = false; + ConfigEditsBuilder::new(codex_home.path()) + .replace_mcp_servers(&servers) + .apply_blocking()?; + + let mut get_cmd = codex_command(codex_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_eq!(stdout.trim_end(), "docs (disabled)"); + + Ok(()) +} diff --git a/code-rs/cli/tests/update.rs b/code-rs/cli/tests/update.rs new file mode 100644 index 00000000000..cf1742cda7f --- /dev/null +++ b/code-rs/cli/tests/update.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use predicates::str::contains; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[cfg(debug_assertions)] +#[tokio::test] +async fn update_does_not_start_interactive_prompt() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .arg("update") + .assert() + .failure() + .stderr(contains("`codex update` is not available in debug builds")); + + Ok(()) +} diff --git a/code-rs/clippy.toml b/code-rs/clippy.toml index 5a6ff7f0523..2feed8a4874 100644 --- a/code-rs/clippy.toml +++ b/code-rs/clippy.toml @@ -1,5 +1,10 @@ allow-expect-in-tests = true allow-unwrap-in-tests = true +await-holding-invalid-types = [ + "tokio::sync::MutexGuard", + "tokio::sync::RwLockReadGuard", + "tokio::sync::RwLockWriteGuard", +] 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." }, @@ -7,3 +12,7 @@ disallowed-methods = [ { 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`." }, ] + +# Increase the size threshold for result_large_err to accommodate +# richer error variants. +large-error-threshold = 256 diff --git a/code-rs/cloud-requirements/BUILD.bazel b/code-rs/cloud-requirements/BUILD.bazel new file mode 100644 index 00000000000..88243aff903 --- /dev/null +++ b/code-rs/cloud-requirements/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "cloud-requirements", + crate_name = "codex_cloud_requirements", +) diff --git a/code-rs/cloud-requirements/Cargo.toml b/code-rs/cloud-requirements/Cargo.toml new file mode 100644 index 00000000000..cc7aefc4785 --- /dev/null +++ b/code-rs/cloud-requirements/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "codex-cloud-requirements" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-backend-client = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-login = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +hmac = "0.12.1" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "sync", "time"] } +toml = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } + +[lib] +doctest = false diff --git a/code-rs/cloud-requirements/src/lib.rs b/code-rs/cloud-requirements/src/lib.rs new file mode 100644 index 00000000000..6f283b43d02 --- /dev/null +++ b/code-rs/cloud-requirements/src/lib.rs @@ -0,0 +1,2316 @@ +//! Cloud-hosted config requirements for Codex. +//! +//! This crate fetches `requirements.toml` data from the backend as an alternative to loading it +//! from the local filesystem. It only applies to Business (aka Enterprise CBP) or Enterprise ChatGPT +//! customers. +//! +//! Fetching fails closed for eligible ChatGPT Business and Enterprise accounts. When cloud +//! requirements cannot be loaded for those accounts, Codex fails configuration loading rather than +//! continuing without them. + +use async_trait::async_trait; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Utc; +use codex_backend_client::Client as BackendClient; +use codex_config::CloudRequirementsLoadError; +use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::CloudRequirementsLoader; +use codex_config::ConfigRequirementsToml; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::util::backoff; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::RefreshTokenError; +use codex_protocol::account::PlanType; +use hmac::Hmac; +use hmac::Mac; +use serde::Deserialize; +use serde::Serialize; +use sha2::Sha256; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::OnceLock; +use std::time::Duration; +use std::time::Instant; +use thiserror::Error; +use tokio::fs; +use tokio::task::JoinHandle; +use tokio::time::sleep; +use tokio::time::timeout; + +const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); +const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; +const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json"; +const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); +const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); +const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_requirements.fetch_attempt"; +const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_requirements.fetch_final"; +const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_requirements.load"; +const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = + "Failed to load cloud requirements (workspace-managed policies)."; +const CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE: &str = concat!( + "Cloud requirements (workspace-managed policies) are invalid and could not be parsed. ", + "Please contact your workspace admin." +); +const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( + "Your authentication session could not be refreshed automatically. ", + "Please log out and sign in again." +); +const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = + b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; +const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = + &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; + +type HmacSha256 = Hmac; + +fn refresher_task_slot() -> &'static Mutex>> { + static REFRESHER_TASK: OnceLock>>> = OnceLock::new(); + REFRESHER_TASK.get_or_init(|| Mutex::new(None)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RetryableFailureKind { + BackendClientInit, + Request { status_code: Option }, +} + +impl RetryableFailureKind { + fn status_code(self) -> Option { + match self { + Self::BackendClientInit => None, + Self::Request { status_code } => status_code, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum FetchAttemptError { + Retryable(RetryableFailureKind), + Unauthorized { + status_code: Option, + message: String, + }, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +enum CacheLoadStatus { + #[error("Skipping cloud requirements cache read because auth identity is incomplete.")] + AuthIdentityIncomplete, + #[error("Cloud requirements cache file not found.")] + CacheFileNotFound, + #[error("Failed to read cloud requirements cache: {0}.")] + CacheReadFailed(String), + #[error("Failed to parse cloud requirements cache: {0}.")] + CacheParseFailed(String), + #[error("Cloud requirements cache failed signature verification.")] + CacheSignatureInvalid, + #[error("Ignoring cloud requirements cache because cached identity is incomplete.")] + CacheIdentityIncomplete, + #[error("Ignoring cloud requirements cache for different auth identity.")] + CacheIdentityMismatch, + #[error("Cloud requirements cache expired.")] + CacheExpired, +} + +#[derive(Debug, Error)] +enum CloudRequirementsError { + #[error("failed to write cloud requirements cache")] + CacheWrite, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CloudRequirementsCacheFile { + signed_payload: CloudRequirementsCacheSignedPayload, + signature: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CloudRequirementsCacheSignedPayload { + cached_at: DateTime, + expires_at: DateTime, + chatgpt_user_id: Option, + account_id: Option, + contents: Option, +} + +impl CloudRequirementsCacheSignedPayload { + fn requirements(&self) -> Option { + self.contents + .as_deref() + .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + } +} +fn sign_cache_payload(payload_bytes: &[u8]) -> Option { + let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; + mac.update(payload_bytes); + let signature = mac.finalize().into_bytes(); + Some(BASE64_STANDARD.encode(signature)) +} + +fn verify_cache_signature_with_key( + payload_bytes: &[u8], + signature_bytes: &[u8], + key: &[u8], +) -> bool { + let mut mac = match HmacSha256::new_from_slice(key) { + Ok(mac) => mac, + Err(_) => return false, + }; + mac.update(payload_bytes); + mac.verify_slice(signature_bytes).is_ok() +} + +fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { + let signature_bytes = match BASE64_STANDARD.decode(signature) { + Ok(signature_bytes) => signature_bytes, + Err(_) => return false, + }; + + CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS + .iter() + .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) +} + +fn auth_identity(auth: &CodexAuth) -> (Option, Option) { + (auth.get_chatgpt_user_id(), auth.get_account_id()) +} + +fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool { + let Some(plan_type) = auth.account_plan_type() else { + return false; + }; + auth.uses_codex_backend() + && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) +} + +fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { + serde_json::to_vec(&payload).ok() +} + +#[async_trait] +trait RequirementsFetcher: Send + Sync { + /// Returns `Ok(None)` when there are no cloud requirements for the account. + /// + /// Returning `Err` indicates cloud requirements could not be fetched. + async fn fetch_requirements( + &self, + auth: &CodexAuth, + ) -> Result, FetchAttemptError>; +} + +struct BackendRequirementsFetcher { + base_url: String, +} + +impl BackendRequirementsFetcher { + fn new(base_url: String) -> Self { + Self { base_url } + } +} + +#[async_trait] +impl RequirementsFetcher for BackendRequirementsFetcher { + async fn fetch_requirements( + &self, + auth: &CodexAuth, + ) -> Result, FetchAttemptError> { + let client = BackendClient::from_auth(self.base_url.clone(), auth) + .inspect_err(|err| { + tracing::warn!( + error = %err, + "Failed to construct backend client for cloud requirements" + ); + }) + .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; + + let response = client + .get_config_requirements_file() + .await + .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) + .map_err(|err| { + let status_code = err.status().map(|status| status.as_u16()); + if err.is_unauthorized() { + FetchAttemptError::Unauthorized { + status_code, + message: err.to_string(), + } + } else { + FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code }) + } + })?; + + let Some(contents) = response.contents else { + tracing::info!( + "Cloud requirements response missing contents; treating as no requirements" + ); + return Ok(None); + }; + + Ok(Some(contents)) + } +} + +#[derive(Clone)] +struct CloudRequirementsService { + auth_manager: Arc, + fetcher: Arc, + cache_path: PathBuf, + timeout: Duration, +} + +impl CloudRequirementsService { + fn new( + auth_manager: Arc, + fetcher: Arc, + codex_home: PathBuf, + timeout: Duration, + ) -> Self { + Self { + auth_manager, + fetcher, + cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), + timeout, + } + } + + async fn fetch_with_timeout( + &self, + ) -> Result, CloudRequirementsLoadError> { + let _timer = + codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); + let started_at = Instant::now(); + let fetch_result = timeout(self.timeout, self.fetch()) + .await + .inspect_err(|_| { + let message = format!( + "Timed out waiting for cloud requirements after {}s", + self.timeout.as_secs() + ); + tracing::error!("{message}"); + emit_load_metric("startup", "error"); + }) + .map_err(|_| { + CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Timeout, + /*status_code*/ None, + format!( + "timed out waiting for cloud requirements after {}s", + self.timeout.as_secs() + ), + ) + })?; + + let result = match fetch_result { + Ok(result) => result, + Err(err) => { + emit_load_metric("startup", "error"); + return Err(err); + } + }; + + match result.as_ref() { + Some(requirements) => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + requirements = ?requirements, + "Cloud requirements load completed" + ); + emit_load_metric("startup", "success"); + } + None => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + "Cloud requirements load completed (none)" + ); + emit_load_metric("startup", "success"); + } + } + + Ok(result) + } + + async fn fetch(&self) -> Result, CloudRequirementsLoadError> { + let Some(auth) = self.auth_manager.auth().await else { + return Ok(None); + }; + if !cloud_requirements_eligible_auth(&auth) { + return Ok(None); + } + let (chatgpt_user_id, account_id) = auth_identity(&auth); + + match self + .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) + .await + { + Ok(signed_payload) => { + tracing::info!( + path = %self.cache_path.display(), + "Using cached cloud requirements" + ); + return Ok(signed_payload.requirements()); + } + Err(cache_load_status) => { + self.log_cache_load_status(&cache_load_status); + } + } + + self.fetch_with_retries(auth, "startup").await + } + + async fn fetch_with_retries( + &self, + mut auth: CodexAuth, + trigger: &'static str, + ) -> Result, CloudRequirementsLoadError> { + let mut attempt = 1; + let mut last_status_code: Option = None; + let mut auth_recovery = self.auth_manager.unauthorized_recovery(); + + while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { + let contents = match self.fetcher.fetch_requirements(&auth).await { + Ok(contents) => { + emit_fetch_attempt_metric( + trigger, attempt, "success", /*status_code*/ None, + ); + contents + } + Err(FetchAttemptError::Retryable(status)) => { + let status_code = status.status_code(); + last_status_code = status_code; + emit_fetch_attempt_metric(trigger, attempt, "error", status_code); + if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { + tracing::warn!( + status = ?status, + attempt, + max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, + "Failed to fetch cloud requirements; retrying" + ); + sleep(backoff(attempt as u64)).await; + } + attempt += 1; + continue; + } + Err(FetchAttemptError::Unauthorized { + status_code, + message, + }) => { + last_status_code = status_code; + emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); + if auth_recovery.has_next() { + tracing::warn!( + attempt, + max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, + "Cloud requirements request was unauthorized; attempting auth recovery" + ); + match auth_recovery.next().await { + Ok(_) => { + let Some(refreshed_auth) = self.auth_manager.auth().await else { + tracing::error!( + "Auth recovery succeeded but no auth is available for cloud requirements" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_missing_auth", + attempt, + status_code, + ); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, + CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, + )); + }; + auth = refreshed_auth; + continue; + } + Err(RefreshTokenError::Permanent(failed)) => { + tracing::warn!( + error = %failed, + "Failed to recover from unauthorized cloud requirements request" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unrecoverable", + attempt, + status_code, + ); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, + failed.message, + )); + } + Err(RefreshTokenError::Transient(recovery_err)) => { + if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { + tracing::warn!( + error = %recovery_err, + attempt, + max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, + "Failed to recover from unauthorized cloud requirements request; retrying" + ); + sleep(backoff(attempt as u64)).await; + } + attempt += 1; + continue; + } + } + } + + tracing::warn!( + error = %message, + "Cloud requirements request was unauthorized and no auth recovery is available" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unavailable", + attempt, + status_code, + ); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, + CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, + )); + } + }; + + let requirements = match contents.as_deref() { + Some(contents) => match parse_cloud_requirements(contents) { + Ok(requirements) => requirements, + Err(err) => { + tracing::error!(error = %err, "Failed to parse cloud requirements"); + emit_fetch_final_metric( + trigger, + "error", + "parse_error", + attempt, + last_status_code, + ); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Parse, + /*status_code*/ None, + format_cloud_requirements_parse_failed_message(contents, &err), + )); + } + }, + None => None, + }; + + let (chatgpt_user_id, account_id) = auth_identity(&auth); + if let Err(err) = self.save_cache(chatgpt_user_id, account_id, contents).await { + tracing::warn!(error = %err, "Failed to write cloud requirements cache"); + } + + emit_fetch_final_metric( + trigger, "success", "none", attempt, /*status_code*/ None, + ); + return Ok(requirements); + } + + emit_fetch_final_metric( + trigger, + "error", + "request_retry_exhausted", + CLOUD_REQUIREMENTS_MAX_ATTEMPTS, + last_status_code, + ); + tracing::error!( + path = %self.cache_path.display(), + "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" + ); + Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::RequestFailed, + last_status_code, + CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, + )) + } + + async fn refresh_cache_in_background(&self) { + loop { + sleep(CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL).await; + match timeout(self.timeout, self.refresh_cache()).await { + Ok(true) => {} + Ok(false) => break, + Err(_) => { + tracing::error!( + "Timed out refreshing cloud requirements cache from remote; keeping existing cache" + ); + emit_load_metric("refresh", "error"); + } + } + } + } + + async fn refresh_cache(&self) -> bool { + let Some(auth) = self.auth_manager.auth().await else { + return false; + }; + if !cloud_requirements_eligible_auth(&auth) { + return false; + } + + match self.fetch_with_retries(auth, "refresh").await { + Ok(_) => emit_load_metric("refresh", "success"), + Err(err) => { + tracing::error!( + path = %self.cache_path.display(), + error = %err, + "Failed to refresh cloud requirements cache from remote" + ); + emit_load_metric("refresh", "error"); + } + } + true + } + + async fn load_cache( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Result { + let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { + return Err(CacheLoadStatus::AuthIdentityIncomplete); + }; + + let bytes = match fs::read(&self.cache_path).await { + Ok(bytes) => bytes, + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); + } + return Err(CacheLoadStatus::CacheFileNotFound); + } + }; + + let cache_file: CloudRequirementsCacheFile = match serde_json::from_slice(&bytes) { + Ok(cache_file) => cache_file, + Err(err) => { + return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); + } + }; + let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { + Some(payload_bytes) => payload_bytes, + None => { + return Err(CacheLoadStatus::CacheParseFailed( + "failed to serialize cache payload".to_string(), + )); + } + }; + if !verify_cache_signature(&payload_bytes, &cache_file.signature) { + return Err(CacheLoadStatus::CacheSignatureInvalid); + } + + let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( + cache_file.signed_payload.chatgpt_user_id.as_deref(), + cache_file.signed_payload.account_id.as_deref(), + ) else { + return Err(CacheLoadStatus::CacheIdentityIncomplete); + }; + + if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { + return Err(CacheLoadStatus::CacheIdentityMismatch); + } + + if cache_file.signed_payload.expires_at <= Utc::now() { + return Err(CacheLoadStatus::CacheExpired); + } + + Ok(cache_file.signed_payload) + } + + fn log_cache_load_status(&self, status: &CacheLoadStatus) { + if matches!(status, CacheLoadStatus::CacheFileNotFound) { + return; + } + + let warn = matches!( + status, + CacheLoadStatus::CacheReadFailed(_) + | CacheLoadStatus::CacheParseFailed(_) + | CacheLoadStatus::CacheSignatureInvalid + ); + + if warn { + tracing::warn!(path = %self.cache_path.display(), "{status}"); + } else { + tracing::info!(path = %self.cache_path.display(), "{status}"); + } + } + + async fn save_cache( + &self, + chatgpt_user_id: Option, + account_id: Option, + contents: Option, + ) -> Result<(), CloudRequirementsError> { + let now = Utc::now(); + let expires_at = now + .checked_add_signed( + ChronoDuration::from_std(CLOUD_REQUIREMENTS_CACHE_TTL) + .map_err(|_| CloudRequirementsError::CacheWrite)?, + ) + .ok_or(CloudRequirementsError::CacheWrite)?; + let signed_payload = CloudRequirementsCacheSignedPayload { + cached_at: now, + expires_at, + chatgpt_user_id, + account_id, + contents, + }; + let payload_bytes = + cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; + let serialized = serde_json::to_vec_pretty(&CloudRequirementsCacheFile { + signature: sign_cache_payload(&payload_bytes) + .ok_or(CloudRequirementsError::CacheWrite)?, + signed_payload, + }) + .map_err(|_| CloudRequirementsError::CacheWrite)?; + + if let Some(parent) = self.cache_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|_| CloudRequirementsError::CacheWrite)?; + } + + fs::write(&self.cache_path, serialized) + .await + .map_err(|_| CloudRequirementsError::CacheWrite)?; + Ok(()) + } +} + +pub fn cloud_requirements_loader( + auth_manager: Arc, + chatgpt_base_url: String, + codex_home: PathBuf, +) -> CloudRequirementsLoader { + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), + codex_home, + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let refresh_service = service.clone(); + let task = tokio::spawn(async move { service.fetch_with_timeout().await }); + let refresh_task = + tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); + let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { + tracing::warn!("cloud requirements refresher task slot was poisoned"); + err.into_inner() + }); + if let Some(existing_task) = refresher_guard.replace(refresh_task) { + existing_task.abort(); + } + CloudRequirementsLoader::new(async move { + task.await.map_err(|err| { + tracing::error!(error = %err, "Cloud requirements task failed"); + CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Internal, + /*status_code*/ None, + format!("cloud requirements load failed: {err}"), + ) + })? + }) +} + +pub async fn cloud_requirements_loader_for_storage( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: String, +) -> CloudRequirementsLoader { + let auth_manager = AuthManager::shared( + codex_home.clone(), + enable_codex_api_key_env, + credentials_store_mode, + Some(chatgpt_base_url.clone()), + ) + .await; + cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) +} + +fn parse_cloud_requirements( + contents: &str, +) -> Result, toml::de::Error> { + if contents.trim().is_empty() { + return Ok(None); + } + + let requirements: ConfigRequirementsToml = toml::from_str(contents)?; + if requirements.is_empty() { + Ok(None) + } else { + Ok(Some(requirements)) + } +} + +fn format_cloud_requirements_parse_failed_message( + _contents: &str, + err: &toml::de::Error, +) -> String { + format!("{CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE}\n\nDetails:\n{err}") +} + +fn emit_fetch_attempt_metric( + trigger: &str, + attempt: usize, + outcome: &str, + status_code: Option, +) { + let attempt_tag = attempt.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("attempt", attempt_tag), + ("outcome", outcome.to_string()), + ("status_code", status_code_tag), + ], + ); +} + +fn emit_fetch_final_metric( + trigger: &str, + outcome: &str, + reason: &str, + attempt_count: usize, + status_code: Option, +) { + let attempt_count_tag = attempt_count.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ("reason", reason.to_string()), + ("attempt_count", attempt_count_tag), + ("status_code", status_code_tag), + ], + ); +} + +fn emit_load_metric(trigger: &str, outcome: &str) { + emit_metric( + CLOUD_REQUIREMENTS_LOAD_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ], + ); +} + +fn status_code_tag(status_code: Option) -> String { + status_code + .map(|status_code| status_code.to_string()) + .unwrap_or_else(|| "none".to_string()) +} + +fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { + if let Some(metrics) = codex_otel::global() { + let tag_refs = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use codex_config::types::AuthCredentialsStoreMode; + use codex_login::auth::AgentIdentityAuth; + use codex_login::auth::AgentIdentityAuthRecord; + use codex_protocol::protocol::AskForApproval; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::collections::BTreeMap; + use std::collections::VecDeque; + use std::ffi::OsString; + use std::future::pending; + use std::io::Read; + use std::io::Write; + use std::net::TcpListener; + use std::path::Path; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use std::thread; + use tempfile::TempDir; + use tempfile::tempdir; + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { + std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; + Ok(()) + } + + async fn auth_manager_with_api_key() -> Arc { + let tmp = tempdir().expect("tempdir"); + let auth_json = json!({ + "OPENAI_API_KEY": "sk-test-key", + "tokens": null, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) + } + + async fn auth_manager_with_plan_and_identity( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Arc { + let tmp = tempdir().expect("tempdir"); + write_auth_json( + tmp.path(), + chatgpt_auth_json( + plan_type, + chatgpt_user_id, + account_id, + "test-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) + } + + fn chatgpt_auth_json( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + ) -> serde_json::Value { + chatgpt_auth_json_with_last_refresh( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + "2025-01-01T00:00:00Z", + ) + } + + fn chatgpt_auth_json_with_last_refresh( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, + ) -> serde_json::Value { + chatgpt_auth_json_with_mode( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + last_refresh, + /*auth_mode*/ None, + ) + } + + fn chatgpt_auth_json_with_mode( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, + auth_mode: Option<&str>, + ) -> serde_json::Value { + let header = json!({ "alg": "none", "typ": "JWT" }); + let auth_payload = json!({ + "chatgpt_plan_type": plan_type, + "chatgpt_user_id": chatgpt_user_id, + "user_id": chatgpt_user_id, + }); + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": auth_payload, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let mut auth_json = json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }); + if let Some(auth_mode) = auth_mode { + auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); + } + auth_json + } + + struct ManagedAuthContext { + _home: TempDir, + manager: Arc, + } + + async fn managed_auth_context( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + ) -> ManagedAuthContext { + let home = tempdir().expect("tempdir"); + write_auth_json( + home.path(), + chatgpt_auth_json( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + ), + ) + .expect("write auth"); + ManagedAuthContext { + manager: Arc::new( + AuthManager::new( + home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ), + _home: home, + } + } + + async fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) + .await + } + + fn parse_for_fetch(contents: Option<&str>) -> Option { + contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + } + + fn request_error() -> FetchAttemptError { + FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code: None }) + } + + struct StaticFetcher { + contents: Option, + } + + #[async_trait::async_trait] + impl RequirementsFetcher for StaticFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result, FetchAttemptError> { + Ok(self.contents.clone()) + } + } + + struct PendingFetcher; + + #[async_trait::async_trait] + impl RequirementsFetcher for PendingFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result, FetchAttemptError> { + pending::<()>().await; + Ok(None) + } + } + + struct SequenceFetcher { + responses: tokio::sync::Mutex, FetchAttemptError>>>, + request_count: AtomicUsize, + } + + impl SequenceFetcher { + fn new(responses: Vec, FetchAttemptError>>) -> Self { + Self { + responses: tokio::sync::Mutex::new(VecDeque::from(responses)), + request_count: AtomicUsize::new(0), + } + } + } + + #[async_trait::async_trait] + impl RequirementsFetcher for SequenceFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result, FetchAttemptError> { + self.request_count.fetch_add(1, Ordering::SeqCst); + let mut responses = self.responses.lock().await; + responses.pop_front().unwrap_or(Ok(None)) + } + } + + struct TokenFetcher { + expected_token: String, + contents: String, + request_count: AtomicUsize, + } + + #[async_trait::async_trait] + impl RequirementsFetcher for TokenFetcher { + async fn fetch_requirements( + &self, + auth: &CodexAuth, + ) -> Result, FetchAttemptError> { + self.request_count.fetch_add(1, Ordering::SeqCst); + if matches!( + auth.get_token().as_deref(), + Ok(token) if token == self.expected_token.as_str() + ) { + Ok(Some(self.contents.clone())) + } else { + Err(FetchAttemptError::Unauthorized { + status_code: Some(401), + message: "GET /config/requirements failed: 401".to_string(), + }) + } + } + } + + struct UnauthorizedFetcher { + message: String, + request_count: AtomicUsize, + } + + #[async_trait::async_trait] + impl RequirementsFetcher for UnauthorizedFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result, FetchAttemptError> { + self.request_count.fetch_add(1, Ordering::SeqCst); + Err(FetchAttemptError::Unauthorized { + status_code: Some(401), + message: self.message.clone(), + }) + } + } + + #[tokio::test] + async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { + let auth_manager = auth_manager_with_api_key().await; + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(StaticFetcher { contents: None }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let result = service.fetch().await; + assert_eq!(result, Ok(None)); + } + + #[tokio::test] + async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("pro").await, + Arc::new(StaticFetcher { contents: None }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let result = service.fetch().await; + assert_eq!(result, Ok(None)); + } + + #[tokio::test] + async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("self_serve_business_usage_based").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!(service.fetch().await, Ok(None)); + } + + #[tokio::test] + async fn fetch_cloud_requirements_allows_business_plan() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + } + + #[tokio::test] + async fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind task registration server"); + let addr = listener + .local_addr() + .expect("task registration server addr"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept task registration request"); + let mut request = [0; 4096]; + let _ = stream + .read(&mut request) + .expect("read task registration request"); + let body = r#"{"task_id":"task-123"}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write task registration response"); + }); + let record = AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-123".to_string(), + agent_private_key: "MC4CAQAwBQYDK2VwBCIEIDQg14jybCLydjHQwXeBzsDM7oB6BSAenodx6oCovQ/D" + .to_string(), + account_id: "account-12345".to_string(), + chatgpt_user_id: "user-12345".to_string(), + email: "user@example.com".to_string(), + plan_type: PlanType::Business, + chatgpt_account_is_fedramp: false, + }; + let authapi_base_url = format!("http://{addr}/backend-api"); + let original_authapi_base_url = std::env::var_os("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"); + unsafe { + std::env::set_var("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url); + } + let _authapi_guard = EnvVarGuard { + key: "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", + original: original_authapi_base_url, + }; + let auth = AgentIdentityAuth::load(record) + .await + .map(CodexAuth::AgentIdentity) + .expect("agent identity auth"); + server.join().expect("task registration server joined"); + + assert!(cloud_requirements_eligible_auth(&auth)); + } + + #[tokio::test] + async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise_cbp_usage_based").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + } + + #[tokio::test] + async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("hc").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + } + + #[tokio::test] + async fn fetch_cloud_requirements_handles_missing_contents() { + let result = parse_for_fetch(/*contents*/ None); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_handles_empty_contents() { + let result = parse_for_fetch(Some(" ")); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_handles_invalid_toml() { + let result = parse_for_fetch(Some("not = [")); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_empty_requirements() { + let result = parse_for_fetch(Some("# comment")); + assert!(result.is_none()); + } + + #[tokio::test] + async fn fetch_cloud_requirements_parses_valid_toml() { + let result = parse_for_fetch(Some("allowed_approval_policies = [\"never\"]")); + + assert_eq!( + result, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + }) + ); + } + + #[tokio::test] + async fn fetch_cloud_requirements_parses_apps_requirements_toml() { + let result = parse_for_fetch(Some( + r#" +[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] +enabled = false +"#, + )); + + assert_eq!( + result, + Some(ConfigRequirementsToml { + apps: Some(codex_config::AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), + codex_config::AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }) + ); + } + + #[tokio::test] + async fn fetch_cloud_requirements_parses_plugin_mcp_requirements_toml() { + let result = parse_for_fetch(Some( + r#" +[plugins."sample@test".mcp_servers.sample.identity] +command = "sample-mcp" +"#, + )); + + assert_eq!( + result, + Some(ConfigRequirementsToml { + plugins: Some(BTreeMap::from([( + "sample@test".to_string(), + codex_config::PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "sample".to_string(), + codex_config::McpServerRequirement { + identity: codex_config::McpServerIdentity::Command { + command: "sample-mcp".to_string(), + }, + }, + )])), + }, + )])), + ..Default::default() + }) + ); + } + + #[tokio::test(start_paused = true)] + async fn fetch_cloud_requirements_times_out() { + let auth_manager = auth_manager_with_plan("enterprise").await; + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager, + Arc::new(PendingFetcher), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); + tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; + + let result = handle.await.expect("cloud requirements task"); + let err = result.expect_err("cloud requirements timeout should fail closed"); + assert!( + err.to_string() + .contains("timed out waiting for cloud requirements") + ); + } + + #[tokio::test(start_paused = true)] + async fn fetch_cloud_requirements_retries_until_success() { + let fetcher = Arc::new(SequenceFetcher::new(vec![ + Err(request_error()), + Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.fetch().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(1)).await; + + assert_eq!( + handle.await.expect("cloud requirements task"), + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + // Keep auth "fresh" so the first request hits unauthorized recovery + // instead of AuthManager::auth() proactively reloading from disk. + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; + + let fetcher = Arc::new(TokenFetcher { + expected_token: "fresh-access-token".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + Arc::clone(&auth.manager), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-99999"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let auth = ManagedAuthContext { + _home: auth_home, + manager: auth_manager, + }; + + let fetcher = Arc::new(TokenFetcher { + expected_token: "fresh-access-token".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + Arc::clone(&auth.manager), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) + .expect("parse cache"); + assert_eq!( + cache_file.signed_payload.chatgpt_user_id, + Some("user-99999".to_string()) + ); + assert_eq!( + cache_file.signed_payload.account_id, + Some("account-12345".to_string()) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { + let auth = managed_auth_context( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ) + .await; + write_auth_json( + auth._home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-99999"), + "fresh-access-token", + "test-refresh-token", + ), + ) + .expect("write mismatched auth"); + + let fetcher = Arc::new(UnauthorizedFetcher { + message: "GET /config/requirements failed: 401".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + Arc::clone(&auth.manager), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let err = service + .fetch() + .await + .expect_err("cloud requirements should surface auth recovery errors"); + assert_eq!( + err.to_string(), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again." + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_unauthorized_without_recovery_uses_generic_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_mode( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "test-access-token", + "test-refresh-token", + "2025-01-01T00:00:00Z", + Some("chatgptAuthTokens"), + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let fetcher = Arc::new(UnauthorizedFetcher { + message: + "GET https://chatgpt.com/backend-api/wham/config/requirements failed: 401; content-type=text/html; body=nope" + .to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let err = service + .fetch() + .await + .expect_err("cloud requirements should fail closed"); + assert_eq!( + err.to_string(), + CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE + ); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Auth); + assert_eq!(err.status_code(), Some(401)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_parse_error_does_not_retry() { + let fetcher = Arc::new(SequenceFetcher::new(vec![ + Ok(Some("not = [".to_string())), + Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let err = service + .fetch() + .await + .expect_err("parse error should fail closed"); + let err_text = err.to_string(); + assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); + assert!(err_text.contains("Details:")); + assert!(err_text.contains("not = [")); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_invalid_enum_value_surfaces_field_name() { + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"definitely-not-valid\"]".to_string(), + ))])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher, + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let err = service + .fetch() + .await + .expect_err("invalid enum value should fail closed"); + let err_text = err.to_string(); + assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); + assert!(err_text.contains("allowed_approval_policies")); + assert!(err_text.contains("definitely-not-valid")); + assert!(err_text.contains("unknown variant")); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); + } + + #[tokio::test] + async fn fetch_cloud_requirements_uses_cache_when_valid() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn fetch_cloud_requirements_writes_cache_when_identity_is_incomplete() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + /*chatgpt_user_id*/ None, + Some("account-12345"), + ) + .await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) + .expect("parse cache"); + assert_eq!(cache_file.signed_payload.chatgpt_user_id, None); + assert_eq!( + cache_file.signed_payload.account_id, + Some("account-12345".to_string()) + ); + } + + #[tokio::test] + async fn fetch_cloud_requirements_does_not_use_cache_when_auth_identity_is_incomplete() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"on-request\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + /*chatgpt_user_id*/ None, + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-12345"), + Some("account-12345"), + ) + .await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"on-request\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-99999"), + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_tampered_cache() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let mut cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read cache")) + .expect("parse cache"); + cache_file.signed_payload.contents = + Some("allowed_approval_policies = [\"on-request\"]".to_string()); + std::fs::write( + &path, + serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), + ) + .expect("write cache"); + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"never\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file = CloudRequirementsCacheFile { + signed_payload: CloudRequirementsCacheSignedPayload { + cached_at: Utc::now(), + expires_at: Utc::now() - ChronoDuration::seconds(1), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + contents: Some("allowed_approval_policies = [\"on-request\"]".to_string()), + }, + signature: String::new(), + }; + let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload"); + let signature = sign_cache_payload(&payload_bytes).expect("sign payload"); + let cache_file = CloudRequirementsCacheFile { + signature, + ..cache_file + }; + std::fs::write( + &path, + serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), + ) + .expect("write cache"); + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"never\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_writes_signed_cache() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let _ = service.fetch().await; + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) + .expect("parse cache"); + assert!( + cache_file.signed_payload.expires_at + <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) + ); + assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); + assert!(cache_file.signed_payload.cached_at <= Utc::now()); + assert_eq!( + cache_file.signed_payload.chatgpt_user_id, + Some("user-12345".to_string()) + ); + assert_eq!( + cache_file.signed_payload.account_id, + Some("account-12345".to_string()) + ); + assert_eq!( + cache_file + .signed_payload + .contents + .as_deref() + .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + }) + ); + let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"); + assert!(verify_cache_signature( + &payload_bytes, + &cache_file.signature + )); + } + + #[tokio::test] + async fn fetch_cloud_requirements_none_is_success_without_retry() { + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(None), Err(request_error())])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn fetch_cloud_requirements_stops_after_max_retries() { + let fetcher = Arc::new(SequenceFetcher::new(vec![ + Err(request_error()); + CLOUD_REQUIREMENTS_MAX_ATTEMPTS + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.fetch().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(5)).await; + tokio::task::yield_now().await; + + let err = handle + .await + .expect("cloud requirements task") + .expect_err("cloud requirements retry exhaustion should fail closed"); + assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); + assert_eq!( + fetcher.request_count.load(Ordering::SeqCst), + CLOUD_REQUIREMENTS_MAX_ATTEMPTS + ); + } + + #[tokio::test] + async fn refresh_from_remote_updates_cached_cloud_requirements() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(SequenceFetcher::new(vec![ + Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), + Ok(Some( + "allowed_approval_policies = [\"on-request\"]".to_string(), + )), + ])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher, + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + })) + ); + + assert!(service.refresh_cache().await); + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) + .expect("parse cache"); + assert_eq!( + cache_file + .signed_payload + .contents + .as_deref() + .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + remote_sandbox_config: None, + allowed_web_search_modes: None, + guardian_policy_config: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + }) + ); + } +} diff --git a/code-rs/cloud-tasks-client/BUILD.bazel b/code-rs/cloud-tasks-client/BUILD.bazel new file mode 100644 index 00000000000..83157266a5e --- /dev/null +++ b/code-rs/cloud-tasks-client/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "cloud-tasks-client", + crate_name = "codex_cloud_tasks_client", +) diff --git a/code-rs/cloud-tasks-client/Cargo.toml b/code-rs/cloud-tasks-client/Cargo.toml index b9e9dcd47a7..df8ec12b206 100644 --- a/code-rs/cloud-tasks-client/Cargo.toml +++ b/code-rs/cloud-tasks-client/Cargo.toml @@ -1,27 +1,25 @@ [package] -name = "code-cloud-tasks-client" -version = { workspace = true } -edition = "2024" +edition.workspace = true +license.workspace = true +name = "codex-cloud-tasks-client" +version.workspace = true [lib] -name = "code_cloud_tasks_client" +name = "codex_cloud_tasks_client" path = "src/lib.rs" +test = false +doctest = false [lints] workspace = true -[features] -default = ["online"] -online = ["dep:code-backend-client"] -mock = [] - [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } chrono = { workspace = true, features = ["serde"] } -diffy = { workspace = true } -serde = { workspace = true, features = ["derive"] } +codex-api = { workspace = true } +codex-backend-client = { workspace = true } +codex-git-utils = { workspace = true } +serde = { version = "1", features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } -code-backend-client = { workspace = true, optional = true } -code-git-apply = { workspace = true } diff --git a/code-rs/cloud-tasks-client/src/api.rs b/code-rs/cloud-tasks-client/src/api.rs index 4bd12939e84..7059bdb39fd 100644 --- a/code-rs/cloud-tasks-client/src/api.rs +++ b/code-rs/cloud-tasks-client/src/api.rs @@ -94,6 +94,12 @@ pub struct CreatedTask { pub id: TaskId, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaskListPage { + pub tasks: Vec, + pub cursor: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct DiffSummary { pub files_changed: usize, @@ -126,7 +132,13 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { - async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn list_tasks( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result; + async fn get_task_summary(&self, id: TaskId) -> Result; async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; diff --git a/code-rs/cloud-tasks-client/src/http.rs b/code-rs/cloud-tasks-client/src/http.rs index 47a48162705..46fed812bac 100644 --- a/code-rs/cloud-tasks-client/src/http.rs +++ b/code-rs/cloud-tasks-client/src/http.rs @@ -6,18 +6,19 @@ use crate::CloudTaskError; use crate::DiffSummary; use crate::Result; use crate::TaskId; +use crate::TaskListPage; use crate::TaskStatus; use crate::TaskSummary; use crate::TurnAttempt; use crate::api::TaskText; use chrono::DateTime; use chrono::Utc; -use std::borrow::Cow; -use std::path::Path; -use std::path::PathBuf; -use code_backend_client as backend; -use code_backend_client::CodeTaskDetailsResponseExt; +use codex_api::SharedAuthProvider; +use codex_backend_client as backend; +use codex_backend_client::CodeTaskDetailsResponseExt; +use codex_git_utils::ApplyGitRequest; +use codex_git_utils::apply_git_patch; #[derive(Clone)] pub struct HttpClient { @@ -32,23 +33,18 @@ impl HttpClient { Ok(Self { base_url, backend }) } - pub fn with_bearer_token(mut self, token: impl Into) -> Self { - self.backend = self.backend.clone().with_bearer_token(token); - self - } - pub fn with_user_agent(mut self, ua: impl Into) -> Self { self.backend = self.backend.clone().with_user_agent(ua); self } - pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> Self { - self.backend = self.backend.clone().with_chatgpt_account_id(account_id); + pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self { + self.backend = self.backend.clone().with_auth_provider(auth); self } - pub fn with_fedramp_routing_header(mut self) -> Self { - self.backend = self.backend.clone().with_fedramp_routing_header(); + pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> Self { + self.backend = self.backend.clone().with_chatgpt_account_id(account_id); self } @@ -67,8 +63,17 @@ impl HttpClient { #[async_trait::async_trait] impl CloudBackend for HttpClient { - async fn list_tasks(&self, env: Option<&str>) -> Result> { - self.tasks_api().list(env).await + async fn list_tasks( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result { + self.tasks_api().list(env, limit, cursor).await + } + + async fn get_task_summary(&self, id: TaskId) -> Result { + self.tasks_api().summary(id).await } async fn get_task_diff(&self, id: TaskId) -> Result> { @@ -92,7 +97,9 @@ impl CloudBackend for HttpClient { } async fn apply_task(&self, id: TaskId, diff_override: Option) -> Result { - self.apply_api().run(id, diff_override, false).await + self.apply_api() + .run(id, diff_override, /*preflight*/ false) + .await } async fn apply_task_preflight( @@ -100,7 +107,9 @@ impl CloudBackend for HttpClient { id: TaskId, diff_override: Option, ) -> Result { - self.apply_api().run(id, diff_override, true).await + self.apply_api() + .run(id, diff_override, /*preflight*/ true) + .await } async fn create_task( @@ -136,10 +145,16 @@ mod api { } } - pub(crate) async fn list(&self, env: Option<&str>) -> Result> { + pub(crate) async fn list( + &self, + env: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> Result { + let limit_i32 = limit.and_then(|lim| i32::try_from(lim).ok()); let resp = self .backend - .list_tasks(Some(20), Some("current"), env) + .list_tasks(limit_i32, Some("current"), env, cursor) .await .map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?; @@ -149,12 +164,89 @@ mod api { .map(map_task_list_item_to_summary) .collect(); - append_debug_log(&format!( - "http.list_tasks: env={} items={}", + append_error_log(&format!( + "http.list_tasks: env={} limit={} cursor_in={} cursor_out={} items={}", env.unwrap_or(""), + limit_i32 + .map(|v| v.to_string()) + .unwrap_or_else(|| "".to_string()), + cursor.unwrap_or(""), + resp.cursor.as_deref().unwrap_or(""), tasks.len() )); - Ok(tasks) + Ok(TaskListPage { + tasks, + cursor: resp.cursor, + }) + } + + pub(crate) async fn summary(&self, id: TaskId) -> Result { + let id_str = id.0.clone(); + let (details, body, ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + let parsed: Value = serde_json::from_str(&body).map_err(|e| { + CloudTaskError::Http(format!( + "Decode error for {}: {e}; content-type={ct}; body={body}", + id.0 + )) + })?; + let task_obj = parsed + .get("task") + .and_then(Value::as_object) + .ok_or_else(|| { + CloudTaskError::Http(format!("Task metadata missing from details for {id_str}")) + })?; + let status_display = parsed + .get("task_status_display") + .or_else(|| task_obj.get("task_status_display")) + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }); + let status = map_status(status_display.as_ref()); + let mut summary = diff_summary_from_status_display(status_display.as_ref()); + if summary.files_changed == 0 + && summary.lines_added == 0 + && summary.lines_removed == 0 + && let Some(diff) = details.unified_diff() + { + summary = diff_summary_from_diff(&diff); + } + let updated_at_raw = task_obj + .get("updated_at") + .and_then(Value::as_f64) + .or_else(|| task_obj.get("created_at").and_then(Value::as_f64)) + .or_else(|| latest_turn_timestamp(status_display.as_ref())); + let environment_id = task_obj + .get("environment_id") + .and_then(Value::as_str) + .map(str::to_string); + let environment_label = env_label_from_status_display(status_display.as_ref()); + let attempt_total = attempt_total_from_status_display(status_display.as_ref()); + let title = task_obj + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let is_review = task_obj + .get("is_review") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(TaskSummary { + id, + title, + status, + updated_at: parse_updated_at(updated_at_raw.as_ref()), + environment_id, + environment_label, + summary, + is_review, + attempt_total, + }) } pub(crate) async fn diff(&self, id: TaskId) -> Result> { @@ -268,7 +360,7 @@ mod api { match self.backend.create_task(request_body).await { Ok(id) => { - append_info_log(&format!( + append_error_log(&format!( "new_task: created id={id} env={} prompt_chars={}", env_id, prompt.chars().count() @@ -370,13 +462,13 @@ mod api { }); } - let req = code_git_apply::ApplyGitRequest { + let req = 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) + let r = apply_git_patch(&req) .map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?; let status = if r.exit_code == 0 { @@ -448,8 +540,8 @@ mod api { let _ = writeln!( &mut log, "stdout_tail=\n{}\nstderr_tail=\n{}", - tail(&r.stdout, 2000), - tail(&r.stderr, 2000) + tail(&r.stdout, /*max*/ 2000), + tail(&r.stderr, /*max*/ 2000) ); let _ = writeln!(&mut log, "{summary}"); let _ = writeln!( @@ -687,6 +779,34 @@ mod api { .map(str::to_string) } + fn diff_summary_from_diff(diff: &str) -> DiffSummary { + let mut files_changed = 0usize; + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + for line in diff.lines() { + if line.starts_with("diff --git ") { + files_changed += 1; + continue; + } + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { + continue; + } + match line.as_bytes().first() { + Some(b'+') => lines_added += 1, + Some(b'-') => lines_removed += 1, + _ => {} + } + } + if files_changed == 0 && !diff.trim().is_empty() { + files_changed = 1; + } + DiffSummary { + files_changed, + lines_added, + lines_removed, + } + } + fn diff_summary_from_status_display(v: Option<&HashMap>) -> DiffSummary { let mut out = DiffSummary::default(); let Some(map) = v else { return out }; @@ -708,6 +828,17 @@ mod api { out } + fn latest_turn_timestamp(v: Option<&HashMap>) -> Option { + let map = v?; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object)?; + latest + .get("updated_at") + .or_else(|| latest.get("created_at")) + .and_then(Value::as_f64) + } + fn attempt_total_from_status_display(v: Option<&HashMap>) -> Option { let map = v?; let latest = map @@ -764,190 +895,14 @@ mod api { } } -const CLOUD_TASKS_LOG_FILE: &str = "cloud-tasks.log"; -const CLOUD_TASKS_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; -const CLOUD_TASKS_LOG_BACKUPS: usize = 2; -const CLOUD_TASKS_LOG_MAX_MESSAGE_BYTES: usize = 64 * 1024; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum CloudLogLevel { - Off = 0, - Error = 1, - Info = 2, - Debug = 3, -} - -fn log_level_from_env() -> CloudLogLevel { - if let Ok(raw) = std::env::var("CODEX_CLOUD_TASKS_LOG_LEVEL") { - let value = raw.trim().to_ascii_lowercase(); - return match value.as_str() { - "off" | "none" | "0" => CloudLogLevel::Off, - "error" | "warn" | "1" => CloudLogLevel::Error, - "info" | "2" => CloudLogLevel::Info, - "debug" | "trace" | "3" => CloudLogLevel::Debug, - _ => CloudLogLevel::Error, - }; - } - - if env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("CODEX_CLOUD_TASKS_DEBUG") { - return CloudLogLevel::Debug; - } - - CloudLogLevel::Off -} - -fn env_truthy(key: &str) -> bool { - std::env::var(key) - .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) -} - -fn should_log(level: CloudLogLevel) -> bool { - let configured = log_level_from_env(); - configured != CloudLogLevel::Off && level <= configured -} - -fn user_home_dir() -> Option { - if let Ok(home) = std::env::var("HOME") { - return Some(PathBuf::from(home)); - } - if let Ok(home) = std::env::var("USERPROFILE") { - return Some(PathBuf::from(home)); - } - None -} - -fn resolve_log_path() -> Option { - if let Ok(path) = std::env::var("CODEX_CLOUD_TASKS_LOG_PATH") { - let trimmed = path.trim(); - if !trimmed.is_empty() { - return Some(PathBuf::from(trimmed)); - } - } - - let base = if let Ok(dir) = std::env::var("CODEX_CLOUD_TASKS_LOG_DIR") { - PathBuf::from(dir) - } else if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("CODEX_HOME")) { - PathBuf::from(home).join("debug_logs") - } else if let Some(home) = user_home_dir() { - home.join(".code").join("debug_logs") - } else { - return None; - }; - - Some(base.join(CLOUD_TASKS_LOG_FILE)) -} - -fn ensure_parent_dir(path: &Path) { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } -} - -fn rotate_log_file(path: &Path, max_bytes: u64, backups: usize) { - if backups == 0 { - let _ = std::fs::remove_file(path); - return; - } - - let Ok(meta) = std::fs::metadata(path) else { - return; - }; - if meta.len() <= max_bytes { - return; - } - - let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { - return; - }; - let Some(dir) = path.parent() else { - return; - }; - - let lock_path = dir.join(format!("{file_name}.rotate.lock")); - let lock_file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&lock_path); - let Ok(_lock_file) = lock_file else { - return; - }; - - let Ok(meta) = std::fs::metadata(path) else { - let _ = std::fs::remove_file(&lock_path); - return; - }; - if meta.len() <= max_bytes { - let _ = std::fs::remove_file(&lock_path); - return; - } - - let oldest = dir.join(format!("{file_name}.{backups}")); - let _ = std::fs::remove_file(&oldest); - - if backups > 1 { - for idx in (1..backups).rev() { - let from = dir.join(format!("{file_name}.{idx}")); - let to = dir.join(format!("{file_name}.{}", idx + 1)); - let _ = std::fs::rename(&from, &to); - } - } - - let rotated = dir.join(format!("{file_name}.1")); - let _ = std::fs::copy(path, &rotated); - if let Ok(file) = std::fs::OpenOptions::new().write(true).open(path) { - let _ = file.set_len(0); - } - - let _ = std::fs::remove_file(&lock_path); -} - -fn truncate_message(message: &str, max_bytes: usize) -> Cow<'_, str> { - if message.len() <= max_bytes { - return Cow::Borrowed(message); - } - let bytes = message.as_bytes(); - let head = String::from_utf8_lossy(&bytes[..max_bytes]).to_string(); - Cow::Owned(format!("{head}\n...truncated...")) -} - fn append_error_log(message: &str) { - append_cloud_log(CloudLogLevel::Error, message); -} - -fn append_info_log(message: &str) { - append_cloud_log(CloudLogLevel::Info, message); -} - -fn append_debug_log(message: &str) { - append_cloud_log(CloudLogLevel::Debug, message); -} - -fn append_cloud_log(level: CloudLogLevel, message: &str) { - if !should_log(level) { - return; - } - - let Some(path) = resolve_log_path() else { - return; - }; - ensure_parent_dir(&path); - rotate_log_file(&path, CLOUD_TASKS_LOG_MAX_BYTES, CLOUD_TASKS_LOG_BACKUPS); - let ts = Utc::now().to_rfc3339(); - let level_label = match level { - CloudLogLevel::Error => "ERROR", - CloudLogLevel::Info => "INFO", - CloudLogLevel::Debug => "DEBUG", - CloudLogLevel::Off => return, - }; - let message = truncate_message(message, CLOUD_TASKS_LOG_MAX_MESSAGE_BYTES); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) .append(true) - .open(&path) + .open("error.log") { use std::io::Write as _; - let _ = writeln!(f, "[{ts}] {level_label} {message}"); + let _ = writeln!(f, "[{ts}] {message}"); } } diff --git a/code-rs/cloud-tasks-client/src/lib.rs b/code-rs/cloud-tasks-client/src/lib.rs index d263dcf0fc3..8ed6469a533 100644 --- a/code-rs/cloud-tasks-client/src/lib.rs +++ b/code-rs/cloud-tasks-client/src/lib.rs @@ -9,21 +9,11 @@ pub use api::CreatedTask; pub use api::DiffSummary; pub use api::Result; pub use api::TaskId; +pub use api::TaskListPage; 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 `code-git-apply`. diff --git a/code-rs/cloud-tasks-mock-client/BUILD.bazel b/code-rs/cloud-tasks-mock-client/BUILD.bazel new file mode 100644 index 00000000000..4d54dab57e2 --- /dev/null +++ b/code-rs/cloud-tasks-mock-client/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "cloud-tasks-mock-client", + crate_name = "codex_cloud_tasks_mock_client", +) diff --git a/code-rs/cloud-tasks-mock-client/Cargo.toml b/code-rs/cloud-tasks-mock-client/Cargo.toml new file mode 100644 index 00000000000..b4531cff63b --- /dev/null +++ b/code-rs/cloud-tasks-mock-client/Cargo.toml @@ -0,0 +1,21 @@ + +[package] +edition.workspace = true +license.workspace = true +name = "codex-cloud-tasks-mock-client" +version.workspace = true + +[lib] +name = "codex_cloud_tasks_mock_client" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +codex-cloud-tasks-client = { workspace = true } +diffy = { workspace = true } diff --git a/code-rs/cloud-tasks-mock-client/src/lib.rs b/code-rs/cloud-tasks-mock-client/src/lib.rs new file mode 100644 index 00000000000..833ea5e1f80 --- /dev/null +++ b/code-rs/cloud-tasks-mock-client/src/lib.rs @@ -0,0 +1,3 @@ +mod mock; + +pub use mock::MockClient; diff --git a/code-rs/cloud-tasks-client/src/mock.rs b/code-rs/cloud-tasks-mock-client/src/mock.rs similarity index 80% rename from code-rs/cloud-tasks-client/src/mock.rs rename to code-rs/cloud-tasks-mock-client/src/mock.rs index 97bc5520a83..4bde0e93b99 100644 --- a/code-rs/cloud-tasks-client/src/mock.rs +++ b/code-rs/cloud-tasks-mock-client/src/mock.rs @@ -1,21 +1,30 @@ -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; +use codex_cloud_tasks_client::ApplyOutcome; +use codex_cloud_tasks_client::ApplyStatus; +use codex_cloud_tasks_client::AttemptStatus; +use codex_cloud_tasks_client::CloudBackend; +use codex_cloud_tasks_client::CloudTaskError; +use codex_cloud_tasks_client::CreatedTask; +use codex_cloud_tasks_client::DiffSummary; +use codex_cloud_tasks_client::Result; +use codex_cloud_tasks_client::TaskId; +use codex_cloud_tasks_client::TaskListPage; +use codex_cloud_tasks_client::TaskStatus; +use codex_cloud_tasks_client::TaskSummary; +use codex_cloud_tasks_client::TaskText; +use codex_cloud_tasks_client::TurnAttempt; #[derive(Clone, Default)] pub struct MockClient; #[async_trait::async_trait] impl CloudBackend for MockClient { - async fn list_tasks(&self, _env: Option<&str>) -> Result> { + async fn list_tasks( + &self, + _env: Option<&str>, + _limit: Option, + _cursor: Option<&str>, + ) -> Result { // 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)], @@ -57,7 +66,21 @@ impl CloudBackend for MockClient { attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }), }); } - Ok(out) + Ok(TaskListPage { + tasks: out, + cursor: None, + }) + } + + async fn get_task_summary(&self, id: TaskId) -> Result { + let tasks = self + .list_tasks(/*env*/ None, /*limit*/ None, /*cursor*/ None) + .await? + .tasks; + tasks + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found (mock)", id.0))) } async fn get_task_diff(&self, id: TaskId) -> Result> { @@ -84,7 +107,7 @@ impl CloudBackend for MockClient { async fn apply_task(&self, id: TaskId, _diff_override: Option) -> Result { Ok(ApplyOutcome { applied: true, - status: crate::ApplyStatus::Success, + status: ApplyStatus::Success, message: format!("Applied task {} locally (mock)", id.0), skipped_paths: Vec::new(), conflict_paths: Vec::new(), @@ -98,7 +121,7 @@ impl CloudBackend for MockClient { ) -> Result { Ok(ApplyOutcome { applied: false, - status: crate::ApplyStatus::Success, + status: ApplyStatus::Success, message: format!("Preflight passed for task {} (mock)", id.0), skipped_paths: Vec::new(), conflict_paths: Vec::new(), @@ -130,10 +153,10 @@ impl CloudBackend for MockClient { git_ref: &str, qa_mode: bool, best_of_n: usize, - ) -> Result { + ) -> Result { 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) }) + Ok(CreatedTask { id: TaskId(id) }) } } diff --git a/code-rs/cloud-tasks/BUILD.bazel b/code-rs/cloud-tasks/BUILD.bazel new file mode 100644 index 00000000000..9beb5f87b02 --- /dev/null +++ b/code-rs/cloud-tasks/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate") + +codex_rust_crate( + name = "cloud-tasks", + crate_name = "codex_cloud_tasks", + rustc_flags_extra = MACOS_WEBRTC_RUSTC_LINK_FLAGS, +) diff --git a/code-rs/cloud-tasks/Cargo.toml b/code-rs/cloud-tasks/Cargo.toml index 11d9c3aaf35..7bdcaaddbaa 100644 --- a/code-rs/cloud-tasks/Cargo.toml +++ b/code-rs/cloud-tasks/Cargo.toml @@ -1,37 +1,46 @@ [package] -name = "code-cloud-tasks" -version = { workspace = true } -edition = "2024" +edition.workspace = true +license.workspace = true +name = "codex-cloud-tasks" +version.workspace = true [lib] -name = "code_cloud_tasks" +name = "codex_cloud_tasks" path = "src/lib.rs" +doctest = false [lints] workspace = true [dependencies] -anyhow = "1" -clap = { version = "4", features = ["derive"] } -code-common = { path = "../common", features = ["cli"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -tracing = { version = "0.1.41", features = ["log"] } -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -code-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] } -ratatui = { version = "0.29.0" } -crossterm = { version = "0.28.1", features = ["event-stream"] } -tokio-stream = "0.1.17" -chrono = { version = "0.4", features = ["serde"] } -code-login = { path = "../login" } -code-core = { path = "../core" } -throbber-widgets-tui = "0.8.0" -base64 = "0.22" -serde_json = "1" -reqwest = { version = "0.12", features = ["json"] } -serde = { version = "1", features = ["derive"] } -unicode-width = "0.1" -unicode-segmentation = "1.12" -code-tui = { path = "../tui" } +anyhow = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +clap = { workspace = true, features = ["derive"] } +codex-client = { workspace = true } +codex-cloud-tasks-client = { workspace = true } +# TODO: codex-cloud-tasks-mock-client should be in dev-dependencies. +codex-cloud-tasks-mock-client = { workspace = true } +codex-core = { workspace = true } +codex-git-utils = { workspace = true } +codex-login = { path = "../login" } +codex-model-provider = { workspace = true } +codex-tui = { workspace = true } +codex-utils-cli = { workspace = true } +crossterm = { workspace = true, features = ["event-stream"] } +owo-colors = { workspace = true, features = ["supports-colors"] } +ratatui = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +supports-color = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio-stream = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +unicode-width = { workspace = true } + +[dependencies.async-trait] +workspace = true [dev-dependencies] -async-trait = "0.1" +pretty_assertions = { workspace = true } diff --git a/code-rs/cloud-tasks/src/app.rs b/code-rs/cloud-tasks/src/app.rs index 8462ebf5eac..aa02be97f1b 100644 --- a/code-rs/cloud-tasks/src/app.rs +++ b/code-rs/cloud-tasks/src/app.rs @@ -1,4 +1,5 @@ use std::time::Duration; +use std::time::Instant; // Environment filter data models for the TUI #[derive(Clone, Debug, Default)] @@ -39,18 +40,16 @@ pub struct ApplyModalState { } use crate::scrollable_diff::ScrollableDiff; -use code_cloud_tasks_client::CloudBackend; -use code_cloud_tasks_client::TaskId; -use code_cloud_tasks_client::TaskSummary; -use throbber_widgets_tui::ThrobberState; - +use codex_cloud_tasks_client::CloudBackend; +use codex_cloud_tasks_client::TaskId; +use codex_cloud_tasks_client::TaskSummary; #[derive(Default)] pub struct App { pub tasks: Vec, pub selected: usize, pub status: String, pub diff_overlay: Option, - pub throbber: ThrobberState, + pub spinner_start: Option, pub refresh_inflight: bool, pub details_inflight: bool, // Environment filter state @@ -82,7 +81,7 @@ impl App { selected: 0, status: "Press r to refresh".to_string(), diff_overlay: None, - throbber: ThrobberState::default(), + spinner_start: None, refresh_inflight: false, details_inflight: false, env_filter: None, @@ -124,9 +123,13 @@ pub async fn load_tasks( env: Option<&str>, ) -> anyhow::Result> { // In later milestones, add a small debounce, spinner, and error display. - let tasks = tokio::time::timeout(Duration::from_secs(5), backend.list_tasks(env)).await??; + let tasks = tokio::time::timeout( + Duration::from_secs(5), + backend.list_tasks(env, Some(20), /*cursor*/ None), + ) + .await??; // Hide review-only tasks from the main list. - let filtered: Vec = tasks.into_iter().filter(|t| !t.is_review).collect(); + let filtered: Vec = tasks.tasks.into_iter().filter(|t| !t.is_review).collect(); Ok(filtered) } @@ -149,7 +152,7 @@ pub struct DiffOverlay { #[derive(Clone, Debug, Default)] pub struct AttemptView { pub turn_id: Option, - pub status: code_cloud_tasks_client::AttemptStatus, + pub status: codex_cloud_tasks_client::AttemptStatus, pub attempt_placement: Option, pub diff_lines: Vec, pub text_lines: Vec, @@ -317,7 +320,7 @@ pub enum AppEvent { turn_id: Option, sibling_turn_ids: Vec, attempt_placement: Option, - attempt_status: code_cloud_tasks_client::AttemptStatus, + attempt_status: codex_cloud_tasks_client::AttemptStatus, }, DetailsFailed { id: TaskId, @@ -326,10 +329,10 @@ pub enum AppEvent { }, AttemptsLoaded { id: TaskId, - attempts: Vec, + attempts: Vec, }, /// Background completion of new task submission - NewTaskSubmitted(Result), + NewTaskSubmitted(Result), /// Background completion of apply preflight when opening modal or on demand ApplyPreflightFinished { id: TaskId, @@ -342,7 +345,7 @@ pub enum AppEvent { /// Background completion of apply action (actual patch application) ApplyFinished { id: TaskId, - result: std::result::Result, + result: std::result::Result, }, } @@ -351,6 +354,7 @@ pub enum AppEvent { mod tests { use super::*; use chrono::Utc; + use codex_cloud_tasks_client::CloudTaskError; struct FakeBackend { // maps env key to titles @@ -358,11 +362,13 @@ mod tests { } #[async_trait::async_trait] - impl code_cloud_tasks_client::CloudBackend for FakeBackend { + impl codex_cloud_tasks_client::CloudBackend for FakeBackend { async fn list_tasks( &self, env: Option<&str>, - ) -> code_cloud_tasks_client::Result> { + limit: Option, + cursor: Option<&str>, + ) -> codex_cloud_tasks_client::Result { let key = env.map(str::to_string); let titles = self .by_env @@ -374,23 +380,47 @@ mod tests { out.push(TaskSummary { id: TaskId(format!("T-{i}")), title: t.to_string(), - status: code_cloud_tasks_client::TaskStatus::Ready, + status: codex_cloud_tasks_client::TaskStatus::Ready, updated_at: Utc::now(), environment_id: env.map(str::to_string), environment_label: None, - summary: code_cloud_tasks_client::DiffSummary::default(), + summary: codex_cloud_tasks_client::DiffSummary::default(), is_review: false, attempt_total: Some(1), }); } - Ok(out) + let max = limit.unwrap_or(i64::MAX); + let max = max.min(20); + let mut limited = Vec::new(); + for task in out { + if (limited.len() as i64) >= max { + break; + } + limited.push(task); + } + Ok(codex_cloud_tasks_client::TaskListPage { + tasks: limited, + cursor: cursor.map(str::to_string), + }) + } + + async fn get_task_summary( + &self, + id: TaskId, + ) -> codex_cloud_tasks_client::Result { + self.list_tasks(/*env*/ None, /*limit*/ None, /*cursor*/ None) + .await? + .tasks + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0))) } async fn get_task_diff( &self, _id: TaskId, - ) -> code_cloud_tasks_client::Result> { - Err(code_cloud_tasks_client::CloudTaskError::Unimplemented( + ) -> codex_cloud_tasks_client::Result> { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( "not used in test", )) } @@ -398,20 +428,20 @@ mod tests { async fn get_task_messages( &self, _id: TaskId, - ) -> code_cloud_tasks_client::Result> { + ) -> codex_cloud_tasks_client::Result> { Ok(vec![]) } async fn get_task_text( &self, _id: TaskId, - ) -> code_cloud_tasks_client::Result { - Ok(code_cloud_tasks_client::TaskText { + ) -> codex_cloud_tasks_client::Result { + Ok(codex_cloud_tasks_client::TaskText { prompt: Some("Example prompt".to_string()), messages: Vec::new(), turn_id: Some("fake-turn".to_string()), sibling_turn_ids: Vec::new(), attempt_placement: Some(0), - attempt_status: code_cloud_tasks_client::AttemptStatus::Completed, + attempt_status: codex_cloud_tasks_client::AttemptStatus::Completed, }) } @@ -419,7 +449,7 @@ mod tests { &self, _task: TaskId, _turn_id: String, - ) -> code_cloud_tasks_client::Result> { + ) -> codex_cloud_tasks_client::Result> { Ok(Vec::new()) } @@ -427,8 +457,8 @@ mod tests { &self, _id: TaskId, _diff_override: Option, - ) -> code_cloud_tasks_client::Result { - Err(code_cloud_tasks_client::CloudTaskError::Unimplemented( + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( "not used in test", )) } @@ -437,8 +467,8 @@ mod tests { &self, _id: TaskId, _diff_override: Option, - ) -> code_cloud_tasks_client::Result { - Err(code_cloud_tasks_client::CloudTaskError::Unimplemented( + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( "not used in test", )) } @@ -450,8 +480,8 @@ mod tests { _git_ref: &str, _qa_mode: bool, _best_of_n: usize, - ) -> code_cloud_tasks_client::Result { - Err(code_cloud_tasks_client::CloudTaskError::Unimplemented( + ) -> codex_cloud_tasks_client::Result { + Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented( "not used in test", )) } @@ -467,7 +497,7 @@ mod tests { let backend = FakeBackend { by_env }; // Act + Assert - let root = load_tasks(&backend, None).await.unwrap(); + let root = load_tasks(&backend, /*env*/ None).await.unwrap(); assert_eq!(root.len(), 2); assert_eq!(root[0].title, "root-1"); diff --git a/code-rs/cloud-tasks/src/cli.rs b/code-rs/cloud-tasks/src/cli.rs index 6d5a76dcaf0..e2c3a244422 100644 --- a/code-rs/cloud-tasks/src/cli.rs +++ b/code-rs/cloud-tasks/src/cli.rs @@ -1,45 +1,120 @@ +use clap::Args; use clap::Parser; -use code_common::CliConfigOverrides; +use codex_utils_cli::CliConfigOverrides; -#[derive(Debug, Clone, clap::Subcommand)] +#[derive(Parser, Debug, Default)] +#[command(version)] +pub struct Cli { + #[clap(skip)] + pub config_overrides: CliConfigOverrides, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, clap::Subcommand)] pub enum Command { - /// Submit a new task non-interactively and print the created id - Submit(SubmitArgs), + /// Submit a new Codex Cloud task without launching the TUI. + Exec(ExecCommand), + /// Show the status of a Codex Cloud task. + Status(StatusCommand), + /// List Codex Cloud tasks. + List(ListCommand), + /// Apply the diff for a Codex Cloud task locally. + Apply(ApplyCommand), + /// Show the unified diff for a Codex Cloud task. + Diff(DiffCommand), } -#[derive(Parser, Debug, Default, Clone)] -pub struct SubmitArgs { - /// The task prompt to submit to Codex Cloud - #[arg(value_name = "PROMPT")] - pub prompt: String, +#[derive(Debug, Args)] +pub struct ExecCommand { + /// Task prompt to run in Codex Cloud. + #[arg(value_name = "QUERY")] + pub query: Option, - /// Optional environment id (falls back to auto-detect when omitted) + /// Target environment identifier (see `codex cloud` to browse). #[arg(long = "env", value_name = "ENV_ID")] - pub env: Option, + pub environment: String, - /// Best-of-N attempts for the assistant (default: 1) - #[arg(long = "best-of", default_value_t = 1)] - pub best_of: usize, + /// Number of assistant attempts (best-of-N). + #[arg( + long = "attempts", + default_value_t = 1usize, + value_parser = parse_attempts + )] + pub attempts: usize, - /// Enable QA/review mode when creating the task - #[arg(long = "qa", default_value_t = false)] - pub qa: bool, + /// Git branch to run in Codex Cloud (defaults to current branch). + #[arg(long = "branch", value_name = "BRANCH")] + pub branch: Option, +} - /// Git ref to associate with the task (default: main) - #[arg(long = "git-ref", default_value = "main")] - pub git_ref: String, +fn parse_attempts(input: &str) -> Result { + let value: usize = input + .parse() + .map_err(|_| "attempts must be an integer between 1 and 4".to_string())?; + if (1..=4).contains(&value) { + Ok(value) + } else { + Err("attempts must be between 1 and 4".to_string()) + } +} - /// Wait for completion and print final results - #[arg(long = "wait", default_value_t = false)] - pub wait: bool, +fn parse_limit(input: &str) -> Result { + let value: i64 = input + .parse() + .map_err(|_| "limit must be an integer between 1 and 20".to_string())?; + if (1..=20).contains(&value) { + Ok(value) + } else { + Err("limit must be between 1 and 20".to_string()) + } } -#[derive(Parser, Debug, Default)] -#[command(version)] -pub struct Cli { - #[clap(skip)] - pub config_overrides: CliConfigOverrides, +#[derive(Debug, Args)] +pub struct StatusCommand { + /// Codex Cloud task identifier to inspect. + #[arg(value_name = "TASK_ID")] + pub task_id: String, +} + +#[derive(Debug, Args)] +pub struct ListCommand { + /// Filter tasks by environment identifier. + #[arg(long = "env", value_name = "ENV_ID")] + pub environment: Option, + + /// Maximum number of tasks to return (1-20). + #[arg(long = "limit", default_value_t = 20, value_parser = parse_limit, value_name = "N")] + pub limit: i64, + + /// Pagination cursor returned by a previous call. + #[arg(long = "cursor", value_name = "CURSOR")] + pub cursor: Option, + + /// Emit JSON instead of plain text. + #[arg(long = "json", default_value_t = false)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct ApplyCommand { + /// Codex Cloud task identifier to apply. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to apply (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} + +#[derive(Debug, Args)] +pub struct DiffCommand { + /// Codex Cloud task identifier to display. + #[arg(value_name = "TASK_ID")] + pub task_id: String, - #[clap(subcommand)] - pub cmd: Option, + /// Attempt number to display (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, } diff --git a/code-rs/cloud-tasks/src/env_detect.rs b/code-rs/cloud-tasks/src/env_detect.rs index 70a01440aa3..cd38c7f3475 100644 --- a/code-rs/cloud-tasks/src/env_detect.rs +++ b/code-rs/cloud-tasks/src/env_detect.rs @@ -1,3 +1,4 @@ +use codex_client::build_reqwest_client_with_custom_ca; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use std::collections::HashMap; @@ -28,7 +29,7 @@ pub async fn autodetect_environment_id( ) -> anyhow::Result { // 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode) let origins = get_git_origins(); - crate::append_debug_log(format!("env: git origins: {origins:?}")); + crate::append_error_log(format!("env: git origins: {origins:?}")); let mut by_repo_envs: Vec = Vec::new(); for origin in &origins { if let Some((owner, repo)) = parse_owner_repo(origin) { @@ -43,16 +44,16 @@ pub async fn autodetect_environment_id( base_url, "github", owner, repo ) }; - crate::append_debug_log(format!("env: GET {url}")); + crate::append_error_log(format!("env: GET {url}")); match get_json::>(&url, headers).await { Ok(mut list) => { - crate::append_debug_log(format!( + crate::append_error_log(format!( "env: by-repo returned {} env(s) for {owner}/{repo}", list.len(), )); by_repo_envs.append(&mut list); } - Err(e) => crate::append_debug_log(format!( + Err(e) => crate::append_error_log(format!( "env: by-repo fetch failed for {owner}/{repo}: {e}" )), } @@ -71,9 +72,9 @@ pub async fn autodetect_environment_id( } else { format!("{base_url}/api/codex/environments") }; - crate::append_debug_log(format!("env: GET {list_url}")); + crate::append_error_log(format!("env: GET {list_url}")); // Fetch and log the full environments JSON for debugging - let http = reqwest::Client::builder().build()?; + let http = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let res = http.get(&list_url).headers(headers.clone()).send().await?; let status = res.status(); let ct = res @@ -83,13 +84,13 @@ pub async fn autodetect_environment_id( .unwrap_or("") .to_string(); let body = res.text().await.unwrap_or_default(); - crate::append_debug_log(format!("env: status={status} content-type={ct}")); + crate::append_error_log(format!("env: status={status} content-type={ct}")); match serde_json::from_str::(&body) { Ok(v) => { let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone()); - crate::append_debug_log(format!("env: /environments JSON (pretty):\n{pretty}")); + crate::append_error_log(format!("env: /environments JSON (pretty):\n{pretty}")); } - Err(_) => crate::append_debug_log(format!("env: /environments (raw):\n{body}")), + Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{body}")), } if !status.is_success() { anyhow::bail!("GET {list_url} failed: {status}; content-type={ct}; body={body}"); @@ -119,16 +120,16 @@ fn pick_environment_row( .iter() .find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc) { - crate::append_debug_log(format!("env: matched by label: {label} -> {}", e.id)); + crate::append_error_log(format!("env: matched by label: {label} -> {}", e.id)); return Some(e.clone()); } } if envs.len() == 1 { - crate::append_debug_log("env: single environment available; selecting it"); + crate::append_error_log("env: single environment available; selecting it"); return Some(envs[0].clone()); } if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) { - crate::append_debug_log(format!("env: selecting pinned environment: {}", e.id)); + crate::append_error_log(format!("env: selecting pinned environment: {}", e.id)); return Some(e.clone()); } // Highest task_count as heuristic @@ -137,7 +138,7 @@ fn pick_environment_row( .max_by_key(|e| e.task_count.unwrap_or(0)) .or_else(|| envs.first()) { - crate::append_debug_log(format!("env: selecting by task_count/first: {}", e.id)); + crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id)); return Some(e.clone()); } None @@ -147,7 +148,7 @@ async fn get_json( url: &str, headers: &HeaderMap, ) -> anyhow::Result { - let http = reqwest::Client::builder().build()?; + let http = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let res = http.get(url).headers(headers.clone()).send().await?; let status = res.status(); let ct = res @@ -157,7 +158,7 @@ async fn get_json( .unwrap_or("") .to_string(); let body = res.text().await.unwrap_or_default(); - crate::append_debug_log(format!("env: status={status} content-type={ct}")); + crate::append_error_log(format!("env: status={status} content-type={ct}")); if !status.is_success() { anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}"); } @@ -228,7 +229,7 @@ fn parse_owner_repo(url: &str) -> Option<(String, String)> { let mut parts = rest.splitn(2, '/'); let owner = parts.next()?.to_string(); let repo = parts.next()?.to_string(); - crate::append_debug_log(format!("env: parsed SSH GitHub origin => {owner}/{repo}")); + crate::append_error_log(format!("env: parsed SSH GitHub origin => {owner}/{repo}")); return Some((owner, repo)); } // HTTPS or git protocol @@ -243,7 +244,7 @@ fn parse_owner_repo(url: &str) -> Option<(String, String)> { let mut parts = rest.splitn(2, '/'); let owner = parts.next()?.to_string(); let repo = parts.next()?.to_string(); - crate::append_debug_log(format!("env: parsed HTTP GitHub origin => {owner}/{repo}")); + crate::append_error_log(format!("env: parsed HTTP GitHub origin => {owner}/{repo}")); return Some((owner, repo)); } } diff --git a/code-rs/cloud-tasks/src/lib.rs b/code-rs/cloud-tasks/src/lib.rs index 0fa14acf71b..e8d6b545b50 100644 --- a/code-rs/cloud-tasks/src/lib.rs +++ b/code-rs/cloud-tasks/src/lib.rs @@ -1,44 +1,619 @@ mod app; mod cli; -pub mod env_detect; +pub(crate) mod env_detect; mod new_task; -pub mod scrollable_diff; +pub(crate) mod scrollable_diff; mod ui; -pub mod util; +pub(crate) mod util; pub use cli::Cli; +use anyhow::anyhow; +use chrono::Utc; +use codex_cloud_tasks_client::TaskStatus; +use codex_git_utils::current_branch_name; +use codex_git_utils::default_branch_name; +use codex_login::default_client::get_codex_user_agent; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use std::cmp::Ordering; use std::io::IsTerminal; +use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; -use unicode_segmentation::UnicodeSegmentation; +use supports_color::Stream as SupportStream; use tokio::sync::mpsc::UnboundedSender; use tracing::info; use tracing_subscriber::EnvFilter; -use code_tui::public_widgets::composer_input::ComposerAction; -use util::append_debug_log; use util::append_error_log; -use util::append_info_log; -use util::log_path_hint; +use util::format_relative_time; use util::set_user_agent_suffix; struct ApplyJob { - task_id: code_cloud_tasks_client::TaskId, + task_id: codex_cloud_tasks_client::TaskId, diff_override: Option, } -fn level_from_status(status: code_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { +struct BackendContext { + backend: Arc, + base_url: String, +} + +async fn init_backend(user_agent_suffix: &str) -> anyhow::Result { + #[cfg(debug_assertions)] + let use_mock = matches!( + std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), + Some("mock") | Some("MOCK") + ); + let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + + set_user_agent_suffix(user_agent_suffix); + + #[cfg(debug_assertions)] + if use_mock { + return Ok(BackendContext { + backend: Arc::new(codex_cloud_tasks_mock_client::MockClient), + base_url, + }); + } + + let ua = get_codex_user_agent(); + let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); + let style = if base_url.contains("/backend-api") { + "wham" + } else { + "codex-api" + }; + append_error_log(format!("startup: base_url={base_url} path_style={style}")); + + let auth_manager = util::load_auth_manager(Some(base_url.clone())).await; + let auth = match auth_manager.as_ref() { + Some(manager) => manager.auth().await, + None => None, + }; + let auth = match auth { + Some(auth) => auth, + None => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + }; + + if let Some(acc) = auth.get_account_id() { + append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); + } + + if !auth.uses_codex_backend() { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + + let auth_provider = codex_model_provider::auth_provider_from_auth(&auth); + http = http.with_auth_provider(auth_provider); + if let Some(acc) = auth.get_account_id() { + append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); + } + + Ok(BackendContext { + backend: Arc::new(http), + base_url, + }) +} + +#[async_trait::async_trait] +trait GitInfoProvider { + async fn default_branch_name(&self, path: &std::path::Path) -> Option; + + async fn current_branch_name(&self, path: &std::path::Path) -> Option; +} + +struct RealGitInfo; + +#[async_trait::async_trait] +impl GitInfoProvider for RealGitInfo { + async fn default_branch_name(&self, path: &std::path::Path) -> Option { + default_branch_name(path).await + } + + async fn current_branch_name(&self, path: &std::path::Path) -> Option { + current_branch_name(path).await + } +} + +async fn resolve_git_ref(branch_override: Option<&String>) -> String { + resolve_git_ref_with_git_info(branch_override, &RealGitInfo).await +} + +async fn resolve_git_ref_with_git_info( + branch_override: Option<&String>, + git_info: &impl GitInfoProvider, +) -> String { + if let Some(branch) = branch_override { + let branch = branch.trim(); + if !branch.is_empty() { + return branch.to_string(); + } + } + + if let Ok(cwd) = std::env::current_dir() { + if let Some(branch) = git_info.current_branch_name(&cwd).await { + branch + } else if let Some(branch) = git_info.default_branch_name(&cwd).await { + branch + } else { + "main".to_string() + } + } else { + "main".to_string() + } +} + +async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> { + let crate::cli::ExecCommand { + query, + environment, + branch, + attempts, + } = args; + let ctx = init_backend("codex_cloud_tasks_exec").await?; + let prompt = resolve_query_input(query)?; + let env_id = resolve_environment_id(&ctx, &environment).await?; + let git_ref = resolve_git_ref(branch.as_ref()).await; + let created = codex_cloud_tasks_client::CloudBackend::create_task( + &*ctx.backend, + &env_id, + &prompt, + &git_ref, + /*qa_mode*/ false, + attempts, + ) + .await?; + let url = util::task_url(&ctx.base_url, &created.id.0); + println!("{url}"); + Ok(()) +} + +async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow::Result { + let trimmed = requested.trim(); + if trimmed.is_empty() { + return Err(anyhow!("environment id must not be empty")); + } + let normalized = util::normalize_base_url(&ctx.base_url); + let headers = util::build_chatgpt_headers().await; + let environments = crate::env_detect::list_environments(&normalized, &headers).await?; + if environments.is_empty() { + return Err(anyhow!( + "no cloud environments are available for this workspace" + )); + } + + if let Some(row) = environments.iter().find(|row| row.id == trimmed) { + return Ok(row.id.clone()); + } + + let label_matches = environments + .iter() + .filter(|row| { + row.label + .as_deref() + .map(|label| label.eq_ignore_ascii_case(trimmed)) + .unwrap_or(false) + }) + .collect::>(); + match label_matches.as_slice() { + [] => Err(anyhow!( + "environment '{trimmed}' not found; run `codex cloud` to list available environments" + )), + [single] => Ok(single.id.clone()), + [first, rest @ ..] => { + let first_id = &first.id; + if rest.iter().all(|row| row.id == *first_id) { + Ok(first_id.clone()) + } else { + Err(anyhow!( + "environment label '{trimmed}' is ambiguous; run `codex cloud` to pick the desired environment id" + )) + } + } + } +} + +fn resolve_query_input(query_arg: Option) -> anyhow::Result { + match query_arg { + Some(q) if q != "-" => Ok(q), + maybe_dash => { + let force_stdin = matches!(maybe_dash.as_deref(), Some("-")); + if std::io::stdin().is_terminal() && !force_stdin { + return Err(anyhow!( + "no query provided. Pass one as an argument or pipe it via stdin." + )); + } + if !force_stdin { + eprintln!("Reading query from stdin..."); + } + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .map_err(|e| anyhow!("failed to read query from stdin: {e}"))?; + if buffer.trim().is_empty() { + return Err(anyhow!( + "no query provided via stdin (received empty input)." + )); + } + Ok(buffer) + } + } +} + +fn parse_task_id(raw: &str) -> anyhow::Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("task id must not be empty"); + } + let without_fragment = trimmed.split('#').next().unwrap_or(trimmed); + let without_query = without_fragment + .split('?') + .next() + .unwrap_or(without_fragment); + let id = without_query + .rsplit('/') + .next() + .unwrap_or(without_query) + .trim(); + if id.is_empty() { + anyhow::bail!("task id must not be empty"); + } + Ok(codex_cloud_tasks_client::TaskId(id.to_string())) +} + +#[derive(Clone, Debug)] +struct AttemptDiffData { + placement: Option, + created_at: Option>, + diff: String, +} + +fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering { + match (lhs.placement, rhs.placement) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => match (lhs.created_at, rhs.created_at) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }, + } +} + +async fn collect_attempt_diffs( + backend: &dyn codex_cloud_tasks_client::CloudBackend, + task_id: &codex_cloud_tasks_client::TaskId, +) -> anyhow::Result> { + let text = + codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?; + let mut attempts = Vec::new(); + if let Some(diff) = + codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await? + { + attempts.push(AttemptDiffData { + placement: text.attempt_placement, + created_at: None, + diff, + }); + } + if let Some(turn_id) = text.turn_id { + let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( + backend, + task_id.clone(), + turn_id, + ) + .await?; + for sibling in siblings { + if let Some(diff) = sibling.diff { + attempts.push(AttemptDiffData { + placement: sibling.attempt_placement, + created_at: sibling.created_at, + diff, + }); + } + } + } + attempts.sort_by(cmp_attempt); + if attempts.is_empty() { + anyhow::bail!( + "No diff available for task {}; it may still be running.", + task_id.0 + ); + } + Ok(attempts) +} + +fn select_attempt( + attempts: &[AttemptDiffData], + attempt: Option, +) -> anyhow::Result<&AttemptDiffData> { + if attempts.is_empty() { + anyhow::bail!("No attempts available"); + } + let desired = attempt.unwrap_or(1); + let idx = desired + .checked_sub(1) + .ok_or_else(|| anyhow!("attempt must be at least 1"))?; + if idx >= attempts.len() { + anyhow::bail!( + "Attempt {desired} not available; only {} attempt(s) found", + attempts.len() + ); + } + Ok(&attempts[idx]) +} + +fn task_status_label(status: &TaskStatus) -> &'static str { match status { - code_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, - code_cloud_tasks_client::ApplyStatus::Partial => app::ApplyResultLevel::Partial, - code_cloud_tasks_client::ApplyStatus::Error => app::ApplyResultLevel::Error, + TaskStatus::Pending => "PENDING", + TaskStatus::Ready => "READY", + TaskStatus::Applied => "APPLIED", + TaskStatus::Error => "ERROR", + } +} + +fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String { + if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 { + let base = "no diff"; + return if colorize { + base.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + base.to_string() + }; + } + let adds = summary.lines_added; + let dels = summary.lines_removed; + let files = summary.files_changed; + if colorize { + let adds_raw = format!("+{adds}"); + let adds_str = adds_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(); + let dels_raw = format!("-{dels}"); + let dels_str = dels_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(); + let bullet = "•" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let file_label = format!("file{}", if files == 1 { "" } else { "s" }) + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + format!("{adds_str}/{dels_str} {bullet} {files} {file_label}") + } else { + format!( + "+{adds}/-{dels} • {files} file{}", + if files == 1 { "" } else { "s" } + ) + } +} + +fn format_task_status_lines( + task: &codex_cloud_tasks_client::TaskSummary, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + let status = task_status_label(&task.status); + let status = if colorize { + match task.status { + TaskStatus::Ready => status + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(), + TaskStatus::Pending => status + .if_supports_color(Stream::Stdout, |t| t.magenta()) + .to_string(), + TaskStatus::Applied => status + .if_supports_color(Stream::Stdout, |t| t.blue()) + .to_string(), + TaskStatus::Error => status + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(), + } + } else { + status.to_string() + }; + lines.push(format!("[{status}] {}", task.title)); + let mut meta_parts = Vec::new(); + if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) { + if colorize { + meta_parts.push( + label + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(label.to_string()); + } + } else if let Some(id) = task.environment_id.as_deref() { + if colorize { + meta_parts.push( + id.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(id.to_string()); + } + } + let when = format_relative_time(now, task.updated_at); + meta_parts.push(if colorize { + when.as_str() + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + when + }); + let sep = if colorize { + " • " + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + " • ".to_string() + }; + lines.push(meta_parts.join(&sep)); + lines.push(summary_line(&task.summary, colorize)); + lines +} + +fn format_task_list_lines( + tasks: &[codex_cloud_tasks_client::TaskSummary], + base_url: &str, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + for (idx, task) in tasks.iter().enumerate() { + lines.push(util::task_url(base_url, &task.id.0)); + for line in format_task_status_lines(task, now, colorize) { + lines.push(format!(" {line}")); + } + if idx + 1 < tasks.len() { + lines.push(String::new()); + } + } + lines +} + +async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_status").await?; + let task_id = parse_task_id(&args.task_id)?; + let summary = + codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?; + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_status_lines(&summary, now, colorize) { + println!("{line}"); + } + if !matches!(summary.status, TaskStatus::Ready) { + std::process::exit(1); + } + Ok(()) +} + +async fn run_list_command(args: crate::cli::ListCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_list").await?; + let env_filter = if let Some(env) = args.environment { + Some(resolve_environment_id(&ctx, &env).await?) + } else { + None + }; + let page = codex_cloud_tasks_client::CloudBackend::list_tasks( + &*ctx.backend, + env_filter.as_deref(), + Some(args.limit), + args.cursor.as_deref(), + ) + .await?; + if args.json { + let tasks: Vec<_> = page + .tasks + .iter() + .map(|task| { + serde_json::json!({ + "id": task.id.0, + "url": util::task_url(&ctx.base_url, &task.id.0), + "title": task.title, + "status": task.status, + "updated_at": task.updated_at, + "environment_id": task.environment_id, + "environment_label": task.environment_label, + "summary": { + "files_changed": task.summary.files_changed, + "lines_added": task.summary.lines_added, + "lines_removed": task.summary.lines_removed, + }, + "is_review": task.is_review, + "attempt_total": task.attempt_total, + }) + }) + .collect(); + let payload = serde_json::json!({ + "tasks": tasks, + "cursor": page.cursor, + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if page.tasks.is_empty() { + println!("No tasks found."); + return Ok(()); + } + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_list_lines(&page.tasks, &ctx.base_url, now, colorize) { + println!("{line}"); + } + if let Some(cursor) = page.cursor { + let command = format!("codex cloud list --cursor='{cursor}'"); + if colorize { + println!( + "\nTo fetch the next page, run {}", + command.if_supports_color(Stream::Stdout, |text| text.cyan()) + ); + } else { + println!("\nTo fetch the next page, run {command}"); + } + } + Ok(()) +} + +async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_diff").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + print!("{}", selected.diff); + Ok(()) +} + +async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_apply").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + let outcome = codex_cloud_tasks_client::CloudBackend::apply_task( + &*ctx.backend, + task_id, + Some(selected.diff.clone()), + ) + .await?; + println!("{}", outcome.message); + if !matches!( + outcome.status, + codex_cloud_tasks_client::ApplyStatus::Success + ) { + std::process::exit(1); + } + Ok(()) +} + +fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { + match status { + codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, + codex_cloud_tasks_client::ApplyStatus::Partial => app::ApplyResultLevel::Partial, + codex_cloud_tasks_client::ApplyStatus::Error => app::ApplyResultLevel::Error, } } fn spawn_preflight( app: &mut app::App, - backend: &Arc, + backend: &Arc, tx: &UnboundedSender, frame_tx: &UnboundedSender, title: String, @@ -63,7 +638,7 @@ fn spawn_preflight( task_id, diff_override, } = job; - let result = code_cloud_tasks_client::CloudBackend::apply_task_preflight( + let result = codex_cloud_tasks_client::CloudBackend::apply_task_preflight( &*backend, task_id.clone(), diff_override, @@ -100,7 +675,7 @@ fn spawn_preflight( fn spawn_apply( app: &mut app::App, - backend: &Arc, + backend: &Arc, tx: &UnboundedSender, frame_tx: &UnboundedSender, job: ApplyJob, @@ -124,7 +699,7 @@ fn spawn_apply( task_id, diff_override, } = job; - let result = code_cloud_tasks_client::CloudBackend::apply_task( + let result = codex_cloud_tasks_client::CloudBackend::apply_task( &*backend, task_id.clone(), diff_override, @@ -153,12 +728,18 @@ fn spawn_apply( // (no standalone patch summarizer needed – UI displays raw diffs) /// Entry point for the `codex cloud` subcommand. -pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> anyhow::Result<()> { - // Non-interactive submit mode: used by the core agent runner to create a - // cloud task and return its id on stdout. - if let Some(crate::cli::Command::Submit(args)) = cli.cmd { - return run_submit(args).await; +pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { + if let Some(command) = cli.command { + return match command { + crate::cli::Command::Exec(args) => run_exec_command(args).await, + crate::cli::Command::Status(args) => run_status_command(args).await, + crate::cli::Command::List(args) => run_list_command(args).await, + crate::cli::Command::Apply(args) => run_apply_command(args).await, + crate::cli::Command::Diff(args) => run_diff_command(args).await, + }; } + let Cli { .. } = cli; + // Very minimal logging setup; mirrors other crates' pattern. let default_level = "error"; let _ = tracing_subscriber::fmt() @@ -172,82 +753,8 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any .try_init(); info!("Launching Cloud Tasks list UI"); - set_user_agent_suffix("code_cloud_tasks_tui"); - - // Default to online unless explicitly configured to use mock. - let use_mock = matches!( - std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), - Some("mock") | Some("MOCK") - ); - - let backend: Arc = if use_mock { - Arc::new(code_cloud_tasks_client::MockClient) - } else { - // Build an HTTP client against the configured (or default) base URL. - let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") - .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - let ua = code_core::default_client::get_code_user_agent(None); - let mut http = - code_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); - // Log which base URL and path style we're going to use. - let style = if base_url.contains("/backend-api") { - "wham" - } else { - "codex-api" - }; - append_info_log(format!("startup: base_url={base_url} path_style={style}")); - - // Require ChatGPT login (SWIC). Exit with a clear message if missing. - let _token = match code_core::config::find_code_home() - .ok() - .map(|home| { - code_login::AuthManager::new( - home, - code_login::AuthMode::ChatGPT, - code_core::default_client::DEFAULT_ORIGINATOR.to_string(), - ) - }) - .and_then(|am| am.auth()) - { - Some(auth) => { - // Log account context for debugging workspace selection. - if let Some(acc) = auth.get_account_id() { - append_info_log(format!("auth: mode=ChatGPT account_id={acc}")); - } - match auth.get_token().await { - Ok(t) if !t.is_empty() => { - // Attach token and ChatGPT-Account-Id header if available - http = http.with_bearer_token(t.clone()); - if let Some(acc) = auth - .get_account_id() - .or_else(|| util::extract_chatgpt_account_id(&t)) - { - append_info_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); - http = http.with_chatgpt_account_id(acc); - } - if auth.is_fedramp_account() { - append_info_log("auth: set X-OpenAI-Fedramp header: true".to_string()); - http = http.with_fedramp_routing_header(); - } - t - } - _ => { - eprintln!( - "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." - ); - std::process::exit(1); - } - } - } - None => { - eprintln!( - "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." - ); - std::process::exit(1); - } - }; - Arc::new(http) - }; + let BackendContext { backend, .. } = init_backend("codex_cloud_tasks_tui").await?; + let backend = backend; // Terminal setup use crossterm::ExecutableCommand; @@ -289,10 +796,10 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any .as_deref(), Some("1") | Some("true") | Some("TRUE") ); - append_info_log(format!( + append_error_log(format!( "startup: wham_force_internal={} ua={}", force_internal, - code_core::default_client::get_code_user_agent(None) + get_codex_user_agent() )); // Non-blocking initial load so the in-box spinner can animate app.status = "Loading tasks…".to_string(); @@ -319,7 +826,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let backend = Arc::clone(&backend); let tx = tx.clone(); tokio::spawn(async move { - let res = app::load_tasks(&*backend, None).await; + let res = app::load_tasks(&*backend, /*env*/ None).await; let _ = tx.send(app::AppEvent::TasksLoaded { env: None, result: res, @@ -353,7 +860,10 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let headers = util::build_chatgpt_headers().await; // Run autodetect. If it fails, we keep using "All". - let res = crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await; + let res = crate::env_detect::autodetect_environment_id( + &base_url, &headers, /*desired_label*/ None, + ) + .await; let _ = tx.send(app::AppEvent::EnvironmentAutodetected(res)); }); } @@ -417,19 +927,24 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any if let Some(page) = app.new_task.as_mut() { if page.composer.flush_paste_burst_if_due() { needs_redraw = true; } if page.composer.is_in_paste_burst() { - let _ = frame_tx.send(Instant::now() + code_tui::ComposerInput::recommended_flush_delay()); + let _ = frame_tx + .send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); } } - // Advance throbber only while loading. + // Keep spinner pulsing only while loading. if app.refresh_inflight || app.details_inflight || app.env_loading || app.apply_preflight_inflight || app.apply_inflight { - app.throbber.calc_next(); + if app.spinner_start.is_none() { + app.spinner_start = Some(Instant::now()); + } needs_redraw = true; - let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + let _ = frame_tx.send(Instant::now() + Duration::from_millis(600)); + } else { + app.spinner_start = None; } render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; } @@ -439,7 +954,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any app::AppEvent::TasksLoaded { env, result } => { // Only apply results for the current filter to avoid races. if env.as_deref() != app.env_filter.as_deref() { - append_debug_log(format!( + append_error_log(format!( "refresh.drop: env={} current={}", env.clone().unwrap_or_else(|| "".to_string()), app.env_filter.clone().unwrap_or_else(|| "".to_string()) @@ -449,7 +964,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any app.refresh_inflight = false; match result { Ok(tasks) => { - append_debug_log(format!( + append_error_log(format!( "refresh.apply: env={} count={}", env.clone().unwrap_or_else(|| "".to_string()), tasks.len() @@ -469,7 +984,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any app::AppEvent::NewTaskSubmitted(result) => { match result { Ok(created) => { - append_info_log(format!("new-task: created id={}", created.id.0)); + append_error_log(format!("new-task: created id={}", created.id.0)); app.status = format!("Submitted as {}", created.id.0); app.new_task = None; // Refresh tasks in background for current filter @@ -489,12 +1004,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any Err(msg) => { append_error_log(format!("new-task: submit failed: {msg}")); if let Some(page) = app.new_task.as_mut() { page.submitting = false; } - if let Some(log_hint) = log_path_hint() { - app.status = - format!("Submit failed: {msg}. See {log_hint} for details."); - } else { - app.status = format!("Submit failed: {msg}."); - } + app.status = format!("Submit failed: {msg}. See error.log for details."); needs_redraw = true; let _ = frame_tx.send(Instant::now()); } @@ -535,7 +1045,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any if let Ok(sel) = result { // Only apply if user hasn't set a filter yet or it's different. if app.env_filter.as_deref() != Some(sel.id.as_str()) { - append_info_log(format!( + append_error_log(format!( "env.select: autodetected id={} label={}", sel.id, sel.label.clone().unwrap_or_else(|| "".to_string()) @@ -598,7 +1108,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any ov.base_can_apply = true; ov.apply_selection_to_fields(); } else { - let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + let mut overlay = app::DiffOverlay::new(id.clone(), title, /*attempt_total_hint*/ None); { let base = overlay.base_attempt_mut(); base.diff_lines = diff_lines.clone(); @@ -651,7 +1161,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let tx = tx.clone(); let task_id = id.clone(); tokio::spawn(async move { - match code_cloud_tasks_client::CloudBackend::list_sibling_attempts( + match codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( &*backend, task_id.clone(), turn_id, @@ -671,7 +1181,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any }); } } else { - let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + let mut overlay = app::DiffOverlay::new(id.clone(), title, /*attempt_total_hint*/ None); { let base = overlay.base_attempt_mut(); base.text_lines = conv.clone(); @@ -709,7 +1219,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any .as_ref() .map(|d| d.lines().map(str::to_string).collect()) .unwrap_or_default(); - let text_lines = conversation_lines(None, &attempt.messages); + let text_lines = conversation_lines(/*prompt*/ None, &attempt.messages); ov.attempts.push(app::AttemptView { turn_id: Some(attempt.turn_id.clone()), status: attempt.status, @@ -756,7 +1266,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any ov.current_view = app::DetailView::Prompt; ov.apply_selection_to_fields(); } else { - let mut overlay = app::DiffOverlay::new(id.clone(), title, None); + let mut overlay = app::DiffOverlay::new(id.clone(), title, /*attempt_total_hint*/ None); { let base = overlay.base_attempt_mut(); base.text_lines = pretty; @@ -780,7 +1290,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any match result { Ok(outcome) => { app.status = outcome.message.clone(); - if matches!(outcome.status, code_cloud_tasks_client::ApplyStatus::Success) { + if matches!(outcome.status, codex_cloud_tasks_client::ApplyStatus::Success) { app.apply_modal = None; app.diff_overlay = None; // Refresh tasks after successful apply @@ -921,7 +1431,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any if let Some(page) = app.new_task.as_mut() { page.best_of_n = new_value; } - append_info_log(format!("best-of.select: attempts={new_value}")); + append_error_log(format!("best-of.select: attempts={new_value}")); app.status = format!( "Best-of updated to {new_value} attempt{}", if new_value == 1 { "" } else { "s" } @@ -942,7 +1452,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any // Close task modal/pending apply if present before opening env modal app.diff_overlay = None; app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); - // Cache environments until user explicitly refreshes with 'r' inside the modal. + // Cache environments while the modal is open to avoid repeated fetches. let should_fetch = app.environments.is_empty(); if should_fetch { app.env_loading = true; @@ -979,10 +1489,12 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any _ => { if page.submitting { // Ignore input while submitting - } else if let ComposerAction::Submitted(text) = page.composer.input(key) { + } else if let codex_tui::ComposerAction::Submitted(text) = + page.composer.input(key) + { // Submit only if we have an env id if let Some(env) = page.env_id.clone() { - append_debug_log(format!( + append_error_log(format!( "new-task: submit env={} size={}", env, text.chars().count() @@ -993,7 +1505,9 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let backend = Arc::clone(&backend); let best_of_n = page.best_of_n; tokio::spawn(async move { - let result = code_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, "main", false, best_of_n).await; + let git_ref = resolve_git_ref(/*branch_override*/ None).await; + + let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, &git_ref, /*qa_mode*/ false, best_of_n).await; let evt = match result { Ok(ok) => app::AppEvent::NewTaskSubmitted(Ok(ok)), Err(e) => app::AppEvent::NewTaskSubmitted(Err(format!("{e}"))), @@ -1001,13 +1515,16 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let _ = tx.send(evt); }); } else { - app.status = "No environment selected (press 'e' to choose)".to_string(); + app.status = "No environment selected".to_string(); } } needs_redraw = true; // If paste‑burst is active, schedule a micro‑flush frame. if page.composer.is_in_paste_burst() { - let _ = frame_tx.send(Instant::now() + code_tui::ComposerInput::recommended_flush_delay()); + let _ = frame_tx.send( + Instant::now() + + codex_tui::ComposerInput::recommended_flush_delay(), + ); } // Always schedule an immediate redraw for key edits in the composer. let _ = frame_tx.send(Instant::now()); @@ -1073,7 +1590,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let total = ov.attempt_display_total(); let current = ov.selected_attempt + 1; app.status = format!("Viewing attempt {current} of {total}"); - ov.sd.to_top(); + ov.sd.scroll_to_top(); needs_redraw = true; } }; @@ -1149,7 +1666,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; if has_text && has_diff { ov.set_view(app::DetailView::Prompt); - ov.sd.to_top(); + ov.sd.scroll_to_top(); needs_redraw = true; } } @@ -1160,7 +1677,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; if has_text && has_diff { ov.set_view(app::DetailView::Diff); - ov.sd.to_top(); + ov.sd.scroll_to_top(); needs_redraw = true; } } @@ -1176,11 +1693,11 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any needs_redraw = true; } KeyCode::Down | KeyCode::Char('j') => { - if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(1); } + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(/*delta*/ 1); } needs_redraw = true; } KeyCode::Up | KeyCode::Char('k') => { - if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(-1); } + if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_by(/*delta*/ -1); } needs_redraw = true; } KeyCode::PageDown | KeyCode::Char(' ') => { @@ -1191,40 +1708,19 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(-step); } needs_redraw = true; } - KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_top(); } needs_redraw = true; } - KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_bottom(); } needs_redraw = true; } + KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_to_top(); } needs_redraw = true; } + KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_to_bottom(); } needs_redraw = true; } _ => {} } } else if app.env_modal.is_some() { // Environment modal key handling match key.code { KeyCode::Esc => { app.env_modal = None; needs_redraw = true; } - KeyCode::Char('r') | KeyCode::Char('R') => { - // Trigger refresh of environments - app.env_loading = true; app.env_error = None; needs_redraw = true; - let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); - let tx = tx.clone(); - tokio::spawn(async move { - let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); - let headers = crate::util::build_chatgpt_headers().await; - let res = crate::env_detect::list_environments(&base_url, &headers).await; - let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); - }); - } KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } needs_redraw = true; } - KeyCode::Backspace => { - if let Some(m) = app.env_modal.as_mut() { - if let Some((idx, _)) = m.query.grapheme_indices(true).last() { - m.query.truncate(idx); - } else { - m.query.clear(); - } - } - needs_redraw = true; - } + KeyCode::Backspace => { if let Some(m) = app.env_modal.as_mut() { m.query.pop(); } needs_redraw = true; } KeyCode::Down | KeyCode::Char('j') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_add(1); } needs_redraw = true; } KeyCode::Up | KeyCode::Char('k') => { if let Some(m) = app.env_modal.as_mut() { m.selected = m.selected.saturating_sub(1); } needs_redraw = true; } KeyCode::Home => { if let Some(m) = app.env_modal.as_mut() { m.selected = 0; } needs_redraw = true; } @@ -1233,7 +1729,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any KeyCode::PageUp => { if let Some(m) = app.env_modal.as_mut() { let step = 10usize; m.selected = m.selected.saturating_sub(step); } needs_redraw = true; } KeyCode::Char('n') => { if app.env_filter.is_none() { - app.new_task = Some(crate::new_task::NewTaskPage::new(None, app.best_of_n)); + app.new_task = Some(crate::new_task::NewTaskPage::new(/*env_id*/ None, app.best_of_n)); } else { app.new_task = Some(crate::new_task::NewTaskPage::new(app.env_filter.clone(), app.best_of_n)); } @@ -1254,11 +1750,11 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any }).collect(); // Keep original order (already sorted) — no need to re-sort let idx = state.selected; - if idx == 0 { app.env_filter = None; append_info_log("env.select: All"); } + if idx == 0 { app.env_filter = None; append_error_log("env.select: All"); } else { let env_idx = idx.saturating_sub(1); if let Some(row) = filtered.get(env_idx) { - append_info_log(format!( + append_error_log(format!( "env.select: id={} label={}", row.id, row.label.clone().unwrap_or_else(|| "".to_string()) @@ -1305,7 +1801,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any // Ensure 'r' does not refresh tasks when the env modal is open. KeyCode::Char('r') | KeyCode::Char('R') => { if app.env_modal.is_some() { break 0; } - append_debug_log(format!( + append_error_log(format!( "refresh.request: env={}", app.env_filter.clone().unwrap_or_else(|| "".to_string()) )); @@ -1326,7 +1822,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any } KeyCode::Char('o') | KeyCode::Char('O') => { app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); - // Cache environments until user explicitly refreshes with 'r' inside the modal. + // Cache environments while the modal is open to avoid repeated fetches. let should_fetch = app.environments.is_empty(); if should_fetch { app.env_loading = true; app.env_error = None; } needs_redraw = true; @@ -1367,12 +1863,12 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let diff_id = id.clone(); let diff_title = title.clone(); tokio::spawn(async move { - match code_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, diff_id.clone()).await { + match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, diff_id.clone()).await { Ok(Some(diff)) => { let _ = tx.send(app::AppEvent::DetailsDiffLoaded { id: diff_id, title: diff_title, diff }); } Ok(None) => { - match code_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { Ok(text) => { let evt = app::AppEvent::DetailsMessagesLoaded { id: diff_id, @@ -1393,7 +1889,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any } Err(e) => { append_error_log(format!("get_task_diff failed for {}: {e}", diff_id.0)); - match code_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { + match codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, diff_id.clone()).await { Ok(text) => { let evt = app::AppEvent::DetailsMessagesLoaded { id: diff_id, @@ -1422,7 +1918,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any let msg_id = id; let msg_title = title; tokio::spawn(async move { - if let Ok(text) = code_cloud_tasks_client::CloudBackend::get_task_text(&*backend, msg_id.clone()).await { + if let Ok(text) = codex_cloud_tasks_client::CloudBackend::get_task_text(&*backend, msg_id.clone()).await { let evt = app::AppEvent::DetailsMessagesLoaded { id: msg_id, title: msg_title, @@ -1449,7 +1945,7 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any } if let Some(task) = app.tasks.get(app.selected).cloned() { - match code_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await { + match codex_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task.id.clone()).await { Ok(Some(diff)) => { let diff_override = Some(diff.clone()); let task_id = task.id.clone(); @@ -1519,163 +2015,6 @@ pub async fn run_main(cli: Cli, _code_linux_sandbox_exe: Option) -> any Ok(()) } -// Lightweight non-interactive submit implementation. Accepts a prompt and -// optional env/best-of/qa/git-ref and prints only the created task id. -async fn run_submit(args: crate::cli::SubmitArgs) -> anyhow::Result<()> { - set_user_agent_suffix("code_cloud_tasks_submit"); - - let use_mock = matches!( - std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), - Some("mock") | Some("MOCK") - ); - - let backend: Arc = if use_mock { - Arc::new(code_cloud_tasks_client::MockClient) - } else { - let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") - .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - let ua = code_core::default_client::get_code_user_agent(None); - let mut http = code_cloud_tasks_client::HttpClient::new(base_url.clone())? - .with_user_agent(ua); - - // Attach ChatGPT auth (required in production) - let _token = match code_core::config::find_code_home() - .ok() - .map(|home| { - code_login::AuthManager::new( - home, - code_login::AuthMode::ChatGPT, - code_core::default_client::DEFAULT_ORIGINATOR.to_string(), - ) - }) - .and_then(|am| am.auth()) - { - Some(auth) => match auth.get_token().await { - Ok(t) if !t.is_empty() => { - http = http.with_bearer_token(t.clone()); - if let Some(acc) = auth - .get_account_id() - .or_else(|| util::extract_chatgpt_account_id(&t)) - { - http = http.with_chatgpt_account_id(acc); - } - t - } - _ => { - eprintln!("Not signed in. Run 'codex login' and retry."); - std::process::exit(1); - } - }, - None => { - eprintln!("Not signed in. Run 'codex login' and retry."); - std::process::exit(1); - } - }; - Arc::new(http) - }; - - // Resolve target environment id - let env_id = if let Some(e) = args.env.clone() { e } else { - let base_url = util::normalize_base_url( - &std::env::var("CODEX_CLOUD_TASKS_BASE_URL") - .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), - ); - let headers = util::build_chatgpt_headers().await; - match crate::env_detect::autodetect_environment_id(&base_url, &headers, None).await { - Ok(sel) => sel.id, - Err(_) => { - eprintln!("Failed to auto-detect environment. Provide --env ."); - std::process::exit(1); - } - } - }; - - // Create the task - let created = code_cloud_tasks_client::CloudBackend::create_task( - &*backend, - &env_id, - &args.prompt, - &args.git_ref, - args.qa, - args.best_of, - ) - .await - .map_err(|e| anyhow::anyhow!("create_task failed: {e}"))?; - - // If not waiting, print id and exit quickly - if !args.wait { - println!("{}", created.id.0); - return Ok(()); - } - - // Poll for completion and output a friendly summary when done - use tokio::time::{sleep, Duration}; - eprintln!("Created task {}; waiting for completion…", created.id.0); - let task_id = created.id; - let mut seen_msgs = 0usize; - loop { - let text = code_cloud_tasks_client::CloudBackend::get_task_text(&*backend, task_id.clone()) - .await - .unwrap_or_default(); - - // Emit progress hints to stderr so stdout remains the final result only - if text.messages.len() > seen_msgs { - let new = text.messages.len() - seen_msgs; - seen_msgs = text.messages.len(); - eprintln!("progress: +{} message(s)", new); - } - - use code_cloud_tasks_client::AttemptStatus as S; - match text.attempt_status { - S::Completed => { - // Try to get a diff snapshot if available - let diff_opt = code_cloud_tasks_client::CloudBackend::get_task_diff(&*backend, task_id.clone()) - .await - .ok() - .flatten(); - - // Build final output as plain text for the agent result - let mut out = String::new(); - out.push_str(&format!("Cloud task completed: {}\n\n", task_id.0)); - if let Some(p) = text.prompt.as_deref() { - out.push_str(&format!("Prompt:\n{}\n\n", p.trim())); - } - if !text.messages.is_empty() { - out.push_str("Assistant Messages:\n"); - // Safe formatting of assistant messages without NULs - for (i, m) in text.messages.iter().enumerate() { - out.push_str(&format!("{}. ", i + 1)); - out.push_str(m.trim()); - out.push_str("\n\n"); - } - // (legacy NUL-containing formatter removed) - } - match diff_opt { - Some(diff) => { - out.push_str("Diff:\n"); - out.push_str(&diff); - out.push_str("\n"); - } - None => out.push_str("No diff available.\n"), - } - // Sanitize any embedded NULs that could corrupt downstream consumers. - let out = out.replace('\0', ""); - print!("{}", out); - return Ok(()); - } - S::Failed => { - anyhow::bail!("Task failed: {}", task_id.0); - } - S::Cancelled => { - anyhow::bail!("Task cancelled: {}", task_id.0); - } - _ => { - sleep(Duration::from_secs(3)).await; - } - } - } -} - // extract_chatgpt_account_id moved to util.rs /// Build plain-text conversation lines: a labeled user prompt followed by assistant messages. @@ -1787,15 +2126,250 @@ fn pretty_lines_from_error(raw: &str) -> Vec { #[cfg(test)] mod tests { - use code_tui::public_widgets::composer_input::ComposerAction; - use code_tui::ComposerInput; + use super::*; + use crate::resolve_git_ref_with_git_info; + use codex_cloud_tasks_client::DiffSummary; + use codex_cloud_tasks_client::TaskId; + use codex_cloud_tasks_client::TaskStatus; + use codex_cloud_tasks_client::TaskSummary; + use codex_cloud_tasks_mock_client::MockClient; + use codex_tui::ComposerAction; + use codex_tui::ComposerInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + struct StubGitInfo { + default_branch: Option, + current_branch: Option, + } + + impl StubGitInfo { + fn new(default_branch: Option, current_branch: Option) -> Self { + Self { + default_branch, + current_branch, + } + } + } + + #[async_trait::async_trait] + impl super::GitInfoProvider for StubGitInfo { + async fn default_branch_name(&self, _path: &std::path::Path) -> Option { + self.default_branch.clone() + } + + async fn current_branch_name(&self, _path: &std::path::Path) -> Option { + self.current_branch.clone() + } + } + + #[tokio::test] + async fn branch_override_is_used_when_provided() { + let git_ref = resolve_git_ref_with_git_info( + Some(&"feature/override".to_string()), + &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), + ) + .await; + + assert_eq!(git_ref, "feature/override"); + } + + #[tokio::test] + async fn trims_override_whitespace() { + let git_ref = resolve_git_ref_with_git_info( + Some(&" feature/spaces ".to_string()), + &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), + ) + .await; + + assert_eq!(git_ref, "feature/spaces"); + } + + #[tokio::test] + async fn prefers_current_branch_when_available() { + let git_ref = resolve_git_ref_with_git_info( + /*branch_override*/ None, + &StubGitInfo::new( + Some("default-main".to_string()), + Some("feature/current".to_string()), + ), + ) + .await; + + assert_eq!(git_ref, "feature/current"); + } + + #[tokio::test] + async fn falls_back_to_current_branch_when_default_is_missing() { + let git_ref = resolve_git_ref_with_git_info( + /*branch_override*/ None, + &StubGitInfo::new(/*default_branch*/ None, Some("develop".to_string())), + ) + .await; + + assert_eq!(git_ref, "develop"); + } + + #[tokio::test] + async fn falls_back_to_main_when_no_git_info_is_available() { + let git_ref = resolve_git_ref_with_git_info( + /*branch_override*/ None, + &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), + ) + .await; + + assert_eq!(git_ref, "main"); + } + #[test] + fn format_task_status_lines_with_diff_and_label() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }; + let lines = format_task_status_lines(&task, now, /*colorize*/ false); + assert_eq!( + lines, + vec![ + "[READY] Example task".to_string(), + "Env • 0s ago".to_string(), + "+5/-2 • 3 files".to_string(), + ] + ); + } + + #[test] + fn format_task_status_lines_without_diff_falls_back() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }; + let lines = format_task_status_lines(&task, now, /*colorize*/ false); + assert_eq!( + lines, + vec![ + "[PENDING] No diff task".to_string(), + "env-2 • 0s ago".to_string(), + "no diff".to_string(), + ] + ); + } + + #[test] + fn format_task_list_lines_formats_urls() { + let now = Utc::now(); + let tasks = vec![ + TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }, + TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }, + ]; + let lines = format_task_list_lines( + &tasks, + "https://chatgpt.com/backend-api", + now, + /*colorize*/ false, + ); + assert_eq!( + lines, + vec![ + "https://chatgpt.com/codex/tasks/task_1".to_string(), + " [READY] Example task".to_string(), + " Env • 0s ago".to_string(), + " +5/-2 • 3 files".to_string(), + String::new(), + "https://chatgpt.com/codex/tasks/task_2".to_string(), + " [PENDING] No diff task".to_string(), + " env-2 • 0s ago".to_string(), + " no diff".to_string(), + ] + ); + } + + #[tokio::test] + async fn collect_attempt_diffs_includes_sibling_attempts() { + let backend = MockClient; + let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id"); + let attempts = collect_attempt_diffs(&backend, &task_id) + .await + .expect("attempts"); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0].placement, Some(0)); + assert_eq!(attempts[1].placement, Some(1)); + assert!(!attempts[0].diff.is_empty()); + assert!(!attempts[1].diff.is_empty()); + } + + #[test] + fn select_attempt_validates_bounds() { + let attempts = vec![AttemptDiffData { + placement: Some(0), + created_at: None, + diff: "diff --git a/file b/file\n".to_string(), + }]; + let first = select_attempt(&attempts, Some(1)).expect("attempt 1"); + assert_eq!(first.diff, "diff --git a/file b/file\n"); + assert!(select_attempt(&attempts, Some(2)).is_err()); + } + + #[test] + fn parse_task_id_from_url_and_raw() { + let raw = parse_task_id("task_i_abc123").expect("raw id"); + assert_eq!(raw.0, "task_i_abc123"); + let url = + parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id"); + assert_eq!(url.0, "task_i_123456"); + assert!(parse_task_id(" ").is_err()); + } + + #[test] + #[ignore = "very slow"] fn composer_input_renders_typed_characters() { let mut composer = ComposerInput::new(); let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); @@ -1812,9 +2386,14 @@ mod tests { assert!(found, "typed character was not rendered: {buf:?}"); composer.set_hint_items(vec![("⌃O", "env"), ("⌃C", "quit")]); - assert!(composer.has_custom_hint_items()); - - composer.clear_hint_items(); - assert!(!composer.has_custom_hint_items()); + composer.render_ref(area, &mut buf); + let footer = buf + .content() + .iter() + .skip((area.width as usize) * (area.height as usize - 1)) + .map(ratatui::buffer::Cell::symbol) + .collect::>() + .join(""); + assert!(footer.contains("⌃O env")); } } diff --git a/code-rs/cloud-tasks/src/new_task.rs b/code-rs/cloud-tasks/src/new_task.rs index 38fd5cdbf34..8708bd62fb6 100644 --- a/code-rs/cloud-tasks/src/new_task.rs +++ b/code-rs/cloud-tasks/src/new_task.rs @@ -1,4 +1,4 @@ -use code_tui::ComposerInput; +use codex_tui::ComposerInput; pub struct NewTaskPage { pub composer: ComposerInput, @@ -30,6 +30,6 @@ impl NewTaskPage { impl Default for NewTaskPage { fn default() -> Self { - Self::new(None, 1) + Self::new(/*env_id*/ None, /*best_of_n*/ 1) } } diff --git a/code-rs/cloud-tasks/src/scrollable_diff.rs b/code-rs/cloud-tasks/src/scrollable_diff.rs index 97dfb248958..59dd076b36d 100644 --- a/code-rs/cloud-tasks/src/scrollable_diff.rs +++ b/code-rs/cloud-tasks/src/scrollable_diff.rs @@ -86,11 +86,11 @@ impl ScrollableDiff { self.scroll_by(delta); } - pub fn to_top(&mut self) { + pub fn scroll_to_top(&mut self) { self.state.scroll = 0; } - pub fn to_bottom(&mut self) { + pub fn scroll_to_bottom(&mut self) { self.state.scroll = self.max_scroll(); } diff --git a/code-rs/cloud-tasks/src/ui.rs b/code-rs/cloud-tasks/src/ui.rs index 975ad0eedf5..38f75e41abc 100644 --- a/code-rs/cloud-tasks/src/ui.rs +++ b/code-rs/cloud-tasks/src/ui.rs @@ -16,14 +16,14 @@ use ratatui::widgets::ListState; use ratatui::widgets::Padding; use ratatui::widgets::Paragraph; use std::sync::OnceLock; +use std::time::Instant; use crate::app::App; use crate::app::AttemptView; -use chrono::Local; -use chrono::Utc; -use code_cloud_tasks_client::AttemptStatus; -use code_cloud_tasks_client::TaskStatus; -use code_tui::render_markdown_text; +use crate::util::format_relative_time_now; +use codex_cloud_tasks_client::AttemptStatus; +use codex_cloud_tasks_client::TaskStatus; +use codex_tui::render_markdown_text; pub fn draw(frame: &mut Frame, app: &mut App) { let area = frame.area(); @@ -229,7 +229,7 @@ fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) { // In-box spinner during initial/refresh loads if app.refresh_inflight { - draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…"); + draw_centered_spinner(frame, inner, &mut app.spinner_start, "Loading tasks…"); } } @@ -291,7 +291,7 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { || app.apply_preflight_inflight || app.apply_inflight { - draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…"); + draw_inline_spinner(frame, top[1], &mut app.spinner_start, "Loading…"); } else { frame.render_widget(Clear, top[1]); } @@ -449,7 +449,12 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { .map(|o| o.sd.wrapped_lines().is_empty()) .unwrap_or(true); if app.details_inflight && raw_empty { - draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…"); + draw_centered_spinner( + frame, + content_area, + &mut app.spinner_start, + "Loading details…", + ); } else { let scroll = app .diff_overlay @@ -494,11 +499,11 @@ pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) { frame.render_widget(header, rows[0]); // Body: spinner while preflight/apply runs; otherwise show result message and path lists if app.apply_preflight_inflight { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Checking…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Checking…"); } else if app.apply_inflight { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Applying…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Applying…"); } else if m.result_message.is_none() { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Loading…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Loading…"); } else if let Some(msg) = &m.result_message { let mut body_lines: Vec = Vec::new(); let first = match m.result_level { @@ -577,7 +582,10 @@ fn style_conversation_lines( speaker = Some(ConversationSpeaker::User); in_code = false; bullet_indent = None; - styled.push(conversation_header_line(ConversationSpeaker::User, None)); + styled.push(conversation_header_line( + ConversationSpeaker::User, + /*attempt*/ None, + )); last_src = Some(src_idx); continue; } @@ -777,7 +785,7 @@ fn style_diff_line(raw: &str) -> Line<'static> { Line::from(vec![Span::raw(raw.to_string())]) } -fn render_task_item(_app: &App, t: &code_cloud_tasks_client::TaskSummary) -> ListItem<'static> { +fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> ListItem<'static> { let status = match t.status { TaskStatus::Ready => "READY".green(), TaskStatus::Pending => "PENDING".magenta(), @@ -798,7 +806,7 @@ fn render_task_item(_app: &App, t: &code_cloud_tasks_client::TaskSummary) -> Lis if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { meta.push(lbl.clone().dim()); } - let when = format_relative_time(t.updated_at).dim(); + let when = format_relative_time_now(t.updated_at).dim(); if !meta.is_empty() { meta.push(" ".into()); meta.push("•".dim()); @@ -835,53 +843,32 @@ fn render_task_item(_app: &App, t: &code_cloud_tasks_client::TaskSummary) -> Lis ListItem::new(vec![title, meta_line, sub, spacer]) } -fn format_relative_time(ts: chrono::DateTime) -> String { - let now = Utc::now(); - let mut secs = (now - ts).num_seconds(); - if secs < 0 { - secs = 0; - } - if secs < 60 { - return format!("{secs}s ago"); - } - let mins = secs / 60; - if mins < 60 { - return format!("{mins}m ago"); - } - let hours = mins / 60; - if hours < 24 { - return format!("{hours}h ago"); - } - let local = ts.with_timezone(&Local); - local.format("%b %e %H:%M").to_string() -} - fn draw_inline_spinner( frame: &mut Frame, area: Rect, - state: &mut throbber_widgets_tui::ThrobberState, + spinner_start: &mut Option, label: &str, ) { - use ratatui::style::Style; - use throbber_widgets_tui::BRAILLE_EIGHT; - use throbber_widgets_tui::Throbber; - use throbber_widgets_tui::WhichUse; - let w = Throbber::default() - .label(label) - .style(Style::default().cyan()) - .throbber_style(Style::default().magenta().bold()) - .throbber_set(BRAILLE_EIGHT) - .use_type(WhichUse::Spin); - frame.render_stateful_widget(w, area, state); + use ratatui::widgets::Paragraph; + let start = spinner_start.get_or_insert_with(Instant::now); + let blink_on = (start.elapsed().as_millis() / 600).is_multiple_of(2); + let dot = if blink_on { + "• ".into() + } else { + "◦ ".dim() + }; + let label = label.cyan(); + let line = Line::from(vec![dot, label]); + frame.render_widget(Paragraph::new(line), area); } fn draw_centered_spinner( frame: &mut Frame, area: Rect, - state: &mut throbber_widgets_tui::ThrobberState, + spinner_start: &mut Option, label: &str, ) { - // Center a 1xN throbber within the given rect + // Center a 1xN spinner within the given rect let rows = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -898,7 +885,7 @@ fn draw_centered_spinner( Constraint::Percentage(50), ]) .split(rows[1]); - draw_inline_spinner(frame, cols[1], state, label); + draw_inline_spinner(frame, cols[1], spinner_start, label); } // Styling helpers for diff rendering live inline where used. @@ -918,7 +905,12 @@ pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { let content = overlay_content(inner); if app.env_loading { - draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…"); + draw_centered_spinner( + frame, + content, + &mut app.spinner_start, + "Loading environments…", + ); return; } @@ -934,9 +926,7 @@ pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { // Subheader with usage hints (dim cyan) let subheader = Paragraph::new(Line::from( - "Type to search, Enter select, Esc cancel; r refresh" - .cyan() - .dim(), + "Type to search, Enter select, Esc cancel".cyan().dim(), )) .wrap(Wrap { trim: true }); frame.render_widget(subheader, rows[0]); diff --git a/code-rs/cloud-tasks/src/util.rs b/code-rs/cloud-tasks/src/util.rs index 78395ae8f54..9a5056aa668 100644 --- a/code-rs/cloud-tasks/src/util.rs +++ b/code-rs/cloud-tasks/src/util.rs @@ -1,216 +1,26 @@ -use base64::Engine as _; +use chrono::DateTime; +use chrono::Local; use chrono::Utc; use reqwest::header::HeaderMap; -use std::borrow::Cow; -use std::path::Path; -use std::path::PathBuf; -const CLOUD_TASKS_LOG_FILE: &str = "cloud-tasks.log"; -const CLOUD_TASKS_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; -const CLOUD_TASKS_LOG_BACKUPS: usize = 2; -const CLOUD_TASKS_LOG_MAX_MESSAGE_BYTES: usize = 64 * 1024; +use codex_core::config::Config; +use codex_login::AuthManager; -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum CloudLogLevel { - Off = 0, - Error = 1, - Info = 2, - Debug = 3, -} - -fn log_level_from_env() -> CloudLogLevel { - if let Ok(raw) = std::env::var("CODEX_CLOUD_TASKS_LOG_LEVEL") { - let value = raw.trim().to_ascii_lowercase(); - return match value.as_str() { - "off" | "none" | "0" => CloudLogLevel::Off, - "error" | "warn" | "1" => CloudLogLevel::Error, - "info" | "2" => CloudLogLevel::Info, - "debug" | "trace" | "3" => CloudLogLevel::Debug, - _ => CloudLogLevel::Error, - }; - } - - if env_truthy("CODE_SUBAGENT_DEBUG") || env_truthy("CODEX_CLOUD_TASKS_DEBUG") { - return CloudLogLevel::Debug; - } - - CloudLogLevel::Off -} - -fn env_truthy(key: &str) -> bool { - std::env::var(key) - .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) -} - -fn should_log(level: CloudLogLevel) -> bool { - let configured = log_level_from_env(); - configured != CloudLogLevel::Off && level <= configured -} - -fn user_home_dir() -> Option { - if let Ok(home) = std::env::var("HOME") { - return Some(PathBuf::from(home)); - } - if let Ok(home) = std::env::var("USERPROFILE") { - return Some(PathBuf::from(home)); - } - None -} - -fn resolve_log_path() -> Option { - if let Ok(path) = std::env::var("CODEX_CLOUD_TASKS_LOG_PATH") { - let trimmed = path.trim(); - if !trimmed.is_empty() { - return Some(PathBuf::from(trimmed)); - } - } - - let base = if let Ok(dir) = std::env::var("CODEX_CLOUD_TASKS_LOG_DIR") { - PathBuf::from(dir) - } else if let Ok(home) = std::env::var("CODE_HOME").or_else(|_| std::env::var("CODEX_HOME")) { - PathBuf::from(home).join("debug_logs") - } else if let Some(home) = user_home_dir() { - home.join(".code").join("debug_logs") - } else { - return None; - }; - - Some(base.join(CLOUD_TASKS_LOG_FILE)) -} - -fn ensure_parent_dir(path: &Path) { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } -} - -fn rotate_log_file(path: &Path, max_bytes: u64, backups: usize) { - if backups == 0 { - let _ = std::fs::remove_file(path); - return; - } - - let Ok(meta) = std::fs::metadata(path) else { - return; - }; - if meta.len() <= max_bytes { - return; - } - - let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { - return; - }; - let Some(dir) = path.parent() else { - return; - }; - - let lock_path = dir.join(format!("{file_name}.rotate.lock")); - let lock_file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&lock_path); - let Ok(_lock_file) = lock_file else { - return; - }; - - let Ok(meta) = std::fs::metadata(path) else { - let _ = std::fs::remove_file(&lock_path); - return; - }; - if meta.len() <= max_bytes { - let _ = std::fs::remove_file(&lock_path); - return; - } - - let oldest = dir.join(format!("{file_name}.{backups}")); - let _ = std::fs::remove_file(&oldest); - - if backups > 1 { - for idx in (1..backups).rev() { - let from = dir.join(format!("{file_name}.{idx}")); - let to = dir.join(format!("{file_name}.{}", idx + 1)); - let _ = std::fs::rename(&from, &to); - } - } - - let rotated = dir.join(format!("{file_name}.1")); - let _ = std::fs::copy(path, &rotated); - if let Ok(file) = std::fs::OpenOptions::new().write(true).open(path) { - let _ = file.set_len(0); - } - - let _ = std::fs::remove_file(&lock_path); -} - -fn truncate_message(message: &str, max_bytes: usize) -> Cow<'_, str> { - if message.len() <= max_bytes { - return Cow::Borrowed(message); - } - let bytes = message.as_bytes(); - let head = String::from_utf8_lossy(&bytes[..max_bytes]).to_string(); - Cow::Owned(format!("{head}\n...truncated...")) -} - -pub fn log_path_hint() -> Option { - if !logging_enabled() { - return None; +pub fn set_user_agent_suffix(suffix: &str) { + if let Ok(mut guard) = codex_login::default_client::USER_AGENT_SUFFIX.lock() { + guard.replace(suffix.to_string()); } - Some( - resolve_log_path() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| CLOUD_TASKS_LOG_FILE.to_string()), - ) -} - -pub fn logging_enabled() -> bool { - log_level_from_env() != CloudLogLevel::Off } pub fn append_error_log(message: impl AsRef) { - append_cloud_log(CloudLogLevel::Error, message.as_ref()); -} - -pub fn append_info_log(message: impl AsRef) { - append_cloud_log(CloudLogLevel::Info, message.as_ref()); -} - -pub fn append_debug_log(message: impl AsRef) { - append_cloud_log(CloudLogLevel::Debug, message.as_ref()); -} - -fn append_cloud_log(level: CloudLogLevel, message: &str) { - if !should_log(level) { - return; - } - let Some(path) = resolve_log_path() else { - return; - }; - - ensure_parent_dir(&path); - rotate_log_file(&path, CLOUD_TASKS_LOG_MAX_BYTES, CLOUD_TASKS_LOG_BACKUPS); - let ts = Utc::now().to_rfc3339(); - let level_label = match level { - CloudLogLevel::Error => "ERROR", - CloudLogLevel::Info => "INFO", - CloudLogLevel::Debug => "DEBUG", - CloudLogLevel::Off => return, - }; - let message = truncate_message(message, CLOUD_TASKS_LOG_MAX_MESSAGE_BYTES); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) .append(true) - .open(&path) + .open("error.log") { use std::io::Write as _; - let _ = writeln!(f, "[{ts}] {level_label} {message}"); - } -} - -pub fn set_user_agent_suffix(suffix: &str) { - if let Ok(mut guard) = code_core::default_client::USER_AGENT_SUFFIX.lock() { - guard.replace(suffix.to_string()); + let _ = writeln!(f, "[{ts}] {}", message.as_ref()); } } @@ -231,61 +41,77 @@ pub fn normalize_base_url(input: &str) -> String { base_url } -/// Extract the ChatGPT account id from a JWT token, when present. -pub fn extract_chatgpt_account_id(token: &str) -> Option { - let mut parts = token.split('.'); - let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => return None, - }; - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .ok()?; - let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; - v.get("https://api.openai.com/auth") - .and_then(|auth| auth.get("chatgpt_account_id")) - .and_then(|id| id.as_str()) - .map(str::to_string) +pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option { + // TODO: pass in cli overrides once cloud tasks properly support them. + let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?; + Some( + AuthManager::new( + config.codex_home.to_path_buf(), + /*enable_codex_api_key_env*/ false, + config.cli_auth_credentials_store_mode, + chatgpt_base_url.or(Some(config.chatgpt_base_url)), + ) + .await, + ) } /// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`, /// and optional `ChatGPT-Account-Id`. pub async fn build_chatgpt_headers() -> HeaderMap { - use reqwest::header::AUTHORIZATION; - use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; - set_user_agent_suffix("code_cloud_tasks_tui"); - let ua = code_core::default_client::get_code_user_agent(None); + set_user_agent_suffix("codex_cloud_tasks_tui"); + let ua = codex_login::default_client::get_codex_user_agent(); let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); - if let Ok(home) = code_core::config::find_code_home() { - let am = code_login::AuthManager::new( - home, - code_login::AuthMode::ChatGPT, - code_core::default_client::DEFAULT_ORIGINATOR.to_string(), - ); - if let Some(auth) = am.auth() - && let Ok(tok) = auth.get_token().await - && !tok.is_empty() - { - let v = format!("Bearer {tok}"); - if let Ok(hv) = HeaderValue::from_str(&v) { - headers.insert(AUTHORIZATION, hv); - } - if let Some(acc) = auth - .get_account_id() - .or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = HeaderValue::from_str(&acc) - { - headers.insert(name, hv); - } - } + if let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await + && let Some(auth) = am.auth().await + && auth.uses_codex_backend() + { + headers.extend(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()); } headers } + +/// Construct a browser-friendly task URL for the given backend base URL. +pub fn task_url(base_url: &str, task_id: &str) -> String { + let normalized = normalize_base_url(base_url); + if let Some(root) = normalized.strip_suffix("/backend-api") { + return format!("{root}/codex/tasks/{task_id}"); + } + if let Some(root) = normalized.strip_suffix("/api/codex") { + return format!("{root}/codex/tasks/{task_id}"); + } + if normalized.ends_with("/codex") { + return format!("{normalized}/tasks/{task_id}"); + } + format!("{normalized}/codex/tasks/{task_id}") +} + +pub fn format_relative_time(reference: DateTime, ts: DateTime) -> String { + let mut secs = (reference - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{secs}s ago"); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +pub fn format_relative_time_now(ts: DateTime) -> String { + format_relative_time(Utc::now(), ts) +} diff --git a/code-rs/cloud-tasks/tests/env_filter.rs b/code-rs/cloud-tasks/tests/env_filter.rs index e6b34513ea7..716e4f05ed8 100644 --- a/code-rs/cloud-tasks/tests/env_filter.rs +++ b/code-rs/cloud-tasks/tests/env_filter.rs @@ -1,22 +1,39 @@ -use code_cloud_tasks_client::CloudBackend; -use code_cloud_tasks_client::MockClient; +use codex_cloud_tasks_client::CloudBackend; +use codex_cloud_tasks_mock_client::MockClient; #[tokio::test] async fn mock_backend_varies_by_env() { let client = MockClient; - let root = CloudBackend::list_tasks(&client, None).await.unwrap(); + let root = CloudBackend::list_tasks( + &client, /*env*/ None, /*limit*/ None, /*cursor*/ None, + ) + .await + .unwrap() + .tasks; assert!(root.iter().any(|t| t.title.contains("Update README"))); - let a = CloudBackend::list_tasks(&client, Some("env-A")) - .await - .unwrap(); + let a = CloudBackend::list_tasks( + &client, + Some("env-A"), + /*limit*/ None, + /*cursor*/ None, + ) + .await + .unwrap() + .tasks; assert_eq!(a.len(), 1); assert_eq!(a[0].title, "A: First"); - let b = CloudBackend::list_tasks(&client, Some("env-B")) - .await - .unwrap(); + let b = CloudBackend::list_tasks( + &client, + Some("env-B"), + /*limit*/ None, + /*cursor*/ None, + ) + .await + .unwrap() + .tasks; assert_eq!(b.len(), 2); assert!(b[0].title.starts_with("B: ")); } diff --git a/code-rs/code-auto-drive-core/Cargo.toml b/code-rs/code-auto-drive-core/Cargo.toml deleted file mode 100644 index 6b44104c2b2..00000000000 --- a/code-rs/code-auto-drive-core/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "code-auto-drive-core" -edition = "2024" -version = { workspace = true } - -[lib] -name = "code_auto_drive_core" -path = "src/lib.rs" - -[features] -# Surface dev fault injection toggles for parity with the existing TUI feature flag. -dev-faults = ["dep:once_cell"] -test-helpers = [] - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -chrono = { workspace = true, features = ["serde"] } -code-common = { path = "../common", features = ["elapsed"] } -code-core = { path = "../core" } -code-app-server-protocol = { workspace = true } -code-git-tooling = { path = "../git-tooling" } -code-protocol = { path = "../protocol" } -futures = { workspace = true } -once_cell = { workspace = true, optional = true } -rand = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "time"] } -tokio-util = { workspace = true, features = ["rt"] } -tracing = { workspace = true, features = ["log"] } -uuid = { workspace = true } - -[dev-dependencies] -pretty_assertions = { workspace = true } -serde_json = { workspace = true } diff --git a/code-rs/code-auto-drive-core/src/auto_compact.rs b/code-rs/code-auto-drive-core/src/auto_compact.rs deleted file mode 100644 index c321ea31696..00000000000 --- a/code-rs/code-auto-drive-core/src/auto_compact.rs +++ /dev/null @@ -1,736 +0,0 @@ -use anyhow::{anyhow, Result}; -use futures::StreamExt; -use std::time::Duration; -use tokio::time::timeout; - -use code_core::codex::compact::{ - collect_compaction_snippets, - make_compaction_summary_message, - sanitize_items_for_compact, -}; -use code_core::model_family::{derive_default_model_family, find_family_for_model}; -use code_core::{content_items_to_text, ModelClient, Prompt, ResponseEvent, TextFormat}; -use code_protocol::models::{ - ContentItem, FunctionCallOutputBody, FunctionCallOutputContentItem, ResponseItem, -}; -const BYTES_PER_TOKEN: usize = 4; -const MAX_TRANSCRIPT_BYTES: usize = 32_000; -const MAX_COMMANDS_IN_SUMMARY: usize = 5; -const MAX_ACTION_LINES: usize = 5; -const SUMMARY_TIMEOUT_SECONDS: u64 = 45; - -pub(crate) struct CheckpointSummary { - pub message: ResponseItem, - pub text: String, -} - -pub(crate) fn compact_with_endpoint( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - conversation: &[ResponseItem], - model_slug: &str, - compact_prompt: &str, -) -> Result> { - let goal_marker = conversation - .iter() - .position(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user")) - .and_then(|idx| conversation.get(idx).cloned().map(|msg| (idx, msg))); - - let sanitized_input = sanitize_items_for_compact(conversation.to_vec()); - - let prompt_instructions = compact_prompt.trim(); - let mut compacted = runtime - .block_on(async { - timeout(Duration::from_secs(SUMMARY_TIMEOUT_SECONDS), async { - let mut prompt = Prompt::default(); - prompt.input = sanitized_input; - prompt.include_additional_instructions = false; - if !prompt_instructions.is_empty() { - prompt.base_instructions_override = Some(prompt_instructions.to_string()); - } - prompt.model_override = Some(model_slug.to_string()); - let family = find_family_for_model(model_slug) - .unwrap_or_else(|| derive_default_model_family(model_slug)); - prompt.model_family_override = Some(family); - prompt.set_log_tag("auto/remote-compact"); - client.compact_conversation_history(&prompt).await - }) - .await - }) - .map_err(|_| { - anyhow!( - "remote compaction request timed out after {SUMMARY_TIMEOUT_SECONDS}s" - ) - })??; - - if let Some((goal_idx, goal_item)) = goal_marker { - ensure_goal_is_present(&mut compacted, goal_item, goal_idx); - } - - Ok(compacted) -} - -fn ensure_goal_is_present( - conversation: &mut Vec, - goal_item: ResponseItem, - original_idx: usize, -) { - let Some(goal_item) = sanitize_items_for_compact(vec![goal_item]).into_iter().next() else { - return; - }; - let Some(goal_text) = message_text(&goal_item) else { - return; - }; - - let already_present = conversation.iter().any(|item| { - matches!(item, ResponseItem::Message { role, .. } if role == "user") - && message_text(item).as_deref() == Some(goal_text.as_str()) - }); - - if already_present { - return; - } - - let insert_at = original_idx.min(conversation.len()); - conversation.insert(insert_at, goal_item); -} - -fn message_text(item: &ResponseItem) -> Option { - let ResponseItem::Message { content, .. } = item else { - return None; - }; - let text = content_items_to_text(content)?; - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -pub(crate) fn compute_slice_bounds(conversation: &[ResponseItem]) -> Option<(usize, usize)> { - let goal_idx = conversation.iter().position(|item| { - matches!(item, ResponseItem::Message { role, .. } if role == "user") - })?; - - if conversation.len() <= goal_idx + 3 { - return None; - } - - let after_goal = &conversation[goal_idx + 1..]; - let token_counts: Vec = after_goal.iter().map(estimate_item_tokens).collect(); - let total_tokens: usize = token_counts.iter().sum(); - let mut midpoint = goal_idx + 1; - - if total_tokens > 0 { - let target = (total_tokens + 1) / 2; - let mut running = 0usize; - for (offset, count) in token_counts.iter().enumerate() { - running = running.saturating_add(*count); - if running >= target { - midpoint = goal_idx + 1 + offset; - break; - } - } - } else { - midpoint = goal_idx + 1 + (after_goal.len() + 1) / 2; - } - - let slice_start = goal_idx + 1; - let slice_end = advance_to_turn_boundary(conversation, midpoint + 1); - - if slice_end <= slice_start { - return None; - } - - Some((slice_start, slice_end)) -} - -pub(crate) fn apply_compaction( - conversation: &mut Vec, - bounds: (usize, usize), - prev_summary_text: Option<&str>, - summary_message: ResponseItem, -) -> Option<()> { - let goal_idx = conversation.iter().position(|item| { - matches!(item, ResponseItem::Message { role, .. } if role == "user") - })?; - - let (slice_start, slice_end) = bounds; - if slice_start <= goal_idx || slice_end > conversation.len() { - return None; - } - - let mut rebuilt = Vec::with_capacity(conversation.len() - (slice_end - slice_start) + 2); - rebuilt.extend_from_slice(&conversation[..=goal_idx]); - - if let Some(prev_text) = prev_summary_text.filter(|text| !text.trim().is_empty()) { - rebuilt.push(make_compaction_summary_message(&[], prev_text)); - } - - rebuilt.push(summary_message); - rebuilt.extend_from_slice(&conversation[slice_end..]); - *conversation = rebuilt; - Some(()) -} - -pub(crate) fn build_checkpoint_summary( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - model_slug: &str, - items: &[ResponseItem], - prev_summary: Option<&str>, - compact_prompt: &str, -) -> (CheckpointSummary, Option) { - let snippets = collect_compaction_snippets(items); - let mut warning: Option = None; - let summary_text = match summarize_with_model( - runtime, - client, - model_slug, - items, - prev_summary, - compact_prompt, - ) { - Ok(text) if !text.trim().is_empty() => text, - Ok(_) => deterministic_summary(items, prev_summary), - Err(err) => { - warning = Some(format!( - "checkpoint summary model request failed: {err:#}")); - deterministic_summary(items, prev_summary) - } - }; - - let message = make_compaction_summary_message(&snippets, &summary_text); - (CheckpointSummary { message, text: summary_text }, warning) -} - -fn summarize_with_model( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - model_slug: &str, - items: &[ResponseItem], - prev_summary: Option<&str>, - compact_prompt: &str, -) -> Result { - let mut aggregate_summary = prev_summary - .filter(|text| !text.trim().is_empty()) - .map(|text| text.to_string()); - - let flattened = flatten_items(items); - let chunks = chunk_text(&flattened); - if chunks.is_empty() { - return Err(anyhow!("empty transcript chunk")); - } - - for chunk in chunks { - if chunk.trim().is_empty() { - continue; - } - - let current_prev = aggregate_summary.as_deref(); - let summary = runtime.block_on(async { - timeout(Duration::from_secs(SUMMARY_TIMEOUT_SECONDS), async { - let mut prompt = Prompt::default(); - prompt.store = false; - prompt.text_format = Some(TextFormat { - r#type: "text".to_string(), - name: None, - strict: None, - schema: None, - }); - prompt.model_override = Some(model_slug.to_string()); - let family = find_family_for_model(model_slug) - .unwrap_or_else(|| derive_default_model_family(model_slug)); - prompt.model_family_override = Some(family); - - push_compaction_prompt(&mut prompt, compact_prompt); - - let mut user_text = String::new(); - if let Some(prev) = current_prev { - user_text.push_str("Previous checkpoint summary:\n"); - user_text.push_str(prev); - user_text.push_str("\n\n"); - } - user_text.push_str("Conversation slice:\n"); - user_text.push_str(&chunk); - - prompt.input.push(plain_message("user", user_text)); - - let mut stream = client.stream(&prompt).await?; - let mut collected = String::new(); - let mut response_items = Vec::new(); - - while let Some(event) = stream.next().await { - match event { - Ok(ResponseEvent::OutputTextDelta { delta, .. }) => { - collected.push_str(&delta) - } - Ok(ResponseEvent::OutputItemDone { item, .. }) => { - response_items.push(item); - } - Ok(ResponseEvent::Completed { .. }) => break, - Ok(_) => {} - Err(err) => return Err(anyhow!(err)), - } - } - - if let Some(message) = response_items.into_iter().find_map(|item| match item { - ResponseItem::Message { role, content, .. } if role == "assistant" => { - Some(content) - } - _ => None, - }) { - let mut text = String::new(); - for chunk in message { - if let ContentItem::OutputText { text: chunk_text } = chunk { - text.push_str(&chunk_text); - } - } - if !text.trim().is_empty() { - return Ok(text); - } - } - - Ok(collected) - }) - .await - }); - - let summary = match summary { - Ok(result) => result?, - Err(_) => { - return Err(anyhow!( - "checkpoint summary request timed out after {SUMMARY_TIMEOUT_SECONDS}s" - )); - } - }; - - if !summary.trim().is_empty() { - aggregate_summary = Some(summary); - } - } - - aggregate_summary.ok_or_else(|| anyhow!("empty summary")) -} - -fn deterministic_summary(items: &[ResponseItem], prev_summary: Option<&str>) -> String { - let mut actions = Vec::new(); - let mut commands = Vec::new(); - for item in items { - match item { - ResponseItem::Message { role, content, .. } => { - let text = content - .iter() - .filter_map(|chunk| match chunk { - ContentItem::InputText { text } - | ContentItem::OutputText { text } => Some(text.trim()), - _ => None, - }) - .collect::>() - .join(" "); - if text.is_empty() { - continue; - } - actions.push(format!("{}: {}", role, text)); - if role == "assistant" { - if let Some(cmd) = text.lines().find(|line| line.trim_start().starts_with('$')) { - commands.push(cmd.trim().to_string()); - } - } - } - ResponseItem::FunctionCall { name, .. } => { - actions.push(format!("Tool call: {name}")); - } - ResponseItem::FunctionCallOutput { output, .. } => { - if let Some(text) = output.body.to_text().filter(|text| !text.trim().is_empty()) { - actions.push(format!("Tool output: {text}")); - } - } - _ => {} - } - } - - let mut lines = Vec::new(); - if let Some(prev) = prev_summary.filter(|text| !text.trim().is_empty()) { - lines.push(format!("Building on previous checkpoint: {}", prev)); - } - lines.push(format!( - "Checkpoint covers {} exchanges and {} tool events.", - actions.len(), - items.iter().filter(|item| matches!(item, ResponseItem::FunctionCall { .. })).count() - )); - if !commands.is_empty() { - let display = commands - .into_iter() - .take(MAX_COMMANDS_IN_SUMMARY) - .collect::>() - .join(" | "); - lines.push(format!("Key commands: {}", display)); - } - if !actions.is_empty() { - let display = actions - .into_iter() - .take(MAX_ACTION_LINES) - .collect::>() - .join(" \n"); - lines.push(display); - } - lines.join("\n\n") -} - -fn flatten_items(items: &[ResponseItem]) -> String { - let mut buf = String::new(); - for item in items { - match item { - ResponseItem::Message { role, content, .. } => { - let text = content - .iter() - .filter_map(|chunk| match chunk { - ContentItem::InputText { text } - | ContentItem::OutputText { text } => Some(text.as_str()), - ContentItem::InputImage { .. } => Some(""), - }) - .collect::>() - .join(" "); - if text.is_empty() { - continue; - } - buf.push_str(&format!("{role}: {text}\n")); - } - ResponseItem::FunctionCall { name, arguments, .. } => { - buf.push_str(&format!("tool_call {name}: {arguments}\n")); - } - ResponseItem::FunctionCallOutput { output, .. } => { - let text = output.body.to_text().unwrap_or_default(); - if !text.trim().is_empty() { - buf.push_str(&format!("tool_output: {text}\n")); - } - } - ResponseItem::CustomToolCall { name, input, .. } => { - buf.push_str(&format!("custom_tool {name}: {input}\n")); - } - ResponseItem::CustomToolCallOutput { output, .. } => { - buf.push_str(&format!("custom_tool_output: {}\n", output)); - } - ResponseItem::Reasoning { summary, .. } => { - for item in summary { - match item { - code_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } => { - buf.push_str(&format!("reasoning: {text}\n")); - } - } - } - } - _ => {} - } - } - buf -} - -fn chunk_text(text: &str) -> Vec { - if text.is_empty() { - return Vec::new(); - } - - let mut chunks = Vec::new(); - let mut start = 0; - let len = text.len(); - while start < len { - let mut end = (start + MAX_TRANSCRIPT_BYTES).min(len); - if end < len { - while end > start && !text.is_char_boundary(end) { - end -= 1; - } - if end == start { - // The next character alone exceeds the byte budget; include it to make progress. - end = start - + text[start..] - .chars() - .next() - .map(|c| c.len_utf8()) - .unwrap_or(len - start); - } - } - - if end <= start { - break; - } - - let chunk = text[start..end].to_string(); - chunks.push(chunk); - start = end; - } - - chunks -} - -fn advance_to_turn_boundary(items: &[ResponseItem], start_idx: usize) -> usize { - let mut idx = start_idx; - while idx < items.len() { - if matches!(&items[idx], ResponseItem::Message { role, .. } if role == "user") { - break; - } - idx += 1; - } - idx -} - -pub(crate) fn estimate_item_tokens(item: &ResponseItem) -> usize { - let byte_count = match item { - ResponseItem::Message { content, .. } => content - .iter() - .map(|chunk| match chunk { - ContentItem::InputText { text } | ContentItem::OutputText { text } => text.len(), - ContentItem::InputImage { image_url } => image_url.len() / 10, - }) - .sum(), - ResponseItem::FunctionCall { name, arguments, .. } => name.len() + arguments.len(), - ResponseItem::FunctionCallOutput { output, .. } => match &output.body { - FunctionCallOutputBody::Text(text) => text.len(), - FunctionCallOutputBody::ContentItems(items) => items - .iter() - .map(|item| match item { - FunctionCallOutputContentItem::InputText { text } => text.len(), - FunctionCallOutputContentItem::InputImage { image_url, .. } => image_url.len() / 10, - }) - .sum(), - }, - ResponseItem::CustomToolCall { name, input, .. } => name.len() + input.len(), - ResponseItem::CustomToolCallOutput { output, .. } => output.to_string().len(), - ResponseItem::Reasoning { summary, content, .. } => { - summary - .iter() - .map(|s| match s { - code_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } => text.len(), - }) - .sum::() - + content - .as_ref() - .map(|segments| { - segments - .iter() - .map(|segment| match segment { - code_protocol::models::ReasoningItemContent::ReasoningText { text } - | code_protocol::models::ReasoningItemContent::Text { text } => text.len(), - }) - .sum::() - }) - .unwrap_or(0) - } - _ => 0, - }; - byte_count.div_ceil(BYTES_PER_TOKEN) -} - -fn plain_message(role: &str, text: String) -> ResponseItem { - ResponseItem::Message { - id: None, - role: role.to_string(), - content: vec![ContentItem::InputText { text }], - end_turn: None, - phase: None, - } -} - -fn push_compaction_prompt(prompt: &mut Prompt, compact_prompt: &str) { - prompt - .input - .push(plain_message("developer", compact_prompt.to_string())); -} - -#[cfg(test)] -mod tests { - use super::*; - use code_core::codex::compact::CompactionSnippet; - use code_core::content_items_to_text; - - fn user_message(text: &str) -> ResponseItem { - plain_message("user", text.to_string()) - } - - fn assistant_message(text: &str) -> ResponseItem { - plain_message("assistant", text.to_string()) - } - - fn system_message(text: &str) -> ResponseItem { - plain_message("system", text.to_string()) - } - - #[test] - fn computes_slice_bounds_midpoint() { - let conversation = vec![ - system_message("System"), - user_message("Goal"), - assistant_message("Step 1"), - user_message("Step 2"), - assistant_message("Step 2 done"), - user_message("Step 3"), - ]; - - let (start, end) = compute_slice_bounds(&conversation).expect("bounds"); - assert_eq!(start, 2); - assert_eq!(end, 5); - } - - #[test] - fn apply_compaction_preserves_goal() { - let mut conversation = vec![ - system_message("System"), - user_message("Goal"), - assistant_message("Old content"), - user_message("More content"), - assistant_message("Final"), - ]; - - let summary = make_compaction_summary_message( - &collect_compaction_snippets(&conversation), - "Summary", - ); - apply_compaction(&mut conversation, (2, 5), Some("Prev"), summary).expect("compaction"); - - assert_eq!(conversation.len(), 4); - assert!(matches!(&conversation[1], ResponseItem::Message { role, .. } if role == "user")); - assert!(matches!(&conversation[2], ResponseItem::Message { .. })); - } - - #[test] - fn apply_compaction_inserts_prev_summary() { - let mut conversation = vec![ - system_message("System"), - user_message("Goal"), - assistant_message("Old"), - user_message("Tail"), - ]; - - let summary = make_compaction_summary_message( - &collect_compaction_snippets(&conversation), - "New summary", - ); - apply_compaction(&mut conversation, (2, 4), Some("Prev summary"), summary).expect("compaction"); - - assert_eq!(conversation.len(), 4); - let prev = &conversation[2]; - if let ResponseItem::Message { content, .. } = prev { - let joined = content - .iter() - .filter_map(|chunk| match chunk { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - Some(text.as_str()) - } - _ => None, - }) - .collect::>() - .join(" "); - assert!(joined.contains("Prev summary")); - } else { - panic!("expected message"); - } - } - - #[test] - fn ensure_goal_reinserted_when_missing_after_compaction() { - let goal = user_message("Rewrite parser"); - let mut compacted = vec![assistant_message("Checkpoint summary")]; - - ensure_goal_is_present(&mut compacted, goal.clone(), 1); - - let goal_count = compacted - .iter() - .filter(|item| message_text(item).as_deref() == Some("Rewrite parser")) - .count(); - assert_eq!(goal_count, 1); - } - - #[test] - fn ensure_goal_not_duplicated_when_already_present() { - let goal = user_message("Rewrite parser"); - let mut compacted = vec![goal.clone(), assistant_message("Checkpoint summary")]; - - ensure_goal_is_present(&mut compacted, goal.clone(), 0); - - let goal_count = compacted - .iter() - .filter(|item| message_text(item).as_deref() == Some("Rewrite parser")) - .count(); - assert_eq!(goal_count, 1); - } - - #[test] - fn flatten_items_preserves_full_messages() { - let large = "a".repeat(MAX_TRANSCRIPT_BYTES * 2); - let items = vec![assistant_message(&large)]; - - let flattened = flatten_items(&items); - assert!(flattened.contains(&large[..32])); - assert!(flattened.contains(&large[large.len() - 32..])); - assert!(flattened.len() > MAX_TRANSCRIPT_BYTES); - } - - #[test] - fn chunk_text_consumes_entire_string() { - let text = "a".repeat(MAX_TRANSCRIPT_BYTES * 2 + 123); - let chunks = chunk_text(&text); - let reconstructed: String = chunks.concat(); - assert_eq!(reconstructed, text); - assert!(chunks.iter().all(|chunk| chunk.len() <= MAX_TRANSCRIPT_BYTES)); - } - - #[test] - fn chunk_text_respects_utf8_boundaries() { - let text = "🙂".repeat((MAX_TRANSCRIPT_BYTES / 4) + 10); - let chunks = chunk_text(&text); - assert!(!chunks.is_empty()); - for chunk in &chunks { - assert!(chunk.is_char_boundary(chunk.len())); - assert!(chunk.len() <= MAX_TRANSCRIPT_BYTES); - } - assert_eq!(chunks.concat(), text); - } - - #[test] - fn compaction_summary_message_includes_snippets() { - let snippets = vec![ - CompactionSnippet { - role: "user".to_string(), - text: "Investigate failing tests".to_string(), - }, - CompactionSnippet { - role: "assistant".to_string(), - text: "Analyzed logs and proposed fix".to_string(), - }, - ]; - let message = make_compaction_summary_message(&snippets, "Tests still red; patch script"); - let ResponseItem::Message { content, .. } = message else { - panic!("expected message response item"); - }; - let rendered = content_items_to_text(&content).expect("text content"); - assert!(rendered.contains("(user) Investigate failing tests")); - assert!(rendered.contains("Key takeaways")); - assert!(rendered.contains("Tests still red")); - } - - #[test] - fn push_compaction_prompt_inserts_override_text() { - let mut prompt = Prompt::default(); - push_compaction_prompt(&mut prompt, "Custom override text"); - - assert_eq!(prompt.input.len(), 1); - match &prompt.input[0] { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "developer"); - let body = content - .iter() - .filter_map(|chunk| match chunk { - ContentItem::InputText { text } => Some(text.as_str()), - ContentItem::OutputText { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(" "); - assert_eq!(body, "Custom override text"); - } - other => panic!("expected developer message, got {other:?}"), - } - } -} diff --git a/code-rs/code-auto-drive-core/src/auto_coordinator.rs b/code-rs/code-auto-drive-core/src/auto_coordinator.rs deleted file mode 100644 index 738124e87f0..00000000000 --- a/code-rs/code-auto-drive-core/src/auto_coordinator.rs +++ /dev/null @@ -1,4782 +0,0 @@ -use std::collections::VecDeque; -use std::process::Command; -use std::sync::mpsc::{self, Receiver, Sender}; -use std::sync::{Arc, Mutex}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant, SystemTime}; - -use anyhow::{anyhow, Context, Result}; -use code_core::config::Config; -use code_core::agent_defaults::{ - build_model_guide_description, - enabled_agent_model_specs_for_auth, - filter_agent_model_names_for_auth, -}; -use code_core::config_types::{ - AutoDriveModelRoutingEntry, - AutoDriveSettings, - ReasoningEffort, - TextVerbosity, -}; -use code_core::debug_logger::DebugLogger; -use code_core::codex::compact::resolve_compact_prompt_text; -use code_core::model_family::{derive_default_model_family, find_family_for_model}; -use code_core::project_doc::read_auto_drive_docs; -use code_core::protocol::SandboxPolicy; -use code_core::slash_commands::get_enabled_agents; -use code_core::{AuthManager, ModelClient, Prompt, ResponseEvent, TextFormat}; -use code_core::{RateLimitSwitchState, switch_active_account_on_rate_limit}; -use code_core::auth; -use code_core::auth_accounts; -use code_core::error::CodexErr; -use code_common::model_presets::clamp_reasoning_effort_for_model; -use code_protocol::models::{ContentItem, ReasoningItemContent, ResponseItem}; -use code_core::protocol::TokenUsage; -use futures::StreamExt; -use reqwest::StatusCode; -use serde::Deserialize; -use serde_json::{self, json, Value}; -use tokio_util::sync::CancellationToken; -use tracing::{debug, warn}; -use uuid::Uuid; - -use crate::auto_compact::{ - apply_compaction, - build_checkpoint_summary, - compact_with_endpoint, - compute_slice_bounds, - estimate_item_tokens, -}; -use crate::coordinator_user_schema::{parse_user_turn_reply, user_turn_schema}; -use crate::session_metrics::SessionMetrics; -use crate::retry::{retry_with_backoff, RetryDecision, RetryError, RetryOptions}; -#[cfg(feature = "dev-faults")] -use crate::faults::{fault_to_error, next_fault, FaultScope, InjectedFault}; -use code_common::elapsed::format_duration; -use chrono::{DateTime, Local, Utc}; -use rand::Rng; - -const RATE_LIMIT_BUFFER: Duration = Duration::from_secs(5); -const RATE_LIMIT_JITTER_MAX: Duration = Duration::from_secs(3); -const MAX_RETRY_ELAPSED: Duration = Duration::from_secs(7 * 24 * 60 * 60); -const MAX_DECISION_RECOVERY_ATTEMPTS: u32 = 3; -const MESSAGE_LIMIT_FALLBACK: usize = 120; -const HARD_MESSAGE_LIMIT: usize = 320; -const MAX_QUEUED_CONVERSATION_UPDATES: usize = 24; -const DEBUG_JSON_MAX_CHARS: usize = 1200; -const CLI_PROMPT_MIN_CHARS: usize = 4; -const CLI_PROMPT_MAX_CHARS: usize = 600; -const AUTO_DRIVE_CLI_MODEL_PRIMARY: &str = "gpt-5.5"; -const AUTO_DRIVE_CLI_MODEL_FAST: &str = "gpt-5.4"; -const AUTO_DRIVE_PRIMARY_ROUTING_DESCRIPTION: &str = - "Hard planning and complex problem solving"; -const AUTO_DRIVE_FAST_ROUTING_DESCRIPTION: &str = - "Fast implementation loops and failing-test iteration"; - -static HARD_LIMIT_TRIMMED_ITEMS_TOTAL: AtomicU64 = AtomicU64::new(0); -static QUEUED_UPDATE_DROPS_TOTAL: AtomicU64 = AtomicU64::new(0); - -#[derive(Debug, thiserror::Error)] -#[error("auto coordinator cancelled")] -struct AutoCoordinatorCancelled; - -pub const MODEL_SLUG: &str = "gpt-5.5"; -const USER_TURN_SCHEMA_NAME: &str = "auto_coordinator_user_turn"; -const COORDINATOR_PROMPT: &str = include_str!("../../core/prompt_coordinator.md"); -const TIMEBOXED_EXEC_COORDINATOR_GUIDANCE: &str = "SYSTEM: Time-boxed autonomous exec is enabled.\n\nYou are coordinating a non-interactive coding run. Optimize for verifier success, not exploration.\n\nContract-first and evidence-led:\n- In the first 3 minutes, force a concrete failing signal by running the most authoritative acceptance check available. Prefer (in order): `/tests/verify.sh` (read it, then run it) > a task-provided verifier script > the narrowest relevant test (e.g. `pytest -q /tests/test_outputs.py`).\n- Treat that check as the contract: exact paths, ports, formats, exit codes, and stated constraints.\n\nConverge with small diffs:\n- Ship a minimal first-pass fix quickly, then iterate against the same check.\n- Avoid speculative redesigns, broad refactors, or dependency churn unless forced by the contract.\n\nDelegate by outcomes only:\n- Each turn: give one short directive phrased as an outcome (\"make X pass\", \"produce file Y at path Z\", \"ensure binary at /path is executable\"). Let the CLI pick tactics.\n\nTime discipline:\n- Prefer cheap proofs before expensive steps (long builds/downloads/services).\n- Prefer local files and official libraries over scraping web UIs/config endpoints.\n\nFinish standard:\n- finish_success only with proof (acceptance check green + required artifacts present exactly where asserted).\n- Otherwise finish_failed with the last check, its output/error, what changed, and the single next verification step."; - -const ALL_TEXT_VERBOSITY: &[TextVerbosity] = &[ - TextVerbosity::Low, - TextVerbosity::Medium, - TextVerbosity::High, -]; - -fn supported_text_verbosity_for_model(model: &str) -> &'static [TextVerbosity] { - if model.eq_ignore_ascii_case("gpt-5.1-codex-max") { - &[TextVerbosity::Medium] - } else { - ALL_TEXT_VERBOSITY - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AutoDriveCliRoutingEntry { - model: String, - reasoning_levels: Vec, - description: String, -} - -fn cli_routing_reasoning_priority(level: ReasoningEffort) -> u8 { - match level { - ReasoningEffort::Minimal => 0, - ReasoningEffort::Low => 1, - ReasoningEffort::Medium => 2, - ReasoningEffort::High => 3, - ReasoningEffort::XHigh => 4, - ReasoningEffort::None => 5, - } -} - -fn normalize_cli_routing_reasoning_levels(levels: &[ReasoningEffort]) -> Vec { - let mut normalized = Vec::new(); - for level in [ - ReasoningEffort::Minimal, - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::XHigh, - ] { - if levels.contains(&level) { - normalized.push(level); - } - } - normalized -} - -fn cli_reasoning_effort_to_str(level: ReasoningEffort) -> &'static str { - match level { - ReasoningEffort::Minimal => "minimal", - ReasoningEffort::Low => "low", - ReasoningEffort::Medium => "medium", - ReasoningEffort::High => "high", - ReasoningEffort::XHigh => "xhigh", - ReasoningEffort::None => "minimal", - } -} - -fn format_cli_reasoning_levels(levels: &[ReasoningEffort]) -> String { - levels - .iter() - .map(|level| cli_reasoning_effort_to_str(*level)) - .collect::>() - .join("/") -} - -fn default_auto_drive_cli_routing_entries() -> Vec { - vec![ - AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::XHigh], - description: AUTO_DRIVE_PRIMARY_ROUTING_DESCRIPTION.to_string(), - }, - AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_FAST.to_string(), - reasoning_levels: vec![ReasoningEffort::High], - description: AUTO_DRIVE_FAST_ROUTING_DESCRIPTION.to_string(), - }, - ] -} - -fn auto_drive_cli_routing_entries_for_auth( - _auth_mode: Option, - _supports_pro_only_models: bool, -) -> Vec { - let mut entries = vec![AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - reasoning_levels: vec![ReasoningEffort::High, ReasoningEffort::XHigh], - description: AUTO_DRIVE_PRIMARY_ROUTING_DESCRIPTION.to_string(), - }]; - entries.push(AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_FAST.to_string(), - reasoning_levels: vec![ReasoningEffort::High], - description: AUTO_DRIVE_FAST_ROUTING_DESCRIPTION.to_string(), - }); - entries -} - -#[cfg(test)] -fn auto_drive_cli_models_for_auth( - auth_mode: Option, - supports_pro_only_models: bool, -) -> Vec { - auto_drive_cli_routing_entries_for_auth(auth_mode, supports_pro_only_models) - .into_iter() - .map(|entry| entry.model) - .collect() -} - -fn normalize_routing_entry_model(model: &str) -> Option { - let trimmed = model.trim(); - if trimmed.is_empty() { - return None; - } - - let normalized = trimmed.to_ascii_lowercase(); - if !normalized.starts_with("gpt-") { - return None; - } - - Some(normalized) -} - -fn normalize_auto_drive_cli_routing_entries( - entries: &[AutoDriveModelRoutingEntry], -) -> Vec { - let mut normalized: Vec = Vec::new(); - - for entry in entries { - if !entry.enabled { - continue; - } - - let Some(model) = normalize_routing_entry_model(&entry.model) else { - continue; - }; - - let reasoning_levels = normalize_cli_routing_reasoning_levels(&entry.reasoning_levels); - if reasoning_levels.is_empty() { - continue; - } - - let description = entry.description.trim().to_string(); - if let Some(existing) = normalized - .iter_mut() - .find(|candidate| candidate.model.eq_ignore_ascii_case(&model)) - { - let mut combined = existing.reasoning_levels.clone(); - for level in reasoning_levels { - if !combined.contains(&level) { - combined.push(level); - } - } - combined.sort_by_key(|level| cli_routing_reasoning_priority(*level)); - existing.reasoning_levels = combined; - if existing.description.is_empty() && !description.is_empty() { - existing.description = description; - } - continue; - } - - normalized.push(AutoDriveCliRoutingEntry { - model, - reasoning_levels, - description, - }); - } - - normalized -} - -fn resolve_auto_drive_cli_routing_entries( - settings: &AutoDriveSettings, - auth_mode: Option, - supports_pro_only_models: bool, - available_models: &[String], -) -> Vec { - let mut entries = normalize_auto_drive_cli_routing_entries(&settings.model_routing_entries); - entries.retain(|entry| { - available_models - .iter() - .any(|model| model.eq_ignore_ascii_case(&entry.model)) - }); - - if entries.is_empty() { - return auto_drive_cli_routing_entries_for_auth(auth_mode, supports_pro_only_models) - .into_iter() - .filter(|entry| { - available_models - .iter() - .any(|model| model.eq_ignore_ascii_case(&entry.model)) - }) - .collect(); - } - - entries -} - -#[derive(Debug, Clone)] -struct AutoTimeBudget { - deadline: Instant, - total: Duration, - next_nudge_at: Instant, -} - -fn retry_max_elapsed(deadline: Option) -> Duration { - let max = MAX_RETRY_ELAPSED; - let Some(deadline) = deadline else { - return max; - }; - let remaining = deadline.saturating_duration_since(Instant::now()); - let remaining = remaining.saturating_sub(RATE_LIMIT_BUFFER); - remaining.min(max) -} - -impl AutoTimeBudget { - fn new(deadline: Instant, total: Duration) -> Self { - let half = total / 2; - let next_nudge_at = deadline.checked_sub(half).unwrap_or(deadline); - Self { - deadline, - total, - next_nudge_at, - } - } - - fn maybe_nudge(&mut self) -> Option { - let now = Instant::now(); - if now < self.next_nudge_at { - return None; - } - - let remaining = self.deadline.saturating_duration_since(now); - let elapsed = self.total.saturating_sub(remaining); - - if elapsed < (self.total / 2) { - let half = self.total / 2; - self.next_nudge_at = self.deadline.checked_sub(half).unwrap_or(self.deadline); - return None; - } - - let guidance = if remaining <= Duration::from_secs(30) { - "Time is nearly up: stop exploring; take the simplest safe path and do one cheap verification before finishing." - } else if remaining <= Duration::from_secs(120) { - "Time is tight: parallelize any remaining scouting/verification (agents or tool-call batching) and finish with the cheapest proof." - } else { - "Past 50% of the time budget: start converging; parallelize remaining scouting/verification and avoid detours." - }; - - self.next_nudge_at = now + next_budget_nudge_interval(remaining); - - Some(format!( - "Time budget update: total={} elapsed={} remaining={}. {guidance}", - format_duration(self.total), - format_duration(elapsed), - format_duration(remaining) - )) - } -} - -fn next_budget_nudge_interval(remaining: Duration) -> Duration { - if remaining >= Duration::from_secs(30 * 60) { - Duration::from_secs(5 * 60) - } else if remaining >= Duration::from_secs(10 * 60) { - Duration::from_secs(2 * 60) - } else if remaining >= Duration::from_secs(5 * 60) { - Duration::from_secs(60) - } else if remaining >= Duration::from_secs(2 * 60) { - Duration::from_secs(30) - } else if remaining >= Duration::from_secs(60) { - Duration::from_secs(15) - } else if remaining >= Duration::from_secs(30) { - Duration::from_secs(10) - } else if remaining >= Duration::from_secs(10) { - Duration::from_secs(5) - } else { - Duration::from_secs(2) - } -} - -#[derive(Clone)] -pub struct AutoCoordinatorEventSender { - inner: Arc, -} - -impl AutoCoordinatorEventSender { - pub fn new(f: F) -> Self - where - F: Fn(AutoCoordinatorEvent) + Send + Sync + 'static, - { - Self { inner: Arc::new(f) } - } - - #[tracing::instrument(skip(self, event), fields(event = event.kind()))] - pub fn send(&self, event: AutoCoordinatorEvent) { - tracing::debug!(target: "auto_drive::coordinator", event = event.kind(), "dispatch coordinator event"); - (self.inner)(event); - } -} - -#[derive(Debug, Clone)] -pub struct AutoTurnCliAction { - pub prompt: String, - pub context: Option, - pub suppress_ui_context: bool, - pub model_override: Option, - pub reasoning_effort_override: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutoTurnAgentsTiming { - Parallel, - Blocking, -} - -#[derive(Debug, Clone)] -pub struct AutoTurnAgentsAction { - pub prompt: String, - pub context: Option, - pub write: bool, - pub write_requested: Option, - pub models: Option>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutoCoordinatorStatus { - Continue, - Success, - Failed, -} - -#[derive(Debug, Clone)] -pub enum AutoCoordinatorEvent { - Decision { - seq: u64, - status: AutoCoordinatorStatus, - status_title: Option, - status_sent_to_user: Option, - goal: Option, - cli: Option, - agents_timing: Option, - agents: Vec, - transcript: Vec, - }, - Thinking { - delta: String, - summary_index: Option, - }, - Action { - message: String, - }, - UserReply { - user_response: Option, - cli_command: Option, - }, - TokenMetrics { - total_usage: TokenUsage, - last_turn_usage: TokenUsage, - turn_count: u32, - duplicate_items: u32, - replay_updates: u32, - }, - CompactedHistory { - conversation: Arc<[ResponseItem]>, - show_notice: bool, - }, - StopAck, -} - -impl AutoCoordinatorEvent { - fn kind(&self) -> &'static str { - match self { - Self::Decision { .. } => "decision", - Self::Thinking { .. } => "thinking", - Self::Action { .. } => "action", - Self::UserReply { .. } => "user_reply", - Self::TokenMetrics { .. } => "token_metrics", - Self::CompactedHistory { .. } => "compacted_history", - Self::StopAck => "stop_ack", - } - } -} - -#[derive(Debug, Clone)] -pub struct AutoCoordinatorHandle { - pub tx: Sender, - cancel_token: CancellationToken, -} - -impl AutoCoordinatorHandle { - pub fn send( - &self, - command: AutoCoordinatorCommand, - ) -> std::result::Result<(), mpsc::SendError> { - self.tx.send(command) - } - - pub fn cancel(&self) { - self.cancel_token.cancel(); - } -} - -#[derive(Debug)] -pub enum AutoCoordinatorCommand { - UpdateConversation(Arc<[ResponseItem]>), - HandleUserPrompt { - _prompt: String, - conversation: Arc<[ResponseItem]>, - }, - AckDecision { seq: u64 }, - Stop, -} - -#[derive(Clone)] -struct PendingDecision { - seq: u64, - status: AutoCoordinatorStatus, - status_title: Option, - status_sent_to_user: Option, - goal: Option, - cli: Option, - agents_timing: Option, - agents: Vec, - transcript: Vec, -} - -impl PendingDecision { - fn into_event(self) -> AutoCoordinatorEvent { - AutoCoordinatorEvent::Decision { - seq: self.seq, - status: self.status, - status_title: self.status_title, - status_sent_to_user: self.status_sent_to_user, - goal: self.goal, - cli: self.cli, - agents_timing: self.agents_timing, - agents: self.agents, - transcript: self.transcript, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TurnComplexity { - Low, - Medium, - High, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct TurnConfig { - #[serde(default)] - pub read_only: bool, - #[serde(default)] - #[allow(dead_code)] - pub complexity: Option, - #[serde(default)] - pub text_format_override: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TurnMode { - Normal, - SubAgentWrite, - SubAgentReadOnly, - Review, -} - -impl Default for TurnMode { - fn default() -> Self { - Self::Normal - } -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Deserialize, Default)] -pub struct AgentPreferences { - #[serde(default)] - pub prefer_research: bool, - #[serde(default)] - pub prefer_planning: bool, - #[serde(default)] - pub requested_models: Option>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReviewTiming { - PostTurn, - PreWrite, - Immediate, -} - -impl Default for ReviewTiming { - fn default() -> Self { - Self::PostTurn - } -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] -pub struct ReviewStrategy { - #[serde(default)] - pub timing: ReviewTiming, - #[serde(default)] - pub custom_prompt: Option, - #[serde(default)] - pub scope_hint: Option, -} - -impl Default for ReviewStrategy { - fn default() -> Self { - Self { - timing: ReviewTiming::PostTurn, - custom_prompt: None, - scope_hint: None, - } - } -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] -pub struct TurnDescriptor { - #[serde(default)] - pub mode: TurnMode, - #[serde(default)] - pub read_only: bool, - #[serde(default)] - pub complexity: Option, - #[serde(default)] - pub agent_preferences: Option, - #[serde(default)] - pub review_strategy: Option, - #[serde(default)] - pub text_format_override: Option, -} - -impl Default for TurnDescriptor { - fn default() -> Self { - Self { - mode: TurnMode::Normal, - read_only: false, - complexity: None, - agent_preferences: None, - review_strategy: None, - text_format_override: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::anyhow; - use code_app_server_protocol::AuthMode; - use code_core::agent_defaults::DEFAULT_AGENT_NAMES; - use code_core::error::{RetryLimitReachedError, UsageLimitReachedError}; - use serde_json::json; - use std::time::Duration; - - #[test] - fn turn_descriptor_defaults_to_normal_mode() { - let value = json!({}); - let descriptor: TurnDescriptor = serde_json::from_value(value).unwrap(); - assert_eq!(descriptor.mode, TurnMode::Normal); - assert!(!descriptor.read_only); - assert!(descriptor.complexity.is_none()); - assert!(descriptor.agent_preferences.is_none()); - assert!(descriptor.review_strategy.is_none()); - } - - #[test] - fn schema_includes_prompt_and_agents() { - let active_agents = vec![ - "codex-plan".to_string(), - "codex-research".to_string(), - ]; - let schema = build_schema( - &active_agents, - SchemaFeatures::default(), - &default_auto_drive_cli_routing_entries(), - ); - let props = schema - .get("properties") - .and_then(|v| v.as_object()) - .expect("schema properties"); - assert!(props.contains_key("goal"), "goal property missing"); - assert!(props.contains_key("phase"), "phase property missing"); - assert!( - props.contains_key("finish_evidence"), - "finish_evidence property missing" - ); - assert!(props.contains_key("status_title"), "status_title property missing"); - assert!( - props.contains_key("status_sent_to_user"), - "status_sent_to_user property missing" - ); - assert!( - props.contains_key("cli_milestone_instruction"), - "cli_milestone_instruction property missing" - ); - assert!(props.contains_key("cli_model"), "cli_model property missing"); - assert!( - props.contains_key("cli_reasoning_effort"), - "cli_reasoning_effort property missing" - ); - assert!(props.contains_key("agents"), "agents property missing"); - assert!(!props.contains_key("code_review")); - assert!(!props.contains_key("cross_check")); - assert!(!props.contains_key("progress")); - - let schema_required = schema - .get("required") - .and_then(|v| v.as_array()) - .expect("root required"); - assert!(schema_required.contains(&json!("finish_status"))); - assert!(schema_required.contains(&json!("phase"))); - assert!(schema_required.contains(&json!("goal"))); - assert!(schema_required.contains(&json!("status_title"))); - assert!(schema_required.contains(&json!("status_sent_to_user"))); - assert!(schema_required.contains(&json!("cli_milestone_instruction"))); - assert!(schema_required.contains(&json!("cli_model"))); - assert!(schema_required.contains(&json!("cli_reasoning_effort"))); - assert!(schema_required.contains(&json!("finish_evidence"))); - assert!(schema_required.contains(&json!("agents"))); - assert_eq!( - schema_required.len(), - props.len(), - "strict schema requires every property to be listed in required" - ); - - let agents_obj = props - .get("agents") - .and_then(|v| v.as_object()) - .expect("agents schema object"); - let agents_required = agents_obj - .get("required") - .and_then(|v| v.as_array()) - .expect("agents required"); - assert!(agents_required.contains(&json!("timing"))); - assert!(agents_required.contains(&json!("list"))); - assert!(!agents_required.contains(&json!("models"))); - - let list_items_schema = agents_obj - .get("properties") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("list")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("items")) - .and_then(|v| v.as_object()) - .expect("agents.list items"); - let item_props = list_items_schema - .get("properties") - .and_then(|v| v.as_object()) - .expect("agents.list item properties"); - let models_schema = item_props - .get("models") - .and_then(|v| v.as_object()) - .expect("agents.list item models schema"); - assert_eq!(models_schema.get("type"), Some(&json!("array"))); - let enum_values = models_schema - .get("items") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("enum")) - .and_then(|v| v.as_array()) - .expect("models enum values"); - let expected_enum: Vec = active_agents - .iter() - .map(|name| Value::String(name.clone())) - .collect(); - assert_eq!(*enum_values, expected_enum); - - assert!(!props.contains_key("code_review")); - assert!(!props.contains_key("cross_check")); - assert!( - schema.get("allOf").is_none(), - "schema should avoid unsupported allOf" - ); - } - - #[test] - fn schema_sets_cli_milestone_instruction_min_without_max_length() { - let active_agents: Vec = Vec::new(); - let schema = build_schema( - &active_agents, - SchemaFeatures::default(), - &default_auto_drive_cli_routing_entries(), - ); - let prompt_schema = schema - .get("properties") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("cli_milestone_instruction")) - .and_then(|v| v.as_object()) - .expect("cli_milestone_instruction schema"); - - assert_eq!( - prompt_schema.get("minLength"), - Some(&json!(CLI_PROMPT_MIN_CHARS)), - "schema minLength should match CLI_PROMPT_MIN_CHARS" - ); - assert!( - !prompt_schema.contains_key("maxLength"), - "schema should omit maxLength to avoid provider truncation" - ); - } - - #[test] - fn enforce_hard_message_limit_keeps_goal_and_recent_tail() { - let total_messages = HARD_MESSAGE_LIMIT + 48; - let mut items = Vec::with_capacity(total_messages + 1); - items.push(make_message("user", "Primary goal should survive".to_string())); - for idx in 0..total_messages { - items.push(make_message("assistant", format!("assistant-msg-{idx}"))); - } - - let trimmed = enforce_hard_message_limit(items); - assert_eq!(trimmed.len(), HARD_MESSAGE_LIMIT); - - let first_is_goal = matches!( - trimmed.first(), - Some(ResponseItem::Message { role, content, .. }) - if role == "user" - && content - .iter() - .any(|item| matches!(item, ContentItem::InputText { text } if text == "Primary goal should survive")) - ); - assert!(first_is_goal, "oldest user goal should be preserved"); - - let latest_tail = format!("assistant-msg-{}", total_messages - 1); - let has_latest = trimmed.iter().any(|item| { - matches!( - item, - ResponseItem::Message { content, .. } - if content - .iter() - .any(|entry| matches!(entry, ContentItem::OutputText { text } if text == &latest_tail)) - ) - }); - assert!(has_latest, "latest assistant message should be retained"); - } - - #[test] - fn queue_update_capped_discards_oldest_overflow() { - let mut queue: VecDeque> = VecDeque::new(); - - for idx in 0..(MAX_QUEUED_CONVERSATION_UPDATES + 7) { - let update = Arc::<[ResponseItem]>::from(vec![make_message( - "assistant", - format!("queued-{idx}"), - )]); - queue_update_capped(&mut queue, update); - } - - assert_eq!(queue.len(), MAX_QUEUED_CONVERSATION_UPDATES); - - let first_text = queue - .front() - .and_then(|conversation| conversation.first()) - .and_then(|item| match item { - ResponseItem::Message { content, .. } => content.first(), - _ => None, - }) - .and_then(|content| match content { - ContentItem::OutputText { text } => Some(text.clone()), - ContentItem::InputText { text } => Some(text.clone()), - _ => None, - }) - .expect("queued message text"); - - assert_eq!(first_text, "queued-7"); - } - - #[test] - fn retry_max_elapsed_defaults_to_global_limit() { - assert_eq!(retry_max_elapsed(None), MAX_RETRY_ELAPSED); - } - - #[test] - fn retry_max_elapsed_zero_when_deadline_has_passed() { - let deadline = Instant::now(); - assert_eq!(retry_max_elapsed(Some(deadline)), Duration::ZERO); - } - - #[test] - fn retry_limit_marked_retryable_is_retried() { - let err = CodexErr::RetryLimit(RetryLimitReachedError { - status: StatusCode::SERVICE_UNAVAILABLE, - request_id: None, - retryable: true, - }); - - match classify_model_error(&anyhow!(err)) { - RetryDecision::RetryAfterBackoff { reason } => { - assert!(reason.contains("retry limit")); - } - other => panic!("expected retry, got {other:?}"), - } - } - - #[test] - fn schema_defaults_to_builtin_agents_enum() { - let schema = build_schema( - &DEFAULT_AGENT_NAMES - .iter() - .map(|name| (*name).to_string()) - .collect::>(), - SchemaFeatures::default(), - &default_auto_drive_cli_routing_entries(), - ); - let props = schema - .get("properties") - .and_then(|v| v.as_object()) - .expect("schema properties"); - let agents_obj = props - .get("agents") - .and_then(|v| v.as_object()) - .expect("agents schema"); - let item_enum = agents_obj - .get("properties") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("list")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("items")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("properties")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("models")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("items")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("enum")) - .and_then(|v| v.as_array()) - .expect("models enum"); - let expected: Vec = DEFAULT_AGENT_NAMES - .iter() - .map(|name| Value::String((*name).to_string())) - .collect(); - assert_eq!(*item_enum, expected); - } - - #[test] - fn schema_omits_agents_when_disabled() { - let active_agents = vec!["codex-plan".to_string()]; - let schema = build_schema( - &active_agents, - SchemaFeatures { - include_agents: false, - ..SchemaFeatures::default() - }, - &default_auto_drive_cli_routing_entries(), - ); - let props = schema - .get("properties") - .and_then(|v| v.as_object()) - .expect("schema properties"); - assert!(!props.contains_key("agents")); - assert!(props.contains_key("cli_model")); - assert!(props.contains_key("cli_reasoning_effort")); - let required = schema - .get("required") - .and_then(|v| v.as_array()) - .expect("required array"); - assert!(!required.contains(&json!("agents"))); - assert!(required.contains(&json!("goal"))); - assert!(required.contains(&json!("cli_model"))); - assert!(required.contains(&json!("cli_reasoning_effort"))); - } - - #[test] - fn schema_omits_cli_model_routing_when_disabled() { - let schema = build_schema( - &Vec::new(), - SchemaFeatures { - include_cli_model_routing: false, - ..SchemaFeatures::default() - }, - &default_auto_drive_cli_routing_entries(), - ); - let props = schema - .get("properties") - .and_then(|v| v.as_object()) - .expect("schema properties"); - assert!(!props.contains_key("cli_model")); - assert!(!props.contains_key("cli_reasoning_effort")); - let required = schema - .get("required") - .and_then(|v| v.as_array()) - .expect("required array"); - assert!(!required.contains(&json!("cli_model"))); - assert!(!required.contains(&json!("cli_reasoning_effort"))); - } - - #[test] - fn schema_cli_model_enum_respects_allowed_models() { - let allowed_cli_routing_entries = vec![AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - reasoning_levels: vec![ReasoningEffort::High], - description: String::new(), - }]; - let schema = build_schema( - &Vec::new(), - SchemaFeatures::default(), - &allowed_cli_routing_entries, - ); - let cli_model_enum = schema - .get("properties") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("cli_model")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("enum")) - .and_then(|v| v.as_array()) - .expect("cli_model enum"); - - assert_eq!( - cli_model_enum, - &vec![json!(AUTO_DRIVE_CLI_MODEL_PRIMARY), Value::Null] - ); - } - - #[test] - fn schema_cli_reasoning_enum_respects_allowed_entries() { - let allowed_cli_routing_entries = vec![AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - reasoning_levels: vec![ReasoningEffort::High], - description: "High only".to_string(), - }]; - let schema = build_schema( - &Vec::new(), - SchemaFeatures::default(), - &allowed_cli_routing_entries, - ); - let cli_reasoning_enum = schema - .get("properties") - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("cli_reasoning_effort")) - .and_then(|v| v.as_object()) - .and_then(|obj| obj.get("enum")) - .and_then(|v| v.as_array()) - .expect("cli_reasoning_effort enum"); - - assert_eq!(cli_reasoning_enum, &vec![json!("high"), Value::Null]); - } - - #[test] - fn schema_marks_goal_required_with_bootstrap_description() { - let mut features = SchemaFeatures::default(); - features.include_goal_field = true; - let schema = build_schema(&Vec::new(), features, &default_auto_drive_cli_routing_entries()); - let required = schema - .get("required") - .and_then(|v| v.as_array()) - .expect("required array"); - assert!(required.contains(&json!("goal")), "goal should be required"); - - let props = schema - .get("properties") - .and_then(|v| v.as_object()) - .expect("schema properties"); - let goal = props - .get("goal") - .and_then(|v| v.as_object()) - .expect("goal schema"); - assert_eq!(goal.get("type"), Some(&json!("string"))); - assert_eq!(goal.get("minLength"), Some(&json!(4))); - let description = goal - .get("description") - .and_then(|v| v.as_str()) - .expect("goal description"); - assert!(description.contains("primary coding goal")); - } - - #[test] - fn developer_message_uses_bootstrap_instructions_when_deriving_goal() { - let (_, _intro_bootstrap, primary_bootstrap) = build_developer_message( - "Deriving goal from recent conversation", - "Env", - None, - true, - ); - assert!(primary_bootstrap.contains("You are preparing to start Auto Drive")); - - let (_, _intro_normal, primary_normal) = - build_developer_message("Ship feature", "Env", None, false); - assert!(primary_normal.contains("Ship feature")); - assert!(!primary_normal.contains("You are preparing to start Auto Drive")); - } - - #[test] - fn parse_decision_new_schema() { - let raw = r#"{ - "finish_status": "continue", - "status_title": "Dispatching fix", - "status_sent_to_user": "Ran smoke tests while validating the fix.", - "cli_milestone_instruction": "Apply the patch for the failing test", - "agents": { - "timing": "blocking", - "list": [ - {"prompt": "Draft alternative fix", "write": false, "context": "Consider module B", "models": ["codex-plan"]} - ] - } - }"#; - - let (decision, _) = parse_decision(raw, DecisionParseOptions::default()) - .expect("parse new schema decision"); - assert_eq!(decision.status, AutoCoordinatorStatus::Continue); - assert_eq!( - decision.status_sent_to_user.as_deref(), - Some("Ran smoke tests while validating the fix.") - ); - assert_eq!(decision.status_title.as_deref(), Some("Dispatching fix")); - - let cli = decision.cli.expect("cli action expected"); - assert_eq!(cli.prompt, "Apply the patch for the failing test"); - assert!(cli.context.is_none()); - - assert_eq!( - decision.agents_timing, - Some(AutoTurnAgentsTiming::Blocking) - ); - assert_eq!(decision.agents.len(), 1); - let agent = &decision.agents[0]; - assert_eq!(agent.prompt, "Draft alternative fix"); - assert_eq!(agent.write, Some(false)); - assert_eq!( - agent.models, - Some(vec!["codex-plan".to_string()]) - ); - - } - - #[test] - fn parse_decision_requires_cli_model_and_reasoning_when_enabled() { - let raw = r#"{ - "finish_status": "continue", - "status_title": "Dispatching fix", - "status_sent_to_user": "Running failing test loop.", - "cli_milestone_instruction": "Run the failing test, apply a minimal fix, and iterate until green." - }"#; - - let err = parse_decision( - raw, - DecisionParseOptions { - require_cli_model_routing: true, - ..DecisionParseOptions::default() - }, - ) - .expect_err("routing-enabled parse should require model fields"); - - assert!(err.to_string().contains("missing cli_model")); - } - - #[test] - fn parse_decision_accepts_cli_model_and_reasoning_when_enabled() { - let raw = r#"{ - "finish_status": "continue", - "status_title": "Fixing tests", - "status_sent_to_user": "Running clear failing-test loops.", - "cli_milestone_instruction": "Take the failing test from red to green and report the passing evidence.", - "cli_model": "gpt-5.4", - "cli_reasoning_effort": "high" - }"#; - - let (decision, _) = parse_decision( - raw, - DecisionParseOptions { - require_cli_model_routing: true, - ..DecisionParseOptions::default() - }, - ) - .expect("routing-enabled decision should parse"); - - let cli = decision.cli.expect("cli action expected"); - assert_eq!(cli.model_override.as_deref(), Some(AUTO_DRIVE_CLI_MODEL_FAST)); - assert_eq!(cli.reasoning_effort_override, Some(ReasoningEffort::High)); - } - - #[test] - fn parse_decision_rejects_fast_model_when_not_allowed() { - let raw = r#"{ - "finish_status": "continue", - "status_title": "Fixing tests", - "status_sent_to_user": "Running clear failing-test loops.", - "cli_milestone_instruction": "Take the failing test from red to green and report the passing evidence.", - "cli_model": "gpt-5.4", - "cli_reasoning_effort": "high" - }"#; - - let err = parse_decision( - raw, - DecisionParseOptions { - require_cli_model_routing: true, - allowed_cli_routing_entries: vec![AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - reasoning_levels: vec![ReasoningEffort::High], - description: String::new(), - }], - }, - ) - .expect_err("restricted routing should reject fast model"); - - assert!(err.to_string().contains("unsupported cli_model")); - assert!(err.to_string().contains(AUTO_DRIVE_CLI_MODEL_PRIMARY)); - } - - #[test] - fn parse_decision_rejects_reasoning_not_allowed_for_model() { - let raw = r#"{ - "finish_status": "continue", - "status_title": "Fixing tests", - "status_sent_to_user": "Running clear failing-test loops.", - "cli_milestone_instruction": "Take the failing test from red to green and report the passing evidence.", - "cli_model": "gpt-5.5", - "cli_reasoning_effort": "xhigh" - }"#; - - let err = parse_decision( - raw, - DecisionParseOptions { - require_cli_model_routing: true, - allowed_cli_routing_entries: vec![AutoDriveCliRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - reasoning_levels: vec![ReasoningEffort::High], - description: String::new(), - }], - }, - ) - .expect_err("unsupported reasoning should fail"); - - assert!(err - .to_string() - .contains("unsupported cli_reasoning_effort 'xhigh'")); - } - - #[test] - fn auto_drive_cli_models_are_auth_independent() { - let pro_models = auto_drive_cli_models_for_auth(Some(AuthMode::Chatgpt), true); - assert!(pro_models.contains(&AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string())); - assert!(pro_models.contains(&AUTO_DRIVE_CLI_MODEL_FAST.to_string())); - - let non_pro_models = auto_drive_cli_models_for_auth(Some(AuthMode::Chatgpt), false); - assert!(non_pro_models.contains(&AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string())); - assert!(non_pro_models.contains(&AUTO_DRIVE_CLI_MODEL_FAST.to_string())); - - let api_key_models = auto_drive_cli_models_for_auth(Some(AuthMode::ApiKey), false); - assert!(api_key_models.contains(&AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string())); - assert!(api_key_models.contains(&AUTO_DRIVE_CLI_MODEL_FAST.to_string())); - } - - #[test] - fn resolve_cli_routing_entries_falls_back_when_enabled_entries_missing() { - let mut settings = AutoDriveSettings::default(); - settings.model_routing_entries = vec![AutoDriveModelRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - enabled: false, - reasoning_levels: vec![ReasoningEffort::High], - description: "disabled".to_string(), - }]; - - let available_models = vec![ - AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - AUTO_DRIVE_CLI_MODEL_FAST.to_string(), - ]; - - let entries = resolve_auto_drive_cli_routing_entries( - &settings, - Some(AuthMode::Chatgpt), - true, - &available_models, - ); - - assert!(entries.iter().any(|entry| entry.model == AUTO_DRIVE_CLI_MODEL_PRIMARY)); - assert!(entries.iter().any(|entry| entry.model == AUTO_DRIVE_CLI_MODEL_FAST)); - } - - #[test] - fn resolve_cli_routing_entries_drop_unavailable_models() { - let settings = AutoDriveSettings { - model_routing_entries: vec![ - AutoDriveModelRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - enabled: true, - reasoning_levels: vec![ReasoningEffort::High], - description: String::new(), - }, - AutoDriveModelRoutingEntry { - model: "gpt-5.5-experimental".to_string(), - enabled: true, - reasoning_levels: vec![ReasoningEffort::High], - description: String::new(), - }, - ], - ..AutoDriveSettings::default() - }; - - let available_models = vec![AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string()]; - let entries = resolve_auto_drive_cli_routing_entries( - &settings, - Some(AuthMode::Chatgpt), - true, - &available_models, - ); - - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].model, AUTO_DRIVE_CLI_MODEL_PRIMARY); - } - - #[test] - fn resolve_cli_routing_entries_empty_when_no_available_models() { - let settings = AutoDriveSettings { - model_routing_entries: vec![AutoDriveModelRoutingEntry { - model: AUTO_DRIVE_CLI_MODEL_PRIMARY.to_string(), - enabled: true, - reasoning_levels: vec![ReasoningEffort::High], - description: String::new(), - }], - ..AutoDriveSettings::default() - }; - - let entries = - resolve_auto_drive_cli_routing_entries(&settings, Some(AuthMode::Chatgpt), true, &[]); - - assert!(entries.is_empty()); - } - - #[test] - fn parse_decision_new_schema_array_backcompat() { - let raw = r#"{ - "finish_status": "continue", - "status_title": "Running tests", - "status_sent_to_user": "Outlined fix before execution.", - "cli_milestone_instruction": "Run cargo test", - "agents": [ - {"prompt": "Investigate benchmark", "write": false} - ] - }"#; - - let (decision, _) = parse_decision(raw, DecisionParseOptions::default()) - .expect("parse array-style agents"); - assert_eq!(decision.status, AutoCoordinatorStatus::Continue); - assert!(decision.cli.is_some()); - assert_eq!(decision.agents.len(), 1); - assert!(decision.agents_timing.is_none()); - assert_eq!(decision.status_title.as_deref(), Some("Running tests")); - assert_eq!( - decision.status_sent_to_user.as_deref(), - Some("Outlined fix before execution.") - ); - } - - #[test] - fn parse_decision_legacy_schema() { - let raw = r#"{ - "finish_status": "continue", - "progress": {"past": "Drafted fix", "current": "Running unit tests"}, - "cli_milestone_instruction": "Run cargo test --package core" - }"#; - - let (decision, _) = parse_decision(raw, DecisionParseOptions::default()) - .expect("parse legacy decision"); - assert_eq!(decision.status, AutoCoordinatorStatus::Continue); - assert_eq!( - decision.status_sent_to_user.as_deref(), - Some("Drafted fix") - ); - assert_eq!( - decision.status_title.as_deref(), - Some("Running unit tests") - ); - - let cli = decision.cli.expect("cli action expected"); - assert_eq!(cli.prompt, "Run cargo test --package core"); - assert!(cli.context.is_none()); - - assert!(decision.agents.is_empty()); - assert!(decision.agents_timing.is_none()); - } - - #[test] - fn parse_decision_continue_rejects_finish_evidence() { - let raw = r#"{ - "finish_status": "continue", - "status_title": "Implementing", - "status_sent_to_user": "Driving the implementation milestone.", - "cli_milestone_instruction": "Deliver the feature end-to-end and validate.", - "finish_evidence": { - "primary_outcome_achieved": "done", - "validation_checks_passed": ["cargo test"], - "edge_cases_handled": [] - } - }"#; - - let err = parse_decision(raw, DecisionParseOptions::default()) - .expect_err("continue should reject finish evidence"); - assert!( - err.to_string() - .contains("finish_evidence must be null when finish_status is continue") - ); - } - - #[test] - fn parse_decision_finish_requires_finish_evidence() { - let raw = r#"{ - "finish_status": "finish_success", - "status_title": "Completed", - "status_sent_to_user": "Everything is green.", - "cli_milestone_instruction": null - }"#; - - let err = parse_decision(raw, DecisionParseOptions::default()) - .expect_err("finish status should require evidence"); - assert!(err.to_string().contains("missing finish_evidence")); - } - - #[test] - fn parse_decision_finish_rejects_cli_prompt() { - let raw = r#"{ - "finish_status": "finish_success", - "status_title": "Completed", - "status_sent_to_user": "Everything is green.", - "cli_milestone_instruction": "Keep working", - "finish_evidence": { - "primary_outcome_achieved": "Resolved primary task.", - "validation_checks_passed": ["cargo test --workspace"], - "edge_cases_handled": ["empty input"] - } - }"#; - - let err = parse_decision(raw, DecisionParseOptions::default()) - .expect_err("finish statuses require null CLI prompt"); - assert!( - err.to_string() - .contains("must set cli_milestone_instruction to null") - ); - } - - #[test] - fn parse_decision_finish_accepts_finish_evidence() { - let raw = r#"{ - "finish_status": "finish_success", - "phase": "lockdown", - "status_title": "Completed", - "status_sent_to_user": "All checks are green.", - "cli_milestone_instruction": null, - "finish_evidence": { - "primary_outcome_achieved": "Resolved primary task end-to-end.", - "validation_checks_passed": ["cargo test --workspace", "./build-fast.sh"], - "edge_cases_handled": ["empty payload", "large payload"] - } - }"#; - - let (decision, _) = parse_decision(raw, DecisionParseOptions::default()) - .expect("finish decision should parse"); - assert_eq!(decision.status, AutoCoordinatorStatus::Success); - assert!(decision.cli.is_none()); - } - - #[test] - fn parse_decision_finish_requires_nonempty_validation_checks() { - let raw = r#"{ - "finish_status": "finish_success", - "status_title": "Completed", - "status_sent_to_user": "All checks are green.", - "cli_milestone_instruction": null, - "finish_evidence": { - "primary_outcome_achieved": "Resolved primary task end-to-end.", - "validation_checks_passed": [], - "edge_cases_handled": ["empty payload"] - } - }"#; - - let err = parse_decision(raw, DecisionParseOptions::default()) - .expect_err("finish decision should require validations"); - assert!( - err.to_string() - .contains("validation_checks_passed must include at least one") - ); - } - - #[test] - fn classify_missing_cli_prompt_is_recoverable() { - let err = anyhow!("model response missing cli_milestone_instruction for continue"); - let info = classify_recoverable_decision_error(&err).expect("recoverable error"); - assert!(info - .summary - .contains("cli_milestone_instruction")); - assert!( - info - .guidance - .as_ref() - .expect("guidance") - .contains("cli_milestone_instruction") - ); - } - - #[test] - fn classify_empty_field_is_recoverable() { - let err = anyhow!("agents[*].prompt is empty"); - let info = classify_recoverable_decision_error(&err).expect("recoverable error"); - assert!(info.summary.contains("agents[*].prompt")); - assert!(info - .guidance - .as_ref() - .expect("guidance") - .contains("agents[*].prompt")); - } - - #[test] - fn classify_missing_field_is_recoverable() { - let err = anyhow!("finish_evidence.validation_checks_passed is missing"); - let info = classify_recoverable_decision_error(&err).expect("recoverable error"); - assert!( - info.summary - .contains("finish_evidence.validation_checks_passed") - ); - } - - #[test] - fn classify_overlong_cli_prompt_is_recoverable_and_guided() { - let err = ensure_cli_prompt_length(&"x".repeat(CLI_PROMPT_MAX_CHARS + 1)) - .expect_err("length check should fail"); - let info = classify_recoverable_decision_error(&err).expect("recoverable error"); - - assert!( - info.summary.contains("length cap"), - "summary should mention length issue" - ); - let guidance = info.guidance.expect("guidance"); - assert!(guidance.contains("<=600"), "guidance should include limit"); - } - - #[test] - fn quota_exceeded_errors_short_circuit_retries() { - let err = anyhow!(CodexErr::QuotaExceeded); - match classify_model_error(&err) { - RetryDecision::Fatal(e) => { - assert!(e.to_string().contains("Quota exceeded")); - } - other => panic!("expected fatal quota decision, got {other:?}"), - } - } - - #[test] - fn usage_limit_without_reset_is_fatal() { - let err = anyhow!(CodexErr::UsageLimitReached(UsageLimitReachedError { - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - })); - match classify_model_error(&err) { - RetryDecision::Fatal(e) => { - assert!( - e.to_string().to_lowercase().contains("usage limit"), - "fatal error should mention usage limit" - ); - } - other => panic!("expected fatal usage limit decision, got {other:?}"), - } - } - - #[test] - fn push_unique_guidance_trims_and_dedupes() { - let mut guidance = vec!["Keep CLI prompts short".to_string()]; - push_unique_guidance(&mut guidance, " keep cli prompts short "); - assert_eq!(guidance.len(), 1, "duplicate hint should not be added"); - push_unique_guidance(&mut guidance, "Respond with JSON only"); - assert_eq!(guidance.len(), 2); - assert!(guidance.iter().any(|hint| hint == "Respond with JSON only")); - } - - #[test] - fn compaction_triggers_when_projected_exceeds_threshold() { - assert!(should_compact("gpt-5.1", 220_000, 10_000, 0, true)); - assert!(!should_compact("gpt-5.1", 100_000, 10_000, 0, true)); - } - - #[test] - fn compaction_falls_back_to_message_limit_when_unknown_model() { - assert!(should_compact( - "unknown-model", - 0, - 0, - MESSAGE_LIMIT_FALLBACK, - false, - )); - assert!(!should_compact( - "unknown-model", - 0, - 0, - MESSAGE_LIMIT_FALLBACK.saturating_sub(1), - false, - )); - } - - #[test] - fn compaction_skip_fallback_when_context_known() { - assert!(!should_compact( - "gpt-5.1", - 0, - 4_000, - MESSAGE_LIMIT_FALLBACK, - false, - )); - } - - #[test] - fn compaction_fallback_stops_once_tokens_recorded() { - assert!(!should_compact( - "unknown-model", - 0, - 0, - MESSAGE_LIMIT_FALLBACK, - true, - )); - } -} - -#[derive(Debug, Deserialize)] -struct CoordinatorDecisionNew { - finish_status: String, - #[serde(default)] - phase: Option, - #[serde(default)] - status_title: Option, - #[serde(default)] - status_sent_to_user: Option, - #[serde(default)] - progress: Option, - #[serde(default)] - cli_milestone_instruction: Option, - #[serde(default)] - cli_model: Option, - #[serde(default)] - cli_reasoning_effort: Option, - #[serde(default)] - agents: Option, - #[serde(default)] - finish_evidence: Option, - #[serde(default)] - goal: Option, -} - -#[derive(Debug, Deserialize)] -struct FinishEvidencePayload { - #[serde(default)] - primary_outcome_achieved: Option, - #[serde(default)] - validation_checks_passed: Option>, - #[serde(default)] - edge_cases_handled: Option>, -} - -#[derive(Debug, Deserialize)] -struct ProgressPayload { - #[serde(default)] - past: Option, - #[serde(default)] - current: Option, -} - -#[derive(Debug, Deserialize)] -struct AgentPayload { - prompt: String, - #[serde(default)] - context: Option, - #[serde(default)] - write: Option, - #[serde(default)] - models: Option>, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum AgentsField { - List(Vec), - Object(AgentsPayload), -} - -#[derive(Debug, Deserialize)] -struct AgentsPayload { - #[serde(default)] - timing: Option, - #[serde(default)] - models: Option>, - #[serde( - default, - alias = "list", - alias = "agents", - alias = "entries", - alias = "requests" - )] - requests: Vec, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "snake_case")] -enum AgentsTimingValue { - Parallel, - Blocking, -} - -impl From for AutoTurnAgentsTiming { - fn from(value: AgentsTimingValue) -> Self { - match value { - AgentsTimingValue::Parallel => AutoTurnAgentsTiming::Parallel, - AgentsTimingValue::Blocking => AutoTurnAgentsTiming::Blocking, - } - } -} - -#[derive(Debug, Deserialize)] -struct CoordinatorDecisionLegacy { - finish_status: String, - #[serde(default)] - progress_past: Option, - #[serde(default)] - progress_current: Option, - #[serde(default)] - cli_context: Option, - #[serde(default)] - cli_prompt: Option, - #[serde(default)] - goal: Option, -} - -#[derive(Debug)] -struct ParsedCoordinatorDecision { - status: AutoCoordinatorStatus, - status_title: Option, - status_sent_to_user: Option, - cli: Option, - agents_timing: Option, - agents: Vec, - goal: Option, - response_items: Vec, - token_usage: Option, - model_slug: String, -} - -#[derive(Debug, Clone)] -struct DecisionParseOptions { - require_cli_model_routing: bool, - allowed_cli_routing_entries: Vec, -} - -impl Default for DecisionParseOptions { - fn default() -> Self { - Self { - require_cli_model_routing: false, - allowed_cli_routing_entries: default_auto_drive_cli_routing_entries(), - } - } -} - -#[derive(Debug, Clone)] -struct CliAction { - prompt: String, - context: Option, - suppress_ui_context: bool, - model_override: Option, - reasoning_effort_override: Option, -} - -#[derive(Debug, Clone)] -struct AgentAction { - prompt: String, - context: Option, - write: Option, - models: Option>, -} - -struct DecisionFailure { - error: anyhow::Error, - schema_label: &'static str, - output_text: Option, -} - -impl DecisionFailure { - fn new(error: anyhow::Error, schema_label: &'static str, output_text: Option) -> Self { - Self { - error, - schema_label, - output_text, - } - } -} - -pub fn start_auto_coordinator( - event_tx: AutoCoordinatorEventSender, - goal_text: String, - conversation: Vec, - config: Config, - debug_enabled: bool, - derive_goal_from_history: bool, -) -> Result { - if std::env::var_os("CODEX_DEBUG_AUTO_COORDINATOR").is_some() { - eprintln!( - "start_auto_coordinator invoked\n{:?}", - std::backtrace::Backtrace::force_capture() - ); - } - - let (cmd_tx, cmd_rx) = mpsc::channel(); - let thread_tx = cmd_tx.clone(); - let cancel_token = CancellationToken::new(); - let thread_cancel = cancel_token.clone(); - - // Keep plenty of stack headroom for deep JSON Schema validation stacks and - // large coordinator transcripts. The previous 256 KiB budget could - // overflow when validation recursed through long assistant responses. - let builder = std::thread::Builder::new() - .name("code-auto-coordinator".to_string()) - .stack_size(1 * 1024 * 1024); - let handle = builder.spawn(move || { - if let Err(err) = run_auto_loop( - event_tx, - goal_text, - conversation, - config, - cmd_rx, - debug_enabled, - thread_cancel, - derive_goal_from_history, - ) { - tracing::error!("auto coordinator loop error: {err:#}"); - } - }); - - if handle.is_err() { - tracing::error!("auto coordinator spawn failed: {:#}", handle.unwrap_err()); - return Err(anyhow!("auto coordinator worker unavailable")); - } - - Ok(AutoCoordinatorHandle { - tx: thread_tx, - cancel_token, - }) -} - -#[tracing::instrument(skip_all, fields(goal = %goal_text, derive_goal = derive_goal_from_history))] -fn run_auto_loop( - event_tx: AutoCoordinatorEventSender, - goal_text: String, - initial_conversation: Vec, - config: Config, - cmd_rx: Receiver, - debug_enabled: bool, - cancel_token: CancellationToken, - derive_goal_from_history: bool, -) -> Result<()> { - let mut config = config; - if config.model.trim().is_empty() { - config.model = MODEL_SLUG.to_string(); - } - if matches!(config.model_reasoning_effort, ReasoningEffort::None) { - config.model_reasoning_effort = ReasoningEffort::High; - } - let requested_effort: code_protocol::config_types::ReasoningEffort = - config.model_reasoning_effort.into(); - let clamped_effort = - clamp_reasoning_effort_for_model(&config.model, requested_effort); - config.model_reasoning_effort = ReasoningEffort::from(clamped_effort); - let allowed_verbosity = supported_text_verbosity_for_model(&config.model); - config.model_text_verbosity = allowed_verbosity - .iter() - .find(|v| matches!(v, TextVerbosity::Medium)) - .copied() - .or_else(|| allowed_verbosity.first().copied()) - .unwrap_or(TextVerbosity::Medium); - let compact_prompt_text = - resolve_compact_prompt_text(config.compact_prompt_override.as_deref()); - - let preferred_auth = if config.using_chatgpt_auth { - code_app_server_protocol::AuthMode::Chatgpt - } else { - code_app_server_protocol::AuthMode::ApiKey - }; - let code_home = config.code_home.clone(); - let responses_originator_header = config.responses_originator_header.clone(); - let auth_mgr = AuthManager::shared_with_mode_and_originator( - code_home, - preferred_auth, - responses_originator_header, - ); - let auth_mode_for_model_access = auth_mgr - .auth() - .map(|auth| auth.mode) - .or(Some(preferred_auth)); - let supports_pro_only_models = auth_mgr.supports_pro_only_models(); - let available_cli_routing_models = enabled_agent_model_specs_for_auth( - auth_mode_for_model_access, - supports_pro_only_models, - ) - .into_iter() - .map(|spec| spec.slug.to_ascii_lowercase()) - .filter(|model| model.starts_with("gpt-")) - .collect::>(); - let allowed_cli_routing_entries = resolve_auto_drive_cli_routing_entries( - &config.auto_drive, - auth_mode_for_model_access, - supports_pro_only_models, - &available_cli_routing_models, - ); - let model_provider = config.model_provider.clone(); - let model_reasoning_summary = config.model_reasoning_summary; - let model_text_verbosity = config.model_text_verbosity; - let sandbox_policy = config.sandbox_policy.clone(); - let mut time_budget = config.max_run_seconds.map(|secs| { - let total = Duration::from_secs(secs); - let deadline = config - .max_run_deadline - .unwrap_or_else(|| Instant::now() + total); - AutoTimeBudget::new(deadline, total) - }); - let coordinator_turn_cap = config.auto_drive.coordinator_turn_cap; - let config = Arc::new(config); - let mut active_agent_names = filter_agent_model_names_for_auth( - get_enabled_agents(&config.agents), - auth_mode_for_model_access, - supports_pro_only_models, - ); - if active_agent_names.is_empty() { - active_agent_names = enabled_agent_model_specs_for_auth( - auth_mode_for_model_access, - supports_pro_only_models, - ) - .into_iter() - .map(|spec| spec.slug.to_string()) - .collect(); - } - let client = Arc::new(ModelClient::new( - config.clone(), - Some(auth_mgr), - None, - model_provider, - config.model_reasoning_effort, - model_reasoning_summary, - model_text_verbosity, - Uuid::new_v4(), - Arc::new(Mutex::new( - DebugLogger::new(debug_enabled) - .unwrap_or_else(|_| DebugLogger::new(false).expect("debug logger")), - )), - )); - - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .context("creating runtime for auto coordinator")?; - - let auto_instructions = match runtime.block_on(read_auto_drive_docs(config.as_ref())) { - Ok(Some(text)) => { - let trimmed = text.trim().to_string(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - } - Ok(None) => None, - Err(err) => { - warn!("failed to read AUTO_AGENTS.md instructions: {err:#}"); - None - } - }; - let sandbox_label = if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { - "full access" - } else { - "limited sandbox" - }; - let environment_details = format_environment_details(sandbox_label); - let coordinator_prompt = read_coordinator_prompt(config.as_ref()); - let (coordinator_prompt_message, mut base_developer_intro, mut primary_goal_message) = build_developer_message( - &goal_text, - &environment_details, - coordinator_prompt.as_deref(), - derive_goal_from_history, - ); - let git_repo_present = run_git_command(["rev-parse", "--is-inside-work-tree"]) - .as_deref() - .map(|value| value == "true") - .unwrap_or(false); - if !git_repo_present { - base_developer_intro.push_str( - "\n\nThe current working directory is not a git repository. Auto Drive must only launch read-only agents. If a request includes write: true, downgrade it to read-only.", - ); - } - if config.auto_drive.model_routing_enabled && !allowed_cli_routing_entries.is_empty() { - let mut routing_lines = Vec::new(); - for entry in &allowed_cli_routing_entries { - let levels = format_cli_reasoning_levels(&entry.reasoning_levels); - let description = if entry.description.trim().is_empty() { - "No additional description".to_string() - } else { - entry.description.trim().to_string() - }; - routing_lines.push(format!( - "- {} ({levels}) — {description}", - entry.model - )); - } - if !routing_lines.is_empty() { - base_developer_intro.push_str("\n\nConfigured CLI routing entries:"); - base_developer_intro.push_str(&format!("\n{}", routing_lines.join("\n"))); - } - } - let mut schema_features = SchemaFeatures::from_auto_settings(&config.auto_drive); - if schema_features.include_cli_model_routing && allowed_cli_routing_entries.is_empty() { - schema_features.include_cli_model_routing = false; - } - if derive_goal_from_history { - schema_features.include_goal_field = true; - } - let include_agents = schema_features.include_agents; - let mut pending_conversation = Some(Arc::<[ResponseItem]>::from( - enforce_hard_message_limit(filter_popular_commands(initial_conversation)), - )); - let mut decision_seq: u64 = 0; - let mut pending_ack_seq: Option = None; - let mut queued_updates: VecDeque> = VecDeque::new(); - if !derive_goal_from_history { - if let Some(seed) = build_initial_planning_seed(&goal_text, include_agents) { - let transcript_item = make_message("assistant", seed.response_json.clone()); - let cli_action = AutoTurnCliAction { - prompt: seed.cli_prompt.clone(), - context: Some(seed.goal_message.clone()), - suppress_ui_context: true, - model_override: None, - reasoning_effort_override: None, - }; - let event = AutoCoordinatorEvent::Decision { - seq: decision_seq, - status: AutoCoordinatorStatus::Continue, - status_title: Some(seed.status_title.clone()), - status_sent_to_user: Some(seed.status_sent_to_user.clone()), - goal: Some(goal_text.clone()), - cli: Some(cli_action), - agents_timing: seed.agents_timing, - agents: Vec::new(), - transcript: vec![transcript_item], - }; - event_tx.send(event); - pending_ack_seq = Some(decision_seq); - pending_conversation = None; - } - } - let mut schema = build_schema( - &active_agent_names, - schema_features, - &allowed_cli_routing_entries, - ); - let platform = std::env::consts::OS; - debug!("[Auto coordinator] starting: goal={goal_text} platform={platform}"); - - let mut stopped = false; - let mut consecutive_decision_failures: u32 = 0; - let mut session_metrics = SessionMetrics::default(); - let mut coordinator_turns_seen: u32 = 0; - let mut active_model_slug = config.model.clone(); - let mut prev_compact_summary: Option = None; - - loop { - if stopped { - break; - } - - let mut next_conversation: Option> = None; - - if let Some(conv) = pending_conversation.take() { - if let Some(pending_seq) = pending_ack_seq { - tracing::debug!(target: "auto_drive::coordinator", pending_seq, "queueing conversation until ack"); - queue_update_capped(&mut queued_updates, conv); - } else { - next_conversation = Some(conv); - } - } else if pending_ack_seq.is_none() { - if let Some(conv) = queued_updates.pop_front() { - next_conversation = Some(conv); - } - } - - if let Some(conv) = next_conversation { - if cancel_token.is_cancelled() { - stopped = true; - continue; - } - - let conv = conv.as_ref().to_vec(); - let mut conv = filter_popular_commands(conv); - let compaction_result = maybe_compact( - &runtime, - client.as_ref(), - &event_tx, - &mut conv, - &session_metrics, - prev_compact_summary.as_deref(), - &active_model_slug, - &compact_prompt_text, - ); - conv = enforce_hard_message_limit(conv); - let conv = Arc::<[ResponseItem]>::from(conv); - if matches!(compaction_result, CompactionResult::Completed { .. }) { - event_tx.send(AutoCoordinatorEvent::CompactedHistory { - conversation: Arc::clone(&conv), - show_notice: true, - }); - } - if let CompactionResult::Completed { summary_text } = compaction_result { - prev_compact_summary = summary_text; - } - let developer_intro = base_developer_intro.as_str(); - let mut retry_conversation: Option> = None; - let time_budget_message = time_budget.as_mut().and_then(|budget| budget.maybe_nudge()); - let time_budget_deadline = time_budget.as_ref().map(|budget| budget.deadline); - let loop_warning = session_metrics.loop_detection_warning(); - match request_coordinator_decision( - &runtime, - client.as_ref(), - developer_intro, - &primary_goal_message, - coordinator_prompt_message.as_deref(), - time_budget_message.as_deref(), - time_budget_deadline, - loop_warning.as_deref(), - &schema, - Arc::clone(&conv), - auto_instructions.as_deref(), - &event_tx, - &cancel_token, - &active_model_slug, - schema_features.include_cli_model_routing, - &allowed_cli_routing_entries, - ) { - Ok(ParsedCoordinatorDecision { - status, - status_title, - status_sent_to_user, - goal, - cli, - mut agents_timing, - mut agents, - mut response_items, - token_usage, - model_slug, - }) => { - retry_conversation.take(); - coordinator_turns_seen = coordinator_turns_seen.saturating_add(1); - if let Some(usage) = token_usage.as_ref() { - session_metrics.record_turn(usage); - emit_auto_drive_metrics(&event_tx, &session_metrics); - } else { - let estimated_prompt_tokens: u64 = conv - .iter() - .map(|item| estimate_item_tokens(item) as u64) - .sum(); - session_metrics.record_turn_without_usage(estimated_prompt_tokens); - emit_auto_drive_metrics(&event_tx, &session_metrics); - } - active_model_slug = model_slug; - if !include_agents { - agents_timing = None; - agents.clear(); - } - consecutive_decision_failures = 0; - - if coordinator_turn_cap > 0 - && coordinator_turns_seen >= coordinator_turn_cap - && matches!(status, AutoCoordinatorStatus::Continue) - { - warn!( - "auto coordinator turn cap reached ({coordinator_turns_seen}/{coordinator_turn_cap}); stopping" - ); - decision_seq = decision_seq.wrapping_add(1); - let current_seq = decision_seq; - let event = AutoCoordinatorEvent::Decision { - seq: current_seq, - status: AutoCoordinatorStatus::Failed, - status_title: Some("Turn limit reached".to_string()), - status_sent_to_user: Some(format!( - "Stopped after {coordinator_turns_seen} coordinator turns (cap={coordinator_turn_cap}) to prevent a runaway session." - )), - goal, - cli: None, - agents_timing: None, - agents: Vec::new(), - transcript: std::mem::take(&mut response_items), - }; - pending_ack_seq = Some(current_seq); - event_tx.send(event); - stopped = true; - continue; - } - - if let Some(goal_text) = goal - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - primary_goal_message = format!("**Primary Goal**\n{goal_text}"); - if schema_features.include_goal_field { - schema_features.include_goal_field = false; - schema = build_schema( - &active_agent_names, - schema_features, - &allowed_cli_routing_entries, - ); - } - } - decision_seq = decision_seq.wrapping_add(1); - let current_seq = decision_seq; - if matches!(status, AutoCoordinatorStatus::Continue) { - let event = AutoCoordinatorEvent::Decision { - seq: current_seq, - status, - status_title: status_title.clone(), - status_sent_to_user: status_sent_to_user.clone(), - goal: goal.clone(), - cli: cli.as_ref().map(cli_action_to_event), - agents_timing, - agents: agents - .iter() - .map(|action| { - agent_action_to_event_with_write_guard( - action, - git_repo_present, - ) - }) - .collect(), - transcript: std::mem::take(&mut response_items), - }; - pending_ack_seq = Some(current_seq); - event_tx.send(event); - continue; - } - - let decision_event = PendingDecision { - seq: current_seq, - status, - status_title, - status_sent_to_user, - goal: goal.clone(), - cli: cli.as_ref().map(cli_action_to_event), - agents_timing, - agents: agents - .iter() - .map(|action| { - agent_action_to_event_with_write_guard(action, git_repo_present) - }) - .collect(), - transcript: response_items, - }; - - let should_stop = matches!(decision_event.status, AutoCoordinatorStatus::Failed); - pending_ack_seq = Some(current_seq); - event_tx.send(decision_event.into_event()); - stopped = should_stop; - continue; - } - Err(failure) => { - let DecisionFailure { - error, - schema_label, - output_text, - } = failure; - let raw_output = output_text.clone(); - if error.downcast_ref::().is_some() { - stopped = true; - continue; - } - if let Some(recoverable) = classify_recoverable_decision_error(&error) { - consecutive_decision_failures = - consecutive_decision_failures.saturating_add(1); - if consecutive_decision_failures <= MAX_DECISION_RECOVERY_ATTEMPTS { - let attempt = consecutive_decision_failures; - - const OVERLONG_MSG: &str = "ERROR: Your last cli_milestone_instruction was greater than 600 characters and was not sent to the CLI. Please try again with a shorter prompt. You must keep prompts succinct (<=600 chars) to give the CLI autonomy to decide how to best execute the task."; - - let mut already_shared_raw = false; - if let Some(raw) = raw_output.as_ref() { - // Assistant message should show the model's raw output so the UI sees the failed response. - let assistant_msg = make_message("assistant", raw.clone()); - retry_conversation - .get_or_insert_with(|| conv.as_ref().to_vec()) - .push(assistant_msg); - already_shared_raw = true; - } - - warn!( - "auto coordinator decision validation failed (attempt {}/{}): {:#}", - attempt, - MAX_DECISION_RECOVERY_ATTEMPTS, - error - ); - let raw_excerpt = if already_shared_raw { - None - } else { - raw_output.as_deref().map(summarize_json_for_debug) - }; - let mut message = format!( - "Coordinator response invalid (attempt {attempt}/{MAX_DECISION_RECOVERY_ATTEMPTS}): {}. Retrying…\nSchema: {schema_label}", - recoverable.summary - ); - if let Some(excerpt) = raw_excerpt.as_ref() { - message.push_str("\nLast JSON:\n"); - message.push_str(&indent_lines(excerpt, " ")); - } else if already_shared_raw { - message.push_str("\nSee assistant message above for the full model output that failed validation."); - } - let _ = event_tx.send(AutoCoordinatorEvent::Thinking { - delta: message, - summary_index: None, - }); - { - let retry_vec = - retry_conversation.get_or_insert_with(|| conv.as_ref().to_vec()); - let mut developer_note = format!( - "Previous coordinator response failed validation (attempt {attempt}/{MAX_DECISION_RECOVERY_ATTEMPTS}).\nError: {error}\nSchema: {schema_label}" - ); - if let Some(guidance) = recoverable.guidance.as_ref() { - developer_note.push_str("\nGuidance: "); - developer_note.push_str(guidance); - } - if already_shared_raw { - developer_note.push_str("\n"); - developer_note.push_str(OVERLONG_MSG); - } else if let Some(excerpt) = raw_excerpt { - developer_note.push_str("\nLast JSON:\n"); - developer_note.push_str(&indent_lines(&excerpt, " ")); - developer_note.push_str("\n"); - developer_note.push_str(OVERLONG_MSG); - } - retry_vec.push(make_message("developer", developer_note)); - } - let retry_snapshot = retry_conversation - .take() - .map(|conversation| { - Arc::<[ResponseItem]>::from(enforce_hard_message_limit( - filter_popular_commands(conversation), - )) - }) - .unwrap_or_else(|| Arc::clone(&conv)); - // Keep the model and UI in sync with the full conversation, but avoid spamming a compaction notice. - let _ = event_tx.send(AutoCoordinatorEvent::CompactedHistory { - conversation: Arc::clone(&retry_snapshot), - show_notice: false, - }); - // Show a user-facing action entry in the Auto Drive card (does not go to the model). - let _ = event_tx.send(AutoCoordinatorEvent::Action { - message: "Retrying prompt generation after the previous response was too long to send to the CLI.".to_string(), - }); - pending_conversation = Some(retry_snapshot); - continue; - } - warn!( - "auto coordinator validation retry limit exceeded after {} attempts: {:#}", - MAX_DECISION_RECOVERY_ATTEMPTS, - error - ); - } - consecutive_decision_failures = 0; - decision_seq = decision_seq.wrapping_add(1); - let current_seq = decision_seq; - let event = AutoCoordinatorEvent::Decision { - seq: current_seq, - status: AutoCoordinatorStatus::Failed, - status_title: Some("Coordinator error".to_string()), - status_sent_to_user: Some(format!("Encountered an error: {error}")), - goal: None, - cli: None, - agents_timing: None, - agents: Vec::new(), - transcript: Vec::new(), - }; - pending_ack_seq = Some(current_seq); - event_tx.send(event); - stopped = true; - continue; - } - } - } - - match cmd_rx.recv() { - Ok(AutoCoordinatorCommand::AckDecision { seq }) => { - if pending_ack_seq == Some(seq) { - tracing::debug!(target: "auto_drive::coordinator", seq, "ack received"); - pending_ack_seq = None; - if let Some(queued) = queued_updates.pop_front() { - pending_conversation = Some(queued); - } - } else { - tracing::debug!(target: "auto_drive::coordinator", pending = ?pending_ack_seq, seq, "ignoring ack for unexpected sequence"); - } - } - Ok(AutoCoordinatorCommand::HandleUserPrompt { _prompt, conversation }) => { - let developer_intro = base_developer_intro.as_str(); - let base_conversation = conversation.as_ref().to_vec(); - let conversation_snapshot = Arc::<[ResponseItem]>::from(base_conversation); - let schema = user_turn_schema(); - let time_budget_message = time_budget.as_mut().and_then(|budget| budget.maybe_nudge()); - let time_budget_deadline = time_budget.as_ref().map(|budget| budget.deadline); - match request_user_turn_decision( - &runtime, - client.as_ref(), - developer_intro, - &primary_goal_message, - coordinator_prompt_message.as_deref(), - time_budget_message.as_deref(), - time_budget_deadline, - &schema, - Arc::clone(&conversation_snapshot), - auto_instructions.as_deref(), - &event_tx, - &cancel_token, - &active_model_slug, - ) { - Ok((user_response, cli_command)) => { - let mut updated_conversation = conversation_snapshot.as_ref().to_vec(); - if let Some(response_text) = user_response.clone() { - updated_conversation.push(make_message("assistant", response_text.clone())); - } - let updated_conversation = - enforce_hard_message_limit(filter_popular_commands(updated_conversation)); - pending_conversation = - Some(Arc::<[ResponseItem]>::from(updated_conversation)); - event_tx.send(AutoCoordinatorEvent::UserReply { - user_response, - cli_command, - }); - } - Err(failure) => { - let DecisionFailure { - error, - schema_label, - output_text, - } = failure; - tracing::warn!( - "failed to handle coordinator user prompt (schema={}): {:#}", - schema_label, - error - ); - if let Some(raw) = output_text.as_ref() { - tracing::debug!( - "user-turn raw response (schema={}): {}", - schema_label, - raw - ); - } - event_tx.send(AutoCoordinatorEvent::UserReply { - user_response: Some(format!("Coordinator error: {error}")), - cli_command: None, - }); - } - } - } - Ok(AutoCoordinatorCommand::UpdateConversation(conv)) => { - consecutive_decision_failures = 0; - let conv = conv.as_ref().to_vec(); - let filtered = Arc::<[ResponseItem]>::from(enforce_hard_message_limit( - filter_popular_commands(conv), - )); - if let Some(pending_seq) = pending_ack_seq { - tracing::debug!(target: "auto_drive::coordinator", pending_seq, "queueing update while awaiting ack"); - session_metrics.record_replay(); - queue_update_capped(&mut queued_updates, filtered); - } else if pending_conversation.is_some() { - session_metrics.record_replay(); - queue_update_capped(&mut queued_updates, filtered); - } else { - pending_conversation = Some(filtered); - } - } - Ok(AutoCoordinatorCommand::Stop) | Err(_) => { - stopped = true; - event_tx.send(AutoCoordinatorEvent::StopAck); - pending_ack_seq = None; - queued_updates.clear(); - } - } - } - - Ok(()) -} - -fn filter_popular_commands(items: Vec) -> Vec { - items - .into_iter() - .filter(|item| !is_popular_commands_message(item)) - .collect() -} - -fn enforce_hard_message_limit(items: Vec) -> Vec { - if items.len() <= HARD_MESSAGE_LIMIT { - return items; - } - - let original_len = items.len(); - - let mut trimmed: Vec = Vec::with_capacity(HARD_MESSAGE_LIMIT); - let goal_index = items.iter().position( - |item| matches!(item, ResponseItem::Message { role, .. } if role == "user"), - ); - - if let Some(idx) = goal_index { - trimmed.push(items[idx].clone()); - } - - let remaining = HARD_MESSAGE_LIMIT.saturating_sub(trimmed.len()); - let tail_start = items.len().saturating_sub(remaining); - for (idx, item) in items.into_iter().enumerate().skip(tail_start) { - if goal_index == Some(idx) { - continue; - } - trimmed.push(item); - } - - if trimmed.len() > HARD_MESSAGE_LIMIT { - let drop = trimmed.len() - HARD_MESSAGE_LIMIT; - trimmed.drain(0..drop); - } - - let dropped = original_len.saturating_sub(trimmed.len()); - if dropped > 0 { - let total = HARD_LIMIT_TRIMMED_ITEMS_TOTAL.fetch_add(dropped as u64, Ordering::Relaxed) - + dropped as u64; - debug!( - dropped, - original_len, - retained = trimmed.len(), - total_trimmed = total, - "auto coordinator trimmed conversation to hard message limit" - ); - } - - trimmed -} - -fn queue_update_capped(queue: &mut VecDeque>, update: Arc<[ResponseItem]>) { - if queue.len() >= MAX_QUEUED_CONVERSATION_UPDATES { - queue.pop_front(); - let total = QUEUED_UPDATE_DROPS_TOTAL.fetch_add(1, Ordering::Relaxed) + 1; - warn!( - cap = MAX_QUEUED_CONVERSATION_UPDATES, - total_dropped = total, - "auto coordinator dropped oldest queued conversation update" - ); - } - queue.push_back(update); -} - -fn is_popular_commands_message(item: &ResponseItem) -> bool { - match item { - ResponseItem::Message { role, content, .. } if role.eq_ignore_ascii_case("user") => { - content.iter().any(|c| match c { - ContentItem::InputText { text } => text.contains("Popular commands:"), - _ => false, - }) - } - _ => false, - } -} -fn read_coordinator_prompt(config: &Config) -> Option { - let trimmed = COORDINATOR_PROMPT.trim(); - if trimmed.is_empty() { - None - } else { - if config.max_run_seconds.is_some() { - Some(format!("{trimmed}\n\n# Time Budget\n{TIMEBOXED_EXEC_COORDINATOR_GUIDANCE}")) - } else { - Some(trimmed.to_string()) - } - } -} - -fn build_developer_message( - goal_text: &str, - environment_details: &str, - coordinator_prompt: Option<&str>, - derive_goal_from_history: bool, -) -> (Option, String, String) { - let prompt_body = coordinator_prompt.unwrap_or("").trim(); - let coordinator_message = if prompt_body.is_empty() { - None - } else { - Some(prompt_body.to_string()) - }; - let intro = format!("Environment: -{environment_details}"); - let primary_goal = if derive_goal_from_history { - "**Primary Goal**\nYou are preparing to start Auto Drive. Review the recent conversation history and identify the single primary coding goal the assistant should pursue next.".to_string() - } else { - format!("**Primary Goal**\n{}", goal_text) - }; - (coordinator_message, intro, primary_goal) -} - -struct InitialPlanningSeed { - response_json: String, - cli_prompt: String, - goal_message: String, - status_title: String, - status_sent_to_user: String, - agents_timing: Option, -} - -fn build_initial_planning_seed(goal_text: &str, include_agents: bool) -> Option { - let goal = goal_text.trim(); - if goal.is_empty() { - return None; - } - - let cli_prompt = if include_agents { - "Please provide a clear plan to best achieve the Primary Goal. If this is not a trivial task, launch agents and use your tools to research the best approach. If this is a trivial task, or the plan is already in the conversation history, immediately provide the plan. Judge the length of research and planning you perform based on the complexity of the task. For more complex tasks, you can break the plan into workstreams that can be performed at the same time." - .to_string() - } else { - "Please provide a clear plan to best achieve the Primary Goal. If this is not a trivial task, use your tools to research the best approach. If this is a trivial task, or the plan is already in the conversation history, immediately provide the plan. Judge the length of research and planning you perform based on the complexity of the task." - .to_string() - }; - - let response_json = format!( - "{{\"finish_status\":\"continue\",\"status_title\":\"Planning\",\"status_sent_to_user\":\"Started initial planning phase\",\"cli_milestone_instruction\":\"{cli_prompt}\"}}" - ); - - Some(InitialPlanningSeed { - response_json, - cli_prompt, - goal_message: format!("Primary Goal: {goal}"), - status_title: "Planning route".to_string(), - status_sent_to_user: "Planning best route to reach the goal.".to_string(), - agents_timing: if include_agents { - Some(AutoTurnAgentsTiming::Parallel) - } else { - None - }, - }) -} - -fn format_environment_details(sandbox: &str) -> String { - let cwd = std::env::current_dir() - .map(|dir| dir.display().to_string()) - .unwrap_or_else(|_| "".to_string()); - let branch = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "".to_string()); - let git_status_raw = run_git_command(["status", "--short"]); - let git_status = match git_status_raw { - Some(raw) if raw.trim().is_empty() => " clean".to_string(), - Some(raw) => raw - .lines() - .map(|line| format!(" {line}")) - .collect::>() - .join("\n"), - None => " ".to_string(), - }; - format!( - "- Access: {sandbox}\n- Working directory: {cwd}\n- Git branch: {branch}\n- Git status:\n{git_status}" - ) -} - -fn run_git_command(args: [&str; N]) -> Option { - let output = Command::new("git").args(args).output().ok()?; - if !output.status.success() { - return None; - } - if args.iter().any(|arg| matches!(*arg, "pull" | "checkout" | "merge" | "apply")) { - code_core::review_coord::bump_snapshot_epoch(); - } - String::from_utf8(output.stdout) - .ok() - .map(|text| text.trim_end().to_string()) -} - -#[derive(Clone, Copy)] -struct SchemaFeatures { - include_agents: bool, - include_goal_field: bool, - include_cli_model_routing: bool, -} - -impl SchemaFeatures { - fn from_auto_settings(settings: &AutoDriveSettings) -> Self { - Self { - include_agents: settings.agents_enabled, - include_goal_field: false, - include_cli_model_routing: settings.model_routing_enabled, - } - } -} - -impl Default for SchemaFeatures { - fn default() -> Self { - Self { - include_agents: true, - include_goal_field: false, - include_cli_model_routing: true, - } - } -} - -fn build_schema( - active_agents: &[String], - features: SchemaFeatures, - cli_routing_entries: &[AutoDriveCliRoutingEntry], -) -> Value { - let models_enum_values: Vec = active_agents - .iter() - .map(|name| Value::String(name.clone())) - .collect(); - - let models_items_schema = { - let mut schema = json!({ - "type": "string", - }); - if !models_enum_values.is_empty() { - schema["enum"] = Value::Array(models_enum_values.clone()); - } - schema - }; - - let models_description = build_model_guide_description(active_agents); - - let models_request_property = json!({ - "type": "array", - "maxItems": 4, - "description": models_description, - "items": models_items_schema, - }); - - let mut properties = serde_json::Map::new(); - let mut required: Vec = Vec::new(); - - properties.insert( - "finish_status".to_string(), - json!({ - "type": "string", - "enum": ["continue", "finish_success", "finish_failed"], - "description": "Prefer 'continue' until the solution is rock solid. Do not finish if there are obvious edge cases or missing tests to write. You MUST populate the 'finish_evidence' object when finishing." - }), - ); - required.push(Value::String("finish_status".to_string())); - - properties.insert( - "phase".to_string(), - json!({ - "type": ["string", "null"], - "enum": ["explore", "implement", "validate", "lockdown", null], - "description": "Optional: tracks mission state. Use 'explore' for recon/planning, 'implement' for coding, and 'validate/lockdown' to trigger deep validation before finishing." - }), - ); - required.push(Value::String("phase".to_string())); - - let goal_schema = if features.include_goal_field { - json!({ - "type": "string", - "minLength": 4, - "maxLength": 200, - "description": "Provide the single primary coding goal derived from the recent conversation history to begin Auto Drive without a user-supplied prompt." - }) - } else { - json!({ - "type": ["string", "null"], - "maxLength": 200, - "description": "Use only when bootstrapping/clarifying the mission goal is required." - }) - }; - properties.insert("goal".to_string(), goal_schema); - required.push(Value::String("goal".to_string())); - - properties.insert( - "status_title".to_string(), - json!({ - "type": ["string", "null"], - "minLength": 2, - "maxLength": 80, - "description": "1-4 words, present-tense milestone headline." - }), - ); - required.push(Value::String("status_title".to_string())); - - properties.insert( - "status_sent_to_user".to_string(), - json!({ - "type": ["string", "null"], - "minLength": 4, - "maxLength": 600, - "description": "1-2 sentences explaining the high-level milestone the CLI (and any agents) are tackling." - }), - ); - required.push(Value::String("status_sent_to_user".to_string())); - - properties.insert( - "cli_milestone_instruction".to_string(), - json!({ - "type": ["string", "null"], - "minLength": CLI_PROMPT_MIN_CHARS, - "description": "Single milestone instruction to the CLI. Outcome-focused, non-procedural. Keep this between 4 and 600 characters; set to null ONLY when finishing." - }), - ); - required.push(Value::String("cli_milestone_instruction".to_string())); - - if features.include_cli_model_routing { - let mut cli_model_enum: Vec = cli_routing_entries - .iter() - .map(|entry| Value::String(entry.model.clone())) - .collect(); - cli_model_enum.push(Value::Null); - let cli_models_description = if cli_routing_entries.is_empty() { - "CLI model for this turn. Set to null only when finishing.".to_string() - } else { - let routes = cli_routing_entries - .iter() - .map(|entry| { - let levels = format_cli_reasoning_levels(&entry.reasoning_levels); - let description = if entry.description.trim().is_empty() { - "No description".to_string() - } else { - entry.description.trim().to_string() - }; - format!("{} ({levels}) — {description}", entry.model) - }) - .collect::>() - .join("; "); - format!( - "CLI model for this turn. Allowed routes: {routes}. Set to null only when finishing." - ) - }; - properties.insert( - "cli_model".to_string(), - json!({ - "type": ["string", "null"], - "enum": cli_model_enum, - "description": cli_models_description, - }), - ); - required.push(Value::String("cli_model".to_string())); - - let mut reasoning_enum: Vec = Vec::new(); - for level in [ - ReasoningEffort::Minimal, - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::XHigh, - ] { - if cli_routing_entries - .iter() - .any(|entry| entry.reasoning_levels.contains(&level)) - { - reasoning_enum.push(Value::String(cli_reasoning_effort_to_str(level).to_string())); - } - } - reasoning_enum.push(Value::Null); - - let reasoning_description = if cli_routing_entries.is_empty() { - "Reasoning effort for the selected CLI model this turn. Set to null only when finishing." - .to_string() - } else { - let per_model = cli_routing_entries - .iter() - .map(|entry| { - format!( - "{}: {}", - entry.model, - format_cli_reasoning_levels(&entry.reasoning_levels) - ) - }) - .collect::>() - .join("; "); - format!( - "Reasoning effort for the selected CLI model this turn. Allowed by model: {per_model}. Set to null only when finishing." - ) - }; - - properties.insert( - "cli_reasoning_effort".to_string(), - json!({ - "type": ["string", "null"], - "enum": reasoning_enum, - "description": reasoning_description, - }), - ); - required.push(Value::String("cli_reasoning_effort".to_string())); - } - - if features.include_agents { - properties.insert( - "agents".to_string(), - json!({ - "type": ["object", "null"], - "additionalProperties": false, - "description": "Optional parallel helper agents. Default to null. Use strategically for parallel research, diverse planning, or parallel isolated implementation using fast models (spark/flash). Do NOT use for trivial CLI tool usage (like running tests or grep).", - "properties": { - "timing": { - "type": "string", - "enum": ["parallel", "blocking"] - }, - "list": { - "type": "array", - "maxItems": 4, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "write": { - "type": "boolean", - "description": "If true, agent can write in isolated worktree. Set to true for coding implementation." - }, - "context": { - "type": ["string", "null"], - "maxLength": 1500, - "description": "All necessary background (agents do not see chat history)." - }, - "prompt": { - "type": "string", - "minLength": 8, - "maxLength": 400, - "description": "Outcome-oriented instruction for the agent." - }, - "models": models_request_property.clone() - }, - "required": ["prompt", "context", "write", "models"] - }, - }, - }, - "required": ["timing", "list"] - }), - ); - required.push(Value::String("agents".to_string())); - } - - properties.insert( - "finish_evidence".to_string(), - json!({ - "type": ["object", "null"], - "additionalProperties": false, - "description": "MANDATORY when finish_status is not 'continue'. Leave null if 'continue'. Concrete proof that the task is entirely complete, edge cases are handled, and no further work is needed.", - "properties": { - "primary_outcome_achieved": { - "type": "string", - "maxLength": 600, - "description": "Summary of what was achieved and fully resolved end-to-end." - }, - "validation_checks_passed": { - "type": "array", - "maxItems": 12, - "items": { - "type": "string", - "maxLength": 140 - }, - "description": "Specific validation commands executed by the CLI that are now passing (e.g., 'npm test', 'cargo clippy', 'browser UX verified')." - }, - "edge_cases_handled": { - "type": "array", - "maxItems": 12, - "items": { - "type": "string", - "maxLength": 180 - }, - "description": "Specific edge cases that were actively handled and verified before finishing. (Leave empty if none apply, but do NOT use this to list unhandled risks)." - } - }, - "required": ["primary_outcome_achieved", "validation_checks_passed", "edge_cases_handled"] - }), - ); - required.push(Value::String("finish_evidence".to_string())); - - let mut schema = serde_json::Map::new(); - schema.insert( - "title".to_string(), - Value::String("Coordinator Turn".to_string()), - ); - schema.insert("type".to_string(), Value::String("object".to_string())); - schema.insert("additionalProperties".to_string(), Value::Bool(false)); - schema.insert("properties".to_string(), Value::Object(properties)); - schema.insert("required".to_string(), Value::Array(required)); - // Avoid JSON schema combinators like allOf/if/then here because the - // Responses API validator currently rejects them for text.format.schema. - // We enforce conditional requirements in parse-time validation instead. - - Value::Object(schema) -} - - -struct RequestStreamResult { - output_text: String, - response_items: Vec, - token_usage: Option, - model_slug: String, -} - -#[tracing::instrument(skip_all, fields(conv_items = conversation.len()))] -fn request_coordinator_decision( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - developer_intro: &str, - primary_goal: &str, - coordinator_prompt: Option<&str>, - time_budget_message: Option<&str>, - time_budget_deadline: Option, - loop_warning: Option<&str>, - schema: &Value, - conversation: Arc<[ResponseItem]>, - auto_instructions: Option<&str>, - event_tx: &AutoCoordinatorEventSender, - cancel_token: &CancellationToken, - preferred_model_slug: &str, - require_cli_model_routing: bool, - allowed_cli_routing_entries: &[AutoDriveCliRoutingEntry], -) -> Result { - let RequestStreamResult { - output_text, - response_items, - token_usage, - model_slug, - } = request_decision( - runtime, - client, - developer_intro, - primary_goal, - coordinator_prompt, - time_budget_message, - time_budget_deadline, - loop_warning, - schema, - Arc::clone(&conversation), - auto_instructions, - event_tx, - cancel_token, - preferred_model_slug, - ) - .map_err(|err| DecisionFailure::new(err, "coordinator_decision", None))?; - if output_text.trim().is_empty() && response_items.is_empty() { - return Err(DecisionFailure::new( - anyhow!("coordinator stream ended without producing output (possible transient error)"), - "coordinator_decision", - Some(output_text), - )); - } - let (mut decision, value) = parse_decision( - &output_text, - DecisionParseOptions { - require_cli_model_routing, - allowed_cli_routing_entries: allowed_cli_routing_entries.to_vec(), - }, - ) - .map_err(|err| DecisionFailure::new(err, "coordinator_decision", Some(output_text.clone())))?; - debug!("[Auto coordinator] model decision: {:?}", value); - decision.response_items = response_items; - decision.token_usage = token_usage; - decision.model_slug = model_slug; - Ok(decision) -} - -fn request_decision( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - developer_intro: &str, - primary_goal: &str, - coordinator_prompt: Option<&str>, - time_budget_message: Option<&str>, - time_budget_deadline: Option, - loop_warning: Option<&str>, - schema: &Value, - conversation: Arc<[ResponseItem]>, - auto_instructions: Option<&str>, - event_tx: &AutoCoordinatorEventSender, - cancel_token: &CancellationToken, - preferred_model_slug: &str, -) -> Result { - match request_decision_with_model( - runtime, - client, - developer_intro, - primary_goal, - coordinator_prompt, - time_budget_message, - time_budget_deadline, - loop_warning, - schema, - Arc::clone(&conversation), - auto_instructions, - event_tx, - cancel_token, - preferred_model_slug, - ) { - Ok(result) => Ok(result), - Err(err) => { - let preferred = preferred_model_slug; - let fallback_candidate = client.default_model_slug().to_string(); - let fallback_slug = if fallback_candidate.eq_ignore_ascii_case(preferred) { - MODEL_SLUG.to_string() - } else { - fallback_candidate - }; - if fallback_slug != preferred_model_slug && should_retry_with_default_model(&err) { - debug!( - preferred = %preferred, - fallback = %fallback_slug, - "auto coordinator falling back to configured model after invalid model error" - ); - let original_error = err.to_string(); - return request_decision_with_model( - runtime, - client, - developer_intro, - primary_goal, - coordinator_prompt.as_deref(), - time_budget_message, - time_budget_deadline, - loop_warning, - schema, - Arc::clone(&conversation), - auto_instructions, - event_tx, - cancel_token, - &fallback_slug, - ) - .map_err(|fallback_err| { - fallback_err.context(format!( - "coordinator fallback with model '{}' failed after original error: {}", - fallback_slug, original_error - )) - }); - } - Err(err) - } - } -} - -fn summarize_json_for_debug(raw: &str) -> String { - let trimmed = raw.trim(); - let mut chars = trimmed.chars(); - if trimmed.chars().count() <= DEBUG_JSON_MAX_CHARS { - return trimmed.to_string(); - } - let mut summary: String = chars.by_ref().take(DEBUG_JSON_MAX_CHARS).collect(); - summary.push('…'); - summary -} - -fn indent_lines(text: &str, prefix: &str) -> String { - text.lines() - .map(|line| format!("{prefix}{line}")) - .collect::>() - .join("\n") -} - -#[tracing::instrument(skip_all, fields(conv_items = conversation.len()))] -fn request_user_turn_decision( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - developer_intro: &str, - primary_goal: &str, - coordinator_prompt: Option<&str>, - time_budget_message: Option<&str>, - time_budget_deadline: Option, - schema: &Value, - conversation: Arc<[ResponseItem]>, - auto_instructions: Option<&str>, - event_tx: &AutoCoordinatorEventSender, - cancel_token: &CancellationToken, - preferred_model_slug: &str, -) -> Result<(Option, Option), DecisionFailure> { - // User turn decisions don't need loop warnings as they handle user prompts. - let result = request_decision( - runtime, - client, - developer_intro, - primary_goal, - coordinator_prompt, - time_budget_message, - time_budget_deadline, - None, - schema, - Arc::clone(&conversation), - auto_instructions, - event_tx, - cancel_token, - preferred_model_slug, - ) - .map_err(|err| DecisionFailure::new(err, "auto_coordinator_user_turn", None))?; - let (user_response, cli_command) = parse_user_turn_reply(&result.output_text) - .map_err(|err| DecisionFailure::new(err, "auto_coordinator_user_turn", Some(result.output_text.clone())))?; - Ok((user_response, cli_command)) -} - -fn request_decision_with_model( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - developer_intro: &str, - primary_goal: &str, - coordinator_prompt: Option<&str>, - time_budget_message: Option<&str>, - time_budget_deadline: Option, - loop_warning: Option<&str>, - schema: &Value, - conversation: Arc<[ResponseItem]>, - auto_instructions: Option<&str>, - event_tx: &AutoCoordinatorEventSender, - cancel_token: &CancellationToken, - model_slug: &str, -) -> Result { - let developer_intro = developer_intro.to_string(); - let primary_goal = primary_goal.to_string(); - let time_budget_message = time_budget_message.map(|text| text.to_string()); - let loop_warning = loop_warning.map(|text| text.to_string()); - let schema = schema.clone(); - let conversation = Arc::clone(&conversation); - let auto_instructions = auto_instructions.map(|text| text.to_string()); - let coordinator_prompt = coordinator_prompt.map(|text| text.to_string()); - let tx = event_tx.clone(); - let cancel = cancel_token.clone(); - let mut rate_limit_switch_state = RateLimitSwitchState::default(); - let selected_model = Arc::new(Mutex::new(model_slug.to_string())); - let classify = |error: &anyhow::Error| { - classify_model_error_with_auto_switch(client, &mut rate_limit_switch_state, event_tx, error) - }; - let options = RetryOptions::with_defaults(retry_max_elapsed(time_budget_deadline)); - let selected_model_for_run = Arc::clone(&selected_model); - - let result = runtime.block_on(async move { - retry_with_backoff( - || { - let instructions = auto_instructions.clone(); - let coordinator_prompt = coordinator_prompt.clone(); - let time_budget_message = time_budget_message.clone(); - let loop_warning = loop_warning.clone(); - let conversation = Arc::clone(&conversation); - let model_slug = selected_model_for_run - .lock() - .ok() - .map(|guard| guard.clone()) - .unwrap_or_else(|| model_slug.to_string()); - let prompt = build_user_turn_prompt( - &developer_intro, - &primary_goal, - coordinator_prompt.as_deref(), - time_budget_message.as_deref(), - loop_warning.as_deref(), - &schema, - conversation.as_ref(), - &model_slug, - instructions.as_deref(), - ); - let tx_inner = tx.clone(); - async move { - #[cfg(feature = "dev-faults")] - if let Some(fault) = next_fault(FaultScope::AutoDrive) { - let err = fault_to_error(fault); - return Err(err); - } - let mut stream = client.stream(&prompt).await?; - let mut out = String::new(); - let mut response_items: Vec = Vec::new(); - let mut reasoning_delta_accumulator = String::new(); - let mut saw_output_text_delta = false; - let mut token_usage: Option = None; - while let Some(ev) = stream.next().await { - match ev { - Ok(ResponseEvent::OutputTextDelta { delta, .. }) => { - out.push_str(&delta); - saw_output_text_delta = true; - } - Ok(ResponseEvent::OutputItemDone { item, .. }) => { - if let ResponseItem::Message { content, .. } = &item { - if !saw_output_text_delta { - for c in content { - if let ContentItem::OutputText { text } = c { - out.push_str(text); - } - } - } - } - if matches!(item, ResponseItem::Reasoning { .. }) { - reasoning_delta_accumulator.clear(); - } - response_items.push(item); - saw_output_text_delta = false; - } - Ok(ResponseEvent::ReasoningSummaryDelta { - delta, - summary_index, - .. - }) => { - let cleaned = strip_role_prefix(&delta); - reasoning_delta_accumulator.push_str(cleaned); - let message = cleaned.to_string(); - tx_inner.send(AutoCoordinatorEvent::Thinking { - delta: message, - summary_index, - }); - } - Ok(ResponseEvent::ReasoningContentDelta { delta, .. }) => { - let cleaned = strip_role_prefix(&delta); - reasoning_delta_accumulator.push_str(cleaned); - let message = cleaned.to_string(); - tx_inner.send(AutoCoordinatorEvent::Thinking { - delta: message, - summary_index: None, - }); - } - Ok(ResponseEvent::Completed { token_usage: usage, .. }) => { - token_usage = usage; - break; - } - Err(err) => return Err(err.into()), - _ => {} - } - } - if !reasoning_delta_accumulator.trim().is_empty() - && !response_items - .iter() - .any(|item| matches!(item, ResponseItem::Reasoning { .. })) - { - response_items.push(ResponseItem::Reasoning { - id: String::new(), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: reasoning_delta_accumulator.trim().to_string(), - }]), - encrypted_content: None, - }); - } - Ok(RequestStreamResult { - output_text: out, - response_items, - token_usage, - model_slug: model_slug.to_string(), - }) - } - }, - classify, - options, - &cancel, - |status| { - let human_delay = status - .sleep - .map(format_duration) - .unwrap_or_else(|| "0s".to_string()); - let elapsed = format_duration(status.elapsed); - let prefix = if status.is_rate_limit { - "Rate limit" - } else { - "Transient error" - }; - let attempt = status.attempt; - let resume_str = status.resume_at.and_then(|resume| { - let now = Instant::now(); - if resume <= now { - Some("now".to_string()) - } else { - let remaining = resume.duration_since(now); - SystemTime::now() - .checked_add(remaining) - .map(|time| { - let local: DateTime = time.into(); - local.format("%Y-%m-%d %H:%M:%S").to_string() - }) - } - }); - let message = format!( - "{prefix} (attempt {attempt}): {}; retrying in {human_delay} (elapsed {elapsed}){}", - status.reason, - resume_str - .map(|s| format!("; next attempt at {s}")) - .unwrap_or_default() - ); - let _ = tx.send(AutoCoordinatorEvent::Thinking { - delta: message, - summary_index: None, - }); - }, - ) - .await - }); - - match result { - Ok(output) => Ok(output), - Err(RetryError::Aborted) => Err(anyhow!(AutoCoordinatorCancelled)), - Err(RetryError::Fatal(err)) => Err(err), - Err(RetryError::Timeout { elapsed, last_error }) => Err(last_error.context(format!( - "auto coordinator retry window exceeded after {}", - format_duration(elapsed) - ))), - } -} - -fn build_user_turn_prompt( - developer_intro: &str, - primary_goal: &str, - coordinator_prompt: Option<&str>, - time_budget_message: Option<&str>, - loop_warning: Option<&str>, - schema: &Value, - conversation: &[ResponseItem], - model_slug: &str, - auto_instructions: Option<&str>, -) -> Prompt { - let mut prompt = Prompt::default(); - prompt.store = true; - prompt.session_id_override = Some(Uuid::new_v4()); - if let Some(instructions) = auto_instructions { - let trimmed = instructions.trim(); - if !trimmed.is_empty() { - prompt - .input - .push(make_message("developer", trimmed.to_string())); - } - } - if let Some(prompt_text) = coordinator_prompt { - let trimmed = prompt_text.trim(); - if !trimmed.is_empty() { - prompt - .prepend_developer_messages - .push(trimmed.to_string()); - } - } - - if let Some(message) = time_budget_message { - let trimmed = message.trim(); - if !trimmed.is_empty() { - prompt - .input - .push(make_message("developer", trimmed.to_string())); - } - } - if let Some(warning) = loop_warning { - let trimmed = warning.trim(); - if !trimmed.is_empty() { - prompt - .input - .push(make_message("developer", trimmed.to_string())); - } - } - prompt - .input - .push(make_message("developer", developer_intro.to_string())); - prompt - .input - .push(make_message("developer", primary_goal.to_string())); - prompt.input.extend(conversation.iter().cloned()); - prompt.text_format = Some(TextFormat { - r#type: "json_schema".to_string(), - name: Some(USER_TURN_SCHEMA_NAME.to_string()), - strict: Some(true), - schema: Some(schema.clone()), - }); - prompt.model_override = Some(model_slug.to_string()); - let family = find_family_for_model(model_slug) - .unwrap_or_else(|| derive_default_model_family(model_slug)); - prompt.model_family_override = Some(family); - prompt.set_log_tag("auto/coordinator"); - prompt -} - -fn should_retry_with_default_model(err: &anyhow::Error) -> bool { - err.chain().any(|cause| { - if let Some(code_err) = cause.downcast_ref::() { - if let CodexErr::UnexpectedStatus(err) = code_err { - if !err.status.is_client_error() { - return false; - } - let body_lower = err.body.to_lowercase(); - return body_lower.contains("invalid model") - || body_lower.contains("unknown model") - || body_lower.contains("model_not_found") - || body_lower.contains("model does not exist"); - } - } - false - }) -} - -pub(crate) fn classify_model_error(error: &anyhow::Error) -> RetryDecision { - if let Some(code_err) = find_in_chain::(error) { - match code_err { - CodexErr::Stream(message, _, _) => { - return RetryDecision::RetryAfterBackoff { - reason: format!("model stream error: {message}"), - }; - } - CodexErr::Timeout => { - return RetryDecision::RetryAfterBackoff { - reason: "model request timed out".to_string(), - }; - } - CodexErr::UnexpectedStatus(err) => { - let status = err.status; - let body = &err.body; - if status == StatusCode::REQUEST_TIMEOUT || status.as_u16() == 408 { - return RetryDecision::RetryAfterBackoff { - reason: format!("provider returned {status}"), - }; - } - if status.as_u16() == 499 { - return RetryDecision::RetryAfterBackoff { - reason: "client closed request (499)".to_string(), - }; - } - if status == StatusCode::TOO_MANY_REQUESTS { - if let Some(wait_until) = parse_rate_limit_hint(body) { - return RetryDecision::RateLimited { - wait_until, - reason: "rate limited; waiting for reset".to_string(), - }; - } - return RetryDecision::RetryAfterBackoff { - reason: "rate limited (429)".to_string(), - }; - } - if status.is_client_error() { - return RetryDecision::Fatal(anyhow!(error.to_string())); - } - if status.is_server_error() { - return RetryDecision::RetryAfterBackoff { - reason: format!("server error {status}"), - }; - } - } - CodexErr::UsageLimitReached(limit) => { - if let Some(seconds) = limit.resets_in_seconds { - let wait_until = compute_rate_limit_wait(Duration::from_secs(seconds)); - return RetryDecision::RateLimited { - wait_until, - reason: "usage limit reached".to_string(), - }; - } - return RetryDecision::Fatal(anyhow!(error.to_string())); - } - CodexErr::UsageNotIncluded => { - return RetryDecision::Fatal(anyhow!(error.to_string())); - } - CodexErr::AuthRefreshPermanent(_) => { - return RetryDecision::Fatal(anyhow!(error.to_string())); - } - CodexErr::QuotaExceeded => { - return RetryDecision::Fatal(anyhow!(error.to_string())); - } - CodexErr::ServerError(_) => { - return RetryDecision::RetryAfterBackoff { - reason: error.to_string(), - }; - } - CodexErr::RetryLimit(status) => { - if status.retryable { - return RetryDecision::RetryAfterBackoff { - reason: format!("retry limit exceeded (status {}), treating as transient", status.status), - }; - } - return RetryDecision::Fatal(anyhow!("retry limit exceeded (status {})", status.status)); - } - CodexErr::Reqwest(req_err) => { - return classify_reqwest_error(req_err); - } - CodexErr::Io(io_err) => { - if io_err.kind() == std::io::ErrorKind::TimedOut { - return RetryDecision::RetryAfterBackoff { - reason: "network timeout".to_string(), - }; - } - } - _ => {} - } - } - - if let Some(req_err) = find_in_chain::(error) { - return classify_reqwest_error(req_err); - } - - if let Some(io_err) = find_in_chain::(error) { - if io_err.kind() == std::io::ErrorKind::TimedOut { - return RetryDecision::RetryAfterBackoff { - reason: "network timeout".to_string(), - }; - } - } - - RetryDecision::Fatal(anyhow!(error.to_string())) -} - -fn classify_model_error_with_auto_switch( - client: &ModelClient, - state: &mut RateLimitSwitchState, - event_tx: &AutoCoordinatorEventSender, - error: &anyhow::Error, -) -> RetryDecision { - if let Some(code_err) = find_in_chain::(error) { - if let CodexErr::UsageLimitReached(limit) = code_err { - if client.auto_switch_accounts_on_rate_limit() - && auth::read_code_api_key_from_env().is_none() - { - if let Some(auth_manager) = client.get_auth_manager() { - let auth = auth_manager.auth(); - let current_account_id = auth - .as_ref() - .and_then(|current| current.get_account_id()) - .or_else(|| { - auth_accounts::get_active_account_id(client.code_home()) - .ok() - .flatten() - }); - if let Some(current_account_id) = current_account_id { - let now = Utc::now(); - let blocked_until = limit - .resets_in_seconds - .map(|seconds| now + chrono::Duration::seconds(seconds as i64)); - let current_auth_mode = auth - .as_ref() - .map(|current| current.mode) - .or_else(|| { - auth_accounts::find_account( - client.code_home(), - current_account_id.as_str(), - ) - .ok() - .flatten() - .map(|account| account.mode) - }); - if let Some(current_auth_mode) = current_auth_mode { - match switch_active_account_on_rate_limit( - client.code_home(), - state, - client.api_key_fallback_on_all_accounts_limited(), - now, - current_account_id.as_str(), - current_auth_mode, - blocked_until, - ) { - Ok(Some(next_account_id)) => { - let next_label = auth_accounts::find_account( - client.code_home(), - &next_account_id, - ) - .ok() - .flatten() - .and_then(|account| account.label) - .unwrap_or_else(|| next_account_id.clone()); - tracing::info!( - from_account_id = %current_account_id, - to_account_id = %next_account_id, - reason = "usage_limit_reached", - "rate limit hit; auto-switching active account" - ); - auth_manager.reload(); - event_tx.send(AutoCoordinatorEvent::Action { - message: format!( - "Auto-switch: now using {next_label} due to usage limit." - ), - }); - return RetryDecision::RateLimited { - wait_until: Instant::now(), - reason: "usage limit reached; switched accounts".to_string(), - }; - } - Ok(None) => {} - Err(err) => { - warn!( - from_account_id = %current_account_id, - error = %err, - "failed to activate account after usage limit" - ); - } - } - } else { - warn!( - from_account_id = %current_account_id, - "skipping account switch after usage limit: missing auth mode" - ); - } - } - } - } - } - } - - classify_model_error(error) -} - -fn classify_reqwest_error(err: &reqwest::Error) -> RetryDecision { - if err.is_timeout() || err.is_connect() || err.is_request() && err.status().is_none() { - return RetryDecision::RetryAfterBackoff { - reason: format!("network error: {err}"), - }; - } - - if let Some(status) = err.status() { - if status == StatusCode::TOO_MANY_REQUESTS { - return RetryDecision::RetryAfterBackoff { - reason: "rate limited (429)".to_string(), - }; - } - if status == StatusCode::REQUEST_TIMEOUT || status.as_u16() == 408 { - return RetryDecision::RetryAfterBackoff { - reason: format!("provider returned {status}"), - }; - } - if status.as_u16() == 499 { - return RetryDecision::RetryAfterBackoff { - reason: "client closed request (499)".to_string(), - }; - } - if status.is_server_error() { - return RetryDecision::RetryAfterBackoff { - reason: format!("server error {status}"), - }; - } - if status.is_client_error() { - return RetryDecision::Fatal(anyhow!(err.to_string())); - } - } - - RetryDecision::Fatal(anyhow!(err.to_string())) -} - -fn parse_rate_limit_hint(body: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(body).ok()?; - let error_obj = value.get("error").unwrap_or(&value); - - if let Some(seconds) = extract_seconds(error_obj) { - return Some(compute_rate_limit_wait(seconds)); - } - - if let Some(reset_at) = extract_reset_at(error_obj) { - return Some(reset_at); - } - - None -} - -fn extract_seconds(value: &serde_json::Value) -> Option { - let fields = [ - "reset_seconds", - "reset_in_seconds", - "resets_in_seconds", - "x-ratelimit-reset", - "x-ratelimit-reset-requests", - ]; - for key in fields { - if let Some(seconds) = value.get(key) { - if let Some(num) = seconds.as_f64() { - if num.is_sign_negative() { - continue; - } - return Some(Duration::from_secs_f64(num)); - } - if let Some(text) = seconds.as_str() { - if let Ok(num) = text.parse::() { - if num.is_sign_negative() { - continue; - } - return Some(Duration::from_secs_f64(num)); - } - } - } - } - None -} - -fn extract_reset_at(value: &serde_json::Value) -> Option { - let reset_at = value.get("reset_at").and_then(|v| v.as_str())?; - let parsed = DateTime::parse_from_rfc3339(reset_at) - .or_else(|_| DateTime::parse_from_str(reset_at, "%+")) - .ok()?; - let reset_utc = parsed.with_timezone(&Utc); - let now = Utc::now(); - let duration = reset_utc.signed_duration_since(now).to_std().unwrap_or_default(); - Some(compute_rate_limit_wait(duration)) -} - -fn compute_rate_limit_wait(base: Duration) -> Instant { - let mut wait = if base > Duration::ZERO { base } else { Duration::ZERO }; - wait += RATE_LIMIT_BUFFER; - wait += random_jitter(RATE_LIMIT_JITTER_MAX); - Instant::now() + wait -} - -fn random_jitter(max: Duration) -> Duration { - if max.is_zero() { - return Duration::ZERO; - } - let mut rng = rand::rng(); - let jitter = rng.random_range(0.0..max.as_secs_f64()); - Duration::from_secs_f64(jitter) -} - -fn find_in_chain<'a, T: std::error::Error + 'static>(error: &'a anyhow::Error) -> Option<&'a T> { - for cause in error.chain() { - if let Some(specific) = cause.downcast_ref::() { - return Some(specific); - } - } - None -} - - -struct RecoverableDecisionError { - summary: String, - #[cfg_attr(not(test), allow(dead_code))] - guidance: Option, -} - -fn classify_recoverable_decision_error(err: &anyhow::Error) -> Option { - let text = err.to_string(); - let lower = text.to_ascii_lowercase(); - - if lower.contains("missing cli_milestone_instruction") - || lower.contains("missing cli prompt for continue") - || lower.contains("missing cli prompt for `finish_status") - || lower.contains("missing cli prompt") - { - return Some(RecoverableDecisionError { - summary: "missing `cli_milestone_instruction` for `finish_status: \"continue\"`" - .to_string(), - guidance: Some( - "Include a non-empty `cli_milestone_instruction` string whenever `finish_status` is `\"continue\"`." - .to_string(), - ), - }); - } - - if lower.contains("missing finish_evidence") { - return Some(RecoverableDecisionError { - summary: "missing `finish_evidence` for finish status".to_string(), - guidance: Some( - "Include a `finish_evidence` object whenever `finish_status` is `finish_success` or `finish_failed`." - .to_string(), - ), - }); - } - - if lower.contains("missing cli_model") { - return Some(RecoverableDecisionError { - summary: "missing `cli_model` for continue turn".to_string(), - guidance: Some( - "When Auto Drive model routing is enabled, include `cli_model` on every continue turn." - .to_string(), - ), - }); - } - - if lower.contains("missing cli_reasoning_effort") { - return Some(RecoverableDecisionError { - summary: "missing `cli_reasoning_effort` for continue turn".to_string(), - guidance: Some( - "When Auto Drive model routing is enabled, include `cli_reasoning_effort` on every continue turn." - .to_string(), - ), - }); - } - - if lower.contains("validation_checks_passed must include at least one") { - return Some(RecoverableDecisionError { - summary: "finish evidence is missing passing validation checks".to_string(), - guidance: Some( - "Provide at least one concrete passing validation check in `finish_evidence.validation_checks_passed` before finishing." - .to_string(), - ), - }); - } - - if lower.contains("finish_evidence must be null") { - return Some(RecoverableDecisionError { - summary: "`finish_evidence` must be null for continue turns".to_string(), - guidance: Some( - "Set `finish_evidence` to null (or omit it) when `finish_status` is `continue`." - .to_string(), - ), - }); - } - - if lower.contains("must set cli_milestone_instruction to null") { - return Some(RecoverableDecisionError { - summary: "`cli_milestone_instruction` must be null for finish statuses".to_string(), - guidance: Some( - "When finishing (`finish_success`/`finish_failed`), set `cli_milestone_instruction` to null and include `finish_evidence`." - .to_string(), - ), - }); - } - - if lower.contains("must set cli_model to null") - || lower.contains("must set cli_reasoning_effort to null") - { - return Some(RecoverableDecisionError { - summary: "CLI model routing fields must be null for finish statuses".to_string(), - guidance: Some( - "When finishing (`finish_success`/`finish_failed`), set `cli_model` and `cli_reasoning_effort` to null." - .to_string(), - ), - }); - } - - if lower.contains("unsupported cli_model") || lower.contains("unsupported cli_reasoning_effort") { - return Some(RecoverableDecisionError { - summary: "unsupported CLI model routing selection".to_string(), - guidance: Some( - "Use a `cli_model` listed in the schema and a `cli_reasoning_effort` allowed for that model." - .to_string(), - ), - }); - } - - if lower.contains("length limit") - || lower.contains("cut off") - || lower.contains("exceeds") && lower.contains("cli_milestone_instruction") - { - return Some(RecoverableDecisionError { - summary: "model output was cut off by a length cap".to_string(), - guidance: Some( - "Regenerate with a shorter `cli_milestone_instruction` (<=600 chars) and more concise status text so the response fits within provider limits." - .to_string(), - ), - }); - } - - if lower.contains("legacy model response missing cli_prompt for continue") { - return Some(RecoverableDecisionError { - summary: "legacy response omitted `cli_prompt` for continue turn".to_string(), - guidance: Some( - "Legacy coordinator responses must populate `cli_prompt` when the turn continues." - .to_string(), - ), - }); - } - - if lower.contains(" is empty") { - if let Some((field, _)) = text.split_once(" is empty") { - let field_trimmed = field.trim().trim_matches('`'); - if !field_trimmed.is_empty() { - let summary = format!("`{field_trimmed}` was empty"); - let guidance = format!( - "Provide a meaningful value for `{field_trimmed}` instead of leaving it blank." - ); - return Some(RecoverableDecisionError { - summary, - guidance: Some(guidance), - }); - } - } - } - - if lower.contains(" is missing") { - if let Some((field, _)) = text.split_once(" is missing") { - let field_trimmed = field.trim().trim_matches('`'); - if !field_trimmed.is_empty() { - let summary = format!("`{field_trimmed}` was missing"); - let guidance = format!( - "Include `{field_trimmed}` with a meaningful value before retrying." - ); - return Some(RecoverableDecisionError { - summary, - guidance: Some(guidance), - }); - } - } - } - - if lower.contains("unexpected finish_status") { - let extracted = text - .split('\'') - .nth(1) - .filter(|value| !value.is_empty()) - .map(|value| format!("unexpected finish_status '{value}'")) - .unwrap_or_else(|| "unexpected finish_status".to_string()); - return Some(RecoverableDecisionError { - summary: extracted, - guidance: Some( - "Use `finish_status` values: `continue`, `finish_success`, or `finish_failed`." - .to_string(), - ), - }); - } - - if lower.contains("model response was not valid json") || lower.contains("parsing json from model output") { - return Some(RecoverableDecisionError { - summary: "response was not valid JSON".to_string(), - guidance: Some( - "Return strictly valid JSON that matches the `auto_coordinator_flow` schema without extra prose." - .to_string(), - ), - }); - } - - if lower.contains("decoding coordinator decision failed") { - return Some(RecoverableDecisionError { - summary: "response did not match the coordinator schema".to_string(), - guidance: Some( - "Ensure every required field is present and spelled correctly per the coordinator schema." - .to_string(), - ), - }); - } - - None -} - -#[cfg(test)] -fn push_unique_guidance(guidance: &mut Vec, message: &str) { - let trimmed = message.trim(); - if trimmed.is_empty() { - return; - } - if guidance - .iter() - .any(|existing| existing.eq_ignore_ascii_case(trimmed)) - { - return; - } - guidance.push(trimmed.to_string()); -} - - -fn parse_decision(raw: &str, options: DecisionParseOptions) -> Result<(ParsedCoordinatorDecision, Value)> { - let value: Value = match serde_json::from_str(raw) { - Ok(v) => v, - Err(_) => { - let Some(json_blob) = extract_first_json_object(raw) else { - return Err(anyhow!("model response was not valid JSON")); - }; - serde_json::from_str(&json_blob).context("parsing JSON from model output")? - } - }; - match serde_json::from_value::(value.clone()) { - Ok(decision) => { - let status = parse_finish_status(&decision.finish_status)?; - let parsed = convert_decision_new(decision, status, options)?; - Ok((parsed, value)) - } - Err(new_err) => { - let decision: CoordinatorDecisionLegacy = serde_json::from_value(value.clone()).map_err(|legacy_err| { - let payload = serde_json::to_string(&value).unwrap_or_else(|_| "".to_string()); - let snippet = if payload.len() > 2000 { - format!("{}…", &payload[..2000]) - } else { - payload - }; - anyhow!("decoding coordinator decision failed: new_schema_err={new_err}; legacy_err={legacy_err}; payload_snippet={snippet}") - })?; - let status = parse_finish_status(&decision.finish_status)?; - let parsed = convert_decision_legacy(decision, status, options)?; - Ok((parsed, value)) - } - } -} - -fn parse_finish_status(finish_status: &str) -> Result { - let normalized = finish_status.trim().to_ascii_lowercase(); - match normalized.as_str() { - "continue" => Ok(AutoCoordinatorStatus::Continue), - "finish_success" => Ok(AutoCoordinatorStatus::Success), - "finish_failed" => Ok(AutoCoordinatorStatus::Failed), - other => Err(anyhow!("unexpected finish_status '{other}'")), - } -} - -fn convert_decision_new( - decision: CoordinatorDecisionNew, - status: AutoCoordinatorStatus, - options: DecisionParseOptions, -) -> Result { - let CoordinatorDecisionNew { - finish_status: _, - phase, - status_title, - status_sent_to_user, - progress, - cli_milestone_instruction, - cli_model, - cli_reasoning_effort, - agents: agent_payloads, - finish_evidence, - goal, - } = decision; - - validate_phase(phase)?; - - let mut status_title = clean_optional(status_title); - let mut status_sent_to_user = clean_optional(status_sent_to_user); - - if let Some(progress) = progress { - let legacy_past = clean_optional(progress.past); - let legacy_current = clean_optional(progress.current); - if status_title.is_none() { - status_title = legacy_current.clone(); - } - if status_sent_to_user.is_none() { - status_sent_to_user = legacy_past.clone(); - } - } - - let goal = clean_optional(goal); - - let cli_prompt = clean_optional(cli_milestone_instruction); - let cli_model = clean_optional(cli_model); - let cli_reasoning_effort = clean_optional(cli_reasoning_effort); - - validate_finish_evidence_for_status(status, finish_evidence)?; - let (cli_model, cli_reasoning_effort) = - validate_cli_model_selection(status, cli_model, cli_reasoning_effort, options)?; - - let cli = match (status, cli_prompt) { - (AutoCoordinatorStatus::Continue, Some(prompt)) => { - let prompt = clean_required(&prompt, "cli_milestone_instruction")?; - ensure_cli_prompt_length(&prompt)?; - - Some(CliAction { - prompt, - context: None, - suppress_ui_context: false, - model_override: cli_model, - reasoning_effort_override: cli_reasoning_effort, - }) - } - (AutoCoordinatorStatus::Continue, None) => { - return Err(anyhow!( - "model response missing cli_milestone_instruction for continue" - )); - } - (_, Some(_prompt)) => { - return Err(anyhow!( - "model response must set cli_milestone_instruction to null when finish_status is finish_success or finish_failed" - )); - } - (_, None) => None, - }; - - let mut agent_actions: Vec = Vec::new(); - let mut agents_timing: Option = None; - if let Some(payloads) = agent_payloads { - match payloads { - AgentsField::List(list) => { - for payload in list { - let AgentPayload { prompt, context, write, models } = payload; - let prompt = clean_required(&prompt, "agents[*].prompt")?; - agent_actions.push(AgentAction { - prompt, - context: clean_optional(context), - write, - models: clean_models(models), - }); - } - } - AgentsField::Object(plan) => { - let AgentsPayload { - timing, - models, - requests, - } = plan; - if let Some(timing_value) = timing { - agents_timing = Some(timing_value.into()); - } - let batch_models = clean_models(models); - for payload in requests { - let AgentPayload { prompt, context, write, models } = payload; - let prompt = clean_required(&prompt, "agents.requests[*].prompt")?; - let models = clean_models(models).or_else(|| batch_models.clone()); - agent_actions.push(AgentAction { - prompt, - context: clean_optional(context), - write, - models, - }); - } - } - } - } - - Ok(ParsedCoordinatorDecision { - status, - status_title, - status_sent_to_user, - cli, - agents_timing, - agents: agent_actions, - goal, - response_items: Vec::new(), - token_usage: None, - model_slug: MODEL_SLUG.to_string(), - }) -} - -fn convert_decision_legacy( - decision: CoordinatorDecisionLegacy, - status: AutoCoordinatorStatus, - options: DecisionParseOptions, -) -> Result { - let CoordinatorDecisionLegacy { - finish_status: _, - progress_past, - progress_current, - cli_context, - cli_prompt, - goal, - } = decision; - - let status_title = clean_optional(progress_current); - let status_sent_to_user = clean_optional(progress_past); - let context = clean_optional(cli_context); - let goal = clean_optional(goal); - - if matches!(status, AutoCoordinatorStatus::Success | AutoCoordinatorStatus::Failed) { - return Err(anyhow!( - "legacy model response missing finish_evidence for finish_status finish_success or finish_failed" - )); - } - - if options.require_cli_model_routing { - return Err(anyhow!( - "legacy model response missing cli_model and cli_reasoning_effort for continue" - )); - } - - let cli = match (status, cli_prompt) { - (AutoCoordinatorStatus::Continue, Some(prompt)) => Some(CliAction { - prompt: clean_required(&prompt, "cli_prompt")?, - context: context.clone(), - suppress_ui_context: false, - model_override: None, - reasoning_effort_override: None, - }), - (AutoCoordinatorStatus::Continue, None) => { - return Err(anyhow!("legacy model response missing cli_prompt for continue")); - } - (_, Some(_prompt)) => { - return Err(anyhow!( - "model response must set cli_milestone_instruction to null when finish_status is finish_success or finish_failed" - )); - } - (_, None) => None, - }; - - Ok(ParsedCoordinatorDecision { - status, - status_title, - status_sent_to_user, - cli, - agents_timing: None, - agents: Vec::new(), - goal, - response_items: Vec::new(), - token_usage: None, - model_slug: MODEL_SLUG.to_string(), - }) -} - -fn clean_optional(input: Option) -> Option { - input.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - let without_prefix = strip_role_prefix(trimmed); - let final_trimmed = without_prefix.trim(); - if final_trimmed.is_empty() { - None - } else { - Some(final_trimmed.to_string()) - } - } - }) -} - -fn clean_models(models: Option>) -> Option> { - let mut cleaned: Vec = models? - .into_iter() - .filter_map(|model| { - let trimmed = model.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) - .collect(); - - if cleaned.is_empty() { - return None; - } - - cleaned.sort(); - cleaned.dedup(); - Some(cleaned) -} - -fn clean_required(value: &str, field: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - Err(anyhow!("{field} is empty")) - } else { - let without_prefix = strip_role_prefix(trimmed); - let final_trimmed = without_prefix.trim(); - if final_trimmed.is_empty() { - Err(anyhow!("{field} is empty")) - } else { - Ok(final_trimmed.to_string()) - } - } -} - -fn clean_required_list(values: Option>, field: &str) -> Result> { - let values = values.ok_or_else(|| anyhow!("{field} is missing"))?; - values - .into_iter() - .enumerate() - .map(|(idx, value)| clean_required(&value, &format!("{field}[{idx}]"))) - .collect() -} - -fn validate_phase(phase: Option) -> Result<()> { - let Some(phase) = clean_optional(phase) else { - return Ok(()); - }; - - match phase.as_str() { - "explore" | "implement" | "validate" | "lockdown" => Ok(()), - _ => Err(anyhow!( - "unexpected phase '{phase}'; use one of: explore, implement, validate, lockdown" - )), - } -} - -fn parse_cli_reasoning_effort(value: &str) -> Result { - let normalized = value.trim().to_ascii_lowercase(); - match normalized.as_str() { - "minimal" => Ok(ReasoningEffort::Minimal), - "none" => Ok(ReasoningEffort::Minimal), - "low" => Ok(ReasoningEffort::Low), - "medium" => Ok(ReasoningEffort::Medium), - "high" => Ok(ReasoningEffort::High), - "xhigh" => Ok(ReasoningEffort::XHigh), - _ => Err(anyhow!( - "unsupported cli_reasoning_effort '{normalized}'; expected one of: minimal, low, medium, high, xhigh" - )), - } -} - -fn normalize_cli_model( - value: &str, - allowed_cli_routing_entries: &[AutoDriveCliRoutingEntry], -) -> Result { - let trimmed = value.trim(); - for entry in allowed_cli_routing_entries { - if trimmed.eq_ignore_ascii_case(&entry.model) { - return Ok(entry.clone()); - } - } - let expected_models = if allowed_cli_routing_entries.is_empty() { - "".to_string() - } else { - allowed_cli_routing_entries - .iter() - .map(|entry| entry.model.clone()) - .collect::>() - .join(", ") - }; - Err(anyhow!( - "unsupported cli_model '{trimmed}'; expected one of: {}", - expected_models - )) -} - -fn validate_cli_model_selection( - status: AutoCoordinatorStatus, - cli_model: Option, - cli_reasoning_effort: Option, - options: DecisionParseOptions, -) -> Result<(Option, Option)> { - if !options.require_cli_model_routing { - return Ok((None, None)); - } - - match status { - AutoCoordinatorStatus::Continue => { - let model = cli_model - .ok_or_else(|| anyhow!("model response missing cli_model for continue"))?; - let reasoning_raw = cli_reasoning_effort.ok_or_else(|| { - anyhow!("model response missing cli_reasoning_effort for continue") - })?; - - let model = - normalize_cli_model(&model, &options.allowed_cli_routing_entries)?; - let reasoning = parse_cli_reasoning_effort(&reasoning_raw)?; - if !model.reasoning_levels.contains(&reasoning) { - let expected = model - .reasoning_levels - .iter() - .map(|level| cli_reasoning_effort_to_str(*level)) - .collect::>() - .join(", "); - return Err(anyhow!( - "unsupported cli_reasoning_effort '{}' for cli_model '{}'; expected one of: {}", - reasoning_raw, - model.model, - expected - )); - } - - Ok((Some(model.model), Some(reasoning))) - } - AutoCoordinatorStatus::Success | AutoCoordinatorStatus::Failed => { - if cli_model.is_some() { - return Err(anyhow!( - "model response must set cli_model to null when finish_status is finish_success or finish_failed" - )); - } - if cli_reasoning_effort.is_some() { - return Err(anyhow!( - "model response must set cli_reasoning_effort to null when finish_status is finish_success or finish_failed" - )); - } - Ok((None, None)) - } - } -} - -fn validate_finish_evidence_for_status( - status: AutoCoordinatorStatus, - finish_evidence: Option, -) -> Result<()> { - match status { - AutoCoordinatorStatus::Continue => { - if finish_evidence.is_some() { - return Err(anyhow!( - "finish_evidence must be null when finish_status is continue" - )); - } - Ok(()) - } - AutoCoordinatorStatus::Success | AutoCoordinatorStatus::Failed => { - let finish_evidence = finish_evidence.ok_or_else(|| { - anyhow!( - "model response missing finish_evidence for finish_status finish_success or finish_failed" - ) - })?; - - let FinishEvidencePayload { - primary_outcome_achieved, - validation_checks_passed, - edge_cases_handled, - } = finish_evidence; - - let _primary_outcome_achieved = clean_required( - &primary_outcome_achieved - .ok_or_else(|| anyhow!("finish_evidence.primary_outcome_achieved is missing"))?, - "finish_evidence.primary_outcome_achieved", - )?; - let validation_checks_passed = clean_required_list( - validation_checks_passed, - "finish_evidence.validation_checks_passed", - )?; - if validation_checks_passed.is_empty() { - return Err(anyhow!( - "finish_evidence.validation_checks_passed must include at least one passing validation check" - )); - } - let _edge_cases_handled = - clean_required_list(edge_cases_handled, "finish_evidence.edge_cases_handled")?; - - Ok(()) - } - } -} - -fn ensure_cli_prompt_length(prompt: &str) -> Result<()> { - let len = prompt.chars().count(); - if len < CLI_PROMPT_MIN_CHARS { - return Err(anyhow!( - "cli_milestone_instruction must be at least {CLI_PROMPT_MIN_CHARS} characters; keep it concise but not empty" - )); - } - if len > CLI_PROMPT_MAX_CHARS { - return Err(anyhow!( - "cli_milestone_instruction exceeds {CLI_PROMPT_MAX_CHARS} characters; keep prompts succinct (<=600 chars) and let the CLI decide how to execute the task" - )); - } - - Ok(()) -} - -fn cli_action_to_event(action: &CliAction) -> AutoTurnCliAction { - AutoTurnCliAction { - prompt: action.prompt.clone(), - context: action.context.clone(), - suppress_ui_context: action.suppress_ui_context, - model_override: action.model_override.clone(), - reasoning_effort_override: action.reasoning_effort_override, - } -} - -fn agent_action_to_event(action: &AgentAction) -> AutoTurnAgentsAction { - AutoTurnAgentsAction { - prompt: action.prompt.clone(), - context: action.context.clone(), - write: action.write.unwrap_or(false), - write_requested: action.write, - models: action.models.clone(), - } -} - -fn agent_action_to_event_with_write_guard( - action: &AgentAction, - allow_write: bool, -) -> AutoTurnAgentsAction { - let mut event = agent_action_to_event(action); - if !allow_write && event.write { - event.write = false; - } - event -} - -pub(crate) fn extract_first_json_object(input: &str) -> Option { - let mut depth = 0usize; - let mut in_str = false; - let mut escape = false; - let mut start: Option = None; - for (idx, ch) in input.char_indices() { - if in_str { - if escape { - escape = false; - continue; - } - match ch { - '"' => in_str = false, - '\\' => escape = true, - _ => {} - } - continue; - } - match ch { - '"' => in_str = true, - '{' => { - if depth == 0 { - start = Some(idx); - } - depth += 1; - } - '}' => { - if depth == 0 { - continue; - } - depth -= 1; - if depth == 0 { - let Some(s) = start else { return None; }; - return Some(input[s..=idx].to_string()); - } - } - _ => {} - } - } - None -} - -pub(crate) fn make_message(role: &str, text: String) -> ResponseItem { - let content = if role.eq_ignore_ascii_case("assistant") { - ContentItem::OutputText { text } - } else { - ContentItem::InputText { text } - }; - - ResponseItem::Message { - id: None, - role: role.to_string(), - content: vec![content], - end_turn: None, - phase: None, - } -} - -fn strip_role_prefix(input: &str) -> &str { - const PREFIXES: [&str; 2] = ["Coordinator:", "CLI:"]; - for prefix in PREFIXES { - if let Some(head) = input.get(..prefix.len()) { - if head.eq_ignore_ascii_case(prefix) { - let rest = input - .get(prefix.len()..) - .unwrap_or_default(); - return rest.strip_prefix(' ').unwrap_or(rest); - } - } - } - input -} - -fn emit_auto_drive_metrics(event_tx: &AutoCoordinatorEventSender, metrics: &SessionMetrics) { - if metrics.turn_count() == 0 && metrics.running_total().is_zero() { - return; - } - - let event = AutoCoordinatorEvent::TokenMetrics { - total_usage: metrics.running_total().clone(), - last_turn_usage: metrics.last_turn().clone(), - turn_count: metrics.turn_count(), - duplicate_items: metrics.duplicate_items(), - replay_updates: metrics.replay_updates(), - }; - event_tx.send(event); -} - -enum CompactionResult { - Skipped, - Completed { summary_text: Option }, -} - -fn maybe_compact( - runtime: &tokio::runtime::Runtime, - client: &ModelClient, - event_tx: &AutoCoordinatorEventSender, - conversation: &mut Vec, - metrics: &SessionMetrics, - prev_summary: Option<&str>, - model_slug: &str, - compact_prompt: &str, -) -> CompactionResult { - let transcript_tokens: u64 = conversation - .iter() - .map(|item| estimate_item_tokens(item) as u64) - .sum(); - let estimated_next = metrics.estimated_next_prompt_tokens(); - let message_count = conversation.len(); - let has_recorded_usage = metrics.has_recorded_usage(); - - if !should_compact( - model_slug, - transcript_tokens, - estimated_next, - message_count, - has_recorded_usage, - ) { - return CompactionResult::Skipped; - } - - let Some(bounds) = compute_slice_bounds(conversation) else { - return CompactionResult::Skipped; - }; - - event_tx.send(AutoCoordinatorEvent::Thinking { - delta: "Compacting history to stay within the context window…".to_string(), - summary_index: None, - }); - - let original_len = conversation.len(); - match compact_with_endpoint(runtime, client, conversation, model_slug, compact_prompt) { - Ok(compacted) => { - let removed = original_len.saturating_sub(compacted.len()); - let plural = if removed == 1 { "" } else { "s" }; - *conversation = compacted; - event_tx.send(AutoCoordinatorEvent::Thinking { - delta: format!( - "Finished compacting history ({removed} message{plural} -> {} total).", - conversation.len() - ), - summary_index: None, - }); - debug!( - "[Auto coordinator] remote compacted {removed} messages; new conversation length {}", - conversation.len() - ); - return CompactionResult::Completed { - summary_text: None, - }; - } - Err(err) => { - warn!("[Auto coordinator] remote compaction failed: {err:#}"); - event_tx.send(AutoCoordinatorEvent::Thinking { - delta: "Remote compaction failed; falling back to local summary.".to_string(), - summary_index: None, - }); - } - } - - let slice: Vec = conversation[bounds.0..bounds.1].to_vec(); - - let (checkpoint, summary_warning) = build_checkpoint_summary( - runtime, - client, - model_slug, - &slice, - prev_summary, - compact_prompt, - ); - - if let Some(warning_text) = summary_warning { - warn!( - "[Auto coordinator] checkpoint summary fell back to deterministic mode: {warning_text}" - ); - event_tx.send(AutoCoordinatorEvent::Thinking { - delta: format!( - "History compaction warning: {warning_text}. Falling back to a deterministic summary." - ), - summary_index: None, - }); - } - - let summary_message = checkpoint.message; - let mut applied = apply_compaction( - conversation, - bounds, - prev_summary, - summary_message.clone(), - ) - .is_some(); - if !applied { - if let Some(retry_bounds) = compute_slice_bounds(conversation) - .filter(|retry_bounds| *retry_bounds != bounds) - { - applied = apply_compaction( - conversation, - retry_bounds, - prev_summary, - summary_message, - ) - .is_some(); - } - } - if !applied { - warn!("[Auto coordinator] apply_compaction returned None; bounds={bounds:?}"); - event_tx.send(AutoCoordinatorEvent::Thinking { - delta: "Failed to compact history because the conversation changed while applying the summary. Continuing without compaction.".to_string(), - summary_index: None, - }); - return CompactionResult::Skipped; - } - - let removed = slice.len(); - let total = conversation.len(); - let plural = if removed == 1 { "" } else { "s" }; - event_tx.send(AutoCoordinatorEvent::Thinking { - delta: format!( - "Finished compacting history ({removed} message{plural} -> {total} total)." - ), - summary_index: None, - }); - - debug!( - "[Auto coordinator] compacted {} messages; new conversation length {}", - slice.len(), - conversation.len() - ); - CompactionResult::Completed { - summary_text: Some(checkpoint.text), - } -} - -/// Determine if compaction should occur based on token usage. -/// -/// Uses 80% of the model's max_context as the threshold. -/// Returns true if `session_total + estimated_next >= 0.8 * model_context_window`. -/// -/// # Arguments -/// * `model_slug` - The model identifier to look up context limits -/// * `session_total` - Total tokens used in the session so far -/// * `estimated_next` - Estimated tokens for the next turn -/// * `message_count` - Number of messages in the current conversation (fallback heuristic) -/// * `has_recorded_usage` - Whether we have real token usage from the backend -pub fn should_compact( - model_slug: &str, - transcript_tokens: u64, - estimated_next: u64, - message_count: usize, - has_recorded_usage: bool, -) -> bool { - // Get model family to look up model info - let family = find_family_for_model(model_slug) - .unwrap_or_else(|| derive_default_model_family(model_slug)); - - let token_limit = family - .auto_compact_token_limit() - .and_then(|limit| (limit > 0).then(|| limit as u64)) - .or(family.context_window); - - if let Some(token_limit) = token_limit { - if token_limit > 0 { - let threshold = (token_limit as f64 * 0.8) as u64; - let projected_total = transcript_tokens.saturating_add(estimated_next); - if projected_total >= threshold { - return true; - } - - // When we have an explicit token budget for the model, rely on it and - // skip the fallback message-count heuristic. This avoids runaway - // compaction loops when restarting Auto Drive with a large but still - // token-safe transcript. - return false; - } - } - - if has_recorded_usage { - return false; - } - - fallback_message_limit(message_count) -} - -fn fallback_message_limit(message_count: usize) -> bool { - message_count >= MESSAGE_LIMIT_FALLBACK -} diff --git a/code-rs/code-auto-drive-core/src/auto_drive_history.rs b/code-rs/code-auto-drive-core/src/auto_drive_history.rs deleted file mode 100644 index cdcd3448d28..00000000000 --- a/code-rs/code-auto-drive-core/src/auto_drive_history.rs +++ /dev/null @@ -1,565 +0,0 @@ -use std::collections::VecDeque; - -use code_core::protocol::TokenUsage; -use code_protocol::models::{ - ContentItem, FunctionCallOutputBody, FunctionCallOutputContentItem, ResponseItem, -}; - -use crate::session_metrics::SessionMetrics; - -/// Token estimation: 4 bytes per token (same as core/truncate.rs) -const BYTES_PER_TOKEN: usize = 4; - -/// Maintains the Auto Drive conversation transcript between coordinator turns. -/// -/// `converted` mirrors what we previously derived from UI history and is used -/// when re-seeding the coordinator conversation. `raw` captures the exact -/// ResponseItems returned by the Auto Drive model so we can retain full -/// reasoning output without depending on UI rendering. -pub struct AutoDriveHistory { - converted: Vec, - raw: Vec, - pending_duplicates: VecDeque, - /// Summary from the previous compaction, if any - prev_compact_summary: Option, - session_metrics: SessionMetrics, -} - -impl AutoDriveHistory { - pub fn new() -> Self { - Self { - converted: Vec::new(), - raw: Vec::new(), - pending_duplicates: VecDeque::new(), - prev_compact_summary: None, - session_metrics: SessionMetrics::default(), - } - } - - /// Replace the stored converted transcript. Returns any new tail items that - /// were not present previously, preserving insertion order. - pub fn replace_converted(&mut self, items: Vec) -> Vec { - let prev_len = self.converted.len(); - self.converted = items; - let tail: Vec<_> = if self.converted.len() <= prev_len { - Vec::new() - } else { - self.converted - .iter() - .skip(prev_len) - .cloned() - .collect() - }; - - if tail.is_empty() { - return tail; - } - - if self.should_skip_entire_tail(&tail) { - self - .session_metrics - .record_duplicate_items(tail.len().saturating_sub(1)); - self.session_metrics.record_replay(); - self.pending_duplicates.clear(); - return Vec::new(); - } - - if self.pending_duplicates.is_empty() { - return tail; - } - - let mut filtered = Vec::with_capacity(tail.len()); - let queue = &mut self.pending_duplicates; - for item in tail.into_iter() { - let matched = normalize_message(&item) - .and_then(|message| queue.front().map(|expected| (message, expected))) - .map(|(message, expected)| message == *expected) - .unwrap_or(false); - - if matched { - self.session_metrics.record_duplicate_items(1); - queue.pop_front(); - continue; - } - - if queue.front().is_some() { - queue.clear(); - } - - filtered.push(item); - } - - filtered - } - - fn should_skip_entire_tail(&self, tail: &[ResponseItem]) -> bool { - if self.pending_duplicates.is_empty() { - return false; - } - - if tail.len() != self.pending_duplicates.len().saturating_add(1) { - return false; - } - - let first_is_user = matches!(tail.first(), Some(ResponseItem::Message { role, .. }) if role == "user"); - if !first_is_user { - return false; - } - - tail.iter() - .skip(1) - .zip(self.pending_duplicates.iter()) - .all(|(item, expected)| { - let Some(message) = normalize_message(item) else { - return false; - }; - if message.role != expected.role { - return false; - } - - let item_segments: Vec<&str> = message - .content - .iter() - .filter_map(content_text) - .collect(); - let expected_segments: Vec<&str> = expected - .content - .iter() - .filter_map(content_text) - .collect(); - - item_segments == expected_segments - }) - } - - pub fn append_raw(&mut self, items: &[ResponseItem]) { - if items.is_empty() { - return; - } - self.raw.extend(items.iter().cloned()); - for item in items.iter() { - if let Some(message) = normalize_message(item) { - self.pending_duplicates.push_back(message); - } - } - } - - pub fn append_converted_tail(&mut self, items: &[ResponseItem]) { - if items.is_empty() { - return; - } - self.raw.extend(items.iter().cloned()); - } - - pub fn raw_snapshot(&self) -> Vec { - self.raw.clone() - } - - pub fn replace_all(&mut self, items: Vec) { - self.converted = items.clone(); - self.raw = items; - self.pending_duplicates.clear(); - } - - pub fn clear(&mut self) { - self.converted.clear(); - self.raw.clear(); - self.pending_duplicates.clear(); - self.prev_compact_summary = None; - self.session_metrics.reset(); - } - - pub fn converted_is_empty(&self) -> bool { - self.converted.is_empty() - } - - /// Replace the tracked metrics with the latest values reported by the coordinator. - pub fn apply_token_metrics( - &mut self, - total: TokenUsage, - last: TokenUsage, - turn_count: u32, - duplicate_items: u32, - replay_updates: u32, - ) { - self.session_metrics.sync_absolute(total, last, turn_count); - self.session_metrics.set_duplicate_items(duplicate_items); - self.session_metrics.set_replay_updates(replay_updates); - } - - /// Returns the cumulative token usage across all coordinator turns. - pub fn total_tokens(&self) -> &TokenUsage { - self.session_metrics.running_total() - } - - /// Returns the token usage from the most recent coordinator turn. - pub fn last_turn_tokens(&self) -> &TokenUsage { - self.session_metrics.last_turn() - } - - /// Returns the number of turns recorded so far. - pub fn recorded_turns(&self) -> u32 { - self.session_metrics.turn_count() - } - - pub fn duplicate_items(&self) -> u32 { - self.session_metrics.duplicate_items() - } - - pub fn replay_updates(&self) -> u32 { - self.session_metrics.replay_updates() - } - - /// Returns the estimated prompt tokens for the next turn. - pub fn estimated_next_prompt_tokens(&self) -> u64 { - self.session_metrics.estimated_next_prompt_tokens() - } - - /// Perform compaction by selecting a slice after the goal message (first user message), - /// finding the 50% token midpoint, advancing to the end of a turn boundary, and replacing - /// the slice with a compact summary item. - /// - /// Returns `Ok(true)` if compaction was performed, `Ok(false)` if skipped, or an error. - pub fn compact_slice(&mut self, summarizer: impl FnOnce(&[ResponseItem]) -> String) -> Result { - // Find the goal message (first user message) - let goal_idx = self.converted.iter().position(|item| { - matches!(item, ResponseItem::Message { role, .. } if role == "user") - }); - - let Some(goal_idx) = goal_idx else { - // No goal message found; nothing to compact - return Ok(false); - }; - - // We need at least a few items after the goal to make compaction worthwhile - if self.converted.len() <= goal_idx + 3 { - return Ok(false); - } - - // Calculate total tokens after the goal message - let items_after_goal = &self.converted[goal_idx + 1..]; - let total_tokens = estimate_tokens(items_after_goal); - - // We need a reasonable amount of content to compact - if total_tokens < 1000 { - return Ok(false); - } - - // Find the 50% midpoint - let target_tokens = total_tokens / 2; - let mut accumulated_tokens = 0; - let mut midpoint_idx = goal_idx + 1; - - for (i, item) in items_after_goal.iter().enumerate() { - accumulated_tokens += estimate_item_tokens(item); - if accumulated_tokens >= target_tokens { - midpoint_idx = goal_idx + 1 + i; - break; - } - } - - // Advance to the end of the turn boundary - let slice_end = advance_to_turn_boundary(&self.converted, midpoint_idx); - - // The slice to compact is from (goal_idx + 1) to slice_end - if slice_end <= goal_idx + 1 { - return Ok(false); - } - - let slice_to_compact = &self.converted[goal_idx + 1..slice_end]; - - // Generate summary - let summary_text = summarizer(slice_to_compact); - - // Build the compact summary item - let compact_item = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!( - "\n{}\n", - summary_text - ), - }], - end_turn: None, - phase: None, - }; - - // Replace the slice with the compact item - let mut new_converted = Vec::new(); - new_converted.extend_from_slice(&self.converted[..=goal_idx]); - new_converted.push(compact_item); - new_converted.extend_from_slice(&self.converted[slice_end..]); - - self.converted = new_converted; - self.prev_compact_summary = Some(summary_text); - - Ok(true) - } - -} - - -#[derive(Clone, Debug, PartialEq, Eq)] -struct NormalizedMessage { - role: String, - content: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum NormalizedContent { - InputText(String), - OutputText(String), - InputImage(String), -} - -fn normalize_message(item: &ResponseItem) -> Option { - if let ResponseItem::Message { role, content, .. } = item { - let normalized = content - .iter() - .map(|chunk| match chunk { - ContentItem::InputText { text } => NormalizedContent::InputText(text.clone()), - ContentItem::OutputText { text } => NormalizedContent::OutputText(text.clone()), - ContentItem::InputImage { image_url } => { - NormalizedContent::InputImage(image_url.clone()) - } - }) - .collect(); - Some(NormalizedMessage { - role: role.clone(), - content: normalized, - }) - } else { - None - } -} - -fn content_text(content: &NormalizedContent) -> Option<&str> { - match content { - NormalizedContent::InputText(text) - | NormalizedContent::OutputText(text) - | NormalizedContent::InputImage(text) => Some(text.as_str()), - } -} - -/// Estimate the total tokens for a slice of ResponseItems. -fn estimate_tokens(items: &[ResponseItem]) -> usize { - items.iter().map(estimate_item_tokens).sum() -} - -/// Estimate tokens for a single ResponseItem. -/// Uses byte count divided by BYTES_PER_TOKEN (4) as fallback, same as core/truncate.rs. -fn estimate_item_tokens(item: &ResponseItem) -> usize { - let byte_count = match item { - ResponseItem::Message { content, .. } => { - content.iter().map(|c| match c { - ContentItem::InputText { text } | ContentItem::OutputText { text } => text.len(), - ContentItem::InputImage { image_url } => image_url.len() / 10, // images are less token-heavy - }).sum() - } - ResponseItem::FunctionCall { name, arguments, .. } => name.len() + arguments.len(), - ResponseItem::FunctionCallOutput { output, .. } => match &output.body { - FunctionCallOutputBody::Text(text) => text.len(), - FunctionCallOutputBody::ContentItems(items) => items - .iter() - .map(|item| match item { - FunctionCallOutputContentItem::InputText { text } => text.len(), - FunctionCallOutputContentItem::InputImage { image_url, .. } => { - image_url.len() / 10 - } - }) - .sum(), - }, - ResponseItem::CustomToolCall { name, input, .. } => name.len() + input.len(), - ResponseItem::CustomToolCallOutput { output, .. } => output.to_string().len(), - ResponseItem::Reasoning { summary, content, .. } => { - summary.iter().map(|s| match s { - code_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } => text.len(), - }).sum::() - + content.as_ref().map(|c| c.iter().map(|item| match item { - code_protocol::models::ReasoningItemContent::ReasoningText { text } | - code_protocol::models::ReasoningItemContent::Text { text } => text.len(), - }).sum()).unwrap_or(0) - } - // Catch-all for other types: Other, LocalShellCall, WebSearchCall, etc. - _ => 0, - }; - byte_count.div_ceil(BYTES_PER_TOKEN) -} - -/// Advance from the given index to the end of the current turn boundary. -/// A turn boundary ends when we see a user message (the start of the next turn). -fn advance_to_turn_boundary(items: &[ResponseItem], start_idx: usize) -> usize { - let mut idx = start_idx; - - // Scan forward to find the next user message - while idx < items.len() { - if matches!(&items[idx], ResponseItem::Message { role, .. } if role == "user") { - // Found the start of the next turn; stop here - return idx; - } - idx += 1; - } - - // Reached the end of the history - idx -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_user_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - fn make_assistant_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - fn make_usage(input: u64, output: u64) -> TokenUsage { - TokenUsage { - input_tokens: input, - cached_input_tokens: 0, - cached_input_tokens_reported: false, - output_tokens: output, - reasoning_output_tokens: 0, - total_tokens: input + output, - } - } - - #[test] - fn test_compact_slice_no_goal_message() { - let mut history = AutoDriveHistory::new(); - history.converted = vec![ - make_assistant_message("Hello"), - ]; - - let result = history.compact_slice(|_| "SUMMARY".to_string()); - assert!(result.is_ok()); - assert!(!result.unwrap()); // Should skip compaction - } - - #[test] - fn test_compact_slice_insufficient_items() { - let mut history = AutoDriveHistory::new(); - history.converted = vec![ - make_user_message("Goal"), - make_assistant_message("Response 1"), - ]; - - let result = history.compact_slice(|_| "SUMMARY".to_string()); - assert!(result.is_ok()); - assert!(!result.unwrap()); // Should skip compaction - } - - #[test] - fn test_compact_slice_basic() { - let mut history = AutoDriveHistory::new(); - // Create a history with a goal and enough content to compact - let large_text = "x".repeat(4000); // ~1000 tokens - history.converted = vec![ - make_user_message("Goal message"), - make_assistant_message(&large_text), - make_user_message("Turn 2"), - make_assistant_message(&large_text), - make_user_message("Turn 3"), - ]; - - let result = history.compact_slice(|items| { - format!("Compacted {} items", items.len()) - }); - - assert!(result.is_ok()); - assert!(result.unwrap()); // Compaction should occur - - // Verify structure: goal + compact summary inserted ahead of remaining turns - assert_eq!(history.converted.len(), 5); - assert!(matches!(&history.converted[0], ResponseItem::Message { role, .. } if role == "user")); - assert!(matches!(&history.converted[1], ResponseItem::Message { role, content, .. } - if role == "user" && content.iter().any(|c| matches!(c, ContentItem::InputText { text } if text.contains(""))))); - assert!(matches!(&history.converted[2], ResponseItem::Message { role, content, .. } - if role == "user" && content.iter().any(|c| matches!(c, ContentItem::InputText { text } if text.contains("Turn 2"))))); - } - - #[test] - fn test_apply_token_metrics_updates_totals() { - let mut history = AutoDriveHistory::new(); - history.apply_token_metrics(make_usage(10, 5), make_usage(4, 2), 3, 0, 0); - - assert_eq!(history.total_tokens().input_tokens, 10); - assert_eq!(history.last_turn_tokens().input_tokens, 4); - assert_eq!(history.recorded_turns(), 3); - assert_eq!(history.estimated_next_prompt_tokens(), 4); - } - - #[test] - fn replace_converted_records_duplicate_items() { - let mut history = AutoDriveHistory::new(); - let goal = make_user_message("Goal"); - let first_reply = make_assistant_message("First reply"); - let duplicate_reply = make_assistant_message("Duplicate reply"); - - history.replace_converted(vec![goal.clone(), first_reply.clone()]); - history.append_raw(&[duplicate_reply.clone()]); - - // Introducing a new tail that begins with a user message followed by the duplicate. - let new_history = vec![ - goal, - first_reply, - make_user_message("Follow-up"), - duplicate_reply, - ]; - let tail = history.replace_converted(new_history); - assert!(tail.is_empty()); - assert_eq!(history.duplicate_items(), 1); - assert_eq!(history.replay_updates(), 1); - } - - #[test] - fn test_advance_to_turn_boundary() { - let items = vec![ - make_user_message("Goal"), - make_assistant_message("Response 1"), - make_assistant_message("Response 2"), - make_user_message("Turn 2"), - make_assistant_message("Response 3"), - ]; - - // Starting from index 1 should advance to index 3 (next user message) - let end = advance_to_turn_boundary(&items, 1); - assert_eq!(end, 3); - - // Starting from index 4 should advance to the end (no more user messages) - let end = advance_to_turn_boundary(&items, 4); - assert_eq!(end, 5); - } - - #[test] - fn test_estimate_tokens() { - let items = vec![ - make_user_message("Hello world"), // ~11 chars / 4 = ~2-3 tokens - make_assistant_message("How are you?"), // ~12 chars / 4 = ~3 tokens - ]; - - let tokens = estimate_tokens(&items); - assert!(tokens > 0); - assert!(tokens < 100); // Reasonable estimate - } -} diff --git a/code-rs/code-auto-drive-core/src/controller.rs b/code-rs/code-auto-drive-core/src/controller.rs deleted file mode 100644 index 724ad5a0c9e..00000000000 --- a/code-rs/code-auto-drive-core/src/controller.rs +++ /dev/null @@ -1,1034 +0,0 @@ -use std::time::{Duration, Instant}; - -use code_common::elapsed::format_duration; -use code_core::config_types::ReasoningEffort; -use code_core::protocol::ReviewContextMetadata; -use code_core::protocol::ReviewOutputEvent; -use code_core::review_coord::{bump_snapshot_epoch, try_acquire_lock}; -use code_git_tooling::GhostCommit; - -use crate::AutoTurnAgentsAction; -use crate::AutoTurnAgentsTiming; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AutoContinueMode { - Immediate, - TenSeconds, - SixtySeconds, - Manual, -} - -impl AutoContinueMode { - pub fn seconds(self) -> Option { - match self { - Self::Immediate => Some(0), - Self::TenSeconds => Some(10), - Self::SixtySeconds => Some(60), - Self::Manual => None, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Immediate => "Immediate", - Self::TenSeconds => "10 seconds", - Self::SixtySeconds => "60 seconds", - Self::Manual => "Manual approval", - } - } - - pub fn cycle_forward(self) -> Self { - match self { - Self::Immediate => Self::TenSeconds, - Self::TenSeconds => Self::SixtySeconds, - Self::SixtySeconds => Self::Manual, - Self::Manual => Self::Immediate, - } - } - - pub fn cycle_backward(self) -> Self { - match self { - Self::Immediate => Self::Manual, - Self::TenSeconds => Self::Immediate, - Self::SixtySeconds => Self::TenSeconds, - Self::Manual => Self::SixtySeconds, - } - } -} - -impl Default for AutoContinueMode { - fn default() -> Self { - Self::TenSeconds - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -/// High-level Auto Drive run phase. Each variant implies a precise legacy -/// boolean configuration so older call sites can continue to rely on mirrored -/// flags during the migration. -pub enum AutoRunPhase { - /// No run in flight; all transient state must be cleared. - Idle, - /// Goal entry panel is visible; awaiting user input to launch. - AwaitingGoalEntry, - /// Launch sequence is preparing the first turn; coordinator not armed yet. - Launching, - /// Run is executing normally with no outstanding coordinator or review gates. - Active, - /// User is editing the prompt; resume behaviour and next-submit bypass state carried in payload. - PausedManual { - resume_after_submit: bool, - bypass_next_submit: bool, - }, - /// Coordinator has a prompt ready for approval. - AwaitingCoordinator { prompt_ready: bool }, - /// Model response is streaming or pending diagnostics. - AwaitingDiagnostics { coordinator_waiting: bool }, - /// Awaiting user review/approval; may include diagnostics to show. - AwaitingReview { diagnostics_pending: bool }, - /// Backoff between restart attempts after a transient failure. - TransientRecovery { backoff_ms: u64 }, -} - -impl AutoRunPhase { - pub fn is_active(&self) -> bool { - matches!( - self, - Self::Launching - | Self::Active - | Self::PausedManual { .. } - | Self::AwaitingCoordinator { .. } - | Self::AwaitingDiagnostics { .. } - | Self::AwaitingReview { .. } - | Self::TransientRecovery { .. } - ) - } - - pub fn is_running(&self) -> bool { - matches!( - self, - Self::Launching - | Self::Active - | Self::PausedManual { .. } - | Self::AwaitingCoordinator { .. } - | Self::AwaitingDiagnostics { .. } - | Self::AwaitingReview { .. } - | Self::TransientRecovery { .. } - ) - } - - pub fn awaiting_coordinator_submit(&self) -> bool { - matches!(self, Self::AwaitingCoordinator { prompt_ready: true }) - } - - pub fn awaiting_review(&self) -> bool { - matches!(self, Self::AwaitingReview { .. }) - } - - pub fn in_transient_recovery(&self) -> bool { - matches!(self, Self::TransientRecovery { .. }) - } - - pub fn is_paused_manual(&self) -> bool { - matches!(self, Self::PausedManual { .. }) - } - - pub fn should_show_goal_entry(&self) -> bool { - matches!(self, Self::AwaitingGoalEntry) - } - - pub fn prompt_ready(&self) -> bool { - matches!(self, Self::AwaitingCoordinator { prompt_ready: true }) - } - - pub fn diagnostics_pending(&self) -> bool { - matches!(self, Self::AwaitingReview { diagnostics_pending: true }) - } - - pub fn is_waiting_for_response(&self) -> bool { - matches!(self, Self::AwaitingDiagnostics { .. }) - } - - pub fn resume_after_submit(&self) -> Option { - match self { - Self::PausedManual { - resume_after_submit, - .. - } => Some(*resume_after_submit), - _ => None, - } - } -} - -impl Default for AutoRunPhase { - fn default() -> Self { - Self::Idle - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PhaseTransition { - BeginLaunch, - LaunchSuccess, - LaunchFailed, - PauseForManualEdit { resume_after_submit: bool }, - ResumeFromManual, - PromptReady, - SubmitPrompt, - AwaitDiagnostics, - BeginReview { diagnostics_pending: bool }, - CompleteReview, - TransientFailure { backoff_ms: u64 }, - RecoveryAttempt, - Stop, -} - -#[derive(Clone, Debug)] -pub struct TransitionEffects { - pub effects: Vec, - pub phase_changed: bool, -} - -#[derive(Debug, Clone)] -pub struct AutoRunSummary { - pub duration: Duration, - pub turns_completed: usize, - pub message: Option, - pub goal: Option, -} - -#[derive(Debug, Clone)] -pub struct AutoRestartState { - pub token: u64, - pub attempt: u32, - pub reason: String, -} - -#[derive(Default, Clone)] -pub struct AutoTurnReviewState { - #[cfg_attr(not(any(test, feature = "test-helpers")), allow(dead_code))] - pub base_commit: Option, -} - -#[derive(Clone)] -pub struct AutoResolveState { - pub prompt: String, - pub hint: String, - pub metadata: Option, - pub attempt: u32, - pub max_attempts: u32, - pub phase: AutoResolvePhase, - pub last_review: Option, - pub last_fix_message: Option, - pub last_reviewed_commit: Option, - pub snapshot_epoch: Option, -} - -impl AutoResolveState { - pub fn new(prompt: String, hint: String, metadata: Option) -> Self { - Self::new_with_limit(prompt, hint, metadata, AUTO_RESOLVE_MAX_REVIEW_ATTEMPTS) - } - - pub fn new_with_limit( - prompt: String, - hint: String, - metadata: Option, - max_attempts: u32, - ) -> Self { - Self { - prompt, - hint, - metadata, - attempt: 0, - max_attempts, - phase: AutoResolvePhase::WaitingForReview, - last_review: None, - last_fix_message: None, - last_reviewed_commit: None, - snapshot_epoch: None, - } - } -} - -#[derive(Clone)] -pub enum AutoResolvePhase { - WaitingForReview, - PendingFix { review: ReviewOutputEvent }, - AwaitingFix { review: ReviewOutputEvent }, - AwaitingJudge { review: ReviewOutputEvent }, -} - -pub const AUTO_RESTART_BASE_DELAY: Duration = Duration::from_secs(5); -pub const AUTO_RESTART_MAX_DELAY: Duration = Duration::from_secs(120); -pub const AUTO_RESOLVE_MAX_REVIEW_ATTEMPTS: u32 = 3; -pub const AUTO_RESOLVE_REVIEW_FOLLOWUP: &str = "This issue has been resolved. Please continue your search and return all remaining issues you find."; - -#[derive(Debug, Clone)] -pub enum AutoControllerEffect { - RefreshUi, - StartCountdown { countdown_id: u64, decision_seq: u64, seconds: u8 }, - SubmitPrompt, - LaunchStarted { goal: String }, - LaunchFailed { goal: String, error: String }, - StopCompleted { summary: AutoRunSummary, message: Option }, - TransientPause { attempt: u32, delay: Duration, reason: String }, - ScheduleRestart { token: u64, attempt: u32, delay: Duration }, - CancelCoordinator, - ResetHistory, - UpdateTerminalHint { hint: Option }, - SetTaskRunning { running: bool }, - EnsureInputFocus, - ClearCoordinatorView, - ShowGoalEntry, -} - -#[derive(Default, Clone)] -pub struct AutoDriveController { - pub goal: Option, - pub current_summary: Option, - pub current_status_sent_to_user: Option, - pub current_status_title: Option, - pub current_cli_prompt: Option, - pub current_cli_context: Option, - pub current_cli_model_override: Option, - pub current_cli_reasoning_effort_override: Option, - pub hide_cli_context_in_ui: bool, - pub suppress_next_cli_display: bool, - pub current_display_line: Option, - pub current_display_is_summary: bool, - pub current_reasoning_title: Option, - pub placeholder_phrase: Option, - pub thinking_prefix_stripped: bool, - pub current_summary_index: Option, - pub countdown_id: u64, - pub countdown_decision_seq: u64, - pub seconds_remaining: u8, - pub countdown_override: Option, - pub last_broadcast_summary: Option, - pub last_decision_summary: Option, - pub last_decision_status_sent_to_user: Option, - pub last_decision_status_title: Option, - pub last_decision_display: Option, - pub last_decision_display_is_summary: bool, - pub review_enabled: bool, - pub subagents_enabled: bool, - pub cross_check_enabled: bool, - pub qa_automation_enabled: bool, - pub pending_agent_actions: Vec, - pub pending_agent_timing: Option, - pub continue_mode: AutoContinueMode, - pub started_at: Option, - pub turns_completed: usize, - pub last_run_summary: Option, - pub pending_restart: Option, - pub restart_token: u64, - pub transient_restart_attempts: u32, - pub intro_started_at: Option, - pub intro_reduced_motion: bool, - pub intro_pending: bool, - pub elapsed_override: Option, - pub pending_stop_message: Option, - pub last_completion_explanation: Option, - pub phase: AutoRunPhase, - // Non-cloneable guard is kept separately; controller stays Clone. - pub review_lock: Option>, -} - -impl AutoDriveController { - fn apply_phase(&mut self, phase: AutoRunPhase) { - self.phase = phase; - } - - pub fn set_waiting_for_response(&mut self, waiting: bool) { - if waiting { - match &mut self.phase { - AutoRunPhase::AwaitingDiagnostics { coordinator_waiting } => { - *coordinator_waiting = true; - } - _ => { - self.apply_phase(AutoRunPhase::AwaitingDiagnostics { coordinator_waiting: true }); - } - } - } else if self.phase.is_waiting_for_response() { - self.apply_phase(AutoRunPhase::Active); - } - } - - pub fn set_coordinator_waiting(&mut self, waiting: bool) { - match &mut self.phase { - AutoRunPhase::AwaitingDiagnostics { coordinator_waiting } => { - *coordinator_waiting = waiting; - } - _ if waiting => { - self.apply_phase(AutoRunPhase::AwaitingDiagnostics { coordinator_waiting: true }); - } - _ => {} - } - } - - pub fn on_prompt_ready(&mut self, prompt_ready: bool) { - self.apply_phase(AutoRunPhase::AwaitingCoordinator { prompt_ready }); - } - - pub fn on_prompt_submitted(&mut self) { - self.countdown_override = None; - self.apply_phase(AutoRunPhase::AwaitingDiagnostics { coordinator_waiting: true }); - } - - pub fn on_pause_for_manual(&mut self, resume_after_submit: bool) { - self.apply_phase(AutoRunPhase::PausedManual { - resume_after_submit, - bypass_next_submit: false, - }); - } - - pub fn on_resume_from_manual(&mut self) { - self.apply_phase(AutoRunPhase::Active); - bump_snapshot_epoch(); - } - - pub fn on_begin_review(&mut self, diagnostics_pending: bool) { - self.apply_phase(AutoRunPhase::AwaitingReview { diagnostics_pending }); - // Acquire global review lock; if busy or error, fall back to Active to avoid overlap. - self.review_lock = try_acquire_lock("auto-drive-review", std::path::Path::new(".")) - .ok() - .flatten() - .map(std::sync::Arc::new); - if self.review_lock.is_none() { - self.apply_phase(AutoRunPhase::Active); - } - } - - pub fn on_complete_review(&mut self) { - self.apply_phase(AutoRunPhase::Active); - self.review_lock = None; - } - - pub fn on_transient_failure(&mut self, backoff_ms: u64) { - self.apply_phase(AutoRunPhase::TransientRecovery { backoff_ms }); - } - - pub fn on_recovery_attempt(&mut self) { - if self.phase.in_transient_recovery() { - self.apply_phase(AutoRunPhase::Active); - } - } - - pub fn transition(&mut self, transition: PhaseTransition) -> TransitionEffects { - let old_phase = self.phase.clone(); - let effects = Vec::new(); - - match (&self.phase, &transition) { - (AutoRunPhase::Idle | AutoRunPhase::AwaitingGoalEntry, PhaseTransition::BeginLaunch) => { - self.apply_phase(AutoRunPhase::Launching); - } - (AutoRunPhase::Launching, PhaseTransition::LaunchSuccess) => { - self.apply_phase(AutoRunPhase::Active); - } - (AutoRunPhase::Launching, PhaseTransition::LaunchFailed) => { - self.apply_phase(AutoRunPhase::AwaitingGoalEntry); - } - (AutoRunPhase::Active, PhaseTransition::PauseForManualEdit { resume_after_submit }) => { - self.apply_phase(AutoRunPhase::PausedManual { - resume_after_submit: *resume_after_submit, - bypass_next_submit: false, - }); - } - (AutoRunPhase::PausedManual { .. }, PhaseTransition::ResumeFromManual) => { - self.apply_phase(AutoRunPhase::Active); - } - (AutoRunPhase::Active, PhaseTransition::PromptReady) => { - self.apply_phase(AutoRunPhase::AwaitingCoordinator { prompt_ready: true }); - } - (AutoRunPhase::AwaitingCoordinator { .. }, PhaseTransition::SubmitPrompt) => { - self.apply_phase(AutoRunPhase::Active); - } - (AutoRunPhase::Active, PhaseTransition::AwaitDiagnostics) => { - self.apply_phase(AutoRunPhase::AwaitingDiagnostics { coordinator_waiting: true }); - } - (AutoRunPhase::AwaitingDiagnostics { .. } | AutoRunPhase::Active, PhaseTransition::BeginReview { diagnostics_pending }) => { - if self.review_lock.is_none() { - self.review_lock = code_core::review_coord::try_acquire_lock( - "auto-drive-review", - std::path::Path::new("."), - ) - .ok() - .flatten() - .map(std::sync::Arc::new); - if self.review_lock.is_none() { - // Unable to secure the global lock; stay active to avoid overlapping reviews. - self.apply_phase(AutoRunPhase::Active); - return TransitionEffects { effects, phase_changed: old_phase != self.phase }; - } - } - - self.apply_phase(AutoRunPhase::AwaitingReview { diagnostics_pending: *diagnostics_pending }); - } - (AutoRunPhase::AwaitingReview { .. }, PhaseTransition::CompleteReview) => { - self.apply_phase(AutoRunPhase::Active); - self.review_lock = None; - } - (_, PhaseTransition::TransientFailure { backoff_ms }) => { - if self.phase.is_active() { - self.apply_phase(AutoRunPhase::TransientRecovery { backoff_ms: *backoff_ms }); - } - } - (AutoRunPhase::TransientRecovery { .. }, PhaseTransition::RecoveryAttempt) => { - self.apply_phase(AutoRunPhase::Active); - } - (_, PhaseTransition::Stop) => { - self.apply_phase(AutoRunPhase::Idle); - self.review_lock = None; - } - _ => { - } - } - - let phase_changed = old_phase != self.phase; - TransitionEffects { effects, phase_changed } - } - - pub fn prepare_launch( - &mut self, - goal: String, - review_enabled: bool, - subagents_enabled: bool, - cross_check_enabled: bool, - qa_automation_enabled: bool, - continue_mode: AutoContinueMode, - reduced_motion: bool, - ) { - let seed_intro = self.take_intro_pending(); - self.reset(); - if seed_intro { - self.mark_intro_pending(); - } - - self.review_enabled = review_enabled; - self.subagents_enabled = subagents_enabled; - self.cross_check_enabled = cross_check_enabled; - self.qa_automation_enabled = qa_automation_enabled; - self.continue_mode = continue_mode; - self.reset_countdown(); - self.ensure_intro_timing(reduced_motion); - self.goal = Some(goal); - self.transition(PhaseTransition::BeginLaunch); - } - - pub fn launch_succeeded( - &mut self, - goal: String, - placeholder_phrase: Option, - now: Instant, - ) -> Vec { - self.started_at = Some(now); - self.turns_completed = 0; - self.last_run_summary = None; - self.last_completion_explanation = None; - self.goal = Some(goal.clone()); - self.current_summary = None; - self.current_status_sent_to_user = None; - self.current_status_title = None; - self.current_cli_prompt = None; - self.current_cli_context = None; - self.current_cli_model_override = None; - self.current_cli_reasoning_effort_override = None; - self.hide_cli_context_in_ui = false; - self.suppress_next_cli_display = false; - self.current_display_line = None; - self.current_display_is_summary = false; - self.current_reasoning_title = None; - self.current_summary_index = None; - self.placeholder_phrase = placeholder_phrase; - self.thinking_prefix_stripped = false; - self.last_broadcast_summary = None; - self.countdown_override = None; - self.last_decision_status_sent_to_user = None; - self.last_decision_status_title = None; - self.reset_countdown(); - self.apply_phase(AutoRunPhase::AwaitingDiagnostics { coordinator_waiting: true }); - - vec![ - AutoControllerEffect::LaunchStarted { goal }, - AutoControllerEffect::RefreshUi, - ] - } - - pub fn launch_failed(&mut self, goal: String, error: String) -> Vec { - self.goal = None; - self.mark_intro_pending(); - self.countdown_override = None; - self.reset_countdown(); - self.apply_phase(AutoRunPhase::AwaitingGoalEntry); - - vec![ - AutoControllerEffect::LaunchFailed { goal, error }, - AutoControllerEffect::ShowGoalEntry, - AutoControllerEffect::RefreshUi, - ] - } - - pub fn stop_run( - &mut self, - now: Instant, - message: Option, - ) -> Vec { - let duration = self - .started_at - .map(|start| now.saturating_duration_since(start)) - .unwrap_or_default(); - let summary = AutoRunSummary { - duration, - turns_completed: self.turns_completed, - message: message.clone(), - goal: self.goal.clone(), - }; - - self.reset(); - self.last_run_summary = Some(summary.clone()); - self.transition(PhaseTransition::Stop); - self.apply_phase(AutoRunPhase::AwaitingGoalEntry); - - self.pending_stop_message = None; - vec![ - AutoControllerEffect::CancelCoordinator, - AutoControllerEffect::ResetHistory, - AutoControllerEffect::ClearCoordinatorView, - AutoControllerEffect::UpdateTerminalHint { hint: None }, - AutoControllerEffect::SetTaskRunning { running: false }, - AutoControllerEffect::EnsureInputFocus, - AutoControllerEffect::StopCompleted { summary, message }, - AutoControllerEffect::RefreshUi, - ] - } - - pub fn pause_for_transient_failure( - &mut self, - _now: Instant, - reason: String, - ) -> Vec { - let pending_attempt = self.transient_restart_attempts.saturating_add(1); - let truncated_reason = Self::truncate_error(&reason); - self.current_cli_prompt = None; - self.current_cli_context = None; - self.current_cli_model_override = None; - self.current_cli_reasoning_effort_override = None; - self.hide_cli_context_in_ui = false; - self.suppress_next_cli_display = false; - self.pending_agent_actions.clear(); - self.pending_agent_timing = None; - let delay = Self::auto_restart_delay(pending_attempt); - self.apply_phase(AutoRunPhase::TransientRecovery { backoff_ms: delay.as_millis() as u64 }); - - self.transient_restart_attempts = pending_attempt; - let delay = Self::auto_restart_delay(pending_attempt); - let token = self.restart_token.wrapping_add(1); - self.restart_token = token; - self.pending_restart = Some(AutoRestartState { - token, - attempt: pending_attempt, - reason: truncated_reason.clone(), - }); - - let human_delay = format_duration(delay); - self.current_display_line = Some(format!( - "Waiting for connection… retrying in {human_delay} (attempt {pending_attempt})" - )); - self.current_display_is_summary = true; - self.current_status_title = Some(format!("Retrying after error")); - self.current_status_sent_to_user = Some(format!( - "Encountered an error: {truncated_reason}. Waiting before retrying." - )); - self.placeholder_phrase = Some("Waiting for connection…".to_string()); - self.thinking_prefix_stripped = false; - - vec![ - AutoControllerEffect::CancelCoordinator, - AutoControllerEffect::SetTaskRunning { running: false }, - AutoControllerEffect::UpdateTerminalHint { - hint: Some("Press Esc to exit Auto Drive".to_string()), - }, - AutoControllerEffect::TransientPause { - attempt: pending_attempt, - delay, - reason: truncated_reason, - }, - AutoControllerEffect::ScheduleRestart { - token, - attempt: pending_attempt, - delay, - }, - AutoControllerEffect::RefreshUi, - ] - } - - pub fn schedule_cli_prompt( - &mut self, - decision_seq: u64, - prompt_text: String, - cli_model_override: Option, - cli_reasoning_effort_override: Option, - countdown_override: Option, - ) -> Vec { - self.current_cli_prompt = Some(prompt_text); - self.current_cli_model_override = cli_model_override; - self.current_cli_reasoning_effort_override = cli_reasoning_effort_override; - self.apply_phase(AutoRunPhase::AwaitingCoordinator { prompt_ready: true }); - self.countdown_override = countdown_override; - self.reset_countdown(); - self.countdown_id = self.countdown_id.wrapping_add(1); - self.countdown_decision_seq = decision_seq; - let countdown_id = self.countdown_id; - let countdown = self.countdown_seconds(); - self.seconds_remaining = countdown.unwrap_or(0); - - let mut effects = vec![AutoControllerEffect::RefreshUi]; - if let Some(seconds) = countdown { - effects.push(AutoControllerEffect::StartCountdown { - countdown_id, - decision_seq, - seconds, - }); - } - effects - } - - pub fn update_continue_mode(&mut self, mode: AutoContinueMode) -> Vec { - self.continue_mode = mode; - self.countdown_override = None; - self.reset_countdown(); - self.seconds_remaining = self.countdown_seconds().unwrap_or(0); - - let mut effects = vec![AutoControllerEffect::RefreshUi]; - if self.phase.awaiting_coordinator_submit() && !self.phase.is_paused_manual() { - self.countdown_id = self.countdown_id.wrapping_add(1); - let countdown_id = self.countdown_id; - let decision_seq = self.countdown_decision_seq; - let countdown = self.countdown_seconds(); - if let Some(seconds) = countdown { - effects.push(AutoControllerEffect::StartCountdown { - countdown_id, - decision_seq, - seconds, - }); - } - } - effects - } - - pub fn handle_countdown_tick( - &mut self, - countdown_id: u64, - decision_seq: u64, - seconds_left: u8, - ) -> Vec { - if !self.phase.is_active() - || countdown_id != self.countdown_id - || decision_seq != self.countdown_decision_seq - || !self.phase.awaiting_coordinator_submit() - || self.phase.is_paused_manual() - { - return Vec::new(); - } - - self.seconds_remaining = seconds_left; - if seconds_left == 0 { - vec![AutoControllerEffect::SubmitPrompt] - } else { - vec![AutoControllerEffect::RefreshUi] - } - } - - pub fn reset(&mut self) { - let review_enabled = self.review_enabled; - let subagents_enabled = self.subagents_enabled; - let cross_check_enabled = self.cross_check_enabled; - let qa_automation_enabled = self.qa_automation_enabled; - let continue_mode = self.continue_mode; - let intro_pending = self.intro_pending; - let intro_started_at = self.intro_started_at; - let intro_reduced_motion = self.intro_reduced_motion; - let elapsed_override = self.elapsed_override; - let pending_stop_message = self.pending_stop_message.clone(); - let last_completion_explanation = self.last_completion_explanation.clone(); - - *self = Self::default(); - - self.review_enabled = review_enabled; - self.subagents_enabled = subagents_enabled; - self.cross_check_enabled = cross_check_enabled; - self.qa_automation_enabled = qa_automation_enabled; - self.continue_mode = continue_mode; - self.seconds_remaining = self.continue_mode.seconds().unwrap_or(0); - self.intro_pending = intro_pending; - self.intro_started_at = intro_started_at; - self.intro_reduced_motion = intro_reduced_motion; - self.elapsed_override = elapsed_override; - self.pending_stop_message = pending_stop_message; - self.last_completion_explanation = last_completion_explanation; - self.review_lock = None; - self.phase = if self.phase.is_active() { - AutoRunPhase::Active - } else { - AutoRunPhase::Idle - }; - } - - pub fn reset_intro_timing(&mut self) { - self.intro_started_at = None; - self.intro_reduced_motion = false; - } - - pub fn ensure_intro_timing(&mut self, reduced_motion: bool) { - if self.intro_started_at.is_none() { - self.intro_started_at = Some(Instant::now()); - } - self.intro_reduced_motion = reduced_motion; - } - - pub fn mark_intro_pending(&mut self) { - self.intro_pending = true; - } - - pub fn take_intro_pending(&mut self) -> bool { - if self.intro_pending { - self.intro_pending = false; - true - } else { - false - } - } - - pub fn countdown_active(&self) -> bool { - self.phase.awaiting_coordinator_submit() - && !self.phase.is_paused_manual() - && self - .countdown_seconds() - .map(|seconds| seconds > 0) - .unwrap_or(false) - } - - pub fn countdown_seconds(&self) -> Option { - self.countdown_override.or_else(|| self.continue_mode.seconds()) - } - - pub fn reset_countdown(&mut self) { - self.seconds_remaining = self.countdown_seconds().unwrap_or(0); - if self.seconds_remaining == 0 { - self.countdown_decision_seq = 0; - } - } - - pub fn set_phase(&mut self, phase: AutoRunPhase) { - self.phase = phase; - } - - pub fn phase(&self) -> &AutoRunPhase { - &self.phase - } - - pub fn is_active(&self) -> bool { - self.phase.is_active() - } - - pub fn is_auto_active(&self) -> bool { - self.phase.is_active() - } - - pub fn current_phase(&self) -> &AutoRunPhase { - &self.phase - } - - pub fn should_show_goal_entry(&self) -> bool { - self.phase.should_show_goal_entry() - } - - pub fn set_bypass_coordinator_next_submit(&mut self) { - if let AutoRunPhase::PausedManual { bypass_next_submit, .. } = &mut self.phase { - *bypass_next_submit = true; - } - } - - pub fn should_bypass_coordinator_next_submit(&self) -> bool { - matches!( - self.phase, - AutoRunPhase::PausedManual { - bypass_next_submit: true, - .. - } - ) - } - - pub fn clear_bypass_coordinator_flag(&mut self) { - if let AutoRunPhase::PausedManual { bypass_next_submit, .. } = &mut self.phase { - *bypass_next_submit = false; - } - } - - pub fn is_paused_manual(&self) -> bool { - self.phase.is_paused_manual() - } - - pub fn resume_after_submit(&self) -> bool { - self.phase.resume_after_submit().unwrap_or(false) - } - - pub fn awaiting_coordinator_submit(&self) -> bool { - self.phase.awaiting_coordinator_submit() - } - - pub fn awaiting_review(&self) -> bool { - self.phase.awaiting_review() - } - - pub fn in_transient_recovery(&self) -> bool { - self.phase.in_transient_recovery() - } - - pub fn is_waiting_for_response(&self) -> bool { - self.phase.is_waiting_for_response() - } - - pub fn is_coordinator_waiting(&self) -> bool { - matches!( - self.phase, - AutoRunPhase::AwaitingDiagnostics { - coordinator_waiting: true, - } - ) - } - - fn auto_restart_delay(attempt: u32) -> Duration { - if attempt == 0 { - return AUTO_RESTART_BASE_DELAY.min(AUTO_RESTART_MAX_DELAY); - } - let exponent = attempt.saturating_sub(1).min(5); - let multiplier = 1u32 << exponent; - let mut delay = AUTO_RESTART_BASE_DELAY.saturating_mul(multiplier); - if delay > AUTO_RESTART_MAX_DELAY { - delay = AUTO_RESTART_MAX_DELAY; - } - delay - } - - fn truncate_error(reason: &str) -> String { - const MAX_LEN: usize = 160; - let text = reason.trim(); - if text.len() <= MAX_LEN { - return text.to_string(); - } - let mut truncated = text.chars().take(MAX_LEN).collect::(); - truncated.push('…'); - truncated - } -} - -#[cfg(test)] -mod tests { - use super::{AutoControllerEffect, AutoDriveController, AutoRunPhase}; - use std::time::Instant; - - #[test] - fn bypass_flag_only_applies_once_across_manual_resume() { - let mut controller = AutoDriveController::default(); - - // Enter manual pause state with resume-after-submit set. - controller.on_pause_for_manual(true); - assert!(matches!( - controller.current_phase(), - AutoRunPhase::PausedManual { - resume_after_submit: true, - bypass_next_submit: false, - } - )); - assert!(!controller.should_bypass_coordinator_next_submit()); - - // First manual edit triggers bypass for the upcoming coordinator submit. - controller.set_bypass_coordinator_next_submit(); - assert!(controller.should_bypass_coordinator_next_submit()); - - // Simulate the manual path consuming the bypass before resuming automation. - controller.clear_bypass_coordinator_flag(); - assert!(!controller.should_bypass_coordinator_next_submit()); - - // Hitting bypass again should work, but resuming to Active must reset it. - controller.set_bypass_coordinator_next_submit(); - assert!(controller.should_bypass_coordinator_next_submit()); - - controller.on_resume_from_manual(); - assert!(matches!(controller.current_phase(), AutoRunPhase::Active)); - assert!(!controller.should_bypass_coordinator_next_submit()); - } - - #[test] - fn awaiting_goal_entry_is_not_active_but_marks_goal_entry_visible() { - let mut controller = AutoDriveController::default(); - assert!(!controller.should_show_goal_entry()); - assert!(!controller.is_active()); - - controller.set_phase(AutoRunPhase::AwaitingGoalEntry); - assert!(controller.should_show_goal_entry()); - assert!(!controller.is_active()); - } - - #[test] - fn transient_failure_enters_recovery_and_schedules_restart() { - let mut controller = AutoDriveController::default(); - controller.goal = Some("Investigate outage".to_string()); - controller.started_at = Some(Instant::now()); - - let effects = controller.pause_for_transient_failure( - Instant::now(), - "network error".to_string(), - ); - - assert!(effects - .iter() - .any(|effect| matches!(effect, AutoControllerEffect::CancelCoordinator))); - assert!(effects - .iter() - .any(|effect| matches!(effect, AutoControllerEffect::ScheduleRestart { .. }))); - assert!(matches!( - controller.current_phase(), - AutoRunPhase::TransientRecovery { .. } - )); - assert!(controller.last_run_summary.is_none()); - } - - #[test] - fn countdown_tick_respects_decision_seq() { - let mut controller = AutoDriveController::default(); - - let _effects = controller.schedule_cli_prompt(1, "Test prompt".to_string(), None, None, None); - let countdown_id = controller.countdown_id; - - let effects = controller.handle_countdown_tick(countdown_id, 1, 5); - assert_eq!(effects.len(), 1); - - let effects = controller.handle_countdown_tick(countdown_id, 2, 5); - assert!(effects.is_empty()); - } - - #[test] - fn countdown_tick_final_emits_submit() { - let mut controller = AutoDriveController::default(); - - let _effects = controller.schedule_cli_prompt(7, "Prompt".to_string(), None, None, None); - let countdown_id = controller.countdown_id; - - let effects = controller.handle_countdown_tick(countdown_id, 7, 0); - assert_eq!(effects.len(), 1); - assert!(matches!(effects[0], AutoControllerEffect::SubmitPrompt)); - } - - #[test] - fn countdown_tick_ignores_stopped_phase() { - let mut controller = AutoDriveController::default(); - let _effects = controller.schedule_cli_prompt(3, "Prompt".to_string(), None, None, None); - let countdown_id = controller.countdown_id; - controller.set_phase(AutoRunPhase::Idle); - - let effects = controller.handle_countdown_tick(countdown_id, 3, 5); - assert!(effects.is_empty()); - } -} diff --git a/code-rs/code-auto-drive-core/src/coordinator_router.rs b/code-rs/code-auto-drive-core/src/coordinator_router.rs deleted file mode 100644 index 5cdb27bb070..00000000000 --- a/code-rs/code-auto-drive-core/src/coordinator_router.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Lightweight, keyword-based router for Auto Drive user prompts. -//! -//! This module offers a tiny heuristic bridge so the TUI can hand user -//! questions to the Auto Drive coordinator before they are sent to the CLI. -//! The real coordinator integration can later replace these heuristics -//! without touching call sites. - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct CoordinatorRouterResponse { - pub user_response: Option, - pub cli_command: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct CoordinatorContext { - pub active_agents: usize, - pub recent_updates: Vec, -} - -impl CoordinatorContext { - pub fn new(active_agents: usize, recent_updates: Vec) -> Self { - Self { - active_agents, - recent_updates, - } - } - - pub fn latest_update(&self) -> Option<&str> { - self.recent_updates.last().map(String::as_str) - } -} - -pub fn route_user_message(msg: &str, ctx: &CoordinatorContext) -> CoordinatorRouterResponse { - let normalized = msg.trim().to_ascii_lowercase(); - - if normalized.is_empty() { - return CoordinatorRouterResponse::default(); - } - - if contains_any(&normalized, &STATUS_PHRASES) { - return status_response(ctx); - } - - if contains_any(&normalized, &PLAN_PHRASES) { - return plan_response(); - } - - if contains_any(&normalized, &STOP_PHRASES) { - return stop_response(); - } - - CoordinatorRouterResponse::default() -} - -const STATUS_PHRASES: [&str; 5] = [ - "what work has been done", - "what have you done", - "status update", - "progress update", - "current status", -]; - -const PLAN_PHRASES: [&str; 4] = [ - "start more agents", - "spin up more agents", - "launch more agents", - "create a plan", -]; - -const STOP_PHRASES: [&str; 3] = [ - "stop all agents", - "halt agents", - "cancel the plan", -]; - -fn contains_any(message: &str, phrases: &[&str]) -> bool { - phrases.iter().any(|phrase| message.contains(phrase)) -} - -fn status_response(ctx: &CoordinatorContext) -> CoordinatorRouterResponse { - let mut summary = format!( - "We currently have {} active agent{}", - ctx.active_agents, - if ctx.active_agents == 1 { "" } else { "s" } - ); - - if let Some(update) = ctx.latest_update() { - summary.push_str("; most recently: "); - summary.push_str(update); - } else { - summary.push('.'); - } - - CoordinatorRouterResponse { - user_response: Some(summary), - cli_command: None, - } -} - -fn plan_response() -> CoordinatorRouterResponse { - CoordinatorRouterResponse { - user_response: Some("Starting a fresh plan via the planner.".to_string()), - cli_command: Some("/plan".to_string()), - } -} - -fn stop_response() -> CoordinatorRouterResponse { - CoordinatorRouterResponse { - user_response: Some("Stopping all active automation.".to_string()), - cli_command: Some("/stop".to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn routes_status_queries() { - let ctx = CoordinatorContext::new( - 2, - vec!["Finished lint pass on the CLI crate".to_string()], - ); - - let response = route_user_message( - "Can you tell me what work has been done so far?", - &ctx, - ); - - let user_response = response.user_response.expect("expected a status message"); - assert!(user_response.contains("2 active agents")); - assert!(user_response.contains("Finished lint pass on the CLI crate")); - assert!(response.cli_command.is_none()); - } - - #[test] - fn routes_plan_requests() { - let ctx = CoordinatorContext::default(); - let response = route_user_message("Please start more agents to handle this.", &ctx); - - assert_eq!(response.cli_command.as_deref(), Some("/plan")); - assert!(response - .user_response - .as_deref() - .unwrap_or_default() - .contains("Starting a fresh plan")); - } - - #[test] - fn returns_default_for_unmatched_input() { - let ctx = CoordinatorContext::default(); - let response = route_user_message("Hello there!", &ctx); - - assert!(response.user_response.is_none()); - assert!(response.cli_command.is_none()); - } -} diff --git a/code-rs/code-auto-drive-core/src/coordinator_user_schema.rs b/code-rs/code-auto-drive-core/src/coordinator_user_schema.rs deleted file mode 100644 index b3f63f392b3..00000000000 --- a/code-rs/code-auto-drive-core/src/coordinator_user_schema.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! JSON schema helper for coordinator user-turn responses. - -use anyhow::Context; -use serde_json::Value; - -use crate::auto_coordinator::extract_first_json_object; - -pub fn user_turn_schema() -> Value { - serde_json::json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "user_response": { - "type": ["string", "null"], - "maxLength": 400, - "description": "Short message to respond the USER immediately." - }, - "cli_command": { - "type": ["string", "null"], - "maxLength": 400, - "description": "Shell command to execute in the CLI this turn. Use null when no CLI action is required." - } - }, - "required": ["user_response", "cli_command"] - }) -} - -pub fn parse_user_turn_reply(raw: &str) -> anyhow::Result<(Option, Option)> { - let value: Value = match serde_json::from_str(raw) { - Ok(v) => v, - Err(first_err) => { - let Some(blob) = extract_first_json_object(raw) else { - return Err(first_err).context("parsing coordinator user turn JSON"); - }; - let first_err_msg = first_err.to_string(); - serde_json::from_str(&blob).with_context(|| { - format!( - "parsing coordinator user turn JSON (after salvage); initial parse error: {first_err_msg}" - ) - })? - } - }; - let obj = value - .as_object() - .ok_or_else(|| anyhow::anyhow!("coordinator response was not a JSON object"))?; - - let extract = |name: &str| -> anyhow::Result> { - let field = obj - .get(name) - .ok_or_else(|| anyhow::anyhow!("coordinator response missing required field '{name}'"))?; - if field.is_null() { - return Ok(None); - } - let Some(text) = field.as_str() else { - return Err(anyhow::anyhow!("coordinator field '{name}' must be string or null")); - }; - let trimmed = text.trim(); - if trimmed.is_empty() { - return Ok(None); - } - if trimmed.chars().count() > 400 { - return Err(anyhow::anyhow!("coordinator field '{name}' exceeded 400 characters")); - } - Ok(Some(trimmed.to_string())) - }; - - Ok((extract("user_response")?, extract("cli_command")?)) -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Result; - - #[test] - fn parse_user_turn_reply_strict_object() -> Result<()> { - let raw = r#"{"user_response":" Thanks! ","cli_command":null}"#; - let (user, cli) = parse_user_turn_reply(raw)?; - assert_eq!(user.as_deref(), Some("Thanks!")); - assert_eq!(cli, None); - Ok(()) - } - - #[test] - fn parse_user_turn_reply_salvages_embedded_json() -> Result<()> { - let raw = r#"Here are two options: do A or B. -{"user_response":null,"cli_command":" echo done "} -Let me know."#; - let (user, cli) = parse_user_turn_reply(raw)?; - assert_eq!(user, None); - assert_eq!(cli.as_deref(), Some("echo done")); - Ok(()) - } -} diff --git a/code-rs/code-auto-drive-core/src/faults.rs b/code-rs/code-auto-drive-core/src/faults.rs deleted file mode 100644 index d502e6d188c..00000000000 --- a/code-rs/code-auto-drive-core/src/faults.rs +++ /dev/null @@ -1,158 +0,0 @@ -#![cfg(feature = "dev-faults")] - -use anyhow::anyhow; -use chrono::{Duration as ChronoDuration, Utc}; -use code_core::error::{CodexErr, UnexpectedResponseError, UsageLimitReachedError}; -use once_cell::sync::OnceCell; -use rand::Rng; -use std::collections::HashMap; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Mutex; -use std::time::{Duration, Instant}; -use reqwest::StatusCode; -use serde_json::json; - -/// Scope flag – currently only `auto_drive` is recognised. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum FaultScope { - AutoDrive, -} - -#[derive(Debug, Default)] -struct FaultConfig { - disconnect: AtomicUsize, - rate_limit: AtomicUsize, - rate_limit_reset: Mutex>, // optional per-call reset hint -} - -#[derive(Debug, Clone)] -enum FaultReset { - Seconds(u64), - Timestamp(Instant), -} - -static CONFIG: OnceCell> = OnceCell::new(); - -fn parse_fault_scope() -> Option { - match std::env::var("CODEX_FAULTS_SCOPE").ok().as_deref() { - Some("auto_drive") => Some(FaultScope::AutoDrive), - _ => None, - } -} - -fn parse_reset_hint() -> Option { - if let Some(seconds) = std::env::var("CODEX_FAULTS_429_RESET").ok() { - if let Ok(value) = seconds.parse::() { - return Some(FaultReset::Seconds(value)); - } - if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&seconds) { - let instant = Instant::now() - + Duration::from_secs(parsed.signed_duration_since(chrono::Utc::now()).num_seconds().clamp(0, i64::MAX) as u64); - return Some(FaultReset::Timestamp(instant)); - } - if let Some(stripped) = seconds.strip_prefix("now+") { - if let Ok(value) = stripped.trim_end_matches('s').parse::() { - return Some(FaultReset::Seconds(value)); - } - } - } - None -} - -fn init_config() -> HashMap { - let mut map = HashMap::new(); - if let Some(scope) = parse_fault_scope() { - if let Ok(spec) = std::env::var("CODEX_FAULTS") { - let cfg = FaultConfig::default(); - for entry in spec.split(',').map(str::trim).filter(|s| !s.is_empty()) { - if let Some((label, count)) = entry.split_once(':') { - if let Ok(num) = count.parse::() { - match label { - "disconnect" => cfg.disconnect.store(num, Ordering::Relaxed), - "429" => cfg.rate_limit.store(num, Ordering::Relaxed), - _ => {} - } - } - } - } - *cfg.rate_limit_reset.lock().unwrap() = parse_reset_hint(); - map.insert(scope, cfg); - } - } - map -} - -fn config() -> &'static HashMap { - CONFIG.get_or_init(init_config) -} - -fn jitter_seconds(max: Duration) -> f64 { - if max.is_zero() { - return 0.0; - } - rand::rng().random_range(0.0..max.as_secs_f64()) -} - -/// Represents a fault to inject. -#[derive(Debug)] -pub enum InjectedFault { - Disconnect, - RateLimit { reset_hint: Option }, -} - -/// Determine whether a fault should fire for the given scope. -pub fn next_fault(scope: FaultScope) -> Option { - let cfg = config().get(&scope)?; - if cfg.disconnect.load(Ordering::Relaxed) > 0 { - let remaining = cfg.disconnect.fetch_sub(1, Ordering::Relaxed); - if remaining > 0 { - tracing::warn!("[faults] inject transient disconnect (remaining {})", remaining - 1); - return Some(InjectedFault::Disconnect); - } - } - if cfg.rate_limit.load(Ordering::Relaxed) > 0 { - let remaining = cfg.rate_limit.fetch_sub(1, Ordering::Relaxed); - if remaining > 0 { - tracing::warn!("[faults] inject 429 rate limit (remaining {})", remaining - 1); - return Some(InjectedFault::RateLimit { - reset_hint: cfg.rate_limit_reset.lock().unwrap().clone(), - }); - } - } - None -} - -/// Convert a fault into an `anyhow::Error` matching production failures. -pub fn fault_to_error(fault: InjectedFault) -> anyhow::Error { - match fault { - InjectedFault::Disconnect => anyhow!("model stream error: stream disconnected before completion"), - InjectedFault::RateLimit { reset_hint } => match reset_hint { - Some(FaultReset::Seconds(secs)) => anyhow!(CodexErr::UsageLimitReached(UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(secs), - rate_limit_reached_type: None, - })), - Some(FaultReset::Timestamp(instant)) => { - let reset_at = chrono::Utc::now() - + ChronoDuration::from_std(instant.saturating_duration_since(Instant::now())) - .unwrap_or_else(|_| ChronoDuration::seconds(0)); - let body = json!({ - "error": { - "reset_at": reset_at.to_rfc3339(), - } - }) - .to_string(); - anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError { - status: StatusCode::TOO_MANY_REQUESTS, - body, - request_id: None, - })) - } - None => anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError { - status: StatusCode::TOO_MANY_REQUESTS, - body: json!({ "error": { "message": "fault injector 429" } }).to_string(), - request_id: None, - })), - }, - } -} diff --git a/code-rs/code-auto-drive-core/src/lib.rs b/code-rs/code-auto-drive-core/src/lib.rs deleted file mode 100644 index 84c52c69dc1..00000000000 --- a/code-rs/code-auto-drive-core/src/lib.rs +++ /dev/null @@ -1,58 +0,0 @@ -mod auto_coordinator; -mod auto_drive_history; -mod auto_compact; -mod session_metrics; -mod coordinator_router; -mod coordinator_user_schema; -mod controller; -mod retry; - -#[cfg(feature = "dev-faults")] -mod faults; - -pub use auto_coordinator::{ - start_auto_coordinator, - AutoCoordinatorCommand, - AutoCoordinatorEvent, - AutoCoordinatorEventSender, - AutoCoordinatorHandle, - AutoCoordinatorStatus, - AutoTurnAgentsAction, - AutoTurnAgentsTiming, - AutoTurnCliAction, - TurnComplexity, - TurnConfig, - TurnDescriptor, - TurnMode, - MODEL_SLUG, -}; - -pub use controller::{ - AutoContinueMode, - AutoControllerEffect, - AutoDriveController, - AutoRunPhase, - AutoResolvePhase, - AutoResolveState, - AutoRestartState, - AutoRunSummary, - AutoTurnReviewState, - PhaseTransition, - TransitionEffects, - AUTO_RESTART_BASE_DELAY, - AUTO_RESTART_MAX_DELAY, - AUTO_RESOLVE_MAX_REVIEW_ATTEMPTS, - AUTO_RESOLVE_REVIEW_FOLLOWUP, -}; - -pub use auto_drive_history::AutoDriveHistory; -pub use session_metrics::SessionMetrics; -pub use coordinator_router::{ - route_user_message, - CoordinatorContext, - CoordinatorRouterResponse, -}; -pub use coordinator_user_schema::{ - parse_user_turn_reply, - user_turn_schema, -}; diff --git a/code-rs/code-auto-drive-core/src/retry.rs b/code-rs/code-auto-drive-core/src/retry.rs deleted file mode 100644 index d8c3cb7202a..00000000000 --- a/code-rs/code-auto-drive-core/src/retry.rs +++ /dev/null @@ -1,246 +0,0 @@ -use std::time::{Duration, Instant}; - -use anyhow::Error; -use rand::{rngs::StdRng, Rng, SeedableRng}; -use tokio::time; -use tokio_util::sync::CancellationToken; -use tracing::warn; - -#[derive(Debug, Clone)] -pub(crate) struct RetryOptions { - pub base_delay: Duration, - pub factor: f64, - pub max_delay: Duration, - pub max_elapsed: Duration, - pub jitter_seed: Option, -} - -impl RetryOptions { - pub fn with_defaults(max_elapsed: Duration) -> Self { - Self { - base_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_secs(15 * 60), - max_elapsed, - jitter_seed: None, - } - } -} - -#[derive(Debug)] -pub(crate) enum RetryDecision { - RetryAfterBackoff { reason: String }, - RateLimited { wait_until: Instant, reason: String }, - Fatal(Error), -} - -#[derive(Debug, Clone)] -pub(crate) struct RetryStatus { - pub attempt: u32, - pub elapsed: Duration, - pub sleep: Option, - pub resume_at: Option, - pub reason: String, - pub is_rate_limit: bool, -} - -#[derive(thiserror::Error, Debug)] -pub(crate) enum RetryError { - #[error("retry aborted")] - Aborted, - #[error("retry timed out after {elapsed:?}")] - Timeout { elapsed: Duration, last_error: Error }, - #[error(transparent)] - Fatal(Error), -} - -pub(crate) async fn retry_with_backoff( - mut run: F, - mut classify: Classify, - options: RetryOptions, - cancel: &CancellationToken, - mut status_cb: StatusCb, -) -> Result -where - F: FnMut() -> Fut + Send, - Fut: std::future::Future> + Send, - T: Send, - Classify: FnMut(&Error) -> RetryDecision + Send, - StatusCb: FnMut(RetryStatus) + Send, -{ - let start_time = Instant::now(); - let mut attempt: u32 = 0; - let mut rng = if let Some(seed) = options.jitter_seed { - StdRng::seed_from_u64(seed) - } else { - let mut thread = rand::rng(); - StdRng::from_rng(&mut thread) - }; - - loop { - if cancel.is_cancelled() { - return Err(RetryError::Aborted); - } - - attempt = attempt.saturating_add(1); - let output = run().await; - match output { - Ok(value) => return Ok(value), - Err(error) => { - let elapsed = start_time.elapsed(); - if elapsed >= options.max_elapsed { - return Err(RetryError::Timeout { - elapsed, - last_error: error, - }); - } - - match classify(&error) { - RetryDecision::Fatal(fatal) => return Err(RetryError::Fatal(fatal)), - RetryDecision::RateLimited { wait_until, reason } => { - let now = Instant::now(); - if wait_until <= now { - warn!(attempt, elapsed = ?elapsed, "{reason}; retrying immediately"); - continue; - } - let sleep = wait_until.duration_since(now); - let remaining = options.max_elapsed.saturating_sub(elapsed); - if sleep > remaining { - let timeout_reason = format!( - "rate-limit wait {sleep:?} exceeds remaining retry budget {remaining:?}" - ); - status_cb(RetryStatus { - attempt, - elapsed, - sleep: None, - resume_at: None, - reason: timeout_reason.clone(), - is_rate_limit: true, - }); - return Err(RetryError::Timeout { - elapsed, - last_error: error.context(timeout_reason), - }); - } - warn!(attempt, elapsed = ?elapsed, wait = ?sleep, resume_at = ?wait_until, "{reason}"); - status_cb(RetryStatus { - attempt, - elapsed, - sleep: Some(sleep), - resume_at: Some(wait_until), - reason, - is_rate_limit: true, - }); - wait_with_cancel(cancel, sleep).await?; - // Once we finish the enforced wait, clear the resume metadata so - // subsequent attempts are not artificially delayed. - status_cb(RetryStatus { - attempt, - elapsed: start_time.elapsed(), - sleep: None, - resume_at: None, - reason: "rate limit window cleared".to_string(), - is_rate_limit: true, - }); - continue; - } - RetryDecision::RetryAfterBackoff { reason } => { - let sleep = compute_delay(&options, attempt, &mut rng); - let remaining = options.max_elapsed.saturating_sub(elapsed); - if sleep > remaining { - let timeout_reason = format!( - "retry delay {sleep:?} exceeds remaining retry budget {remaining:?}" - ); - status_cb(RetryStatus { - attempt, - elapsed, - sleep: None, - resume_at: None, - reason: timeout_reason.clone(), - is_rate_limit: false, - }); - return Err(RetryError::Timeout { - elapsed, - last_error: error.context(timeout_reason), - }); - } - let resume_at = Instant::now() + sleep; - warn!(attempt, elapsed = ?elapsed, wait = ?sleep, resume_at = ?resume_at, "{reason}"); - status_cb(RetryStatus { - attempt, - elapsed, - sleep: Some(sleep), - resume_at: Some(resume_at), - reason, - is_rate_limit: false, - }); - wait_with_cancel(cancel, sleep).await?; - continue; - } - } - } - } - } -} - -fn compute_delay(options: &RetryOptions, attempt: u32, rng: &mut StdRng) -> Duration { - let exponent = attempt.saturating_sub(1) as i32; - let factor = options.factor.powi(exponent); - let base = options.base_delay.as_secs_f64() * factor; - let capped = base.min(options.max_delay.as_secs_f64()); - if capped <= f64::EPSILON { - return Duration::ZERO; - } - - let jitter = rng.random_range(0.0..capped); - Duration::from_secs_f64(jitter) -} - -async fn wait_with_cancel(cancel: &CancellationToken, duration: Duration) -> Result<(), RetryError> { - if duration.is_zero() { - return Ok(()); - } - - tokio::select! { - _ = time::sleep(duration) => Ok(()), - _ = cancel.cancelled() => Err(RetryError::Aborted), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::anyhow; - use tokio_util::sync::CancellationToken; - - #[tokio::test] - async fn rate_limit_wait_exceeding_budget_times_out() { - let cancel = CancellationToken::new(); - let options = RetryOptions { - base_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_secs(60), - max_elapsed: Duration::from_secs(2), - jitter_seed: Some(1), - }; - let mut attempts = 0u32; - - let result: Result<(), RetryError> = retry_with_backoff( - || { - attempts += 1; - async { Err(anyhow!("rate limited")) } - }, - |_| RetryDecision::RateLimited { - wait_until: Instant::now() + Duration::from_secs(60), - reason: "rate limited".to_string(), - }, - options, - &cancel, - |_| {}, - ) - .await; - - assert!(matches!(result, Err(RetryError::Timeout { .. }))); - assert_eq!(attempts, 1); - } -} diff --git a/code-rs/code-auto-drive-core/src/session_metrics.rs b/code-rs/code-auto-drive-core/src/session_metrics.rs deleted file mode 100644 index 9adbb567706..00000000000 --- a/code-rs/code-auto-drive-core/src/session_metrics.rs +++ /dev/null @@ -1,300 +0,0 @@ -use std::collections::VecDeque; - -use code_core::protocol::TokenUsage; - -const DEFAULT_PROMPT_ESTIMATE: u64 = 4_000; - -#[derive(Debug, Clone)] -pub struct SessionMetrics { - running_total: TokenUsage, - last_turn: TokenUsage, - turn_count: u32, - replay_updates: u32, - duplicate_items: u32, - recent_prompt_tokens: VecDeque, - window: usize, -} - -impl Default for SessionMetrics { - fn default() -> Self { - Self::new(3) - } -} - -impl SessionMetrics { - pub fn new(window: usize) -> Self { - Self { - running_total: TokenUsage::default(), - last_turn: TokenUsage::default(), - turn_count: 0, - replay_updates: 0, - duplicate_items: 0, - recent_prompt_tokens: VecDeque::with_capacity(window), - window: window.max(1), - } - } - - pub fn record_turn(&mut self, usage: &TokenUsage) { - self.running_total.add_assign(usage); - self.last_turn = usage.clone(); - self.turn_count = self.turn_count.saturating_add(1); - self.push_prompt_observation(usage.non_cached_input()); - } - - pub fn record_turn_without_usage(&mut self, estimated_prompt_tokens: u64) { - self.turn_count = self.turn_count.saturating_add(1); - self.push_prompt_observation(estimated_prompt_tokens); - } - - pub fn sync_absolute(&mut self, total: TokenUsage, last: TokenUsage, turn_count: u32) { - self.running_total = total; - self.last_turn = last.clone(); - self.turn_count = turn_count; - self.replay_updates = 0; - self.duplicate_items = 0; - self.recent_prompt_tokens.clear(); - self.push_prompt_observation(last.non_cached_input()); - } - - pub fn running_total(&self) -> &TokenUsage { - &self.running_total - } - - pub fn last_turn(&self) -> &TokenUsage { - &self.last_turn - } - - pub fn turn_count(&self) -> u32 { - self.turn_count - } - - pub fn has_recorded_usage(&self) -> bool { - !self.running_total.is_zero() || !self.last_turn.is_zero() - } - - pub fn blended_total(&self) -> u64 { - self.running_total.blended_total() - } - - pub fn estimated_next_prompt_tokens(&self) -> u64 { - if !self.recent_prompt_tokens.is_empty() { - let sum: u64 = self.recent_prompt_tokens.iter().copied().sum(); - return sum / self.recent_prompt_tokens.len() as u64; - } - let fallback = self.last_turn.non_cached_input(); - if fallback > 0 { - fallback - } else { - DEFAULT_PROMPT_ESTIMATE - } - } - - pub fn reset(&mut self) { - *self = Self::new(self.window); - } - - pub fn record_replay(&mut self) { - self.replay_updates = self.replay_updates.saturating_add(1); - } - - pub fn replay_updates(&self) -> u32 { - self.replay_updates - } - - pub fn record_duplicate_items(&mut self, count: usize) { - if count == 0 { - return; - } - self.duplicate_items = self - .duplicate_items - .saturating_add(count.min(u32::MAX as usize) as u32); - } - - pub fn set_duplicate_items(&mut self, count: u32) { - self.duplicate_items = count; - } - - pub fn set_replay_updates(&mut self, count: u32) { - self.replay_updates = count; - } - - pub fn duplicate_items(&self) -> u32 { - self.duplicate_items - } - - /// Returns a loop detection warning message if the session shows signs of - /// repetitive behavior (replays or high duplicate counts). Returns `None` - /// if no concerning patterns are detected. - /// - /// This guidance is intended to be injected into the coordinator's context - /// to help it break out of unproductive loops. - pub fn loop_detection_warning(&self) -> Option { - let replays = self.replay_updates; - let duplicates = self.duplicate_items; - - const REPLAY_WARNING_THRESHOLD: u32 = 2; - const REPLAY_CRITICAL_THRESHOLD: u32 = 4; - const DUPLICATE_WARNING_THRESHOLD: u32 = 3; - - if replays >= REPLAY_CRITICAL_THRESHOLD { - return Some(format!( - "LOOP DETECTED: {replays} consecutive replay attempts observed. \ - The same commands are being issued repeatedly without progress. \ - STOP and reassess: (1) Check if the task is already complete, \ - (2) Try a fundamentally different approach, \ - (3) If stuck, use finish_failed with a clear explanation of the blocker." - )); - } - - if replays >= REPLAY_WARNING_THRESHOLD { - return Some(format!( - "Potential loop detected: {replays} replay attempts observed. \ - Recent actions may be repeating. Consider: \ - (1) Verifying actual progress was made, \ - (2) Trying a different strategy if the current approach isn't working, \ - (3) Checking for already-completed conditions before retrying." - )); - } - - if duplicates >= DUPLICATE_WARNING_THRESHOLD { - return Some(format!( - "Repetition detected: {duplicates} duplicate items in conversation history. \ - This may indicate a stuck state. Ensure each action produces new, \ - meaningful progress toward the goal." - )); - } - - None - } - - fn push_prompt_observation(&mut self, tokens: u64) { - if tokens == 0 { - return; - } - if self.recent_prompt_tokens.len() == self.window { - self.recent_prompt_tokens.pop_front(); - } - self.recent_prompt_tokens.push_back(tokens); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn usage(input: u64, output: u64) -> TokenUsage { - TokenUsage { - input_tokens: input, - cached_input_tokens: 0, - cached_input_tokens_reported: false, - output_tokens: output, - reasoning_output_tokens: 0, - total_tokens: input + output, - } - } - - #[test] - fn record_turn_tracks_totals_and_estimate() { - let mut metrics = SessionMetrics::default(); - metrics.record_turn(&usage(1_000, 500)); - metrics.record_turn(&usage(4_000, 2_000)); - - assert_eq!(metrics.turn_count(), 2); - assert_eq!(metrics.running_total().input_tokens, 5_000); - assert_eq!(metrics.running_total().output_tokens, 2_500); - - // Average of observed prompt tokens (non-cached input) - assert_eq!(metrics.estimated_next_prompt_tokens(), 2_500); - assert_eq!(metrics.duplicate_items(), 0); - assert_eq!(metrics.replay_updates(), 0); - } - - #[test] - fn record_turn_without_usage_does_not_mark_usage() { - let mut metrics = SessionMetrics::default(); - assert!(!metrics.has_recorded_usage()); - - metrics.record_turn_without_usage(2_000); - assert!(!metrics.has_recorded_usage()); - - metrics.record_turn(&usage(1_000, 500)); - assert!(metrics.has_recorded_usage()); - } - - #[test] - fn sync_absolute_resets_window() { - let mut metrics = SessionMetrics::default(); - metrics.record_turn(&usage(1_000, 500)); - metrics.sync_absolute(usage(10_000, 4_000), usage(3_000, 1_000), 3); - - assert_eq!(metrics.turn_count(), 3); - assert_eq!(metrics.running_total().input_tokens, 10_000); - assert_eq!(metrics.last_turn().input_tokens, 3_000); - assert_eq!(metrics.estimated_next_prompt_tokens(), 3_000); - assert_eq!(metrics.duplicate_items(), 0); - assert_eq!(metrics.replay_updates(), 0); - } - - #[test] - fn record_replay_increments_counter() { - let mut metrics = SessionMetrics::default(); - metrics.record_replay(); - metrics.record_replay(); - assert_eq!(metrics.replay_updates(), 2); - } - - #[test] - fn loop_detection_warning_returns_none_when_no_issues() { - let metrics = SessionMetrics::default(); - assert!(metrics.loop_detection_warning().is_none()); - } - - #[test] - fn loop_detection_warning_at_replay_warning_threshold() { - let mut metrics = SessionMetrics::default(); - metrics.record_replay(); - assert!(metrics.loop_detection_warning().is_none()); - - metrics.record_replay(); - let warning = metrics.loop_detection_warning(); - assert!(warning.is_some()); - assert!(warning.unwrap().contains("Potential loop detected")); - } - - #[test] - fn loop_detection_warning_at_replay_critical_threshold() { - let mut metrics = SessionMetrics::default(); - for _ in 0..4 { - metrics.record_replay(); - } - let warning = metrics.loop_detection_warning(); - assert!(warning.is_some()); - let warning_text = warning.unwrap(); - assert!(warning_text.contains("LOOP DETECTED")); - assert!(warning_text.contains("4 consecutive replay")); - } - - #[test] - fn loop_detection_warning_on_duplicate_items() { - let mut metrics = SessionMetrics::default(); - metrics.record_duplicate_items(2); - assert!(metrics.loop_detection_warning().is_none()); - - metrics.record_duplicate_items(1); - let warning = metrics.loop_detection_warning(); - assert!(warning.is_some()); - assert!(warning.unwrap().contains("Repetition detected")); - } - - #[test] - fn loop_detection_warning_replay_takes_priority_over_duplicates() { - let mut metrics = SessionMetrics::default(); - metrics.record_duplicate_items(5); - metrics.record_replay(); - metrics.record_replay(); - - let warning = metrics.loop_detection_warning(); - assert!(warning.is_some()); - assert!(warning.unwrap().contains("Potential loop detected")); - } -} diff --git a/code-rs/code-auto-drive-diagnostics/Cargo.toml b/code-rs/code-auto-drive-diagnostics/Cargo.toml deleted file mode 100644 index cf46862742f..00000000000 --- a/code-rs/code-auto-drive-diagnostics/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "code-auto-drive-diagnostics" -edition = "2024" -version = { workspace = true } - -[lib] -name = "code_auto_drive_diagnostics" -path = "src/lib.rs" - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -code-auto-drive-core = { path = "../code-auto-drive-core" } -code-core = { path = "../core" } -code-protocol = { path = "../protocol" } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true, features = ["log"] } - -[dev-dependencies] -pretty_assertions = { workspace = true } diff --git a/code-rs/code-auto-drive-diagnostics/src/lib.rs b/code-rs/code-auto-drive-diagnostics/src/lib.rs deleted file mode 100644 index 7e593431ddb..00000000000 --- a/code-rs/code-auto-drive-diagnostics/src/lib.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Auto Drive diagnostics interposer. -//! -//! This crate will orchestrate a post-success verification pass whenever -//! Auto Drive reports `AutoCoordinatorStatus::Success`. It will force a -//! structured JSON response from the model indicating whether the -//! original goal is genuinely complete before allowing the run to exit. - -#![allow(dead_code)] - -use anyhow::Result; - -/// Schema for the forced JSON diagnostics reply. -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)] -pub struct CompletionCheck { - pub complete: bool, - pub explanation: String, -} - -/// Configuration for diagnostics behaviour. -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)] -pub struct DiagnosticsConfig { - pub max_retries: u8, -} - -impl Default for DiagnosticsConfig { - fn default() -> Self { - Self { max_retries: 2 } - } -} - -/// Placeholder diagnostics facade. -pub struct AutoDriveDiagnostics; - -impl AutoDriveDiagnostics { - pub fn new() -> Self { - Self - } - - pub fn completion_schema() -> serde_json::Value { - serde_json::json!({ - "type": "object", - "required": ["complete", "explanation"], - "properties": { - "complete": { "type": "boolean" }, - "explanation": { "type": "string" } - }, - "additionalProperties": false - }) - } - - pub async fn run_check(&self, _goal: &str) -> Result { - unimplemented!("Diagnostics check not yet implemented"); - } -} diff --git a/code-rs/code-backend-openapi-models/Cargo.toml b/code-rs/code-backend-openapi-models/Cargo.toml deleted file mode 100644 index dc14c0b1087..00000000000 --- a/code-rs/code-backend-openapi-models/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "code-backend-openapi-models" -version = { workspace = true } -edition = "2024" - -[lib] -name = "code_backend_openapi_models" -path = "src/lib.rs" - -# Important: generated code often violates our workspace lints. -# Allow unwrap/expect in this crate so the workspace builds cleanly -# after models are regenerated. -# Lint overrides are applied in src/lib.rs via crate attributes - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/code-rs/code-backend-openapi-models/src/models/mod.rs b/code-rs/code-backend-openapi-models/src/models/mod.rs deleted file mode 100644 index 8fa503ac08f..00000000000 --- a/code-rs/code-backend-openapi-models/src/models/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Curated minimal export list for current workspace usage. -// NOTE: This file was previously auto-generated by the OpenAPI generator. -// Currently export only the types referenced by the workspace -// The process for this will change - -pub mod code_task_details_response; -pub use self::code_task_details_response::CodeTaskDetailsResponse; - -pub mod config_file_response; -pub use self::config_file_response::ConfigFileResponse; - -pub mod task_response; -pub use self::task_response::TaskResponse; - -pub mod external_pull_request_response; -pub use self::external_pull_request_response::ExternalPullRequestResponse; - -pub mod git_pull_request; -pub use self::git_pull_request::GitPullRequest; - -pub mod task_list_item; -pub use self::task_list_item::TaskListItem; - -pub mod paginated_list_task_list_item_; -pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem; diff --git a/code-rs/code-mode/BUILD.bazel b/code-rs/code-mode/BUILD.bazel new file mode 100644 index 00000000000..bf39d9d5a53 --- /dev/null +++ b/code-rs/code-mode/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "code-mode", + crate_name = "codex_code_mode", +) diff --git a/code-rs/code-mode/Cargo.toml b/code-rs/code-mode/Cargo.toml new file mode 100644 index 00000000000..19d6c3ab45c --- /dev/null +++ b/code-rs/code-mode/Cargo.toml @@ -0,0 +1,31 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-code-mode" +version.workspace = true + +[lib] +doctest = false +name = "codex_code_mode" +path = "src/lib.rs" + +[features] +sandbox = ["v8/v8_enable_sandbox"] + +[lints] +workspace = true + +[dependencies] +async-channel = { workspace = true } +async-trait = { workspace = true } +codex-protocol = { workspace = true } +deno_core_icudata = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +v8 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/code-rs/code-mode/src/description.rs b/code-rs/code-mode/src/description.rs new file mode 100644 index 00000000000..4c5eb6fbdc8 --- /dev/null +++ b/code-rs/code-mode/src/description.rs @@ -0,0 +1,1101 @@ +use codex_protocol::ToolName; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; + +use crate::PUBLIC_TOOL_NAME; + +const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; +const CODE_MODE_ONLY_PREFACE: &str = + "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; +const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some nested MCP/app tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. +To find one, filter `ALL_TOOLS` by `name` and `description`; do not print the full `ALL_TOOLS` array. Print only a small set of relevant matches if you need to inspect them."#; +const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls +- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module. +- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. +- Nested tool methods take either a string or an object as their input argument. +- Nested tools return either an object or a string, based on the description. +- Runs raw JavaScript -- no Node, no file system, no network access, no console. +- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences. +- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. +- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. +- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. +- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded. + +- Global helpers: +- `exit()`: Immediately ends the current script successfully (like an early return from the top level). +- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. +- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. +- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. +- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. +- `setTimeout(callback: () => void, delayMs?: number)`: schedules a callback to run later and returns a timeout id. Pending timeouts do not keep `exec` alive by themselves; await an explicit promise if you need to wait for one. +- `clearTimeout(timeoutId?: number)`: cancels a timeout created by `setTimeout`. +- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. +- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; +const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. +- `cell_id` identifies the running `exec` cell to resume. +- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. +- `max_tokens` limits how much new output this wait call returns. +- `terminate: true` stops the running cell instead of waiting for more output. +- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. +- If the cell is still running, `wait` may yield again with the same `cell_id`. +- If the cell has already finished, `wait` returns the completed result and closes the cell."#; +// Based off of https://modelcontextprotocol.io/specification/draft/schema#calltoolresult +const MCP_TYPESCRIPT_PREAMBLE: &str = r#"type Role = "user" | "assistant"; +type MetaObject = Record; +type Annotations = { + audience?: Role[]; + priority?: number; + lastModified?: string; +}; +type Icon = { + src: string; + mimeType?: string; + sizes?: string[]; + theme?: "light" | "dark"; +}; +type TextResourceContents = { + uri: string; + mimeType?: string; + _meta?: MetaObject; + text: string; +}; +type BlobResourceContents = { + uri: string; + mimeType?: string; + _meta?: MetaObject; + blob: string; +}; +type TextContent = { + type: "text"; + text: string; + annotations?: Annotations; + _meta?: MetaObject; +}; +type ImageContent = { + type: "image"; + data: string; + mimeType: string; + annotations?: Annotations; + _meta?: MetaObject; +}; +type AudioContent = { + type: "audio"; + data: string; + mimeType: string; + annotations?: Annotations; + _meta?: MetaObject; +}; +type ResourceLink = { + icons?: Icon[]; + name: string; + title?: string; + uri: string; + description?: string; + mimeType?: string; + annotations?: Annotations; + size?: number; + _meta?: MetaObject; + type: "resource_link"; +}; +type EmbeddedResource = { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + annotations?: Annotations; + _meta?: MetaObject; +}; +type ContentBlock = + | TextContent + | ImageContent + | AudioContent + | ResourceLink + | EmbeddedResource; +type CallToolResult = { + _meta?: MetaObject; + content: ContentBlock[]; + isError?: boolean; + structuredContent?: TStructured; + [key: string]: unknown; +};"#; + +pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CodeModeToolKind { + Function, + Freeform, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ToolDefinition { + pub name: String, + pub tool_name: ToolName, + pub description: String, + pub kind: CodeModeToolKind, + pub input_schema: Option, + pub output_schema: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ToolNamespaceDescription { + pub name: String, + pub description: String, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct CodeModeExecPragma { + #[serde(default)] + yield_time_ms: Option, + #[serde(default)] + max_output_tokens: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedExecSource { + pub code: String, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +pub fn parse_exec_source(input: &str) -> Result { + if input.trim().is_empty() { + return Err( + "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), + ); + } + + let mut args = ParsedExecSource { + code: input.to_string(), + yield_time_ms: None, + max_output_tokens: None, + }; + + let mut lines = input.splitn(2, '\n'); + let first_line = lines.next().unwrap_or_default(); + let rest = lines.next().unwrap_or_default(); + let trimmed = first_line.trim_start(); + let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { + return Ok(args); + }; + + if rest.trim().is_empty() { + return Err( + "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), + ); + } + + let directive = pragma.trim(); + if directive.is_empty() { + return Err( + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string(), + ); + } + + let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { + format!( + "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" + ) + })?; + let object = value.as_object().ok_or_else(|| { + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string() + })?; + for key in object.keys() { + match key.as_str() { + "yield_time_ms" | "max_output_tokens" => {} + _ => { + return Err(format!( + "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" + )); + } + } + } + + let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { + format!( + "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" + ) + })?; + if pragma + .yield_time_ms + .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) + { + return Err( + "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), + ); + } + if pragma.max_output_tokens.is_some_and(|max_output_tokens| { + u64::try_from(max_output_tokens) + .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) + .unwrap_or(true) + }) { + return Err( + "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), + ); + } + + args.code = rest.to_string(); + args.yield_time_ms = pragma.yield_time_ms; + args.max_output_tokens = pragma.max_output_tokens; + Ok(args) +} + +pub fn is_code_mode_nested_tool(tool_name: &str) -> bool { + tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME +} + +pub fn build_exec_tool_description( + enabled_tools: &[ToolDefinition], + namespace_descriptions: &BTreeMap, + code_mode_only: bool, + deferred_tools_available: bool, +) -> String { + let mut sections = Vec::new(); + if code_mode_only { + sections.push(CODE_MODE_ONLY_PREFACE.to_string()); + } + sections.push(EXEC_DESCRIPTION_TEMPLATE.to_string()); + if deferred_tools_available { + sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string()); + } + if !code_mode_only { + return sections.join("\n\n"); + } + + if !enabled_tools.is_empty() { + let mut current_namespace: Option<&str> = None; + let mut nested_tool_sections = Vec::with_capacity(enabled_tools.len()); + let has_mcp_tools = enabled_tools + .iter() + .any(|tool| mcp_structured_content_schema(tool.output_schema.as_ref()).is_some()); + + for tool in enabled_tools { + let name = tool.name.as_str(); + let nested_description = render_code_mode_sample_for_definition(tool); + let namespace_description = tool + .tool_name + .namespace + .as_ref() + .and_then(|namespace| namespace_descriptions.get(namespace)); + let next_namespace = namespace_description + .map(|namespace_description| namespace_description.name.as_str()); + if next_namespace != current_namespace { + if let Some(namespace_description) = namespace_description { + let namespace_description_text = namespace_description.description.trim(); + if !namespace_description_text.is_empty() { + nested_tool_sections.push(format!( + "## {}\n{namespace_description_text}", + namespace_description.name + )); + } + } + current_namespace = next_namespace; + } + + let global_name = normalize_code_mode_identifier(name); + let nested_description = nested_description.trim(); + if nested_description.is_empty() { + nested_tool_sections.push(render_tool_heading(&global_name, name)); + } else { + nested_tool_sections.push(format!( + "{}\n{nested_description}", + render_tool_heading(&global_name, name) + )); + } + } + + if has_mcp_tools { + sections.push(format!( + "Shared MCP Types:\n```ts\n{MCP_TYPESCRIPT_PREAMBLE}\n```" + )); + } + let nested_tool_reference = nested_tool_sections.join("\n\n"); + sections.push(nested_tool_reference); + } + + sections.join("\n\n") +} + +pub fn build_wait_tool_description() -> &'static str { + WAIT_DESCRIPTION_TEMPLATE +} + +pub fn normalize_code_mode_identifier(tool_key: &str) -> String { + let mut identifier = String::new(); + + for (index, ch) in tool_key.chars().enumerate() { + let is_valid = if index == 0 { + ch == '_' || ch == '$' || ch.is_ascii_alphabetic() + } else { + ch == '_' || ch == '$' || ch.is_ascii_alphanumeric() + }; + + if is_valid { + identifier.push(ch); + } else { + identifier.push('_'); + } + } + + if identifier.is_empty() { + "_".to_string() + } else { + identifier + } +} + +pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition { + if definition.name != PUBLIC_TOOL_NAME { + definition.description = render_code_mode_sample_for_definition(&definition); + } + definition +} + +pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata { + EnabledToolMetadata { + tool_name: definition.tool_name.clone(), + global_name: normalize_code_mode_identifier(&definition.name), + description: definition.description.clone(), + kind: definition.kind, + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct EnabledToolMetadata { + pub tool_name: ToolName, + pub global_name: String, + pub description: String, + pub kind: CodeModeToolKind, +} + +pub fn render_code_mode_sample( + description: &str, + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let declaration = format!( + "declare const tools: {{ {} }};", + render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) + ); + format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") +} + +fn render_code_mode_sample_for_definition(definition: &ToolDefinition) -> String { + let input_name = match definition.kind { + CodeModeToolKind::Function => "args", + CodeModeToolKind::Freeform => "input", + }; + let input_type = match definition.kind { + CodeModeToolKind::Function => definition + .input_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()), + CodeModeToolKind::Freeform => "string".to_string(), + }; + let output_type = if let Some(structured_content_schema) = + mcp_structured_content_schema(definition.output_schema.as_ref()) + { + let structured_content_type = render_json_schema_to_typescript(structured_content_schema); + if structured_content_type == "unknown" { + "CallToolResult".to_string() + } else { + format!("CallToolResult<{structured_content_type}>") + } + } else { + definition + .output_schema + .as_ref() + .map(render_json_schema_to_typescript) + .unwrap_or_else(|| "unknown".to_string()) + }; + render_code_mode_sample( + &definition.description, + &definition.name, + input_name, + input_type, + output_type, + ) +} + +fn render_code_mode_tool_declaration( + tool_name: &str, + input_name: &str, + input_type: String, + output_type: String, +) -> String { + let tool_name = normalize_code_mode_identifier(tool_name); + format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") +} + +fn render_tool_heading(global_name: &str, raw_name: &str) -> String { + if global_name == raw_name { + format!("### `{global_name}`") + } else { + format!("### `{global_name}` (`{raw_name}`)") + } +} + +pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { + render_json_schema_to_typescript_inner(schema) +} + +fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&JsonValue> { + let output_schema = output_schema?; + let properties = output_schema + .get("properties") + .and_then(JsonValue::as_object)?; + let content_schema = properties.get("content").and_then(JsonValue::as_object)?; + if content_schema.get("type").and_then(JsonValue::as_str) != Some("array") { + return None; + } + + if content_schema + .get("items") + .and_then(JsonValue::as_object) + .is_none_or(|items| items.get("type").and_then(JsonValue::as_str) != Some("object")) + { + return None; + } + + if properties + .get("isError") + .and_then(JsonValue::as_object) + .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("boolean")) + { + return None; + } + + if properties + .get("_meta") + .and_then(JsonValue::as_object) + .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("object")) + { + return None; + } + + Some( + properties + .get("structuredContent") + .unwrap_or(&JsonValue::Bool(true)), + ) +} + +fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { + match schema { + JsonValue::Bool(true) => "unknown".to_string(), + JsonValue::Bool(false) => "never".to_string(), + JsonValue::Object(map) => { + if let Some(value) = map.get("const") { + return render_json_schema_literal(value); + } + + if let Some(values) = map.get("enum").and_then(JsonValue::as_array) { + let rendered = values + .iter() + .map(render_json_schema_literal) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + for key in ["anyOf", "oneOf"] { + if let Some(variants) = map.get(key).and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + } + + if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) { + let rendered = variants + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" & "); + } + } + + if let Some(schema_type) = map.get("type") { + if let Some(types) = schema_type.as_array() { + let rendered = types + .iter() + .filter_map(JsonValue::as_str) + .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) + .collect::>(); + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + if let Some(schema_type) = schema_type.as_str() { + return render_json_schema_type_keyword(map, schema_type); + } + } + + if map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("required") + { + return render_json_schema_object(map); + } + + if map.contains_key("items") || map.contains_key("prefixItems") { + return render_json_schema_array(map); + } + + "unknown".to_string() + } + _ => "unknown".to_string(), + } +} + +fn render_json_schema_type_keyword( + map: &serde_json::Map, + schema_type: &str, +) -> String { + match schema_type { + "string" => "string".to_string(), + "number" | "integer" => "number".to_string(), + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => render_json_schema_array(map), + "object" => render_json_schema_object(map), + _ => "unknown".to_string(), + } +} + +fn render_json_schema_array(map: &serde_json::Map) -> String { + if let Some(items) = map.get("items") { + let item_type = render_json_schema_to_typescript_inner(items); + return format!("Array<{item_type}>"); + } + + if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) { + let item_types = items + .iter() + .map(render_json_schema_to_typescript_inner) + .collect::>(); + if !item_types.is_empty() { + return format!("[{}]", item_types.join(", ")); + } + } + + "unknown[]".to_string() +} + +fn append_additional_properties_line( + lines: &mut Vec, + map: &serde_json::Map, + properties: &serde_json::Map, + line_prefix: &str, +) { + if let Some(additional_properties) = map.get("additionalProperties") { + let property_type = match additional_properties { + JsonValue::Bool(true) => Some("unknown".to_string()), + JsonValue::Bool(false) => None, + value => Some(render_json_schema_to_typescript_inner(value)), + }; + + if let Some(property_type) = property_type { + lines.push(format!("{line_prefix}[key: string]: {property_type};")); + } + } else if properties.is_empty() { + lines.push(format!("{line_prefix}[key: string]: unknown;")); + } +} + +fn has_property_description(value: &JsonValue) -> bool { + value + .get("description") + .and_then(JsonValue::as_str) + .is_some_and(|description| !description.is_empty()) +} + +fn render_json_schema_object_property(name: &str, value: &JsonValue, required: &[&str]) -> String { + let optional = if required.iter().any(|required_name| required_name == &name) { + "" + } else { + "?" + }; + let property_name = render_json_schema_property_name(name); + let property_type = render_json_schema_to_typescript_inner(value); + format!("{property_name}{optional}: {property_type};") +} + +fn render_json_schema_object(map: &serde_json::Map) -> String { + let required = map + .get("required") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(JsonValue::as_str) + .collect::>() + }) + .unwrap_or_default(); + let properties = map + .get("properties") + .and_then(JsonValue::as_object) + .cloned() + .unwrap_or_default(); + + let mut sorted_properties = properties.iter().collect::>(); + sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + if sorted_properties + .iter() + .any(|(_, value)| has_property_description(value)) + { + let mut lines = vec!["{".to_string()]; + for (name, value) in sorted_properties { + if let Some(description) = value.get("description").and_then(JsonValue::as_str) { + for description_line in description + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + lines.push(format!(" // {description_line}")); + } + } + + lines.push(format!( + " {}", + render_json_schema_object_property(name, value, &required) + )); + } + + append_additional_properties_line(&mut lines, map, &properties, " "); + lines.push("}".to_string()); + return lines.join("\n"); + } + + let mut lines = sorted_properties + .into_iter() + .map(|(name, value)| render_json_schema_object_property(name, value, &required)) + .collect::>(); + + append_additional_properties_line(&mut lines, map, &properties, ""); + + if lines.is_empty() { + return "{}".to_string(); + } + + format!("{{ {} }}", lines.join(" ")) +} + +fn render_json_schema_property_name(name: &str) -> String { + if normalize_code_mode_identifier(name) == name { + name.to_string() + } else { + serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) + } +} + +fn render_json_schema_literal(value: &JsonValue) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::CodeModeToolKind; + use super::ParsedExecSource; + use super::ToolDefinition; + use super::ToolNamespaceDescription; + use super::augment_tool_definition; + use super::build_exec_tool_description; + use super::normalize_code_mode_identifier; + use super::parse_exec_source; + use codex_protocol::ToolName; + use pretty_assertions::assert_eq; + use serde_json::Value as JsonValue; + use serde_json::json; + use std::collections::BTreeMap; + + fn mcp_call_tool_result_schema(structured_content_schema: JsonValue) -> JsonValue { + json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": structured_content_schema, + "isError": { "type": "boolean" }, + "_meta": { "type": "object" } + }, + "required": ["content"], + "additionalProperties": false + }) + } + + #[test] + fn parse_exec_source_without_pragma() { + assert_eq!( + parse_exec_source("text('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: None, + max_output_tokens: None, + } + ); + } + + #[test] + fn parse_exec_source_with_pragma() { + assert_eq!( + parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(), + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: Some(10), + max_output_tokens: None, + } + ); + } + + #[test] + fn normalize_identifier_rewrites_invalid_characters() { + assert_eq!( + "mcp__ologs__get_profile", + normalize_code_mode_identifier("mcp__ologs__get_profile") + ); + assert_eq!( + "hidden_dynamic_tool", + normalize_code_mode_identifier("hidden-dynamic-tool") + ); + } + + #[test] + fn augment_tool_definition_appends_typed_declaration() { + let definition = ToolDefinition { + name: "hidden_dynamic_tool".to_string(), + tool_name: ToolName::plain("hidden_dynamic_tool"), + description: "Test tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { "ok": { "type": "boolean" } }, + "required": ["ok"] + })), + }; + + let description = augment_tool_definition(definition).description; + assert!(description.contains("declare const tools")); + assert!( + description.contains( + "hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;" + ) + ); + } + + #[test] + fn augment_tool_definition_includes_property_descriptions_as_comments() { + let definition = ToolDefinition { + name: "weather_tool".to_string(), + tool_name: ToolName::plain("weather_tool"), + description: "Weather tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": { + "weather": { + "type": "array", + "description": "look up weather for a given list of locations", + "items": { + "type": "object", + "properties": { + "location": { "type": "string" } + }, + "required": ["location"] + } + } + }, + "required": ["weather"] + })), + output_schema: Some(json!({ + "type": "object", + "properties": { + "forecast": { + "type": "string", + "description": "human readable weather forecast" + } + }, + "required": ["forecast"] + })), + }; + + let description = augment_tool_definition(definition).description; + assert!(description.contains( + r#"weather_tool(args: { + // look up weather for a given list of locations + weather: Array<{ location: string; }>; +}): Promise<{ + // human readable weather forecast + forecast: string; +}>;"# + )); + } + + #[test] + fn code_mode_only_description_includes_nested_tools() { + let description = build_exec_tool_description( + &[ToolDefinition { + name: "foo".to_string(), + tool_name: ToolName::plain("foo"), + description: "bar".to_string(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + }], + &BTreeMap::new(), + /*code_mode_only*/ true, + /*deferred_tools_available*/ false, + ); + assert!(description.contains( + "### `foo` +bar" + )); + } + + #[test] + fn exec_description_mentions_timeout_helpers() { + let description = build_exec_tool_description( + &[], + &BTreeMap::new(), + /*code_mode_only*/ false, + /*deferred_tools_available*/ false, + ); + assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`")); + assert!(description.contains("`clearTimeout(timeoutId?: number)`")); + } + + #[test] + fn code_mode_only_description_groups_namespace_instructions_once() { + let namespace_descriptions = BTreeMap::from([( + "mcp__sample__".to_string(), + ToolNamespaceDescription { + name: "mcp__sample".to_string(), + description: "Shared namespace guidance.".to_string(), + }, + )]); + let description = build_exec_tool_description( + &[ + ToolDefinition { + name: "mcp__sample__alpha".to_string(), + tool_name: ToolName::namespaced("mcp__sample__", "alpha"), + description: "First tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(mcp_call_tool_result_schema(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }))), + }, + ToolDefinition { + name: "mcp__sample__beta".to_string(), + tool_name: ToolName::namespaced("mcp__sample__", "beta"), + description: "Second tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(mcp_call_tool_result_schema(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }))), + }, + ], + &namespace_descriptions, + /*code_mode_only*/ true, + /*deferred_tools_available*/ false, + ); + assert_eq!(description.matches("## mcp__sample").count(), 1); + assert!(description.contains("## mcp__sample\nShared namespace guidance.")); + assert!(description.contains( + "declare const tools: { mcp__sample__alpha(args: {}): Promise>; };" + )); + assert!(description.contains( + "declare const tools: { mcp__sample__beta(args: {}): Promise>; };" + )); + } + + #[test] + fn code_mode_only_description_omits_empty_namespace_sections() { + let namespace_descriptions = BTreeMap::from([( + "mcp__sample__".to_string(), + ToolNamespaceDescription { + name: "mcp__sample".to_string(), + description: String::new(), + }, + )]); + let description = build_exec_tool_description( + &[ToolDefinition { + name: "mcp__sample__alpha".to_string(), + tool_name: ToolName::namespaced("mcp__sample__", "alpha"), + description: "First tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(mcp_call_tool_result_schema(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }))), + }], + &namespace_descriptions, + /*code_mode_only*/ true, + /*deferred_tools_available*/ false, + ); + + assert!(!description.contains("## mcp__sample")); + assert!(description.contains("### `mcp__sample__alpha`")); + } + + #[test] + fn code_mode_only_description_renders_shared_mcp_types_once() { + let first_tool = augment_tool_definition(ToolDefinition { + name: "mcp__sample__alpha".to_string(), + tool_name: ToolName::namespaced("mcp__sample__", "alpha"), + description: "First tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": { + "type": "object", + "properties": { + "echo": { "type": "string" } + }, + "required": ["echo"], + "additionalProperties": false + }, + "isError": { "type": "boolean" }, + "_meta": { "type": "object" } + }, + "required": ["content"], + "additionalProperties": false + })), + }); + let second_tool = augment_tool_definition(ToolDefinition { + name: "mcp__sample__beta".to_string(), + tool_name: ToolName::namespaced("mcp__sample__", "beta"), + description: "Second tool".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": { + "type": "object", + "properties": { + "count": { "type": "integer" } + }, + "required": ["count"], + "additionalProperties": false + }, + "isError": { "type": "boolean" }, + "_meta": { "type": "object" } + }, + "required": ["content"], + "additionalProperties": false + })), + }); + + let description = build_exec_tool_description( + &[ + ToolDefinition { + name: first_tool.name, + tool_name: first_tool.tool_name, + description: "First tool".to_string(), + kind: first_tool.kind, + input_schema: first_tool.input_schema, + output_schema: first_tool.output_schema, + }, + ToolDefinition { + name: second_tool.name, + tool_name: second_tool.tool_name, + description: "Second tool".to_string(), + kind: second_tool.kind, + input_schema: second_tool.input_schema, + output_schema: second_tool.output_schema, + }, + ], + &BTreeMap::new(), + /*code_mode_only*/ true, + /*deferred_tools_available*/ false, + ); + + assert_eq!( + description + .matches("type CallToolResult") + .count(), + 1 + ); + assert_eq!(description.matches("Shared MCP Types:").count(), 1); + } + + #[test] + fn exec_description_mentions_deferred_nested_tools_when_available() { + let description = build_exec_tool_description( + &[], + &BTreeMap::new(), + /*code_mode_only*/ false, + /*deferred_tools_available*/ true, + ); + + assert!(description.contains("Some nested MCP/app tools may be omitted")); + assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`")); + } +} diff --git a/code-rs/code-mode/src/lib.rs b/code-rs/code-mode/src/lib.rs new file mode 100644 index 00000000000..bf0ce699ccc --- /dev/null +++ b/code-rs/code-mode/src/lib.rs @@ -0,0 +1,34 @@ +mod description; +mod response; +mod runtime; +mod service; + +pub use description::CODE_MODE_PRAGMA_PREFIX; +pub use description::CodeModeToolKind; +pub use description::ToolDefinition; +pub use description::ToolNamespaceDescription; +pub use description::augment_tool_definition; +pub use description::build_exec_tool_description; +pub use description::build_wait_tool_description; +pub use description::is_code_mode_nested_tool; +pub use description::normalize_code_mode_identifier; +pub use description::parse_exec_source; +pub use description::render_code_mode_sample; +pub use description::render_json_schema_to_typescript; +pub use response::DEFAULT_IMAGE_DETAIL; +pub use response::FunctionCallOutputContentItem; +pub use response::ImageDetail; +pub use runtime::CodeModeNestedToolCall; +pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; +pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; +pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; +pub use runtime::ExecuteRequest; +pub use runtime::RuntimeResponse; +pub use runtime::WaitOutcome; +pub use runtime::WaitRequest; +pub use service::CodeModeService; +pub use service::CodeModeTurnHost; +pub use service::CodeModeTurnWorker; + +pub const PUBLIC_TOOL_NAME: &str = "exec"; +pub const WAIT_TOOL_NAME: &str = "wait"; diff --git a/code-rs/code-mode/src/response.rs b/code-rs/code-mode/src/response.rs new file mode 100644 index 00000000000..0ac3a03770e --- /dev/null +++ b/code-rs/code-mode/src/response.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageDetail { + Auto, + Low, + High, + Original, +} + +pub const DEFAULT_IMAGE_DETAIL: ImageDetail = ImageDetail::High; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FunctionCallOutputContentItem { + InputText { + text: String, + }, + InputImage { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} diff --git a/code-rs/code-mode/src/runtime/callbacks.rs b/code-rs/code-mode/src/runtime/callbacks.rs new file mode 100644 index 00000000000..a9755f6eb0d --- /dev/null +++ b/code-rs/code-mode/src/runtime/callbacks.rs @@ -0,0 +1,274 @@ +use crate::response::FunctionCallOutputContentItem; + +use super::EXIT_SENTINEL; +use super::RuntimeEvent; +use super::RuntimeState; +use super::timers; +use super::value::json_to_v8; +use super::value::normalize_output_image; +use super::value::serialize_output_text; +use super::value::throw_type_error; +use super::value::v8_value_to_json; + +pub(super) fn tool_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let tool_index = match args.data().to_rust_string_lossy(scope).parse::() { + Ok(tool_index) => tool_index, + Err(_) => { + throw_type_error(scope, "invalid tool callback data"); + return; + } + }; + let input = if args.length() == 0 { + Ok(None) + } else { + v8_value_to_json(scope, args.get(0)) + }; + let input = match input { + Ok(input) => input, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + + let Some(resolver) = v8::PromiseResolver::new(scope) else { + throw_type_error(scope, "failed to create tool promise"); + return; + }; + let promise = resolver.get_promise(scope); + + let resolver = v8::Global::new(scope, resolver); + let tool_name = { + let Some(state) = scope.get_slot::() else { + throw_type_error(scope, "runtime state unavailable"); + return; + }; + let Some(tool_name) = state + .enabled_tools + .get(tool_index) + .map(|tool| tool.tool_name.clone()) + else { + throw_type_error(scope, "tool callback data is out of range"); + return; + }; + tool_name + }; + + let Some(state) = scope.get_slot_mut::() else { + throw_type_error(scope, "runtime state unavailable"); + return; + }; + let id = format!("tool-{}", state.next_tool_call_id); + state.next_tool_call_id = state.next_tool_call_id.saturating_add(1); + let event_tx = state.event_tx.clone(); + state.pending_tool_calls.insert(id.clone(), resolver); + let _ = event_tx.send(RuntimeEvent::ToolCall { + id, + name: tool_name, + input, + }); + retval.set(promise.into()); +} + +pub(super) fn text_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { text }, + )); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn image_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let detail_override = if args.length() < 2 { + None + } else { + let detail = args.get(1); + if detail.is_string() { + Some(detail.to_rust_string_lossy(scope)) + } else if detail.is_null() || detail.is_undefined() { + None + } else { + throw_type_error(scope, "image detail must be a string when provided"); + return; + } + }; + let image_item = match normalize_output_image(scope, value, detail_override) { + Ok(image_item) => image_item, + Err(()) => return, + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item)); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn store_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "store key must be a string"); + return; + } + }; + let value = args.get(1); + let serialized = match v8_value_to_json(scope, value) { + Ok(Some(value)) => value, + Ok(None) => { + throw_type_error( + scope, + &format!("Unable to store {key:?}. Only plain serializable objects can be stored."), + ); + return; + } + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if let Some(state) = scope.get_slot_mut::() { + state.stored_values.insert(key, serialized); + } +} + +pub(super) fn load_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let key = match args.get(0).to_string(scope) { + Some(key) => key.to_rust_string_lossy(scope), + None => { + throw_type_error(scope, "load key must be a string"); + return; + } + }; + let value = scope + .get_slot::() + .and_then(|state| state.stored_values.get(&key)) + .cloned(); + let Some(value) = value else { + retval.set(v8::undefined(scope).into()); + return; + }; + let Some(value) = json_to_v8(scope, &value) else { + throw_type_error(scope, "failed to load stored value"); + return; + }; + retval.set(value); +} + +pub(super) fn notify_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let text = match serialize_output_text(scope, value) { + Ok(text) => text, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + if text.trim().is_empty() { + throw_type_error(scope, "notify expects non-empty text"); + return; + } + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::Notify { + call_id: state.tool_call_id.clone(), + text, + }); + } + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn set_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let timeout_id = match timers::schedule_timeout(scope, args) { + Ok(timeout_id) => timeout_id, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + + retval.set(v8::Number::new(scope, timeout_id as f64).into()); +} + +pub(super) fn clear_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + if let Err(error_text) = timers::clear_timeout(scope, args) { + throw_type_error(scope, &error_text); + return; + } + + retval.set(v8::undefined(scope).into()); +} + +pub(super) fn yield_control_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::YieldRequested); + } +} + +pub(super) fn exit_callback( + scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + if let Some(state) = scope.get_slot_mut::() { + state.exit_requested = true; + } + if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) { + scope.throw_exception(error.into()); + } +} diff --git a/code-rs/code-mode/src/runtime/globals.rs b/code-rs/code-mode/src/runtime/globals.rs new file mode 100644 index 00000000000..2ec6953f093 --- /dev/null +++ b/code-rs/code-mode/src/runtime/globals.rs @@ -0,0 +1,157 @@ +use super::RuntimeState; +use super::callbacks::clear_timeout_callback; +use super::callbacks::exit_callback; +use super::callbacks::image_callback; +use super::callbacks::load_callback; +use super::callbacks::notify_callback; +use super::callbacks::set_timeout_callback; +use super::callbacks::store_callback; +use super::callbacks::text_callback; +use super::callbacks::tool_callback; +use super::callbacks::yield_control_callback; + +pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> { + let global = scope.get_current_context().global(scope); + delete_global(scope, global, "console")?; + delete_global(scope, global, "Atomics")?; + delete_global(scope, global, "SharedArrayBuffer")?; + delete_global(scope, global, "WebAssembly")?; + + let tools = build_tools_object(scope)?; + let all_tools = build_all_tools_value(scope)?; + let clear_timeout = helper_function(scope, "clearTimeout", clear_timeout_callback)?; + let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?; + let text = helper_function(scope, "text", text_callback)?; + let image = helper_function(scope, "image", image_callback)?; + let store = helper_function(scope, "store", store_callback)?; + let load = helper_function(scope, "load", load_callback)?; + let notify = helper_function(scope, "notify", notify_callback)?; + let yield_control = helper_function(scope, "yield_control", yield_control_callback)?; + let exit = helper_function(scope, "exit", exit_callback)?; + + set_global(scope, global, "tools", tools.into())?; + set_global(scope, global, "ALL_TOOLS", all_tools)?; + set_global(scope, global, "clearTimeout", clear_timeout.into())?; + set_global(scope, global, "setTimeout", set_timeout.into())?; + set_global(scope, global, "text", text.into())?; + set_global(scope, global, "image", image.into())?; + set_global(scope, global, "store", store.into())?; + set_global(scope, global, "load", load.into())?; + set_global(scope, global, "notify", notify.into())?; + set_global(scope, global, "yield_control", yield_control.into())?; + set_global(scope, global, "exit", exit.into())?; + Ok(()) +} + +fn build_tools_object<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let tools = v8::Object::new(scope); + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + + for (tool_index, tool) in enabled_tools.iter().enumerate() { + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate tool name".to_string())?; + let function = tool_function(scope, tool_index)?; + tools.set(scope, name.into(), function.into()); + } + Ok(tools) +} + +fn build_all_tools_value<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result, String> { + let enabled_tools = scope + .get_slot::() + .map(|state| state.enabled_tools.clone()) + .unwrap_or_default(); + let array = v8::Array::new(scope, enabled_tools.len() as i32); + let name_key = v8::String::new(scope, "name") + .ok_or_else(|| "failed to allocate ALL_TOOLS name key".to_string())?; + let description_key = v8::String::new(scope, "description") + .ok_or_else(|| "failed to allocate ALL_TOOLS description key".to_string())?; + + for (index, tool) in enabled_tools.iter().enumerate() { + let item = v8::Object::new(scope); + let name = v8::String::new(scope, &tool.global_name) + .ok_or_else(|| "failed to allocate ALL_TOOLS name".to_string())?; + let description = v8::String::new(scope, &tool.description) + .ok_or_else(|| "failed to allocate ALL_TOOLS description".to_string())?; + + if item.set(scope, name_key.into(), name.into()) != Some(true) { + return Err("failed to set ALL_TOOLS name".to_string()); + } + if item.set(scope, description_key.into(), description.into()) != Some(true) { + return Err("failed to set ALL_TOOLS description".to_string()); + } + if array.set_index(scope, index as u32, item.into()) != Some(true) { + return Err("failed to append ALL_TOOLS metadata".to_string()); + } + } + + Ok(array.into()) +} + +fn helper_function<'s, F>( + scope: &mut v8::PinScope<'s, '_>, + name: &str, + callback: F, +) -> Result, String> +where + F: v8::MapFnTo, +{ + let name = + v8::String::new(scope, name).ok_or_else(|| "failed to allocate helper name".to_string())?; + let template = v8::FunctionTemplate::builder(callback) + .data(name.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create helper function".to_string()) +} + +fn tool_function<'s>( + scope: &mut v8::PinScope<'s, '_>, + tool_index: usize, +) -> Result, String> { + let data = v8::String::new(scope, &tool_index.to_string()) + .ok_or_else(|| "failed to allocate tool callback data".to_string())?; + let template = v8::FunctionTemplate::builder(tool_callback) + .data(data.into()) + .build(scope); + template + .get_function(scope) + .ok_or_else(|| "failed to create tool function".to_string()) +} + +fn set_global<'s>( + scope: &mut v8::PinScope<'s, '_>, + global: v8::Local<'s, v8::Object>, + name: &str, + value: v8::Local<'s, v8::Value>, +) -> Result<(), String> { + let key = v8::String::new(scope, name) + .ok_or_else(|| format!("failed to allocate global `{name}`"))?; + if global.set(scope, key.into(), value) == Some(true) { + Ok(()) + } else { + Err(format!("failed to set global `{name}`")) + } +} + +fn delete_global<'s>( + scope: &mut v8::PinScope<'s, '_>, + global: v8::Local<'s, v8::Object>, + name: &str, +) -> Result<(), String> { + let key = v8::String::new(scope, name) + .ok_or_else(|| format!("failed to allocate global `{name}`"))?; + if global.delete(scope, key.into()) == Some(true) { + Ok(()) + } else { + Err(format!("failed to remove global `{name}`")) + } +} diff --git a/code-rs/code-mode/src/runtime/mod.rs b/code-rs/code-mode/src/runtime/mod.rs new file mode 100644 index 00000000000..200a47c9897 --- /dev/null +++ b/code-rs/code-mode/src/runtime/mod.rs @@ -0,0 +1,416 @@ +mod callbacks; +mod globals; +mod module_loader; +mod timers; +mod value; + +use std::collections::HashMap; +use std::sync::OnceLock; +use std::sync::mpsc as std_mpsc; +use std::thread; + +use codex_protocol::ToolName; +use serde::Serialize; +use serde_json::Value as JsonValue; +use tokio::sync::mpsc; + +use crate::description::EnabledToolMetadata; +use crate::description::ToolDefinition; +use crate::description::enabled_tool_metadata; +use crate::response::FunctionCallOutputContentItem; + +pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000; +const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; + +#[derive(Clone, Debug)] +pub struct ExecuteRequest { + /// Runtime cell id for this execution. + /// + /// Callers allocate this before execution so tracing, waits, and nested tool + /// calls can refer to the cell as soon as JavaScript starts. + pub cell_id: String, + pub tool_call_id: String, + pub enabled_tools: Vec, + pub source: String, + pub stored_values: HashMap, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct WaitRequest { + pub cell_id: String, + pub yield_time_ms: u64, + pub terminate: bool, +} + +/// Result of waiting on a code-mode cell. +/// +/// The wrapped `RuntimeResponse` is the model-facing wait result. The enum +/// variant carries the extra lifecycle provenance that `RuntimeResponse` cannot: +/// a failed real cell and a missing-cell wait both use +/// `RuntimeResponse::Result { error_text: Some(..), .. }`, but only the former +/// should be treated as a code-cell lifecycle event. +#[derive(Debug, PartialEq)] +pub enum WaitOutcome { + /// The requested code cell was live when the wait command was accepted. + LiveCell(RuntimeResponse), + /// The requested code cell was not live. + MissingCell(RuntimeResponse), +} + +impl From for RuntimeResponse { + fn from(outcome: WaitOutcome) -> Self { + match outcome { + WaitOutcome::LiveCell(response) | WaitOutcome::MissingCell(response) => response, + } + } +} + +#[derive(Debug, PartialEq, Serialize)] +pub enum RuntimeResponse { + Yielded { + cell_id: String, + content_items: Vec, + }, + Terminated { + cell_id: String, + content_items: Vec, + }, + Result { + cell_id: String, + content_items: Vec, + stored_values: HashMap, + error_text: Option, + }, +} + +/// Nested tool request emitted by one code-mode cell. +/// +/// Code mode owns the per-cell runtime id. Hosts should preserve it for +/// provenance/debugging, but should still assign their own runtime tool call id +/// if their tool-call graph requires globally unique ids. +#[derive(Debug)] +pub struct CodeModeNestedToolCall { + pub cell_id: String, + pub runtime_tool_call_id: String, + pub tool_name: ToolName, + pub input: Option, +} + +#[derive(Debug)] +pub(crate) enum TurnMessage { + ToolCall(CodeModeNestedToolCall), + Notify { + cell_id: String, + call_id: String, + text: String, + }, +} + +#[derive(Debug)] +pub(crate) enum RuntimeCommand { + ToolResponse { id: String, result: JsonValue }, + ToolError { id: String, error_text: String }, + TimeoutFired { id: u64 }, + Terminate, +} + +#[derive(Debug)] +pub(crate) enum RuntimeEvent { + Started, + ContentItem(FunctionCallOutputContentItem), + YieldRequested, + ToolCall { + id: String, + name: ToolName, + input: Option, + }, + Notify { + call_id: String, + text: String, + }, + Result { + stored_values: HashMap, + error_text: Option, + }, +} + +pub(crate) fn spawn_runtime( + request: ExecuteRequest, + event_tx: mpsc::UnboundedSender, +) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { + initialize_v8()?; + + let (command_tx, command_rx) = std_mpsc::channel(); + let runtime_command_tx = command_tx.clone(); + let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); + let enabled_tools = request + .enabled_tools + .iter() + .map(enabled_tool_metadata) + .collect::>(); + let config = RuntimeConfig { + tool_call_id: request.tool_call_id, + enabled_tools, + source: request.source, + stored_values: request.stored_values, + }; + + thread::spawn(move || { + run_runtime( + config, + event_tx, + command_rx, + isolate_handle_tx, + runtime_command_tx, + ); + }); + + let isolate_handle = isolate_handle_rx + .recv() + .map_err(|_| "failed to initialize code mode runtime".to_string())?; + Ok((command_tx, isolate_handle)) +} + +#[derive(Clone)] +struct RuntimeConfig { + tool_call_id: String, + enabled_tools: Vec, + source: String, + stored_values: HashMap, +} + +pub(super) struct RuntimeState { + event_tx: mpsc::UnboundedSender, + pending_tool_calls: HashMap>, + pending_timeouts: HashMap, + stored_values: HashMap, + enabled_tools: Vec, + next_tool_call_id: u64, + next_timeout_id: u64, + tool_call_id: String, + runtime_command_tx: std_mpsc::Sender, + exit_requested: bool, +} + +pub(super) enum CompletionState { + Pending, + Completed { + stored_values: HashMap, + error_text: Option, + }, +} + +fn initialize_v8() -> Result<(), String> { + static PLATFORM: OnceLock, String>> = OnceLock::new(); + + match PLATFORM.get_or_init(|| { + v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA) + .map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?; + let platform = v8::new_default_platform(0, false).make_shared(); + v8::V8::initialize_platform(platform.clone()); + v8::V8::initialize(); + Ok(platform) + }) { + Ok(_) => Ok(()), + Err(error_text) => Err(error_text.clone()), + } +} + +fn run_runtime( + config: RuntimeConfig, + event_tx: mpsc::UnboundedSender, + command_rx: std_mpsc::Receiver, + isolate_handle_tx: std_mpsc::SyncSender, + runtime_command_tx: std_mpsc::Sender, +) { + let isolate = &mut v8::Isolate::new(v8::CreateParams::default()); + let isolate_handle = isolate.thread_safe_handle(); + if isolate_handle_tx.send(isolate_handle).is_err() { + return; + } + isolate.set_host_import_module_dynamically_callback(module_loader::dynamic_import_callback); + + v8::scope!(let scope, isolate); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + + scope.set_slot(RuntimeState { + event_tx: event_tx.clone(), + pending_tool_calls: HashMap::new(), + pending_timeouts: HashMap::new(), + stored_values: config.stored_values, + enabled_tools: config.enabled_tools, + next_tool_call_id: 1, + next_timeout_id: 1, + tool_call_id: config.tool_call_id, + runtime_command_tx, + exit_requested: false, + }); + + if let Err(error_text) = globals::install_globals(scope) { + send_result(&event_tx, HashMap::new(), Some(error_text)); + return; + } + + let _ = event_tx.send(RuntimeEvent::Started); + + let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) { + Ok(pending_promise) => pending_promise, + Err(error_text) => { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + }; + + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + let mut pending_promise = pending_promise; + loop { + let Ok(command) = command_rx.recv() else { + break; + }; + + match command { + RuntimeCommand::Terminate => break, + RuntimeCommand::ToolResponse { id, result } => { + if let Err(error_text) = + module_loader::resolve_tool_response(scope, &id, Ok(result)) + { + capture_scope_send_error(scope, &event_tx, Some(error_text)); + return; + } + } + RuntimeCommand::ToolError { id, error_text } => { + if let Err(runtime_error) = + module_loader::resolve_tool_response(scope, &id, Err(error_text)) + { + capture_scope_send_error(scope, &event_tx, Some(runtime_error)); + return; + } + } + RuntimeCommand::TimeoutFired { id } => { + if let Err(runtime_error) = timers::invoke_timeout_callback(scope, id) { + capture_scope_send_error(scope, &event_tx, Some(runtime_error)); + return; + } + } + } + + scope.perform_microtask_checkpoint(); + match module_loader::completion_state(scope, pending_promise.as_ref()) { + CompletionState::Completed { + stored_values, + error_text, + } => { + send_result(&event_tx, stored_values, error_text); + return; + } + CompletionState::Pending => {} + } + + if let Some(promise) = pending_promise.as_ref() { + let promise = v8::Local::new(scope, promise); + if promise.state() != v8::PromiseState::Pending { + pending_promise = None; + } + } + } +} + +fn capture_scope_send_error( + scope: &mut v8::PinScope<'_, '_>, + event_tx: &mpsc::UnboundedSender, + error_text: Option, +) { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + send_result(event_tx, stored_values, error_text); +} + +fn send_result( + event_tx: &mpsc::UnboundedSender, + stored_values: HashMap, + error_text: Option, +) { + let _ = event_tx.send(RuntimeEvent::Result { + stored_values, + error_text, + }); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + + use super::ExecuteRequest; + use super::RuntimeEvent; + use super::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + cell_id: "1".to_string(), + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + #[tokio::test] + async fn terminate_execution_stops_cpu_bound_module() { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let (_runtime_tx, runtime_terminate_handle) = + spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap(); + + let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(started_event, RuntimeEvent::Started)); + + assert!(runtime_terminate_handle.terminate_execution()); + + let result_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + let RuntimeEvent::Result { + stored_values, + error_text, + } = result_event + else { + panic!("expected runtime result after termination"); + }; + assert_eq!(stored_values, HashMap::new()); + assert!(error_text.is_some()); + + assert!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .is_none() + ); + } +} diff --git a/code-rs/code-mode/src/runtime/module_loader.rs b/code-rs/code-mode/src/runtime/module_loader.rs new file mode 100644 index 00000000000..83ce3d347af --- /dev/null +++ b/code-rs/code-mode/src/runtime/module_loader.rs @@ -0,0 +1,235 @@ +use serde_json::Value as JsonValue; + +use super::CompletionState; +use super::EXIT_SENTINEL; +use super::RuntimeState; +use super::value::json_to_v8; +use super::value::value_to_error_text; + +pub(super) fn evaluate_main_module( + scope: &mut v8::PinScope<'_, '_>, + source_text: &str, +) -> Result>, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let source = v8::String::new(&tc, source_text) + .ok_or_else(|| "failed to allocate exec source".to_string())?; + let origin = script_origin(&mut tc, "exec_main.mjs")?; + let mut source = v8::script_compiler::Source::new(source, Some(&origin)); + let module = v8::script_compiler::compile_module(&tc, &mut source).ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + module + .instantiate_module(&tc, resolve_module_callback) + .ok_or_else(|| { + tc.exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string()) + })?; + let result = match module.evaluate(&tc) { + Some(result) => result, + None => { + if let Some(exception) = tc.exception() { + if is_exit_exception(&mut tc, exception) { + return Ok(None); + } + return Err(value_to_error_text(&mut tc, exception)); + } + return Err("unknown code mode exception".to_string()); + } + }; + tc.perform_microtask_checkpoint(); + + if result.is_promise() { + let promise = v8::Local::::try_from(result) + .map_err(|_| "failed to read exec promise".to_string())?; + return Ok(Some(v8::Global::new(&tc, promise))); + } + + Ok(None) +} + +fn is_exit_exception( + scope: &mut v8::PinScope<'_, '_>, + exception: v8::Local<'_, v8::Value>, +) -> bool { + scope + .get_slot::() + .map(|state| state.exit_requested) + .unwrap_or(false) + && exception.is_string() + && exception.to_rust_string_lossy(scope) == EXIT_SENTINEL +} + +pub(super) fn resolve_tool_response( + scope: &mut v8::PinScope<'_, '_>, + id: &str, + response: Result, +) -> Result<(), String> { + let resolver = { + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + state.pending_tool_calls.remove(id) + } + .ok_or_else(|| format!("unknown tool call `{id}`"))?; + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let resolver = v8::Local::new(&tc, &resolver); + match response { + Ok(result) => { + let value = json_to_v8(&mut tc, &result) + .ok_or_else(|| "failed to serialize tool response".to_string())?; + resolver.resolve(&tc, value); + } + Err(error_text) => { + let value = v8::String::new(&tc, &error_text) + .ok_or_else(|| "failed to allocate tool error".to_string())?; + resolver.reject(&tc, value.into()); + } + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(()) +} + +pub(super) fn completion_state( + scope: &mut v8::PinScope<'_, '_>, + pending_promise: Option<&v8::Global>, +) -> CompletionState { + let stored_values = scope + .get_slot::() + .map(|state| state.stored_values.clone()) + .unwrap_or_default(); + + let Some(pending_promise) = pending_promise else { + return CompletionState::Completed { + stored_values, + error_text: None, + }; + }; + + let promise = v8::Local::new(scope, pending_promise); + match promise.state() { + v8::PromiseState::Pending => CompletionState::Pending, + v8::PromiseState::Fulfilled => CompletionState::Completed { + stored_values, + error_text: None, + }, + v8::PromiseState::Rejected => { + let result = promise.result(scope); + let error_text = if is_exit_exception(scope, result) { + None + } else { + Some(value_to_error_text(scope, result)) + }; + CompletionState::Completed { + stored_values, + error_text, + } + } + } +} + +fn script_origin<'s>( + scope: &mut v8::PinScope<'s, '_>, + resource_name_: &str, +) -> Result, String> { + let resource_name = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate script origin".to_string())?; + let source_map_url = v8::String::new(scope, resource_name_) + .ok_or_else(|| "failed to allocate source map url".to_string())?; + Ok(v8::ScriptOrigin::new( + scope, + resource_name.into(), + 0, + 0, + true, + 0, + Some(source_map_url.into()), + true, + false, + true, + None, + )) +} + +fn resolve_module_callback<'s>( + context: v8::Local<'s, v8::Context>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, + _referrer: v8::Local<'s, v8::Module>, +) -> Option> { + v8::callback_scope!(unsafe scope, context); + let specifier = specifier.to_rust_string_lossy(scope); + resolve_module(scope, &specifier) +} + +pub(super) fn dynamic_import_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + _host_defined_options: v8::Local<'s, v8::Data>, + _resource_name: v8::Local<'s, v8::Value>, + specifier: v8::Local<'s, v8::String>, + _import_attributes: v8::Local<'s, v8::FixedArray>, +) -> Option> { + let specifier = specifier.to_rust_string_lossy(scope); + let resolver = v8::PromiseResolver::new(scope)?; + + match resolve_module(scope, &specifier) { + Some(module) => { + if module.get_status() == v8::ModuleStatus::Uninstantiated + && module + .instantiate_module(scope, resolve_module_callback) + .is_none() + { + let error = v8::String::new(scope, "failed to instantiate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + if matches!( + module.get_status(), + v8::ModuleStatus::Instantiated | v8::ModuleStatus::Evaluated + ) && module.evaluate(scope).is_none() + { + let error = v8::String::new(scope, "failed to evaluate module") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + return Some(resolver.get_promise(scope)); + } + let namespace = module.get_module_namespace(); + resolver.resolve(scope, namespace); + Some(resolver.get_promise(scope)) + } + None => { + let error = v8::String::new(scope, "unsupported import in exec") + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + resolver.reject(scope, error); + Some(resolver.get_promise(scope)) + } + } +} + +fn resolve_module<'s>( + scope: &mut v8::PinScope<'s, '_>, + specifier: &str, +) -> Option> { + if let Some(message) = + v8::String::new(scope, &format!("Unsupported import in exec: {specifier}")) + { + scope.throw_exception(message.into()); + } else { + scope.throw_exception(v8::undefined(scope).into()); + } + None +} diff --git a/code-rs/code-mode/src/runtime/timers.rs b/code-rs/code-mode/src/runtime/timers.rs new file mode 100644 index 00000000000..01c414cefe7 --- /dev/null +++ b/code-rs/code-mode/src/runtime/timers.rs @@ -0,0 +1,114 @@ +use std::thread; +use std::time::Duration; + +use super::RuntimeCommand; +use super::RuntimeState; +use super::value::value_to_error_text; + +pub(super) struct ScheduledTimeout { + callback: v8::Global, +} + +pub(super) fn schedule_timeout( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result { + let callback = args.get(0); + if !callback.is_function() { + return Err("setTimeout expects a function callback".to_string()); + } + let callback = v8::Local::::try_from(callback) + .map_err(|_| "setTimeout expects a function callback".to_string())?; + + let delay_ms = args + .get(1) + .number_value(scope) + .map(normalize_delay_ms) + .unwrap_or(0); + + let callback = v8::Global::new(scope, callback); + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + let timeout_id = state.next_timeout_id; + state.next_timeout_id = state.next_timeout_id.saturating_add(1); + let runtime_command_tx = state.runtime_command_tx.clone(); + state + .pending_timeouts + .insert(timeout_id, ScheduledTimeout { callback }); + thread::spawn(move || { + thread::sleep(Duration::from_millis(delay_ms)); + let _ = runtime_command_tx.send(RuntimeCommand::TimeoutFired { id: timeout_id }); + }); + + Ok(timeout_id) +} + +pub(super) fn clear_timeout( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result<(), String> { + let Some(timeout_id) = timeout_id_from_args(scope, args)? else { + return Ok(()); + }; + + let Some(state) = scope.get_slot_mut::() else { + return Err("runtime state unavailable".to_string()); + }; + state.pending_timeouts.remove(&timeout_id); + Ok(()) +} + +pub(super) fn invoke_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + timeout_id: u64, +) -> Result<(), String> { + let callback = { + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + state.pending_timeouts.remove(&timeout_id) + }; + let Some(callback) = callback else { + return Ok(()); + }; + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let callback = v8::Local::new(&tc, &callback.callback); + let receiver = v8::undefined(&tc).into(); + let _ = callback.call(&tc, receiver, &[]); + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + + Ok(()) +} +fn timeout_id_from_args( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result, String> { + if args.length() == 0 || args.get(0).is_null_or_undefined() { + return Ok(None); + } + + let Some(timeout_id) = args.get(0).number_value(scope) else { + return Err("clearTimeout expects a numeric timeout id".to_string()); + }; + if !timeout_id.is_finite() || timeout_id <= 0.0 { + return Ok(None); + } + + Ok(Some(timeout_id.trunc().min(u64::MAX as f64) as u64)) +} + +fn normalize_delay_ms(delay_ms: f64) -> u64 { + if !delay_ms.is_finite() || delay_ms <= 0.0 { + 0 + } else { + delay_ms.trunc().min(u64::MAX as f64) as u64 + } +} diff --git a/code-rs/code-mode/src/runtime/value.rs b/code-rs/code-mode/src/runtime/value.rs new file mode 100644 index 00000000000..8d76a832d36 --- /dev/null +++ b/code-rs/code-mode/src/runtime/value.rs @@ -0,0 +1,228 @@ +use serde_json::Value as JsonValue; + +use crate::response::DEFAULT_IMAGE_DETAIL; +use crate::response::FunctionCallOutputContentItem; +use crate::response::ImageDetail; + +const IMAGE_HELPER_EXPECTS_MESSAGE: &str = "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block"; +const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail"; + +pub(super) fn serialize_output_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result { + if value.is_undefined() + || value.is_null() + || value.is_boolean() + || value.is_number() + || value.is_big_int() + || value.is_string() + { + return Ok(value.to_rust_string_lossy(scope)); + } + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + if let Some(stringified) = v8::json::stringify(&tc, value) { + return Ok(stringified.to_rust_string_lossy(&tc)); + } + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + Ok(value.to_rust_string_lossy(&tc)) +} + +pub(super) fn normalize_output_image( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, + detail_override: Option, +) -> Result { + let result = (|| -> Result { + let (image_url, detail) = if value.is_string() { + (value.to_rust_string_lossy(scope), None) + } else if value.is_object() && !value.is_array() { + let object = v8::Local::::try_from(value) + .map_err(|_| IMAGE_HELPER_EXPECTS_MESSAGE.to_string())?; + if let Some(image) = parse_non_mcp_output_image(scope, object)? { + image + } else { + parse_mcp_output_image(scope, value)? + } + } else { + return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string()); + }; + + if image_url.is_empty() { + return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string()); + } + let lower = image_url.to_ascii_lowercase(); + if !(lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("data:")) + { + return Err("image expects an http(s) or data URL".to_string()); + } + + let detail = detail_override.or(detail); + let detail = match detail { + Some(detail) => { + let normalized = detail.to_ascii_lowercase(); + Some(match normalized.as_str() { + "auto" => ImageDetail::Auto, + "low" => ImageDetail::Low, + "high" => ImageDetail::High, + "original" => ImageDetail::Original, + _ => { + return Err( + "image detail must be one of: auto, low, high, original".to_string() + ); + } + }) + } + None => Some(DEFAULT_IMAGE_DETAIL), + }; + + Ok(FunctionCallOutputContentItem::InputImage { image_url, detail }) + })(); + + match result { + Ok(item) => Ok(item), + Err(error_text) => { + throw_type_error(scope, &error_text); + Err(()) + } + } +} + +fn parse_non_mcp_output_image( + scope: &mut v8::PinScope<'_, '_>, + object: v8::Local<'_, v8::Object>, +) -> Result)>, String> { + let image_url_key = v8::String::new(scope, "image_url") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let Some(image_url) = object.get(scope, image_url_key.into()) else { + return Ok(None); + }; + if image_url.is_undefined() { + return Ok(None); + } + if !image_url.is_string() { + return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string()); + } + let detail_key = v8::String::new(scope, "detail") + .ok_or_else(|| "failed to allocate image helper keys".to_string())?; + let detail = parse_image_detail_value(scope, object.get(scope, detail_key.into()))?; + Ok(Some((image_url.to_rust_string_lossy(scope), detail))) +} + +fn parse_mcp_output_image( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result<(String, Option), String> { + let Some(result) = v8_value_to_json(scope, value)? else { + return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string()); + }; + let JsonValue::Object(result) = result else { + return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string()); + }; + let Some(item_type) = result.get("type").and_then(JsonValue::as_str) else { + return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string()); + }; + if item_type != "image" { + return Err(format!( + "image only accepts MCP image blocks, got \"{item_type}\"" + )); + } + let data = result + .get("data") + .and_then(JsonValue::as_str) + .ok_or_else(|| "image expected MCP image data".to_string())?; + if data.is_empty() { + return Err("image expected MCP image data".to_string()); + } + + let image_url = if data.to_ascii_lowercase().starts_with("data:") { + data.to_string() + } else { + let mime_type = result + .get("mimeType") + .or_else(|| result.get("mime_type")) + .and_then(JsonValue::as_str) + .filter(|mime_type| !mime_type.is_empty()) + .unwrap_or("application/octet-stream"); + format!("data:{mime_type};base64,{data}") + }; + let detail = result + .get("_meta") + .and_then(JsonValue::as_object) + .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) + .and_then(JsonValue::as_str) + .filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original")) + .map(str::to_string); + Ok((image_url, detail)) +} + +fn parse_image_detail_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: Option>, +) -> Result, String> { + match value { + Some(value) if value.is_string() => Ok(Some(value.to_rust_string_lossy(scope))), + Some(value) if value.is_null() || value.is_undefined() => Ok(None), + Some(_) => Err("image detail must be a string when provided".to_string()), + None => Ok(None), + } +} + +pub(super) fn v8_value_to_json( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result, String> { + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let Some(stringified) = v8::json::stringify(&tc, value) else { + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + return Ok(None); + }; + serde_json::from_str(&stringified.to_rust_string_lossy(&tc)) + .map(Some) + .map_err(|err| format!("failed to serialize JavaScript value: {err}")) +} + +pub(super) fn json_to_v8<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: &JsonValue, +) -> Option> { + let json = serde_json::to_string(value).ok()?; + let json = v8::String::new(scope, &json)?; + v8::json::parse(scope, json) +} + +pub(super) fn value_to_error_text( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> String { + if value.is_object() + && let Ok(object) = v8::Local::::try_from(value) + && let Some(key) = v8::String::new(scope, "stack") + && let Some(stack) = object.get(scope, key.into()) + && stack.is_string() + { + return stack.to_rust_string_lossy(scope); + } + value.to_rust_string_lossy(scope) +} + +pub(super) fn throw_type_error(scope: &mut v8::PinScope<'_, '_>, message: &str) { + if let Some(message) = v8::String::new(scope, message) { + scope.throw_exception(message.into()); + } +} diff --git a/code-rs/code-mode/src/service.rs b/code-rs/code-mode/src/service.rs new file mode 100644 index 00000000000..7326c834e2a --- /dev/null +++ b/code-rs/code-mode/src/service.rs @@ -0,0 +1,954 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use crate::FunctionCallOutputContentItem; +use crate::runtime::CodeModeNestedToolCall; +use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; +use crate::runtime::ExecuteRequest; +use crate::runtime::RuntimeCommand; +use crate::runtime::RuntimeEvent; +use crate::runtime::RuntimeResponse; +use crate::runtime::TurnMessage; +use crate::runtime::WaitOutcome; +use crate::runtime::WaitRequest; +use crate::runtime::spawn_runtime; + +#[async_trait] +pub trait CodeModeTurnHost: Send + Sync { + async fn invoke_tool( + &self, + invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> Result; + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; +} + +#[derive(Clone)] +struct SessionHandle { + control_tx: mpsc::UnboundedSender, + runtime_tx: std::sync::mpsc::Sender, +} + +struct Inner { + stored_values: Mutex>, + sessions: Mutex>, + turn_message_tx: async_channel::Sender, + turn_message_rx: async_channel::Receiver, + next_cell_id: AtomicU64, +} + +pub struct CodeModeService { + inner: Arc, +} + +impl CodeModeService { + pub fn new() -> Self { + let (turn_message_tx, turn_message_rx) = async_channel::unbounded(); + + Self { + inner: Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx, + next_cell_id: AtomicU64::new(1), + }), + } + } + + pub async fn stored_values(&self) -> HashMap { + self.inner.stored_values.lock().await.clone() + } + + pub async fn replace_stored_values(&self, values: HashMap) { + *self.inner.stored_values.lock().await = values; + } + + /// Reserves the runtime cell id for a future `execute` request. + /// + /// The runtime can issue nested tool calls before the first `execute` + /// response is returned. Hosts that need a parent trace object for those + /// nested calls should allocate the cell id up front and pass it back on the + /// `ExecuteRequest`. + pub fn allocate_cell_id(&self) -> String { + self.inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string() + } + + pub async fn execute(&self, request: ExecuteRequest) -> Result { + let cell_id = request.cell_id.clone(); + let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (response_tx, response_rx) = oneshot::channel(); + let (runtime_tx, runtime_terminate_handle) = { + let mut sessions = self.inner.sessions.lock().await; + if sessions.contains_key(&cell_id) { + return Err(format!("exec cell {cell_id} already exists")); + } + + let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request, event_tx)?; + + // Keep the session registry locked through insertion so a + // caller-owned cell id cannot race with another execute and replace + // a live runtime. + sessions.insert( + cell_id.clone(), + SessionHandle { + control_tx, + runtime_tx: runtime_tx.clone(), + }, + ); + (runtime_tx, runtime_terminate_handle) + }; + + tokio::spawn(run_session_control( + Arc::clone(&self.inner), + SessionControlContext { + cell_id: cell_id.clone(), + runtime_tx, + runtime_terminate_handle, + }, + event_rx, + control_rx, + response_tx, + initial_yield_time_ms, + )); + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + pub async fn wait(&self, request: WaitRequest) -> Result { + let cell_id = request.cell_id.clone(); + let handle = self + .inner + .sessions + .lock() + .await + .get(&request.cell_id) + .cloned(); + let Some(handle) = handle else { + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); + }; + let (response_tx, response_rx) = oneshot::channel(); + let control_message = if request.terminate { + SessionControlCommand::Terminate { response_tx } + } else { + SessionControlCommand::Poll { + yield_time_ms: request.yield_time_ms, + response_tx, + } + }; + if handle.control_tx.send(control_message).is_err() { + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); + } + match response_rx.await { + Ok(response) => Ok(WaitOutcome::LiveCell(response)), + Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response( + request.cell_id, + ))), + } + } + + pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let inner = Arc::clone(&self.inner); + let turn_message_rx = self.inner.turn_message_rx.clone(); + + tokio::spawn(async move { + loop { + let next_message = tokio::select! { + _ = &mut shutdown_rx => break, + message = turn_message_rx.recv() => message.ok(), + }; + let Some(next_message) = next_message else { + break; + }; + match next_message { + TurnMessage::Notify { + cell_id, + call_id, + text, + } => { + if let Err(err) = host.notify(call_id, cell_id.clone(), text).await { + warn!( + "failed to deliver code mode notification for cell {cell_id}: {err}" + ); + } + } + TurnMessage::ToolCall(invocation) => { + let host = Arc::clone(&host); + let inner = Arc::clone(&inner); + tokio::spawn(async move { + let cell_id = invocation.cell_id.clone(); + let runtime_tool_call_id = invocation.runtime_tool_call_id.clone(); + let response = + host.invoke_tool(invocation, CancellationToken::new()).await; + let runtime_tx = inner + .sessions + .lock() + .await + .get(&cell_id) + .map(|handle| handle.runtime_tx.clone()); + let Some(runtime_tx) = runtime_tx else { + return; + }; + let command = match response { + Ok(result) => RuntimeCommand::ToolResponse { + id: runtime_tool_call_id, + result, + }, + Err(error_text) => RuntimeCommand::ToolError { + id: runtime_tool_call_id, + error_text, + }, + }; + let _ = runtime_tx.send(command); + }); + } + } + } + }); + + CodeModeTurnWorker { + shutdown_tx: Some(shutdown_tx), + } + } +} + +impl Default for CodeModeService { + fn default() -> Self { + Self::new() + } +} + +pub struct CodeModeTurnWorker { + shutdown_tx: Option>, +} + +impl Drop for CodeModeTurnWorker { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +enum SessionControlCommand { + Poll { + yield_time_ms: u64, + response_tx: oneshot::Sender, + }, + Terminate { + response_tx: oneshot::Sender, + }, +} + +struct PendingResult { + content_items: Vec, + stored_values: HashMap, + error_text: Option, +} + +struct SessionControlContext { + cell_id: String, + runtime_tx: std::sync::mpsc::Sender, + runtime_terminate_handle: v8::IsolateHandle, +} + +fn missing_cell_response(cell_id: String) -> RuntimeResponse { + RuntimeResponse::Result { + error_text: Some(format!("exec cell {cell_id} not found")), + cell_id, + content_items: Vec::new(), + stored_values: HashMap::new(), + } +} + +fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeResponse { + RuntimeResponse::Result { + cell_id: cell_id.to_string(), + content_items: result.content_items, + stored_values: result.stored_values, + error_text: result.error_text, + } +} + +fn send_or_buffer_result( + cell_id: &str, + result: PendingResult, + response_tx: &mut Option>, + pending_result: &mut Option, +) -> bool { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(pending_result_response(cell_id, result)); + return true; + } + + *pending_result = Some(result); + false +} + +async fn run_session_control( + inner: Arc, + context: SessionControlContext, + mut event_rx: mpsc::UnboundedReceiver, + mut control_rx: mpsc::UnboundedReceiver, + initial_response_tx: oneshot::Sender, + initial_yield_time_ms: u64, +) { + let SessionControlContext { + cell_id, + runtime_tx, + runtime_terminate_handle, + } = context; + let mut content_items = Vec::new(); + let mut pending_result: Option = None; + let mut response_tx = Some(initial_response_tx); + let mut termination_requested = false; + let mut runtime_closed = false; + let mut yield_timer: Option>> = None; + + loop { + tokio::select! { + maybe_event = async { + if runtime_closed { + std::future::pending::>().await + } else { + event_rx.recv().await + } + } => { + let Some(event) = maybe_event else { + runtime_closed = true; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + if pending_result.is_none() { + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values: HashMap::new(), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + continue; + }; + match event { + RuntimeEvent::Started => { + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms)))); + } + RuntimeEvent::ContentItem(item) => { + content_items.push(item); + } + RuntimeEvent::YieldRequested => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + RuntimeEvent::Notify { call_id, text } => { + let _ = inner.turn_message_tx.send(TurnMessage::Notify { + cell_id: cell_id.clone(), + call_id, + text, + }).await; + } + RuntimeEvent::ToolCall { id, name, input } => { + let tool_call = CodeModeNestedToolCall { + cell_id: cell_id.clone(), + runtime_tool_call_id: id, + tool_name: name, + input, + }; + let _ = inner + .turn_message_tx + .send(TurnMessage::ToolCall(tool_call)) + .await; + } + RuntimeEvent::Result { + stored_values, + error_text, + } => { + yield_timer = None; + if termination_requested { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } + let result = PendingResult { + content_items: std::mem::take(&mut content_items), + stored_values, + error_text, + }; + if send_or_buffer_result( + &cell_id, + result, + &mut response_tx, + &mut pending_result, + ) { + break; + } + } + } + } + maybe_command = control_rx.recv() => { + let Some(command) = maybe_command else { + break; + }; + match command { + SessionControlCommand::Poll { + yield_time_ms, + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + response_tx = Some(next_response_tx); + yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); + } + SessionControlCommand::Terminate { response_tx: next_response_tx } => { + if let Some(result) = pending_result.take() { + let _ = next_response_tx.send(pending_result_response(&cell_id, result)); + break; + } + + response_tx = Some(next_response_tx); + termination_requested = true; + yield_timer = None; + let _ = runtime_tx.send(RuntimeCommand::Terminate); + let _ = runtime_terminate_handle.terminate_execution(); + if runtime_closed { + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Terminated { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + break; + } else { + continue; + } + } + } + } + _ = async { + if let Some(yield_timer) = yield_timer.as_mut() { + yield_timer.await; + } else { + std::future::pending::<()>().await; + } + } => { + yield_timer = None; + if let Some(response_tx) = response_tx.take() { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + }); + } + } + } + } + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + inner.sessions.lock().await.remove(&cell_id); +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::atomic::AtomicU64; + use std::time::Duration; + + use pretty_assertions::assert_eq; + use tokio::sync::Mutex; + use tokio::sync::mpsc; + use tokio::sync::oneshot; + + use super::CodeModeService; + use super::Inner; + use super::RuntimeCommand; + use super::RuntimeResponse; + use super::SessionControlCommand; + use super::SessionControlContext; + use super::WaitOutcome; + use super::WaitRequest; + use super::run_session_control; + use crate::FunctionCallOutputContentItem; + use crate::runtime::ExecuteRequest; + use crate::runtime::RuntimeEvent; + use crate::runtime::spawn_runtime; + + fn execute_request(source: &str) -> ExecuteRequest { + ExecuteRequest { + cell_id: "1".to_string(), + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + stored_values: HashMap::new(), + yield_time_ms: Some(1), + max_output_tokens: None, + } + } + + fn test_inner() -> Arc { + let (turn_message_tx, turn_message_rx) = async_channel::unbounded(); + Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + turn_message_tx, + turn_message_rx, + next_cell_id: AtomicU64::new(1), + }) + } + + #[tokio::test] + async fn synchronous_exit_returns_successfully() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text("before"); exit(); text("after");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn v8_console_is_not_exposed_on_global_this() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "false".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn date_locale_string_formats_with_icu_data() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +const value = new Date("2025-01-02T03:04:05Z") + .toLocaleString("fr-FR", { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", + }); +text(value); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn intl_date_time_format_formats_with_icu_data() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +const formatter = new Intl.DateTimeFormat("fr-FR", { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", +}); +text(formatter.format(new Date("2025-01-02T03:04:05Z"))); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn output_helpers_return_undefined() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +const returnsUndefined = [ + text("first"), + image("https://example.com/image.jpg"), + notify("ping"), +].map((value) => value === undefined); +text(JSON.stringify(returnsUndefined)); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![ + FunctionCallOutputContentItem::InputText { + text: "first".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "[true,true,true]".to_string(), + }, + ], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: Some(crate::ImageDetail::Original), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn image_helper_second_arg_overrides_explicit_object_detail() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image( + { + image_url: "https://example.com/image.jpg", + detail: "low", + }, + "original", +); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: Some(crate::ImageDetail::Original), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn image_helper_second_arg_overrides_raw_mcp_image_detail() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image( + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, + }, + "low", +); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: Some(crate::ImageDetail::Low), + }], + stored_values: HashMap::new(), + error_text: None, + } + ); + } + + #[tokio::test] + async fn image_helper_rejects_raw_mcp_result_container() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image({ + content: [ + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + mimeType: "image/png", + _meta: { "codex/imageDetail": "original" }, + }, + ], + isError: false, +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: Vec::new(), + stored_values: HashMap::new(), + error_text: Some( + "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(), + ), + } + ); + } + + #[tokio::test] + async fn wait_reports_missing_cell_separately_from_runtime_results() { + let service = CodeModeService::new(); + + let response = service + .wait(WaitRequest { + cell_id: "missing".to_string(), + yield_time_ms: 1, + terminate: false, + }) + .await + .unwrap(); + + assert_eq!( + response, + WaitOutcome::MissingCell(RuntimeResponse::Result { + cell_id: "missing".to_string(), + content_items: Vec::new(), + stored_values: HashMap::new(), + error_text: Some("exec cell missing not found".to_string()), + }) + ); + } + + #[tokio::test] + async fn terminate_waits_for_runtime_shutdown_before_responding() { + let inner = test_inner(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (initial_response_tx, initial_response_rx) = oneshot::channel(); + let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime( + ExecuteRequest { + source: "await new Promise(() => {})".to_string(), + yield_time_ms: None, + ..execute_request("") + }, + runtime_event_tx, + ) + .unwrap(); + + tokio::spawn(run_session_control( + inner, + SessionControlContext { + cell_id: "cell-1".to_string(), + runtime_tx: runtime_tx.clone(), + runtime_terminate_handle, + }, + event_rx, + control_rx, + initial_response_tx, + /*initial_yield_time_ms*/ 60_000, + )); + + event_tx.send(RuntimeEvent::Started).unwrap(); + event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert_eq!( + initial_response_rx.await.unwrap(), + RuntimeResponse::Yielded { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); + control_tx + .send(SessionControlCommand::Terminate { + response_tx: terminate_response_tx, + }) + .unwrap(); + let terminate_response = async { terminate_response_rx.await.unwrap() }; + tokio::pin!(terminate_response); + assert!( + tokio::time::timeout(Duration::from_millis(100), terminate_response.as_mut()) + .await + .is_err() + ); + + drop(event_tx); + + assert_eq!( + terminate_response.await, + RuntimeResponse::Terminated { + cell_id: "cell-1".to_string(), + content_items: Vec::new(), + } + ); + + let _ = runtime_tx.send(RuntimeCommand::Terminate); + } +} diff --git a/code-rs/code-version/Cargo.toml b/code-rs/code-version/Cargo.toml deleted file mode 100644 index fe3c66883d7..00000000000 --- a/code-rs/code-version/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "code-version" -version = { workspace = true } -edition = { workspace = true } -build = "build.rs" - -[lib] -path = "src/lib.rs" - -[dependencies] -serde_json = { workspace = true } diff --git a/code-rs/code-version/build.rs b/code-rs/code-version/build.rs deleted file mode 100644 index 1a0e3dc9a07..00000000000 --- a/code-rs/code-version/build.rs +++ /dev/null @@ -1,15 +0,0 @@ -fn main() { - // Prefer an explicit CODE_VERSION provided by CI; fall back to the - // crate's package version to keep local builds sane. - let version = std::env::var("CODE_VERSION") - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); - - // Inject the version as a rustc env so it participates in the compiler - // invocation hash (sccache-friendly) and guarantees a cache miss when - // the version changes. - println!("cargo:rustc-env=CODE_VERSION={}", version); - - // Ensure dependent crates rebuild when CODE_VERSION changes even if the - // source graph stays the same. - println!("cargo:rerun-if-env-changed=CODE_VERSION"); -} diff --git a/code-rs/code-version/src/lib.rs b/code-rs/code-version/src/lib.rs deleted file mode 100644 index cc41e789364..00000000000 --- a/code-rs/code-version/src/lib.rs +++ /dev/null @@ -1,259 +0,0 @@ -use std::collections::HashMap; -use std::sync::LazyLock; - -use serde_json::Value; - -// Compile-time embedded version string. -// Prefer the CODE_VERSION provided by CI; fall back to the package -// version for local builds. -pub const CODE_VERSION: &str = { - match option_env!("CODE_VERSION") { - Some(v) => v, - None => env!("CARGO_PKG_VERSION"), - } -}; - -pub const PRODUCT_NAME: &str = "Every Code"; -pub const LAB_BUILD_NAME: &str = "Every Code Lab"; -pub const LAB_REPOSITORY: &str = "cbusillo/code"; - -const ANNOUNCEMENT_TIP: &str = include_str!("../../../announcement_tip.toml"); -const MODELS_MANIFEST: &str = include_str!("../../../codex-rs/models-manager/models.json"); -pub const MIN_WIRE_COMPAT_VERSION_FALLBACK: &str = "0.101.0"; - -static MIN_WIRE_COMPAT_VERSION: LazyLock = LazyLock::new(|| { - let mut minimum = MIN_WIRE_COMPAT_VERSION_FALLBACK.to_string(); - - if let Some(extracted) = extract_max_semver(ANNOUNCEMENT_TIP) { - minimum = max_semver(&minimum, extracted).to_string(); - } - - minimum -}); - -static MODEL_MINIMUM_CLIENT_VERSIONS: LazyLock> = - LazyLock::new(|| parse_model_minimum_client_versions(MODELS_MANIFEST)); - -fn max_semver<'a>(current: &'a str, candidate: &'a str) -> &'a str { - let Some(current_triplet) = parse_semver_triplet(current) else { - return candidate; - }; - let Some(candidate_triplet) = parse_semver_triplet(candidate) else { - return current; - }; - - if candidate_triplet > current_triplet { - candidate - } else { - current - } -} - -fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> { - let trimmed = version.trim().trim_start_matches('v'); - let core = trimmed - .split_once(['-', '+']) - .map_or(trimmed, |(v, _)| v); - let mut parts = core.split('.'); - - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next()?.parse().ok()?; - - if parts.next().is_some() { - return None; - } - - Some((major, minor, patch)) -} - -fn extract_max_semver(input: &'static str) -> Option<&'static str> { - let mut max: Option<((u64, u64, u64), &'static str)> = None; - - for token in input.split_whitespace() { - let candidate = token.trim_matches(|ch: char| { - !(ch.is_ascii_alphanumeric() || matches!(ch, '-' | '+' | 'v')) - }); - if candidate.is_empty() { - continue; - } - - let Some(triplet) = parse_semver_triplet(candidate) else { - continue; - }; - - let should_update = max.as_ref().is_none_or(|(current_max, _)| triplet > *current_max); - if should_update { - max = Some((triplet, candidate)); - } - } - - max.map(|(_, version)| version) -} - -fn parse_model_minimum_client_versions(input: &str) -> HashMap { - let Ok(root) = serde_json::from_str::(input) else { - return HashMap::new(); - }; - let Some(models) = root.get("models").and_then(Value::as_array) else { - return HashMap::new(); - }; - let mut versions = HashMap::new(); - - for model in models { - let Some(slug) = model.get("slug").and_then(Value::as_str) else { - continue; - }; - let Some(candidate) = model.get("minimal_client_version").and_then(Value::as_str) else { - continue; - }; - - if parse_semver_triplet(candidate).is_none() { - continue; - }; - - versions.insert(slug.to_ascii_lowercase(), candidate.to_string()); - } - - versions -} - -fn wire_compatible_version_for<'a>(version: &'a str, minimum: &'a str) -> &'a str { - let Some(version_triplet) = parse_semver_triplet(version) else { - return version; - }; - let Some(min_triplet) = parse_semver_triplet(minimum) else { - return version; - }; - - if version_triplet < min_triplet { - minimum - } else { - version - } -} - -#[inline] -pub fn version() -> &'static str { - CODE_VERSION -} - -#[inline] -pub fn min_wire_compat_version() -> &'static str { - MIN_WIRE_COMPAT_VERSION.as_str() -} - -#[inline] -pub fn wire_compatible_version() -> &'static str { - wire_compatible_version_for(CODE_VERSION, min_wire_compat_version()) -} - -pub fn wire_compatible_version_for_model(model: &str) -> String { - let canonical_model = model.rsplit('/').next().unwrap_or(model).trim(); - let Some(required_version) = MODEL_MINIMUM_CLIENT_VERSIONS - .get(&canonical_model.to_ascii_lowercase()) - else { - return wire_compatible_version().to_string(); - }; - - max_semver(wire_compatible_version(), required_version).to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn wire_compat_clamps_old_versions() { - assert_eq!( - wire_compatible_version_for("0.0.0", "0.101.0"), - "0.101.0" - ); - assert_eq!( - wire_compatible_version_for("0.6.59", "0.101.0"), - "0.101.0" - ); - assert_eq!( - wire_compatible_version_for("0.6.59-dev+abc123", "0.101.0"), - "0.101.0" - ); - } - - #[test] - fn wire_compat_keeps_new_versions() { - assert_eq!( - wire_compatible_version_for("0.101.0", "0.101.0"), - "0.101.0" - ); - assert_eq!( - wire_compatible_version_for("0.101.1", "0.101.0"), - "0.101.1" - ); - assert_eq!(wire_compatible_version_for("1.0.0", "0.101.0"), "1.0.0"); - } - - #[test] - fn wire_compat_keeps_invalid_versions() { - assert_eq!(wire_compatible_version_for("dev", "0.101.0"), "dev"); - assert_eq!(wire_compatible_version_for("0.1", "0.101.0"), "0.1"); - } - - #[test] - fn extract_max_semver_picks_highest_semver() { - let input = "v0.98.0 preview and later 0.102.1 with regex ^0\\.0\\.0$"; - assert_eq!(extract_max_semver(input), Some("0.102.1")); - } - - #[test] - fn extract_max_semver_strips_sentence_punctuation() { - let input = "Upgrade to 0.99.0. Then 0.98.1."; - assert_eq!(extract_max_semver(input), Some("0.99.0")); - } - - #[test] - fn configured_minimum_defaults_to_semver() { - assert!(parse_semver_triplet(min_wire_compat_version()).is_some()); - } - - #[test] - fn configured_minimum_is_at_least_fallback() { - let configured = parse_semver_triplet(min_wire_compat_version()).expect("configured semver"); - let fallback = - parse_semver_triplet(MIN_WIRE_COMPAT_VERSION_FALLBACK).expect("fallback semver"); - assert!(configured >= fallback); - } - - #[test] - fn parse_model_minimum_client_versions_extracts_versions() { - let input = r#"{ - "models": [ - {"slug": "gpt-5.4", "minimal_client_version": "0.98.0"}, - {"slug": "gpt-5.5", "minimal_client_version": "0.124.0"}, - {"slug": "legacy", "minimal_client_version": "0.0.1"} - ] - }"#; - - assert_eq!( - parse_model_minimum_client_versions(input).get("gpt-5.5"), - Some(&"0.124.0".to_string()) - ); - } - - #[test] - fn wire_compatible_version_for_model_raises_for_gpt_5_5() { - assert_eq!(wire_compatible_version_for_model("gpt-5.5"), "0.124.0"); - } - - #[test] - fn wire_compatible_version_for_model_uses_base_version_for_unknown_models() { - assert_eq!( - wire_compatible_version_for_model("unknown-model"), - wire_compatible_version() - ); - } - - #[test] - fn parse_semver_triplet_rejects_four_component_versions() { - assert_eq!(parse_semver_triplet("1.2.3.4"), None); - } -} diff --git a/code-rs/codex-api/BUILD.bazel b/code-rs/codex-api/BUILD.bazel new file mode 100644 index 00000000000..c87c9052606 --- /dev/null +++ b/code-rs/codex-api/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "codex-api", + crate_name = "codex_api", +) diff --git a/code-rs/codex-api/Cargo.toml b/code-rs/codex-api/Cargo.toml new file mode 100644 index 00000000000..08f70cf33cf --- /dev/null +++ b/code-rs/codex-api/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "codex-api" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +async-channel = { workspace = true } +async-trait = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +chrono = { workspace = true } +codex-client = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-rustls-provider = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +reqwest = { workspace = true, features = ["json", "stream"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros", "net", "rt", "sync", "time"] } +tokio-tungstenite = { workspace = true } +tungstenite = { workspace = true } +tracing = { workspace = true } +eventsource-stream = { workspace = true } +regex-lite = { workspace = true } +tokio-util = { workspace = true, features = ["codec", "io"] } +url = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +assert_matches = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio-test = { workspace = true } +wiremock = { workspace = true } +reqwest = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false diff --git a/code-rs/codex-api/README.md b/code-rs/codex-api/README.md new file mode 100644 index 00000000000..a344cfb9888 --- /dev/null +++ b/code-rs/codex-api/README.md @@ -0,0 +1,37 @@ +# codex-api + +Typed clients for Codex/OpenAI APIs built on top of the generic transport in `codex-client`. + +- Hosts the request/response models and request builders for Responses and Compact APIs. +- Owns provider configuration (base URLs, headers, query params), auth header injection, retry tuning, and stream idle settings. +- Parses SSE streams into `ResponseEvent`/`ResponseStream`, including rate-limit snapshots and API-specific error mapping. +- Serves as the wire-level layer consumed by `codex-core`; higher layers handle auth refresh and business logic. + +## Core interface + +The public interface of this crate is intentionally small and uniform: + +- **Responses endpoint** + - Input: + - `ResponsesApiRequest` for the request body (`model`, `instructions`, `input`, `tools`, `parallel_tool_calls`, reasoning/text controls). + - `ResponsesOptions` for transport/header concerns (`conversation_id`, `session_source`, `extra_headers`, `compression`, `turn_state`). + - Output: a `ResponseStream` of `ResponseEvent` (both re-exported from `common`). + +- **Compaction endpoint** + - Input: `CompactionInput<'a>` (re-exported as `codex_api::CompactionInput`): + - `model: &str`. + - `input: &[ResponseItem]` – history to compact. + - `instructions: &str` – fully-resolved compaction instructions. + - Output: `Vec`. + - `CompactClient::compact_input(&CompactionInput, extra_headers)` wraps the JSON encoding and retry/telemetry wiring. + +- **Memory summarize endpoint** + - Input: `MemorySummarizeInput` (re-exported as `codex_api::MemorySummarizeInput`): + - `model: String`. + - `raw_memories: Vec` (serialized as `traces` for wire compatibility). + - `RawMemory` includes `id`, `metadata.source_path`, and normalized `items`. + - `reasoning: Option`. + - Output: `Vec`. + - `MemoriesClient::summarize_input(&MemorySummarizeInput, extra_headers)` wraps JSON encoding and retry/telemetry wiring. + +All HTTP details (URLs, headers, retry/backoff policies, SSE framing) are encapsulated in `codex-api` and `codex-client`. Callers construct prompts/inputs using protocol types and work with typed streams of `ResponseEvent` or compacted `ResponseItem` values. diff --git a/code-rs/codex-api/src/api_bridge.rs b/code-rs/codex-api/src/api_bridge.rs new file mode 100644 index 00000000000..4f37c2ff9bc --- /dev/null +++ b/code-rs/codex-api/src/api_bridge.rs @@ -0,0 +1,190 @@ +use crate::TransportError; +use crate::error::ApiError; +use crate::rate_limits::parse_promo_message; +use crate::rate_limits::parse_rate_limit_for_limit; +use base64::Engine; +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::auth::PlanType; +use codex_protocol::error::CodexErr; +use codex_protocol::error::RetryLimitReachedError; +use codex_protocol::error::UnexpectedResponseError; +use codex_protocol::error::UsageLimitReachedError; +use http::HeaderMap; +use serde::Deserialize; +use serde_json::Value; + +pub fn map_api_error(err: ApiError) -> CodexErr { + match err { + ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded, + ApiError::QuotaExceeded => CodexErr::QuotaExceeded, + ApiError::UsageNotIncluded => CodexErr::UsageNotIncluded, + ApiError::Retryable { message, delay } => CodexErr::Stream(message, delay), + ApiError::Stream(msg) => CodexErr::Stream(msg, None), + ApiError::ServerOverloaded => CodexErr::ServerOverloaded, + ApiError::Api { status, message } => CodexErr::UnexpectedStatus(UnexpectedResponseError { + status, + body: message, + url: None, + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }), + ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), + ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message }, + ApiError::Transport(transport) => match transport { + TransportError::Http { + status, + url, + headers, + body, + } => { + let body_text = body.unwrap_or_default(); + + if status == http::StatusCode::SERVICE_UNAVAILABLE + && let Ok(value) = serde_json::from_str::(&body_text) + && matches!( + value + .get("error") + .and_then(|error| error.get("code")) + .and_then(serde_json::Value::as_str), + Some("server_is_overloaded" | "slow_down") + ) + { + return CodexErr::ServerOverloaded; + } + + if status == http::StatusCode::BAD_REQUEST { + if let Ok(parsed) = serde_json::from_str::(&body_text) + && let Some(error) = parsed.get("error") + && error.get("code").and_then(Value::as_str) + == Some(CYBER_POLICY_ERROR_CODE) + { + let message = error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string()); + CodexErr::CyberPolicy { message } + } else if body_text + .contains("The image data you provided does not represent a valid image") + { + CodexErr::InvalidImageRequest() + } else { + CodexErr::InvalidRequest(body_text) + } + } else if status == http::StatusCode::INTERNAL_SERVER_ERROR { + CodexErr::InternalServerError + } else if status == http::StatusCode::TOO_MANY_REQUESTS { + if let Ok(err) = serde_json::from_str::(&body_text) { + if err.error.error_type.as_deref() == Some("usage_limit_reached") { + let limit_id = extract_header(headers.as_ref(), ACTIVE_LIMIT_HEADER); + let rate_limits = headers.as_ref().and_then(|map| { + parse_rate_limit_for_limit(map, limit_id.as_deref()) + }); + let promo_message = headers.as_ref().and_then(parse_promo_message); + let resets_at = err + .error + .resets_at + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)); + return CodexErr::UsageLimitReached(UsageLimitReachedError { + plan_type: err.error.plan_type, + resets_at, + rate_limits: rate_limits.map(Box::new), + promo_message, + }); + } else if err.error.error_type.as_deref() == Some("usage_not_included") { + return CodexErr::UsageNotIncluded; + } + } + + CodexErr::RetryLimit(RetryLimitReachedError { + status, + request_id: extract_request_tracking_id(headers.as_ref()), + }) + } else { + CodexErr::UnexpectedStatus(UnexpectedResponseError { + status, + body: body_text, + url, + cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER), + request_id: extract_request_id(headers.as_ref()), + identity_authorization_error: extract_header( + headers.as_ref(), + X_OPENAI_AUTHORIZATION_ERROR_HEADER, + ), + identity_error_code: extract_x_error_json_code(headers.as_ref()), + }) + } + } + TransportError::RetryLimit => CodexErr::RetryLimit(RetryLimitReachedError { + status: http::StatusCode::INTERNAL_SERVER_ERROR, + request_id: None, + }), + TransportError::Timeout => CodexErr::Timeout, + TransportError::Network(msg) | TransportError::Build(msg) => { + CodexErr::Stream(msg, None) + } + }, + ApiError::RateLimit(msg) => CodexErr::Stream(msg, None), + } +} + +const ACTIVE_LIMIT_HEADER: &str = "x-codex-active-limit"; +const REQUEST_ID_HEADER: &str = "x-request-id"; +const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; +const CF_RAY_HEADER: &str = "cf-ray"; +const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error"; +const X_ERROR_JSON_HEADER: &str = "x-error-json"; +const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy"; +const CYBER_POLICY_FALLBACK_MESSAGE: &str = + "This request has been flagged for possible cybersecurity risk."; + +#[cfg(test)] +#[path = "api_bridge_tests.rs"] +mod tests; + +fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option { + extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER)) +} + +fn extract_request_id(headers: Option<&HeaderMap>) -> Option { + extract_header(headers, REQUEST_ID_HEADER) + .or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER)) +} + +fn extract_header(headers: Option<&HeaderMap>, name: &str) -> Option { + headers.and_then(|map| { + map.get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }) +} + +fn extract_x_error_json_code(headers: Option<&HeaderMap>) -> Option { + let encoded = extract_header(headers, X_ERROR_JSON_HEADER)?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let parsed = serde_json::from_slice::(&decoded).ok()?; + parsed + .get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_str) + .map(str::to_string) +} + +#[derive(Debug, Deserialize)] +struct UsageErrorResponse { + error: UsageErrorBody, +} + +#[derive(Debug, Deserialize)] +struct UsageErrorBody { + #[serde(rename = "type")] + error_type: Option, + plan_type: Option, + resets_at: Option, +} diff --git a/code-rs/codex-api/src/api_bridge_tests.rs b/code-rs/codex-api/src/api_bridge_tests.rs new file mode 100644 index 00000000000..af7e34a6492 --- /dev/null +++ b/code-rs/codex-api/src/api_bridge_tests.rs @@ -0,0 +1,230 @@ +use super::*; +use base64::Engine; +use pretty_assertions::assert_eq; + +#[test] +fn map_api_error_maps_server_overloaded() { + let err = map_api_error(ApiError::ServerOverloaded); + assert!(matches!(err, CodexErr::ServerOverloaded)); +} + +#[test] +fn map_api_error_maps_server_overloaded_from_503_body() { + let body = serde_json::json!({ + "error": { + "code": "server_is_overloaded" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::SERVICE_UNAVAILABLE, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + assert!(matches!(err, CodexErr::ServerOverloaded)); +} + +#[test] +fn map_api_error_maps_cyber_policy_from_400_body() { + let body = serde_json::json!({ + "error": { + "message": "This request has been flagged for potentially high-risk cyber activity.", + "type": "invalid_request", + "param": null, + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!( + message, + "This request has been flagged for potentially high-risk cyber activity." + ); +} + +#[test] +fn map_api_error_maps_wrapped_websocket_cyber_policy_from_400_body() { + let body = serde_json::json!({ + "type": "error", + "status": 400, + "error": { + "message": "This websocket request was flagged.", + "type": "invalid_request", + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("ws://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!(message, "This websocket request was flagged."); +} + +#[test] +fn map_api_error_uses_cyber_policy_fallback_for_missing_message() { + let body = serde_json::json!({ + "error": { + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!( + message, + "This request has been flagged for possible cybersecurity risk." + ); +} + +#[test] +fn map_api_error_keeps_unknown_400_errors_generic() { + let body = serde_json::json!({ + "error": { + "message": "Some other bad request.", + "code": "some_other_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body.clone()), + })); + + let CodexErr::InvalidRequest(message) = err else { + panic!("expected CodexErr::InvalidRequest, got {err:?}"); + }; + assert_eq!(message, body); +} + +#[test] +fn map_api_error_maps_usage_limit_limit_name_header() { + let mut headers = HeaderMap::new(); + headers.insert( + ACTIVE_LIMIT_HEADER, + http::HeaderValue::from_static("codex_other"), + ); + headers.insert( + "x-codex-other-limit-name", + http::HeaderValue::from_static("codex_other"), + ); + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!( + usage_limit + .rate_limits + .as_ref() + .and_then(|snapshot| snapshot.limit_name.as_deref()), + Some("codex_other") + ); +} + +#[test] +fn map_api_error_does_not_fallback_limit_name_to_limit_id() { + let mut headers = HeaderMap::new(); + headers.insert( + ACTIVE_LIMIT_HEADER, + http::HeaderValue::from_static("codex_other"), + ); + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!( + usage_limit + .rate_limits + .as_ref() + .and_then(|snapshot| snapshot.limit_name.as_deref()), + None + ); +} + +#[test] +fn map_api_error_extracts_identity_auth_details_from_headers() { + let mut headers = HeaderMap::new(); + headers.insert(REQUEST_ID_HEADER, http::HeaderValue::from_static("req-401")); + headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-401")); + headers.insert( + X_OPENAI_AUTHORIZATION_ERROR_HEADER, + http::HeaderValue::from_static("missing_authorization_header"), + ); + let x_error_json = + base64::engine::general_purpose::STANDARD.encode(r#"{"error":{"code":"token_expired"}}"#); + headers.insert( + X_ERROR_JSON_HEADER, + http::HeaderValue::from_str(&x_error_json).expect("valid x-error-json header"), + ); + + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::UNAUTHORIZED, + url: Some("https://chatgpt.com/backend-api/codex/models".to_string()), + headers: Some(headers), + body: Some(r#"{"detail":"Unauthorized"}"#.to_string()), + })); + + let CodexErr::UnexpectedStatus(err) = err else { + panic!("expected CodexErr::UnexpectedStatus, got {err:?}"); + }; + assert_eq!(err.request_id.as_deref(), Some("req-401")); + assert_eq!(err.cf_ray.as_deref(), Some("ray-401")); + assert_eq!( + err.identity_authorization_error.as_deref(), + Some("missing_authorization_header") + ); + assert_eq!(err.identity_error_code.as_deref(), Some("token_expired")); +} diff --git a/code-rs/codex-api/src/auth.rs b/code-rs/codex-api/src/auth.rs new file mode 100644 index 00000000000..41394a22584 --- /dev/null +++ b/code-rs/codex-api/src/auth.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use codex_client::Request; +use codex_client::TransportError; +use http::HeaderMap; +use std::sync::Arc; + +/// Error returned while applying authentication to an outbound request. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("request auth build error: {0}")] + Build(String), + #[error("transient auth error: {0}")] + Transient(String), +} + +impl From for TransportError { + fn from(error: AuthError) -> Self { + match error { + AuthError::Build(message) => TransportError::Build(message), + AuthError::Transient(message) => TransportError::Network(message), + } + } +} + +/// Applies authentication to API requests. +/// +/// Header-only providers can implement `add_auth_headers`; providers that sign +/// complete requests can override `apply_auth`. +#[async_trait] +pub trait AuthProvider: Send + Sync { + /// Adds any auth headers that are available without request body access. + /// + /// Implementations should be cheap and non-blocking. This method is also + /// used by telemetry and non-HTTP request paths. + fn add_auth_headers(&self, headers: &mut HeaderMap); + + /// Returns any auth headers that are available without request body access. + fn to_auth_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + self.add_auth_headers(&mut headers); + headers + } + + /// Applies auth to a complete outbound request and returns the request to send. + /// + /// The input `request` is moved into this method. Implementations may mutate + /// the owned request, or replace it entirely, before returning. + /// + /// Header-only auth providers can rely on the default implementation. + /// Request-signing providers can override this to inspect the final URL, + /// headers, and body bytes before the transport sends the request. + /// + /// Callers must always use the returned request as authoritative. + /// If this returns [`AuthError`], the request should not be sent. + async fn apply_auth(&self, request: Request) -> Result { + let mut request = request; + self.add_auth_headers(&mut request.headers); + Ok(request) + } +} + +/// Shared auth handle passed through API clients. +pub type SharedAuthProvider = Arc; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct AuthHeaderTelemetry { + pub attached: bool, + pub name: Option<&'static str>, +} + +pub fn auth_header_telemetry(auth: &dyn AuthProvider) -> AuthHeaderTelemetry { + let mut headers = HeaderMap::new(); + auth.add_auth_headers(&mut headers); + let name = headers + .contains_key(http::header::AUTHORIZATION) + .then_some("authorization"); + AuthHeaderTelemetry { + attached: name.is_some(), + name, + } +} diff --git a/code-rs/codex-api/src/common.rs b/code-rs/codex-api/src/common.rs new file mode 100644 index 00000000000..91b251c41f6 --- /dev/null +++ b/code-rs/codex-api/src/common.rs @@ -0,0 +1,311 @@ +use crate::error::ApiError; +use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::Verbosity as VerbosityConfig; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::ModelVerification; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::W3cTraceContext; +use futures::Stream; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use tokio::sync::mpsc; + +pub const WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY: &str = "ws_request_header_traceparent"; +pub const WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY: &str = "ws_request_header_tracestate"; + +/// Canonical input payload for the compaction endpoint. +#[derive(Debug, Clone, Serialize)] +pub struct CompactionInput<'a> { + pub model: &'a str, + pub input: &'a [ResponseItem], + #[serde(skip_serializing_if = "str::is_empty")] + pub instructions: &'a str, + pub tools: Vec, + pub parallel_tool_calls: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +/// Canonical input payload for the memory summarize endpoint. +#[derive(Debug, Clone, Serialize)] +pub struct MemorySummarizeInput { + pub model: String, + #[serde(rename = "traces")] + pub raw_memories: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RawMemory { + pub id: String, + pub metadata: RawMemoryMetadata, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RawMemoryMetadata { + pub source_path: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct MemorySummarizeOutput { + #[serde(rename = "trace_summary", alias = "raw_memory")] + pub raw_memory: String, + pub memory_summary: String, +} + +#[derive(Debug)] +pub enum ResponseEvent { + Created, + OutputItemDone(ResponseItem), + OutputItemAdded(ResponseItem), + /// Emitted when the server includes `OpenAI-Model` on the stream response. + /// This can differ from the requested model when backend safety routing applies. + ServerModel(String), + /// Emitted when the server recommends additional account verification. + ModelVerifications(Vec), + /// Emitted when `X-Reasoning-Included: true` is present on the response, + /// meaning the server already accounted for past reasoning tokens and the + /// client should not re-estimate them. + ServerReasoningIncluded(bool), + Completed { + response_id: String, + token_usage: Option, + /// Did the model affirmatively end its turn? Some providers do not set this, + /// so we rely on fallback logic when this is `None`. + end_turn: Option, + }, + OutputTextDelta(String), + ToolCallInputDelta { + item_id: String, + call_id: Option, + delta: String, + }, + ReasoningSummaryDelta { + delta: String, + summary_index: i64, + }, + ReasoningContentDelta { + delta: String, + content_index: i64, + }, + ReasoningSummaryPartAdded { + summary_index: i64, + }, + RateLimits(RateLimitSnapshot), + ModelsEtag(String), +} + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct Reasoning { + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TextFormatType { + #[default] + JsonSchema, +} + +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +pub struct TextFormat { + /// Format type used by the OpenAI text controls. + pub r#type: TextFormatType, + /// When true, the server is expected to strictly validate responses. + pub strict: bool, + /// JSON schema for the desired output. + pub schema: Value, + /// Friendly name for the format, used in telemetry/debugging. + pub name: String, +} + +/// Controls the `text` field for the Responses API, combining verbosity and +/// optional JSON schema output formatting. +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +pub struct TextControls { + #[serde(skip_serializing_if = "Option::is_none")] + pub verbosity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum OpenAiVerbosity { + Low, + #[default] + Medium, + High, +} + +impl From for OpenAiVerbosity { + fn from(v: VerbosityConfig) -> Self { + match v { + VerbosityConfig::Low => OpenAiVerbosity::Low, + VerbosityConfig::Medium => OpenAiVerbosity::Medium, + VerbosityConfig::High => OpenAiVerbosity::High, + } + } +} + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct ResponsesApiRequest { + pub model: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub instructions: String, + pub input: Vec, + pub tools: Vec, + pub tool_choice: String, + pub parallel_tool_calls: bool, + pub reasoning: Option, + pub store: bool, + pub stream: bool, + pub include: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_metadata: Option>, +} + +impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { + fn from(request: &ResponsesApiRequest) -> Self { + Self { + model: request.model.clone(), + instructions: request.instructions.clone(), + previous_response_id: None, + input: request.input.clone(), + tools: request.tools.clone(), + tool_choice: request.tool_choice.clone(), + parallel_tool_calls: request.parallel_tool_calls, + reasoning: request.reasoning.clone(), + store: request.store, + stream: request.stream, + include: request.include.clone(), + service_tier: request.service_tier.clone(), + prompt_cache_key: request.prompt_cache_key.clone(), + text: request.text.clone(), + generate: None, + client_metadata: request.client_metadata.clone(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct ResponseCreateWsRequest { + pub model: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub instructions: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + pub input: Vec, + pub tools: Vec, + pub tool_choice: String, + pub parallel_tool_calls: bool, + pub reasoning: Option, + pub store: bool, + pub stream: bool, + pub include: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_metadata: Option>, +} + +#[derive(Debug, Serialize)] +pub struct ResponseProcessedWsRequest { + pub response_id: String, +} + +pub fn response_create_client_metadata( + client_metadata: Option>, + trace: Option<&W3cTraceContext>, +) -> Option> { + let mut client_metadata = client_metadata.unwrap_or_default(); + + if let Some(traceparent) = trace.and_then(|trace| trace.traceparent.as_deref()) { + client_metadata.insert( + WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY.to_string(), + traceparent.to_string(), + ); + } + if let Some(tracestate) = trace.and_then(|trace| trace.tracestate.as_deref()) { + client_metadata.insert( + WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY.to_string(), + tracestate.to_string(), + ); + } + + (!client_metadata.is_empty()).then_some(client_metadata) +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +#[allow(clippy::large_enum_variant)] +pub enum ResponsesWsRequest { + #[serde(rename = "response.create")] + ResponseCreate(ResponseCreateWsRequest), + #[serde(rename = "response.processed")] + ResponseProcessed(ResponseProcessedWsRequest), +} + +pub fn create_text_param_for_request( + verbosity: Option, + output_schema: &Option, + output_schema_strict: bool, +) -> Option { + if verbosity.is_none() && output_schema.is_none() { + return None; + } + + Some(TextControls { + verbosity: verbosity.map(std::convert::Into::into), + format: output_schema.as_ref().map(|schema| TextFormat { + r#type: TextFormatType::JsonSchema, + strict: output_schema_strict, + schema: schema.clone(), + name: "codex_output_schema".to_string(), + }), + }) +} + +pub struct ResponseStream { + pub rx_event: mpsc::Receiver>, + /// Server-assigned `x-request-id` response header, when present. + pub upstream_request_id: Option, +} + +impl Stream for ResponseStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.rx_event.poll_recv(cx) + } +} diff --git a/code-rs/codex-api/src/endpoint/compact.rs b/code-rs/codex-api/src/endpoint/compact.rs new file mode 100644 index 00000000000..336e958d4e4 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/compact.rs @@ -0,0 +1,93 @@ +use crate::auth::SharedAuthProvider; +use crate::common::CompactionInput; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use codex_protocol::models::ResponseItem; +use http::HeaderMap; +use http::Method; +use serde::Deserialize; +use serde_json::to_value; +use std::sync::Arc; + +pub struct CompactClient { + session: EndpointSession, +} + +impl CompactClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + fn path() -> &'static str { + "responses/compact" + } + + pub async fn compact( + &self, + body: serde_json::Value, + extra_headers: HeaderMap, + ) -> Result, ApiError> { + let resp = self + .session + .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .await?; + let parsed: CompactHistoryResponse = + serde_json::from_slice(&resp.body).map_err(|e| ApiError::Stream(e.to_string()))?; + Ok(parsed.output) + } + + pub async fn compact_input( + &self, + input: &CompactionInput<'_>, + extra_headers: HeaderMap, + ) -> Result, ApiError> { + let body = to_value(input) + .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; + self.compact(body, extra_headers).await + } +} + +#[derive(Debug, Deserialize)] +struct CompactHistoryResponse { + output: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + + #[derive(Clone, Default)] + struct DummyTransport; + + #[async_trait] + impl HttpTransport for DummyTransport { + async fn execute(&self, _req: Request) -> Result { + Err(TransportError::Build("execute should not run".to_string())) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + #[test] + fn path_is_responses_compact() { + assert_eq!(CompactClient::::path(), "responses/compact"); + } +} diff --git a/code-rs/codex-api/src/endpoint/memories.rs b/code-rs/codex-api/src/endpoint/memories.rs new file mode 100644 index 00000000000..a6c25641f25 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/memories.rs @@ -0,0 +1,228 @@ +use crate::auth::SharedAuthProvider; +use crate::common::MemorySummarizeInput; +use crate::common::MemorySummarizeOutput; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::Method; +use serde::Deserialize; +use serde_json::to_value; +use std::sync::Arc; + +pub struct MemoriesClient { + session: EndpointSession, +} + +impl MemoriesClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + fn path() -> &'static str { + "memories/trace_summarize" + } + + pub async fn summarize( + &self, + body: serde_json::Value, + extra_headers: HeaderMap, + ) -> Result, ApiError> { + let resp = self + .session + .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .await?; + let parsed: SummarizeResponse = + serde_json::from_slice(&resp.body).map_err(|e| ApiError::Stream(e.to_string()))?; + Ok(parsed.output) + } + + pub async fn summarize_input( + &self, + input: &MemorySummarizeInput, + extra_headers: HeaderMap, + ) -> Result, ApiError> { + let body = to_value(input).map_err(|e| { + ApiError::Stream(format!("failed to encode memory summarize input: {e}")) + })?; + self.summarize(body, extra_headers).await + } +} + +#[derive(Debug, Deserialize)] +struct SummarizeResponse { + output: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::common::RawMemory; + use crate::common::RawMemoryMetadata; + use crate::provider::RetryConfig; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::RequestBody; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use http::HeaderMap; + use http::Method; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct DummyTransport; + + #[async_trait] + impl HttpTransport for DummyTransport { + async fn execute(&self, _req: Request) -> Result { + Err(TransportError::Build("execute should not run".to_string())) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_body: Arc>, + } + + impl CapturingTransport { + fn new(response_body: Vec) -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_body: Arc::new(response_body), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().expect("lock request store") = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: self.response_body.as_ref().clone().into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + fn provider(base_url: &str) -> Provider { + Provider { + name: "test".to_string(), + base_url: base_url.to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + #[test] + fn path_is_memories_trace_summarize_for_wire_compatibility() { + assert_eq!( + MemoriesClient::::path(), + "memories/trace_summarize" + ); + } + + #[tokio::test] + async fn summarize_input_posts_expected_payload_and_parses_output() { + let transport = CapturingTransport::new( + serde_json::to_vec(&json!({ + "output": [ + { + "trace_summary": "raw summary", + "memory_summary": "memory summary" + } + ] + })) + .expect("serialize response"), + ); + let client = MemoriesClient::new( + transport.clone(), + provider("https://example.com/api/codex"), + Arc::new(DummyAuth), + ); + + let input = MemorySummarizeInput { + model: "gpt-test".to_string(), + raw_memories: vec![RawMemory { + id: "trace-1".to_string(), + metadata: RawMemoryMetadata { + source_path: "/tmp/trace.json".to_string(), + }, + items: vec![json!({"type": "message", "role": "user", "content": []})], + }], + reasoning: None, + }; + + let output = client + .summarize_input(&input, HeaderMap::new()) + .await + .expect("summarize input request should succeed"); + assert_eq!(output.len(), 1); + assert_eq!(output[0].raw_memory, "raw summary"); + assert_eq!(output[0].memory_summary, "memory summary"); + + let request = transport + .last_request + .lock() + .expect("lock request store") + .clone() + .expect("request should be captured"); + assert_eq!(request.method, Method::POST); + assert_eq!( + request.url, + "https://example.com/api/codex/memories/trace_summarize" + ); + let body = request + .body + .as_ref() + .and_then(RequestBody::json) + .expect("request body should be JSON"); + assert_eq!(body["model"], "gpt-test"); + assert_eq!(body["traces"][0]["id"], "trace-1"); + assert_eq!( + body["traces"][0]["metadata"]["source_path"], + "/tmp/trace.json" + ); + } +} diff --git a/code-rs/codex-api/src/endpoint/mod.rs b/code-rs/codex-api/src/endpoint/mod.rs new file mode 100644 index 00000000000..c16687ff281 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/mod.rs @@ -0,0 +1,27 @@ +pub(crate) mod compact; +pub(crate) mod memories; +pub(crate) mod models; +pub(crate) mod realtime_call; +pub(crate) mod realtime_websocket; +pub(crate) mod responses; +pub(crate) mod responses_websocket; +mod session; + +pub use compact::CompactClient; +pub use memories::MemoriesClient; +pub use models::ModelsClient; +pub use realtime_call::RealtimeCallClient; +pub use realtime_call::RealtimeCallResponse; +pub use realtime_websocket::RealtimeEventParser; +pub use realtime_websocket::RealtimeOutputModality; +pub use realtime_websocket::RealtimeSessionConfig; +pub use realtime_websocket::RealtimeSessionMode; +pub use realtime_websocket::RealtimeWebsocketClient; +pub use realtime_websocket::RealtimeWebsocketConnection; +pub use realtime_websocket::RealtimeWebsocketEvents; +pub use realtime_websocket::RealtimeWebsocketWriter; +pub use realtime_websocket::session_update_session_json; +pub use responses::ResponsesClient; +pub use responses::ResponsesOptions; +pub use responses_websocket::ResponsesWebsocketClient; +pub use responses_websocket::ResponsesWebsocketConnection; diff --git a/code-rs/codex-api/src/endpoint/models.rs b/code-rs/codex-api/src/endpoint/models.rs new file mode 100644 index 00000000000..ec9ee7aac6d --- /dev/null +++ b/code-rs/codex-api/src/endpoint/models.rs @@ -0,0 +1,271 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelsResponse; +use http::HeaderMap; +use http::Method; +use http::header::ETAG; +use std::sync::Arc; + +pub struct ModelsClient { + session: EndpointSession, +} + +impl ModelsClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + fn path() -> &'static str { + "models" + } + + fn append_client_version_query(req: &mut codex_client::Request, client_version: &str) { + let separator = if req.url.contains('?') { '&' } else { '?' }; + req.url = format!("{}{}client_version={client_version}", req.url, separator); + } + + pub async fn list_models( + &self, + client_version: &str, + extra_headers: HeaderMap, + ) -> Result<(Vec, Option), ApiError> { + let resp = self + .session + .execute_with( + Method::GET, + Self::path(), + extra_headers, + /*body*/ None, + |req| { + Self::append_client_version_query(req, client_version); + }, + ) + .await?; + + let header_etag = resp + .headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + + let ModelsResponse { models } = serde_json::from_slice::(&resp.body) + .map_err(|e| { + ApiError::Stream(format!( + "failed to decode models response: {e}; body: {}", + String::from_utf8_lossy(&resp.body) + )) + })?; + + Ok((models, header_etag)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::provider::RetryConfig; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use http::HeaderMap; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + body: Arc, + etag: Option, + } + + impl Default for CapturingTransport { + fn default() -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(ModelsResponse { models: Vec::new() }), + etag: None, + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().unwrap() = Some(req); + let body = serde_json::to_vec(&*self.body).unwrap(); + let mut headers = HeaderMap::new(); + if let Some(etag) = &self.etag { + headers.insert(ETAG, etag.parse().unwrap()); + } + Ok(Response { + status: StatusCode::OK, + headers, + body: body.into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + fn provider(base_url: &str) -> Provider { + Provider { + name: "test".to_string(), + base_url: base_url.to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + #[tokio::test] + async fn appends_client_version_query() { + let response = ModelsResponse { models: Vec::new() }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + etag: None, + }; + + let client = ModelsClient::new( + transport.clone(), + provider("https://example.com/api/codex"), + Arc::new(DummyAuth), + ); + + let (models, _) = client + .list_models("0.99.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(models.len(), 0); + + let url = transport + .last_request + .lock() + .unwrap() + .as_ref() + .unwrap() + .url + .clone(); + assert_eq!( + url, + "https://example.com/api/codex/models?client_version=0.99.0" + ); + } + + #[tokio::test] + async fn parses_models_response() { + let response = ModelsResponse { + models: vec![ + serde_json::from_value(json!({ + "slug": "gpt-test", + "display_name": "gpt-test", + "description": "desc", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": [0, 99, 0], + "supported_in_api": true, + "priority": 1, + "upgrade": null, + "base_instructions": "base instructions", + "supports_reasoning_summaries": false, + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "truncation_policy": {"mode": "bytes", "limit": 10_000}, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, + "context_window": 272_000, + "experimental_supported_tools": [], + })) + .unwrap(), + ], + }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + etag: None, + }; + + let client = ModelsClient::new( + transport, + provider("https://example.com/api/codex"), + Arc::new(DummyAuth), + ); + + let (models, _) = client + .list_models("0.99.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].slug, "gpt-test"); + assert_eq!(models[0].supported_in_api, true); + assert_eq!(models[0].priority, 1); + } + + #[tokio::test] + async fn list_models_includes_etag() { + let response = ModelsResponse { models: Vec::new() }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + etag: Some("\"abc\"".to_string()), + }; + + let client = ModelsClient::new( + transport, + provider("https://example.com/api/codex"), + Arc::new(DummyAuth), + ); + + let (models, etag) = client + .list_models("0.1.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(models.len(), 0); + assert_eq!(etag, Some("\"abc\"".to_string())); + } +} diff --git a/code-rs/codex-api/src/endpoint/realtime_call.rs b/code-rs/codex-api/src/endpoint/realtime_call.rs new file mode 100644 index 00000000000..b0342c53498 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_call.rs @@ -0,0 +1,546 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::realtime_websocket::RealtimeSessionConfig; +use crate::endpoint::realtime_websocket::session_update_session_json; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use bytes::Bytes; +use codex_client::HttpTransport; +use codex_client::RequestBody; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::HeaderValue; +use http::Method; +use http::header::CONTENT_TYPE; +use http::header::LOCATION; +use serde::Serialize; +use serde_json::Value; +use serde_json::to_string; +use serde_json::to_value; +use std::sync::Arc; +use tracing::instrument; +use tracing::trace; + +const MULTIPART_BOUNDARY: &str = "codex-realtime-call-boundary"; +const MULTIPART_CONTENT_TYPE: &str = "multipart/form-data; boundary=codex-realtime-call-boundary"; + +pub struct RealtimeCallClient { + session: EndpointSession, +} + +/// Answer from creating a WebRTC Realtime call. +/// +/// `sdp` configures the peer connection. `call_id` is parsed from the response `Location` header +/// and is later used by the server-side sideband WebSocket to join this exact call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RealtimeCallResponse { + pub sdp: String, + pub call_id: String, +} + +#[derive(Serialize)] +struct BackendRealtimeCallRequest<'a> { + sdp: &'a str, + session: &'a Value, +} + +impl RealtimeCallClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + fn path() -> &'static str { + "realtime/calls" + } + + fn uses_backend_request_shape(&self) -> bool { + self.session.provider().base_url.contains("/backend-api") + } + + #[instrument( + name = "realtime_call.create", + level = "info", + skip_all, + fields( + http.method = "POST", + api.path = "realtime/calls" + ) + )] + pub async fn create(&self, sdp: String) -> Result { + self.create_with_headers(sdp, HeaderMap::new()).await + } + + pub async fn create_with_session( + &self, + sdp: String, + session_config: RealtimeSessionConfig, + ) -> Result { + self.create_with_session_and_headers(sdp, session_config, HeaderMap::new()) + .await + } + + pub async fn create_with_headers( + &self, + sdp: String, + extra_headers: HeaderMap, + ) -> Result { + let resp = self + .session + .execute_with( + Method::POST, + Self::path(), + extra_headers, + /*body*/ None, + |req| { + req.headers + .insert(CONTENT_TYPE, HeaderValue::from_static("application/sdp")); + req.body = Some(RequestBody::Raw(Bytes::from(sdp.clone()))); + }, + ) + .await?; + + let sdp = decode_sdp_response(resp.body.as_ref())?; + let call_id = decode_call_id_from_location(&resp.headers)?; + + Ok(RealtimeCallResponse { sdp, call_id }) + } + + pub async fn create_with_session_and_headers( + &self, + sdp: String, + session_config: RealtimeSessionConfig, + extra_headers: HeaderMap, + ) -> Result { + trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}"); + // WebRTC can begin inference as soon as the peer connection comes up, so the initial + // session payload is sent with call creation. The sideband WebSocket still sends its normal + // session.update after it joins. + let mut session = realtime_session_json(session_config)?; + if let Some(session) = session.as_object_mut() { + session.remove("id"); + } + // TODO(aibrahim): Align the SIWC route with the API multipart shape and remove this branch. + if self.uses_backend_request_shape() { + let body = to_value(BackendRealtimeCallRequest { + sdp: &sdp, + session: &session, + }) + .map_err(|err| ApiError::Stream(format!("failed to encode realtime call: {err}")))?; + let resp = self + .session + .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .await?; + let sdp = decode_sdp_response(resp.body.as_ref())?; + let call_id = decode_call_id_from_location(&resp.headers)?; + return Ok(RealtimeCallResponse { sdp, call_id }); + } + + let session = to_string(&session).map_err(|err| ApiError::InvalidRequest { + message: err.to_string(), + })?; + let mut body = Vec::new(); + body.extend_from_slice(format!("--{MULTIPART_BOUNDARY}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"sdp\"\r\n"); + body.extend_from_slice(b"Content-Type: application/sdp\r\n\r\n"); + body.extend_from_slice(sdp.as_bytes()); + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(format!("--{MULTIPART_BOUNDARY}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"session\"\r\n"); + body.extend_from_slice(b"Content-Type: application/json\r\n\r\n"); + body.extend_from_slice(session.as_bytes()); + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(format!("--{MULTIPART_BOUNDARY}--\r\n").as_bytes()); + + let resp = self + .session + .execute_with( + Method::POST, + Self::path(), + extra_headers, + /*body*/ None, + |req| { + req.headers.insert( + CONTENT_TYPE, + HeaderValue::from_static(MULTIPART_CONTENT_TYPE), + ); + req.body = Some(RequestBody::Raw(Bytes::from(body.clone()))); + }, + ) + .await?; + + let sdp = decode_sdp_response(resp.body.as_ref())?; + let call_id = decode_call_id_from_location(&resp.headers)?; + + Ok(RealtimeCallResponse { sdp, call_id }) + } +} + +fn realtime_session_json(session_config: RealtimeSessionConfig) -> Result { + session_update_session_json(session_config) + .map_err(|err| ApiError::Stream(format!("failed to encode realtime call session: {err}"))) +} + +fn decode_sdp_response(body: &[u8]) -> Result { + String::from_utf8(body.to_vec()).map_err(|err| { + ApiError::Stream(format!( + "failed to decode realtime call SDP response: {err}" + )) + }) +} + +fn decode_call_id_from_location(headers: &HeaderMap) -> Result { + let location = headers + .get(LOCATION) + .ok_or_else(|| ApiError::Stream("realtime call response missing Location".to_string()))? + .to_str() + .map_err(|err| ApiError::Stream(format!("invalid realtime call Location: {err}")))?; + trace!("realtime call Location: {location}"); + + location + .split('?') + .next() + .unwrap_or(location) + .rsplit('/') + .find(|segment| segment.starts_with("rtc_") && segment.len() > "rtc_".len()) + .map(str::to_string) + .ok_or_else(|| { + ApiError::Stream(format!( + "realtime call Location does not contain a call id: {location}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::endpoint::realtime_websocket::RealtimeEventParser; + use crate::endpoint::realtime_websocket::RealtimeOutputModality; + use crate::endpoint::realtime_websocket::RealtimeSessionMode; + use crate::provider::RetryConfig; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use codex_protocol::protocol::RealtimeVoice; + use http::StatusCode; + use pretty_assertions::assert_eq; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_headers: HeaderMap, + } + + impl CapturingTransport { + fn new() -> Self { + Self::with_location("/v1/realtime/calls/rtc_test") + } + + fn with_location(location: &str) -> Self { + let mut response_headers = HeaderMap::new(); + response_headers.insert(LOCATION, HeaderValue::from_str(location).unwrap()); + Self { + last_request: Arc::new(Mutex::new(None)), + response_headers, + } + } + + fn without_location() -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_headers: HeaderMap::new(), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().unwrap() = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: self.response_headers.clone(), + body: Bytes::from_static(b"v=0\r\n"), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer test-token"), + ); + } + } + + fn provider(base_url: &str) -> Provider { + Provider { + name: "test".to_string(), + base_url: base_url.to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + fn realtime_session_config(session_id: &str) -> RealtimeSessionConfig { + RealtimeSessionConfig { + instructions: "hi".to_string(), + model: Some("gpt-realtime".to_string()), + session_id: Some(session_id.to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Marin, + } + } + + #[tokio::test] + async fn sends_sdp_offer_as_raw_body() { + let transport = CapturingTransport::new(); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://api.openai.com/v1"), + Arc::new(DummyAuth), + ); + + let response = client + .create("v=offer\r\n".to_string()) + .await + .expect("request should succeed"); + + assert_eq!( + response, + RealtimeCallResponse { + sdp: "v=0\r\n".to_string(), + call_id: "rtc_test".to_string(), + } + ); + + let request = transport.last_request.lock().unwrap().clone().unwrap(); + assert_eq!(request.method, Method::POST); + assert_eq!(request.url, "https://api.openai.com/v1/realtime/calls"); + assert_eq!( + request.headers.get(CONTENT_TYPE).unwrap(), + HeaderValue::from_static("application/sdp") + ); + assert_eq!( + request + .headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer test-token") + ); + assert_eq!( + request.body, + Some(RequestBody::Raw(Bytes::from_static(b"v=offer\r\n"))) + ); + } + + #[tokio::test] + async fn extracts_call_id_from_forwarded_backend_location() { + let transport = + CapturingTransport::with_location("/v1/realtime/calls/calls/rtc_backend_test"); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://chatgpt.com/backend-api/codex"), + Arc::new(DummyAuth), + ); + + let response = client + .create("v=offer\r\n".to_string()) + .await + .expect("request should succeed"); + + assert_eq!( + response, + RealtimeCallResponse { + sdp: "v=0\r\n".to_string(), + call_id: "rtc_backend_test".to_string(), + } + ); + + let request = transport.last_request.lock().unwrap().clone().unwrap(); + assert_eq!(request.method, Method::POST); + assert_eq!( + request.url, + "https://chatgpt.com/backend-api/codex/realtime/calls" + ); + assert_eq!( + request.body, + Some(RequestBody::Raw(Bytes::from_static(b"v=offer\r\n"))) + ); + } + + #[tokio::test] + async fn sends_api_session_call_as_multipart_body() { + let transport = CapturingTransport::new(); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://api.openai.com/v1"), + Arc::new(DummyAuth), + ); + + let response = client + .create_with_session( + "v=offer\r\n".to_string(), + realtime_session_config("sess-api"), + ) + .await + .expect("request should succeed"); + + assert_eq!( + response, + RealtimeCallResponse { + sdp: "v=0\r\n".to_string(), + call_id: "rtc_test".to_string(), + } + ); + + let request = transport.last_request.lock().unwrap().clone().unwrap(); + assert_eq!(request.method, Method::POST); + assert_eq!(request.url, "https://api.openai.com/v1/realtime/calls"); + assert_eq!( + request.headers.get(CONTENT_TYPE).unwrap(), + HeaderValue::from_static(MULTIPART_CONTENT_TYPE) + ); + let Some(RequestBody::Raw(body)) = request.body else { + panic!("multipart body should be raw"); + }; + let body = std::str::from_utf8(&body).expect("multipart body should be utf-8"); + let mut session = realtime_session_json(realtime_session_config("sess-api")) + .expect("session should encode"); + session + .as_object_mut() + .expect("session should be an object") + .remove("id"); + let session = to_string(&session).expect("session should serialize"); + assert_eq!( + body, + format!( + "--codex-realtime-call-boundary\r\n\ + Content-Disposition: form-data; name=\"sdp\"\r\n\ + Content-Type: application/sdp\r\n\ + \r\n\ + v=offer\r\n\ + \r\n\ + --codex-realtime-call-boundary\r\n\ + Content-Disposition: form-data; name=\"session\"\r\n\ + Content-Type: application/json\r\n\ + \r\n\ + {session}\r\n\ + --codex-realtime-call-boundary--\r\n" + ) + ); + } + + #[tokio::test] + async fn sends_backend_session_call_as_json_body() { + let transport = CapturingTransport::new(); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://chatgpt.com/backend-api/codex"), + Arc::new(DummyAuth), + ); + + let response = client + .create_with_session( + "v=offer\r\n".to_string(), + realtime_session_config("sess-backend"), + ) + .await + .expect("request should succeed"); + + assert_eq!( + response, + RealtimeCallResponse { + sdp: "v=0\r\n".to_string(), + call_id: "rtc_test".to_string(), + } + ); + + let request = transport.last_request.lock().unwrap().clone().unwrap(); + assert_eq!(request.method, Method::POST); + assert_eq!( + request.url, + "https://chatgpt.com/backend-api/codex/realtime/calls" + ); + let mut expected_session = realtime_session_json(realtime_session_config("sess-backend")) + .expect("session should encode"); + expected_session + .as_object_mut() + .expect("session should be an object") + .remove("id"); + assert_eq!( + request.body, + Some(RequestBody::Json( + to_value(BackendRealtimeCallRequest { + sdp: "v=offer\r\n", + session: &expected_session, + }) + .expect("request should encode") + )) + ); + } + + #[tokio::test] + async fn errors_when_location_is_missing() { + let transport = CapturingTransport::without_location(); + let client = RealtimeCallClient::new( + transport, + provider("https://api.openai.com/v1"), + Arc::new(DummyAuth), + ); + + let err = client + .create("v=offer\r\n".to_string()) + .await + .expect_err("request should require Location"); + + assert_eq!( + err.to_string(), + "stream error: realtime call response missing Location" + ); + } + + #[test] + fn rejects_location_without_call_id() { + let mut headers = HeaderMap::new(); + headers.insert(LOCATION, HeaderValue::from_static("/v1/realtime/calls")); + + let err = decode_call_id_from_location(&headers) + .expect_err("Location without rtc_ segment should fail"); + + assert_eq!( + err.to_string(), + "stream error: realtime call Location does not contain a call id: /v1/realtime/calls" + ); + } +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/methods.rs new file mode 100644 index 00000000000..9fcca1c3e31 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -0,0 +1,2328 @@ +use crate::endpoint::realtime_websocket::methods_common::conversation_function_call_output_message; +use crate::endpoint::realtime_websocket::methods_common::conversation_item_create_message; +use crate::endpoint::realtime_websocket::methods_common::normalized_session_mode; +use crate::endpoint::realtime_websocket::methods_common::session_update_session; +use crate::endpoint::realtime_websocket::methods_common::websocket_intent; +use crate::endpoint::realtime_websocket::protocol::RealtimeAudioFrame; +use crate::endpoint::realtime_websocket::protocol::RealtimeEvent; +use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser; +use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage; +use crate::endpoint::realtime_websocket::protocol::RealtimeOutputModality; +use crate::endpoint::realtime_websocket::protocol::RealtimeSessionConfig; +use crate::endpoint::realtime_websocket::protocol::RealtimeSessionMode; +use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry; +use crate::endpoint::realtime_websocket::protocol::RealtimeVoice; +use crate::endpoint::realtime_websocket::protocol::parse_realtime_event; +use crate::error::ApiError; +use crate::provider::Provider; +use codex_client::backoff; +use codex_client::maybe_build_rustls_client_config_with_custom_ca; +use codex_protocol::protocol::RealtimeTranscriptDelta; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use futures::SinkExt; +use futures::StreamExt; +use http::HeaderMap; +use http::HeaderValue; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::sleep; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Error as WsError; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tracing::debug; +use tracing::error; +use tracing::info; +use tracing::trace; +use tracing::warn; +use tungstenite::protocol::WebSocketConfig; +use url::Url; + +const REALTIME_WIRE_LOG_TARGET: &str = "codex_api::realtime_websocket::wire"; + +struct WsStream { + tx_command: mpsc::Sender, + pump_task: tokio::task::JoinHandle<()>, +} + +enum WsCommand { + Send { + message: Message, + tx_result: oneshot::Sender>, + }, + Close { + tx_result: oneshot::Sender>, + }, +} + +impl WsStream { + fn new( + inner: WebSocketStream>, + ) -> (Self, async_channel::Receiver>) { + let (tx_command, mut rx_command) = mpsc::channel::(32); + let (tx_message, rx_message) = async_channel::unbounded::>(); + + let pump_task = tokio::spawn(async move { + let mut inner = inner; + loop { + tokio::select! { + command = rx_command.recv() => { + let Some(command) = command else { + break; + }; + match command { + WsCommand::Send { message, tx_result } => { + debug!("realtime websocket sending message"); + let result = inner.send(message).await; + let should_break = result.is_err(); + if let Err(err) = &result { + error!("realtime websocket send failed: {err}"); + } + let _ = tx_result.send(result); + if should_break { + break; + } + } + WsCommand::Close { tx_result } => { + info!("realtime websocket sending close"); + let result = inner.close(None).await; + if let Err(err) = &result { + error!("realtime websocket close failed: {err}"); + } + let _ = tx_result.send(result); + break; + } + } + } + message = inner.next() => { + let Some(message) = message else { + break; + }; + match message { + Ok(Message::Ping(payload)) => { + trace!(payload_len = payload.len(), "realtime websocket received ping"); + if let Err(err) = inner.send(Message::Pong(payload)).await { + error!("realtime websocket failed to send pong: {err}"); + let _ = tx_message.send(Err(err)).await; + break; + } + } + Ok(Message::Pong(_)) => {} + Ok(message @ (Message::Text(_) + | Message::Binary(_) + | Message::Close(_) + | Message::Frame(_))) => { + let is_close = matches!(message, Message::Close(_)); + match &message { + Message::Text(_) => trace!("realtime websocket received text frame"), + Message::Binary(binary) => { + error!( + payload_len = binary.len(), + "realtime websocket received unexpected binary frame" + ); + } + Message::Close(frame) => info!( + "realtime websocket received close frame: code={:?} reason={:?}", + frame.as_ref().map(|frame| frame.code), + frame.as_ref().map(|frame| frame.reason.as_str()) + ), + Message::Frame(_) => { + trace!("realtime websocket received raw frame"); + } + Message::Ping(_) | Message::Pong(_) => {} + } + if tx_message.send(Ok(message)).await.is_err() { + break; + } + if is_close { + break; + } + } + Err(err) => { + error!("realtime websocket receive failed: {err}"); + let _ = tx_message.send(Err(err)).await; + break; + } + } + } + } + } + info!("realtime websocket pump exiting"); + }); + + ( + Self { + tx_command, + pump_task, + }, + rx_message, + ) + } + + async fn request( + &self, + make_command: impl FnOnce(oneshot::Sender>) -> WsCommand, + ) -> Result<(), WsError> { + let (tx_result, rx_result) = oneshot::channel(); + if self.tx_command.send(make_command(tx_result)).await.is_err() { + return Err(WsError::ConnectionClosed); + } + rx_result.await.unwrap_or(Err(WsError::ConnectionClosed)) + } + + async fn send(&self, message: Message) -> Result<(), WsError> { + self.request(|tx_result| WsCommand::Send { message, tx_result }) + .await + } + + async fn close(&self) -> Result<(), WsError> { + self.request(|tx_result| WsCommand::Close { tx_result }) + .await + } +} + +impl Drop for WsStream { + fn drop(&mut self) { + self.pump_task.abort(); + } +} + +pub struct RealtimeWebsocketConnection { + writer: RealtimeWebsocketWriter, + events: RealtimeWebsocketEvents, +} + +#[derive(Clone)] +pub struct RealtimeWebsocketWriter { + stream: Arc, + is_closed: Arc, + event_parser: RealtimeEventParser, +} + +#[derive(Clone)] +pub struct RealtimeWebsocketEvents { + rx_message: async_channel::Receiver>, + active_transcript: Arc>, + event_parser: RealtimeEventParser, + is_closed: Arc, +} + +#[derive(Default)] +struct ActiveTranscriptState { + entries: Vec, + last_handoff_entry_count: usize, + new_input_entry: bool, + new_output_entry: bool, +} + +impl RealtimeWebsocketConnection { + pub async fn send_audio_frame(&self, frame: RealtimeAudioFrame) -> Result<(), ApiError> { + self.writer.send_audio_frame(frame).await + } + + pub async fn send_conversation_item_create(&self, text: String) -> Result<(), ApiError> { + self.writer.send_conversation_item_create(text).await + } + + pub async fn send_conversation_function_call_output( + &self, + call_id: String, + output_text: String, + ) -> Result<(), ApiError> { + self.writer + .send_conversation_function_call_output(call_id, output_text) + .await + } + + pub async fn close(&self) -> Result<(), ApiError> { + self.writer.close().await + } + + pub async fn next_event(&self) -> Result, ApiError> { + self.events.next_event().await + } + + pub fn writer(&self) -> RealtimeWebsocketWriter { + self.writer.clone() + } + + pub fn events(&self) -> RealtimeWebsocketEvents { + self.events.clone() + } + + fn new( + stream: WsStream, + rx_message: async_channel::Receiver>, + event_parser: RealtimeEventParser, + ) -> Self { + let stream = Arc::new(stream); + let is_closed = Arc::new(AtomicBool::new(false)); + Self { + writer: RealtimeWebsocketWriter { + stream: Arc::clone(&stream), + is_closed: Arc::clone(&is_closed), + event_parser, + }, + events: RealtimeWebsocketEvents { + rx_message, + active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())), + event_parser, + is_closed, + }, + } + } +} + +impl RealtimeWebsocketWriter { + pub async fn send_audio_frame(&self, frame: RealtimeAudioFrame) -> Result<(), ApiError> { + self.send_json(&RealtimeOutboundMessage::InputAudioBufferAppend { audio: frame.data }) + .await + } + + pub async fn send_conversation_item_create(&self, text: String) -> Result<(), ApiError> { + self.send_json(&conversation_item_create_message(self.event_parser, text)) + .await + } + + pub async fn send_conversation_function_call_output( + &self, + call_id: String, + output_text: String, + ) -> Result<(), ApiError> { + self.send_json(&conversation_function_call_output_message( + self.event_parser, + call_id, + output_text, + )) + .await + } + + pub async fn send_response_create(&self) -> Result<(), ApiError> { + self.send_json(&RealtimeOutboundMessage::ResponseCreate) + .await + } + + pub async fn send_session_update( + &self, + instructions: String, + session_mode: RealtimeSessionMode, + output_modality: RealtimeOutputModality, + voice: RealtimeVoice, + ) -> Result<(), ApiError> { + let session_mode = normalized_session_mode(self.event_parser, session_mode); + let session = session_update_session( + self.event_parser, + instructions, + session_mode, + output_modality, + voice, + ); + self.send_json(&RealtimeOutboundMessage::SessionUpdate { session }) + .await + } + + pub async fn close(&self) -> Result<(), ApiError> { + if self.is_closed.swap(true, Ordering::SeqCst) { + return Ok(()); + } + if let Err(err) = self.stream.close().await + && !matches!(err, WsError::ConnectionClosed | WsError::AlreadyClosed) + { + return Err(ApiError::Stream(format!( + "failed to close websocket: {err}" + ))); + } + Ok(()) + } + + async fn send_json(&self, message: &RealtimeOutboundMessage) -> Result<(), ApiError> { + let payload = serde_json::to_string(message) + .map_err(|err| ApiError::Stream(format!("failed to encode realtime request: {err}")))?; + debug!(?message, "realtime websocket request"); + self.send_payload(payload).await + } + + pub async fn send_payload(&self, payload: String) -> Result<(), ApiError> { + if self.is_closed.load(Ordering::SeqCst) { + return Err(ApiError::Stream( + "realtime websocket connection is closed".to_string(), + )); + } + + trace!(target: REALTIME_WIRE_LOG_TARGET, "realtime websocket request: {payload}"); + self.stream + .send(Message::Text(payload.into())) + .await + .map_err(|err| ApiError::Stream(format!("failed to send realtime request: {err}")))?; + Ok(()) + } +} + +impl RealtimeWebsocketEvents { + pub async fn next_event(&self) -> Result, ApiError> { + if self.is_closed.load(Ordering::SeqCst) { + return Ok(None); + } + + loop { + let msg = match self.rx_message.recv().await { + Ok(Ok(msg)) => msg, + Ok(Err(err)) => { + self.is_closed.store(true, Ordering::SeqCst); + error!("realtime websocket read failed: {err}"); + return Err(ApiError::Stream(format!( + "failed to read websocket message: {err}" + ))); + } + Err(_) => { + self.is_closed.store(true, Ordering::SeqCst); + info!("realtime websocket event stream ended"); + return Ok(None); + } + }; + + match msg { + Message::Text(text) => { + trace!(target: REALTIME_WIRE_LOG_TARGET, "realtime websocket event: {text}"); + if let Some(mut event) = parse_realtime_event(&text, self.event_parser) { + self.update_active_transcript(&mut event).await; + debug!(?event, "realtime websocket parsed event"); + return Ok(Some(event)); + } + debug!("realtime websocket ignored unsupported text frame"); + } + Message::Close(frame) => { + self.is_closed.store(true, Ordering::SeqCst); + info!( + "realtime websocket closed: code={:?} reason={:?}", + frame.as_ref().map(|frame| frame.code), + frame.as_ref().map(|frame| frame.reason.as_str()) + ); + return Ok(None); + } + Message::Binary(_) => { + return Ok(Some(RealtimeEvent::Error( + "unexpected binary realtime websocket event".to_string(), + ))); + } + Message::Frame(_) | Message::Ping(_) | Message::Pong(_) => {} + } + } + } + + async fn update_active_transcript(&self, event: &mut RealtimeEvent) { + let mut active_transcript = self.active_transcript.lock().await; + match event { + RealtimeEvent::InputAudioSpeechStarted(_) => { + active_transcript.new_input_entry = true; + } + RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => { + let force_new = active_transcript.new_input_entry; + append_transcript_delta(&mut active_transcript.entries, "user", delta, force_new); + active_transcript.new_input_entry = false; + } + RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => { + let force_new = active_transcript.new_output_entry; + append_transcript_delta( + &mut active_transcript.entries, + "assistant", + delta, + force_new, + ); + active_transcript.new_output_entry = false; + } + RealtimeEvent::InputTranscriptDone(done) => { + let force_new = active_transcript.new_input_entry; + apply_transcript_done( + &mut active_transcript.entries, + "user", + &done.text, + force_new, + ); + active_transcript.new_input_entry = false; + } + RealtimeEvent::OutputTranscriptDone(done) => { + let force_new = active_transcript.new_output_entry; + apply_transcript_done( + &mut active_transcript.entries, + "assistant", + &done.text, + force_new, + ); + active_transcript.new_output_entry = false; + } + RealtimeEvent::HandoffRequested(handoff) => { + append_handoff_input(&mut active_transcript.entries, &handoff.input_transcript); + handoff.active_transcript = active_transcript.entries + [active_transcript.last_handoff_entry_count..] + .to_vec(); + active_transcript.last_handoff_entry_count = active_transcript.entries.len(); + active_transcript.new_input_entry = true; + active_transcript.new_output_entry = true; + } + RealtimeEvent::ResponseCreated(_) => { + active_transcript.new_output_entry = true; + } + RealtimeEvent::SessionUpdated { .. } + | RealtimeEvent::AudioOut(_) + | RealtimeEvent::ResponseCancelled(_) + | RealtimeEvent::ResponseDone(_) + | RealtimeEvent::ConversationItemDone { .. } + | RealtimeEvent::NoopRequested(_) + | RealtimeEvent::ConversationItemAdded(_) + | RealtimeEvent::Error(_) => {} + } + } +} + +fn append_transcript_delta( + entries: &mut Vec, + role: &str, + delta: &str, + force_new: bool, +) { + if delta.is_empty() { + return; + } + + if !force_new + && let Some(last_entry) = entries.last_mut() + && last_entry.role == role + { + last_entry.text.push_str(delta); + return; + } + + entries.push(RealtimeTranscriptEntry { + role: role.to_string(), + text: delta.to_string(), + }); +} + +fn apply_transcript_done( + entries: &mut Vec, + role: &str, + text: &str, + force_new: bool, +) { + if text.is_empty() { + return; + } + + if !force_new + && let Some(last_entry) = entries.last_mut() + && last_entry.role == role + { + last_entry.text = text.to_string(); + return; + } + + entries.push(RealtimeTranscriptEntry { + role: role.to_string(), + text: text.to_string(), + }); +} + +fn append_handoff_input(entries: &mut Vec, input: &str) { + let input = input.trim(); + if input.is_empty() || contains_transcript_entry(entries, "user", input) { + return; + } + + entries.push(RealtimeTranscriptEntry { + role: "user".to_string(), + text: input.to_string(), + }); +} + +fn contains_transcript_entry(entries: &[RealtimeTranscriptEntry], role: &str, text: &str) -> bool { + entries + .iter() + .any(|entry| entry.role == role && entry.text.trim() == text.trim()) +} + +pub struct RealtimeWebsocketClient { + provider: Provider, +} + +impl RealtimeWebsocketClient { + pub fn new(provider: Provider) -> Self { + Self { provider } + } + + pub async fn connect( + &self, + config: RealtimeSessionConfig, + extra_headers: HeaderMap, + default_headers: HeaderMap, + ) -> Result { + let ws_url = websocket_url_from_api_url( + self.provider.base_url.as_str(), + self.provider.query_params.as_ref(), + config.model.as_deref(), + config.event_parser, + config.session_mode, + )?; + self.connect_realtime_websocket_url(ws_url, config, extra_headers, default_headers) + .await + } + + pub async fn connect_webrtc_sideband( + &self, + config: RealtimeSessionConfig, + call_id: &str, + extra_headers: HeaderMap, + default_headers: HeaderMap, + ) -> Result { + // The WebRTC call already exists; this loop only retries joining its sideband control + // socket. Once joined, the returned connection is the same reader/writer state that the + // ordinary websocket start path uses. + for attempt in 0..=self.provider.retry.max_attempts { + let result = self + .connect_webrtc_sideband_once( + config.clone(), + call_id, + extra_headers.clone(), + default_headers.clone(), + ) + .await; + match result { + Ok(connection) => return Ok(connection), + Err(err) if attempt < self.provider.retry.max_attempts => { + let delay = backoff(self.provider.retry.base_delay, attempt + 1); + warn!( + attempt = attempt + 1, + call_id, + delay_ms = delay.as_millis(), + "realtime sideband websocket connect failed; retrying: {err}" + ); + sleep(delay).await; + } + Err(err) => return Err(err), + } + } + + Err(ApiError::Stream( + "realtime sideband websocket retry loop exhausted".to_string(), + )) + } + + async fn connect_webrtc_sideband_once( + &self, + config: RealtimeSessionConfig, + call_id: &str, + extra_headers: HeaderMap, + default_headers: HeaderMap, + ) -> Result { + // Keep the parser/session query shaping from standalone realtime while replacing the model + // query with a call_id join onto an existing WebRTC session. + let ws_url = websocket_url_from_api_url_for_call( + self.provider.base_url.as_str(), + self.provider.query_params.as_ref(), + config.event_parser, + config.session_mode, + call_id, + )?; + self.connect_realtime_websocket_url(ws_url, config, extra_headers, default_headers) + .await + } + + async fn connect_realtime_websocket_url( + &self, + ws_url: Url, + config: RealtimeSessionConfig, + extra_headers: HeaderMap, + default_headers: HeaderMap, + ) -> Result { + ensure_rustls_crypto_provider(); + + let mut request = ws_url + .as_str() + .into_client_request() + .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; + let headers = merge_request_headers( + &self.provider.headers, + with_session_id_header(extra_headers, config.session_id.as_deref())?, + default_headers, + ); + request.headers_mut().extend(headers); + + info!("connecting realtime websocket: {ws_url}"); + // Realtime websocket TLS should honor the same custom-CA env vars as the rest of Codex's + // outbound HTTPS and websocket traffic. + let connector = maybe_build_rustls_client_config_with_custom_ca() + .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))? + .map(tokio_tungstenite::Connector::Rustls); + let (stream, response) = tokio_tungstenite::connect_async_tls_with_config( + request, + Some(websocket_config()), + false, + connector, + ) + .await + .map_err(|err| ApiError::Stream(format!("failed to connect realtime websocket: {err}")))?; + info!( + ws_url = %ws_url, + status = %response.status(), + "realtime websocket connected" + ); + + let (stream, rx_message) = WsStream::new(stream); + let connection = RealtimeWebsocketConnection::new(stream, rx_message, config.event_parser); + debug!( + session_id = config.session_id.as_deref().unwrap_or(""), + "realtime websocket sending session.update" + ); + connection + .writer + .send_session_update( + config.instructions, + config.session_mode, + config.output_modality, + config.voice, + ) + .await?; + Ok(connection) + } +} + +fn merge_request_headers( + provider_headers: &HeaderMap, + extra_headers: HeaderMap, + default_headers: HeaderMap, +) -> HeaderMap { + let mut headers = provider_headers.clone(); + headers.extend(extra_headers); + for (name, value) in &default_headers { + if let http::header::Entry::Vacant(entry) = headers.entry(name) { + entry.insert(value.clone()); + } + } + headers +} + +fn with_session_id_header( + mut headers: HeaderMap, + session_id: Option<&str>, +) -> Result { + let Some(session_id) = session_id else { + return Ok(headers); + }; + headers.insert( + "x-session-id", + HeaderValue::from_str(session_id).map_err(|err| { + ApiError::Stream(format!("invalid realtime session id header: {err}")) + })?, + ); + Ok(headers) +} + +fn websocket_config() -> WebSocketConfig { + WebSocketConfig::default() +} + +fn websocket_url_from_api_url( + api_url: &str, + query_params: Option<&HashMap>, + model: Option<&str>, + event_parser: RealtimeEventParser, + _session_mode: RealtimeSessionMode, +) -> Result { + let mut url = Url::parse(api_url) + .map_err(|err| ApiError::Stream(format!("failed to parse realtime api_url: {err}")))?; + + normalize_realtime_path(&mut url); + + match url.scheme() { + "ws" | "wss" => {} + "http" | "https" => { + let scheme = if url.scheme() == "http" { "ws" } else { "wss" }; + let _ = url.set_scheme(scheme); + } + scheme => { + return Err(ApiError::Stream(format!( + "unsupported realtime api_url scheme: {scheme}" + ))); + } + } + + let intent = websocket_intent(event_parser); + let has_extra_query_params = query_params.is_some_and(|query_params| { + query_params + .iter() + .any(|(key, _)| key != "intent" && !(key == "model" && model.is_some())) + }); + if intent.is_some() || model.is_some() || has_extra_query_params { + let mut query = url.query_pairs_mut(); + if let Some(intent) = intent { + query.append_pair("intent", intent); + } + if let Some(model) = model { + query.append_pair("model", model); + } + if let Some(query_params) = query_params { + for (key, value) in query_params { + if key == "intent" || (key == "model" && model.is_some()) { + continue; + } + query.append_pair(key, value); + } + } + } + + Ok(url) +} + +fn websocket_url_from_api_url_for_call( + api_url: &str, + query_params: Option<&HashMap>, + event_parser: RealtimeEventParser, + session_mode: RealtimeSessionMode, + call_id: &str, +) -> Result { + let mut url = websocket_url_from_api_url( + api_url, + query_params, + /*model*/ None, + event_parser, + session_mode, + )?; + url.query_pairs_mut().append_pair("call_id", call_id); + Ok(url) +} + +fn normalize_realtime_path(url: &mut Url) { + let path = url.path().to_string(); + if path.is_empty() || path == "/" { + url.set_path("/v1/realtime"); + return; + } + + if path.ends_with("/realtime") { + return; + } + + if path.ends_with("/realtime/") { + url.set_path(path.trim_end_matches('/')); + return; + } + + if path.ends_with("/v1") { + url.set_path(&format!("{path}/realtime")); + return; + } + + if path.ends_with("/v1/") { + url.set_path(&format!("{path}realtime")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry; + use codex_protocol::protocol::RealtimeHandoffRequested; + use codex_protocol::protocol::RealtimeInputAudioSpeechStarted; + use codex_protocol::protocol::RealtimeNoopRequested; + use codex_protocol::protocol::RealtimeResponseCancelled; + use codex_protocol::protocol::RealtimeResponseCreated; + use codex_protocol::protocol::RealtimeResponseDone; + use codex_protocol::protocol::RealtimeTranscriptDelta; + use codex_protocol::protocol::RealtimeTranscriptDone; + use codex_protocol::protocol::RealtimeVoice; + use http::HeaderValue; + use pretty_assertions::assert_eq; + use serde_json::Value; + use serde_json::json; + use std::collections::HashMap; + use std::time::Duration; + use tokio::net::TcpListener; + use tokio_tungstenite::accept_async; + use tokio_tungstenite::tungstenite::Message; + + #[test] + fn parse_session_updated_event() { + let payload = json!({ + "type": "session.updated", + "session": {"id": "sess_123", "instructions": "backend prompt"} + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_123".to_string(), + instructions: Some("backend prompt".to_string()), + }) + ); + } + + #[test] + fn parse_audio_delta_event() { + let payload = json!({ + "type": "conversation.output_audio.delta", + "delta": "AAA=", + "sample_rate": 48000, + "channels": 1, + "samples_per_channel": 960 + }) + .to_string(); + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data: "AAA=".to_string(), + sample_rate: 48000, + num_channels: 1, + samples_per_channel: Some(960), + item_id: None, + })) + ); + } + + #[test] + fn parse_conversation_item_added_event() { + let payload = json!({ + "type": "conversation.item.added", + "item": {"type": "message", "seq": 7} + }) + .to_string(); + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::ConversationItemAdded( + json!({"type": "message", "seq": 7}) + )) + ); + } + + #[test] + fn parse_conversation_item_done_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": {"id": "item_123", "type": "message"} + }) + .to_string(); + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::ConversationItemDone { + item_id: "item_123".to_string(), + }) + ); + } + + #[test] + fn parse_handoff_requested_event() { + let payload = json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_123", + "item_id": "item_123", + "input_transcript": "delegate this" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_123".to_string(), + item_id: "item_123".to_string(), + input_transcript: "delegate this".to_string(), + active_transcript: Vec::new(), + })) + ); + } + + #[test] + fn parse_input_transcript_delta_event() { + let payload = json!({ + "type": "conversation.input_transcript.delta", + "delta": "hello " + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::InputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hello ".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_input_audio_transcription_delta_event() { + let payload = json!({ + "type": "conversation.item.input_audio_transcription.delta", + "item_id": "item_input_1", + "content_index": 0, + "delta": "hello" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::InputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hello".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_input_audio_transcription_completed_event() { + let payload = json!({ + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "item_input_1", + "content_index": 0, + "transcript": "hello world" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone { + text: "hello world".to_string(), + })) + ); + } + + #[test] + fn parse_output_transcript_delta_event() { + let payload = json!({ + "type": "conversation.output_transcript.delta", + "delta": "hi" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::OutputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hi".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_output_audio_transcript_delta_event() { + let payload = json!({ + "type": "response.output_audio_transcript.delta", + "delta": "hi" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::OutputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hi".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_output_audio_transcript_done_event() { + let payload = json!({ + "type": "response.output_audio_transcript.done", + "transcript": "hi there" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::OutputTranscriptDone( + RealtimeTranscriptDone { + text: "hi there".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_item_done_output_text_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": { + "id": "item_output_1", + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "hello"}, + {"type": "output_text", "text": " world"} + ] + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::ConversationItemDone { + item_id: "item_output_1".to_string(), + }) + ); + } + + #[test] + fn parse_realtime_v2_handoff_tool_call_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": { + "id": "item_123", + "type": "function_call", + "name": "background_agent", + "call_id": "call_123", + "arguments": "{\"prompt\":\"delegate this\"}" + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "call_123".to_string(), + item_id: "item_123".to_string(), + input_transcript: "delegate this".to_string(), + active_transcript: Vec::new(), + })) + ); + } + + #[test] + fn parse_realtime_v2_noop_tool_call_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": { + "id": "item_silent", + "type": "function_call", + "name": "remain_silent", + "call_id": "call_silent", + "arguments": "{}" + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::NoopRequested(RealtimeNoopRequested { + call_id: "call_silent".to_string(), + item_id: "item_silent".to_string(), + })) + ); + } + + #[test] + fn parse_realtime_v2_input_audio_transcription_delta_event() { + let payload = json!({ + "type": "conversation.item.input_audio_transcription.delta", + "item_id": "item_input_1", + "content_index": 0, + "delta": "hello" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::InputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hello".to_string(), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_output_audio_transcript_done_event() { + let payload = json!({ + "type": "response.output_audio_transcript.done", + "transcript": "hello there" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::OutputTranscriptDone( + RealtimeTranscriptDone { + text: "hello there".to_string(), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_output_text_done_event() { + let payload = json!({ + "type": "response.output_text.done", + "text": "hello there" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::OutputTranscriptDone( + RealtimeTranscriptDone { + text: "hello there".to_string(), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_conversation_item_created_event() { + let payload = json!({ + "type": "conversation.item.created", + "item": {"type": "message", "role": "user"} + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::ConversationItemAdded( + json!({"type": "message", "role": "user"}) + )) + ); + } + + #[test] + fn parse_realtime_v2_item_done_output_text_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": { + "id": "item_output_1", + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "hello"}, + {"type": "output_text", "text": " world"} + ] + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::ConversationItemDone { + item_id: "item_output_1".to_string(), + }) + ); + } + + #[test] + fn parse_realtime_v2_output_audio_delta_defaults_audio_shape() { + let payload = json!({ + "type": "response.output_audio.delta", + "delta": "AQID" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: None, + item_id: None, + })) + ); + } + + #[test] + fn parse_realtime_v2_response_audio_delta_with_item_id() { + let payload = json!({ + "type": "response.audio.delta", + "delta": "AQID", + "item_id": "item_audio_1" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: None, + item_id: Some("item_audio_1".to_string()), + })) + ); + } + + #[test] + fn parse_realtime_v2_speech_started_event() { + let payload = json!({ + "type": "input_audio_buffer.speech_started", + "item_id": "item_input_1" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::InputAudioSpeechStarted( + RealtimeInputAudioSpeechStarted { + item_id: Some("item_input_1".to_string()), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_response_cancelled_event() { + let payload = json!({ + "type": "response.cancelled", + "response": {"id": "resp_cancelled_1"} + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::ResponseCancelled( + RealtimeResponseCancelled { + response_id: Some("resp_cancelled_1".to_string()), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_response_done_event() { + let payload = json!({ + "type": "response.done", + "response": { + "output": [{ + "id": "item_123", + "type": "function_call", + "name": "background_agent", + "call_id": "call_123", + "arguments": "{\"prompt\":\"delegate from done\"}" + }] + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::ResponseDone(RealtimeResponseDone { + response_id: None + })) + ); + } + + #[test] + fn parse_realtime_v2_response_created_event() { + let payload = json!({ + "type": "response.created", + "response": {"id": "resp_created_1"} + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::ResponseCreated(RealtimeResponseCreated { + response_id: Some("resp_created_1".to_string()) + })) + ); + } + + #[test] + fn merge_request_headers_matches_http_precedence() { + let mut provider_headers = HeaderMap::new(); + provider_headers.insert( + "originator", + HeaderValue::from_static("provider-originator"), + ); + provider_headers.insert("x-priority", HeaderValue::from_static("provider")); + + let mut extra_headers = HeaderMap::new(); + extra_headers.insert("x-priority", HeaderValue::from_static("extra")); + + let mut default_headers = HeaderMap::new(); + default_headers.insert("originator", HeaderValue::from_static("default-originator")); + default_headers.insert("x-priority", HeaderValue::from_static("default")); + default_headers.insert("x-default-only", HeaderValue::from_static("default-only")); + + let merged = merge_request_headers(&provider_headers, extra_headers, default_headers); + + assert_eq!( + merged.get("originator"), + Some(&HeaderValue::from_static("provider-originator")) + ); + assert_eq!( + merged.get("x-priority"), + Some(&HeaderValue::from_static("extra")) + ); + assert_eq!( + merged.get("x-default-only"), + Some(&HeaderValue::from_static("default-only")) + ); + } + + #[test] + fn websocket_url_from_http_base_defaults_to_ws_path() { + let url = websocket_url_from_api_url( + "http://127.0.0.1:8011", + /*query_params*/ None, + /*model*/ None, + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "ws://127.0.0.1:8011/v1/realtime?intent=quicksilver" + ); + } + + #[test] + fn websocket_url_from_ws_base_defaults_to_ws_path() { + let url = websocket_url_from_api_url( + "wss://example.com", + /*query_params*/ None, + Some("realtime-test-model"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?intent=quicksilver&model=realtime-test-model" + ); + } + + #[test] + fn websocket_url_from_v1_base_appends_realtime_path() { + let url = websocket_url_from_api_url( + "https://api.openai.com/v1", + /*query_params*/ None, + Some("snapshot"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://api.openai.com/v1/realtime?intent=quicksilver&model=snapshot" + ); + } + + #[test] + fn websocket_url_from_nested_v1_base_appends_realtime_path() { + let url = websocket_url_from_api_url( + "https://example.com/openai/v1", + /*query_params*/ None, + Some("snapshot"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/openai/v1/realtime?intent=quicksilver&model=snapshot" + ); + } + + #[test] + fn websocket_url_preserves_existing_realtime_path_and_extra_query_params() { + let url = websocket_url_from_api_url( + "https://example.com/v1/realtime?foo=bar", + Some(&HashMap::from([ + ("trace".to_string(), "1".to_string()), + ("intent".to_string(), "ignored".to_string()), + ])), + Some("snapshot"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?foo=bar&intent=quicksilver&model=snapshot&trace=1" + ); + } + + #[test] + fn websocket_url_v1_ignores_transcription_mode() { + let url = websocket_url_from_api_url( + "https://example.com", + /*query_params*/ None, + /*model*/ None, + RealtimeEventParser::V1, + RealtimeSessionMode::Transcription, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?intent=quicksilver" + ); + } + + #[test] + fn websocket_url_omits_intent_for_realtime_v2_conversational_mode() { + let url = websocket_url_from_api_url( + "https://example.com/v1/realtime?foo=bar", + Some(&HashMap::from([ + ("trace".to_string(), "1".to_string()), + ("intent".to_string(), "ignored".to_string()), + ])), + Some("snapshot"), + RealtimeEventParser::RealtimeV2, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?foo=bar&model=snapshot&trace=1" + ); + } + + #[test] + fn websocket_url_omits_intent_for_realtime_v2_transcription_mode() { + let url = websocket_url_from_api_url( + "https://example.com", + /*query_params*/ None, + /*model*/ None, + RealtimeEventParser::RealtimeV2, + RealtimeSessionMode::Transcription, + ) + .expect("build ws url"); + assert_eq!(url.as_str(), "wss://example.com/v1/realtime"); + } + + #[test] + fn websocket_url_for_call_id_joins_existing_realtime_session() { + let url = websocket_url_from_api_url_for_call( + "https://api.openai.com/v1", + /*query_params*/ None, + RealtimeEventParser::RealtimeV2, + RealtimeSessionMode::Conversational, + "rtc_test", + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://api.openai.com/v1/realtime?call_id=rtc_test" + ); + } + + #[tokio::test] + async fn e2e_connect_and_exchange_events_against_mock_ws_server() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("quicksilver".to_string()) + ); + assert_eq!( + first_json["session"]["instructions"], + Value::String("backend prompt".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["format"]["type"], + Value::String("audio/pcm".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["format"]["rate"], + Value::from(24_000) + ); + assert_eq!( + first_json["session"]["audio"]["output"]["voice"], + Value::String("breeze".to_string()) + ); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_mock", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); + + let third = ws + .next() + .await + .expect("third msg") + .expect("third msg ok") + .into_text() + .expect("text"); + let third_json: Value = serde_json::from_str(&third).expect("json"); + assert_eq!(third_json["type"], "conversation.item.create"); + assert_eq!(third_json["item"]["content"][0]["text"], "hello agent"); + + let fourth = ws + .next() + .await + .expect("fourth msg") + .expect("fourth msg ok") + .into_text() + .expect("text"); + let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); + assert_eq!(fourth_json["type"], "conversation.handoff.append"); + assert_eq!(fourth_json["handoff_id"], "handoff_1"); + assert_eq!( + fourth_json["output_text"], + "\"Agent Final Message\":\n\nhello from background agent" + ); + + ws.send(Message::Text( + json!({ + "type": "conversation.output_audio.delta", + "delta": "AQID", + "sample_rate": 48000, + "channels": 1 + }) + .to_string() + .into(), + )) + .await + .expect("send audio"); + + ws.send(Message::Text( + json!({ + "type": "conversation.input_transcript.delta", + "delta": "delegate " + }) + .to_string() + .into(), + )) + .await + .expect("send input transcript delta"); + + ws.send(Message::Text( + json!({ + "type": "conversation.input_transcript.delta", + "delta": "now" + }) + .to_string() + .into(), + )) + .await + .expect("send input transcript delta"); + + ws.send(Message::Text( + json!({ + "type": "conversation.output_transcript.delta", + "delta": "working" + }) + .to_string() + .into(), + )) + .await + .expect("send output transcript delta"); + + ws.send(Message::Text( + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_1", + "item_id": "item_2", + "input_transcript": "delegate now" + }) + .to_string() + .into(), + )) + .await + .expect("send item added"); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Breeze, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_mock".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection + .send_audio_frame(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 48000, + num_channels: 1, + samples_per_channel: Some(960), + item_id: None, + }) + .await + .expect("send audio"); + connection + .send_conversation_item_create("hello agent".to_string()) + .await + .expect("send item"); + connection + .send_conversation_function_call_output( + "handoff_1".to_string(), + "hello from background agent".to_string(), + ) + .await + .expect("send handoff"); + + let audio_event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + audio_event, + RealtimeEvent::AudioOut(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 48000, + num_channels: 1, + samples_per_channel: None, + item_id: None, + }) + ); + + let input_delta_event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + input_delta_event, + RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { + delta: "delegate ".to_string(), + }) + ); + + let input_delta_event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + input_delta_event, + RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { + delta: "now".to_string(), + }) + ); + + let output_delta_event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + output_delta_event, + RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { + delta: "working".to_string(), + }) + ); + + let added_event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + added_event, + RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_2".to_string(), + input_transcript: "delegate now".to_string(), + active_transcript: vec![ + RealtimeTranscriptEntry { + role: "user".to_string(), + text: "delegate now".to_string(), + }, + RealtimeTranscriptEntry { + role: "assistant".to_string(), + text: "working".to_string(), + }, + ], + }) + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } + + #[tokio::test] + async fn realtime_v2_session_update_includes_background_agent_tool_and_handoff_output_item() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("realtime".to_string()) + ); + assert_eq!(first_json["session"]["output_modalities"], json!(["audio"])); + assert_eq!( + first_json["session"]["audio"]["input"]["format"], + json!({ + "type": "audio/pcm", + "rate": 24_000, + }) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["noise_reduction"], + json!({ + "type": "near_field", + }) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["transcription"], + json!({ + "model": "gpt-4o-mini-transcribe", + }) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["turn_detection"], + json!({ + "type": "server_vad", + "interrupt_response": true, + "create_response": true, + "silence_duration_ms": 500, + }) + ); + assert_eq!( + first_json["session"]["audio"]["output"]["format"], + json!({ + "type": "audio/pcm", + "rate": 24_000, + }) + ); + assert_eq!( + first_json["session"]["audio"]["output"]["voice"], + Value::String("cedar".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][0]["type"], + Value::String("function".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][0]["name"], + Value::String("background_agent".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][0]["parameters"]["required"], + json!(["prompt"]) + ); + assert_eq!( + first_json["session"]["tools"][1]["type"], + Value::String("function".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][1]["name"], + Value::String("remain_silent".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][1]["parameters"]["properties"], + json!({}) + ); + assert_eq!( + first_json["session"]["tool_choice"], + Value::String("auto".to_string()) + ); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_v2", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "conversation.item.create"); + assert_eq!( + second_json["item"]["type"], + Value::String("message".to_string()) + ); + assert_eq!( + second_json["item"]["content"][0]["type"], + Value::String("input_text".to_string()) + ); + assert_eq!( + second_json["item"]["content"][0]["text"], + Value::String("delegate this".to_string()) + ); + + let third = ws + .next() + .await + .expect("third msg") + .expect("third msg ok") + .into_text() + .expect("text"); + let third_json: Value = serde_json::from_str(&third).expect("json"); + assert_eq!(third_json["type"], "conversation.item.create"); + assert_eq!( + third_json["item"]["type"], + Value::String("function_call_output".to_string()) + ); + assert_eq!( + third_json["item"]["call_id"], + Value::String("call_1".to_string()) + ); + assert_eq!( + third_json["item"]["output"], + Value::String("delegated result".to_string()) + ); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cedar, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_v2".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection + .send_conversation_item_create("delegate this".to_string()) + .await + .expect("send text item"); + connection + .send_conversation_function_call_output( + "call_1".to_string(), + "delegated result".to_string(), + ) + .await + .expect("send handoff output"); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } + + #[tokio::test] + async fn transcription_mode_session_update_omits_output_audio_and_instructions() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("transcription".to_string()) + ); + assert!(first_json["session"].get("instructions").is_none()); + assert_eq!( + first_json["session"]["audio"]["input"]["transcription"], + json!({ + "model": "gpt-4o-mini-transcribe", + }) + ); + assert!(first_json["session"]["audio"].get("output").is_none()); + assert!(first_json["session"].get("tools").is_none()); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_transcription"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Transcription, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Marin, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_transcription".to_string(), + instructions: None, + } + ); + + connection + .send_audio_frame(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(480), + item_id: None, + }) + .await + .expect("send audio"); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } + + #[tokio::test] + async fn v1_transcription_mode_is_treated_as_conversational() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("quicksilver".to_string()) + ); + assert_eq!( + first_json["session"]["instructions"], + Value::String("backend prompt".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["output"]["voice"], + Value::String("cove".to_string()) + ); + assert!(first_json["session"].get("tools").is_none()); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_v1_mode"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Transcription, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_v1_mode".to_string(), + instructions: None, + } + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } + + #[tokio::test] + async fn send_does_not_block_while_next_event_waits_for_inbound_data() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_after_send", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let (send_result, next_result) = tokio::join!( + async { + tokio::time::timeout( + Duration::from_millis(200), + connection.send_audio_frame(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 48000, + num_channels: 1, + samples_per_channel: Some(960), + item_id: None, + }), + ) + .await + }, + connection.next_event() + ); + + send_result + .expect("send should not block on next_event") + .expect("send audio"); + let next_event = next_result.expect("next event").expect("event"); + assert_eq!( + next_event, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_after_send".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs new file mode 100644 index 00000000000..1e47fb6fbf4 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs @@ -0,0 +1,93 @@ +use crate::endpoint::realtime_websocket::methods_v1::conversation_handoff_append_message as v1_conversation_handoff_append_message; +use crate::endpoint::realtime_websocket::methods_v1::conversation_item_create_message as v1_conversation_item_create_message; +use crate::endpoint::realtime_websocket::methods_v1::session_update_session as v1_session_update_session; +use crate::endpoint::realtime_websocket::methods_v1::websocket_intent as v1_websocket_intent; +use crate::endpoint::realtime_websocket::methods_v2::conversation_function_call_output_message as v2_conversation_function_call_output_message; +use crate::endpoint::realtime_websocket::methods_v2::conversation_item_create_message as v2_conversation_item_create_message; +use crate::endpoint::realtime_websocket::methods_v2::session_update_session as v2_session_update_session; +use crate::endpoint::realtime_websocket::methods_v2::websocket_intent as v2_websocket_intent; +use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser; +use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage; +use crate::endpoint::realtime_websocket::protocol::RealtimeOutputModality; +use crate::endpoint::realtime_websocket::protocol::RealtimeSessionConfig; +use crate::endpoint::realtime_websocket::protocol::RealtimeSessionMode; +use crate::endpoint::realtime_websocket::protocol::RealtimeVoice; +use crate::endpoint::realtime_websocket::protocol::SessionUpdateSession; +use serde_json::Result as JsonResult; +use serde_json::Value; +use serde_json::to_value; + +pub(super) const REALTIME_AUDIO_SAMPLE_RATE: u32 = 24_000; +const AGENT_FINAL_MESSAGE_PREFIX: &str = "\"Agent Final Message\":\n\n"; + +pub(super) fn normalized_session_mode( + event_parser: RealtimeEventParser, + session_mode: RealtimeSessionMode, +) -> RealtimeSessionMode { + match event_parser { + RealtimeEventParser::V1 => RealtimeSessionMode::Conversational, + RealtimeEventParser::RealtimeV2 => session_mode, + } +} + +pub(super) fn conversation_item_create_message( + event_parser: RealtimeEventParser, + text: String, +) -> RealtimeOutboundMessage { + match event_parser { + RealtimeEventParser::V1 => v1_conversation_item_create_message(text), + RealtimeEventParser::RealtimeV2 => v2_conversation_item_create_message(text), + } +} + +pub(super) fn conversation_function_call_output_message( + event_parser: RealtimeEventParser, + call_id: String, + output_text: String, +) -> RealtimeOutboundMessage { + match event_parser { + RealtimeEventParser::V1 => v1_conversation_handoff_append_message( + call_id, + format!("{AGENT_FINAL_MESSAGE_PREFIX}{output_text}"), + ), + RealtimeEventParser::RealtimeV2 => { + v2_conversation_function_call_output_message(call_id, output_text) + } + } +} + +pub(super) fn session_update_session( + event_parser: RealtimeEventParser, + instructions: String, + session_mode: RealtimeSessionMode, + output_modality: RealtimeOutputModality, + voice: RealtimeVoice, +) -> SessionUpdateSession { + let session_mode = normalized_session_mode(event_parser, session_mode); + match event_parser { + RealtimeEventParser::V1 => v1_session_update_session(instructions, voice), + RealtimeEventParser::RealtimeV2 => { + v2_session_update_session(instructions, session_mode, output_modality, voice) + } + } +} + +pub fn session_update_session_json(config: RealtimeSessionConfig) -> JsonResult { + let mut session = session_update_session( + config.event_parser, + config.instructions, + config.session_mode, + config.output_modality, + config.voice, + ); + session.id = config.session_id; + session.model = config.model; + to_value(session) +} + +pub(super) fn websocket_intent(event_parser: RealtimeEventParser) -> Option<&'static str> { + match event_parser { + RealtimeEventParser::V1 => v1_websocket_intent(), + RealtimeEventParser::RealtimeV2 => v2_websocket_intent(), + } +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs new file mode 100644 index 00000000000..19e4fa203a8 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs @@ -0,0 +1,73 @@ +use crate::endpoint::realtime_websocket::methods_common::REALTIME_AUDIO_SAMPLE_RATE; +use crate::endpoint::realtime_websocket::protocol::AudioFormatType; +use crate::endpoint::realtime_websocket::protocol::ConversationContentType; +use crate::endpoint::realtime_websocket::protocol::ConversationItemContent; +use crate::endpoint::realtime_websocket::protocol::ConversationItemPayload; +use crate::endpoint::realtime_websocket::protocol::ConversationItemType; +use crate::endpoint::realtime_websocket::protocol::ConversationMessageItem; +use crate::endpoint::realtime_websocket::protocol::ConversationRole; +use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage; +use crate::endpoint::realtime_websocket::protocol::RealtimeVoice; +use crate::endpoint::realtime_websocket::protocol::SessionAudio; +use crate::endpoint::realtime_websocket::protocol::SessionAudioFormat; +use crate::endpoint::realtime_websocket::protocol::SessionAudioInput; +use crate::endpoint::realtime_websocket::protocol::SessionAudioOutput; +use crate::endpoint::realtime_websocket::protocol::SessionType; +use crate::endpoint::realtime_websocket::protocol::SessionUpdateSession; + +pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutboundMessage { + RealtimeOutboundMessage::ConversationItemCreate { + item: ConversationItemPayload::Message(ConversationMessageItem { + r#type: ConversationItemType::Message, + role: ConversationRole::User, + content: vec![ConversationItemContent { + r#type: ConversationContentType::Text, + text, + }], + }), + } +} + +pub(super) fn conversation_handoff_append_message( + handoff_id: String, + output_text: String, +) -> RealtimeOutboundMessage { + RealtimeOutboundMessage::ConversationHandoffAppend { + handoff_id, + output_text, + } +} + +pub(super) fn session_update_session( + instructions: String, + voice: RealtimeVoice, +) -> SessionUpdateSession { + SessionUpdateSession { + id: None, + r#type: SessionType::Quicksilver, + model: None, + instructions: Some(instructions), + output_modalities: None, + audio: SessionAudio { + input: SessionAudioInput { + format: SessionAudioFormat { + r#type: AudioFormatType::AudioPcm, + rate: REALTIME_AUDIO_SAMPLE_RATE, + }, + noise_reduction: None, + transcription: None, + turn_detection: None, + }, + output: Some(SessionAudioOutput { + format: None, + voice, + }), + }, + tools: None, + tool_choice: None, + } +} + +pub(super) fn websocket_intent() -> Option<&'static str> { + Some("quicksilver") +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs new file mode 100644 index 00000000000..29206774839 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs @@ -0,0 +1,170 @@ +use crate::endpoint::realtime_websocket::methods_common::REALTIME_AUDIO_SAMPLE_RATE; +use crate::endpoint::realtime_websocket::protocol::AudioFormatType; +use crate::endpoint::realtime_websocket::protocol::ConversationContentType; +use crate::endpoint::realtime_websocket::protocol::ConversationFunctionCallOutputItem; +use crate::endpoint::realtime_websocket::protocol::ConversationItemContent; +use crate::endpoint::realtime_websocket::protocol::ConversationItemPayload; +use crate::endpoint::realtime_websocket::protocol::ConversationItemType; +use crate::endpoint::realtime_websocket::protocol::ConversationMessageItem; +use crate::endpoint::realtime_websocket::protocol::ConversationRole; +use crate::endpoint::realtime_websocket::protocol::NoiseReductionType; +use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage; +use crate::endpoint::realtime_websocket::protocol::RealtimeOutputModality; +use crate::endpoint::realtime_websocket::protocol::RealtimeSessionMode; +use crate::endpoint::realtime_websocket::protocol::RealtimeVoice; +use crate::endpoint::realtime_websocket::protocol::SessionAudio; +use crate::endpoint::realtime_websocket::protocol::SessionAudioFormat; +use crate::endpoint::realtime_websocket::protocol::SessionAudioInput; +use crate::endpoint::realtime_websocket::protocol::SessionAudioOutput; +use crate::endpoint::realtime_websocket::protocol::SessionAudioOutputFormat; +use crate::endpoint::realtime_websocket::protocol::SessionFunctionTool; +use crate::endpoint::realtime_websocket::protocol::SessionInputAudioTranscription; +use crate::endpoint::realtime_websocket::protocol::SessionNoiseReduction; +use crate::endpoint::realtime_websocket::protocol::SessionToolType; +use crate::endpoint::realtime_websocket::protocol::SessionTurnDetection; +use crate::endpoint::realtime_websocket::protocol::SessionType; +use crate::endpoint::realtime_websocket::protocol::SessionUpdateSession; +use crate::endpoint::realtime_websocket::protocol::TurnDetectionType; +use serde_json::json; + +const REALTIME_V2_OUTPUT_MODALITY_AUDIO: &str = "audio"; +const REALTIME_V2_OUTPUT_MODALITY_TEXT: &str = "text"; +const REALTIME_V2_TOOL_CHOICE: &str = "auto"; +const REALTIME_V2_BACKGROUND_AGENT_TOOL_NAME: &str = "background_agent"; +const REALTIME_V2_BACKGROUND_AGENT_TOOL_DESCRIPTION: &str = "Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later."; +const REALTIME_V2_SILENCE_TOOL_NAME: &str = "remain_silent"; +const REALTIME_V2_SILENCE_TOOL_DESCRIPTION: &str = "Call this when the best response is to say nothing. Use it instead of speaking after hidden system/control messages, after background agent updates in silent modes, or whenever acknowledging aloud would be distracting. This tool has no user-visible effect."; +const REALTIME_V2_INPUT_TRANSCRIPTION_MODEL: &str = "gpt-4o-mini-transcribe"; + +pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutboundMessage { + RealtimeOutboundMessage::ConversationItemCreate { + item: ConversationItemPayload::Message(ConversationMessageItem { + r#type: ConversationItemType::Message, + role: ConversationRole::User, + content: vec![ConversationItemContent { + r#type: ConversationContentType::InputText, + text, + }], + }), + } +} + +pub(super) fn conversation_function_call_output_message( + call_id: String, + output_text: String, +) -> RealtimeOutboundMessage { + RealtimeOutboundMessage::ConversationItemCreate { + item: ConversationItemPayload::FunctionCallOutput(ConversationFunctionCallOutputItem { + r#type: ConversationItemType::FunctionCallOutput, + call_id, + output: output_text, + }), + } +} + +pub(super) fn session_update_session( + instructions: String, + session_mode: RealtimeSessionMode, + output_modality: RealtimeOutputModality, + voice: RealtimeVoice, +) -> SessionUpdateSession { + match session_mode { + RealtimeSessionMode::Conversational => SessionUpdateSession { + id: None, + r#type: SessionType::Realtime, + model: None, + instructions: Some(instructions), + output_modalities: Some(vec![output_modality_value(output_modality).to_string()]), + audio: SessionAudio { + input: SessionAudioInput { + format: SessionAudioFormat { + r#type: AudioFormatType::AudioPcm, + rate: REALTIME_AUDIO_SAMPLE_RATE, + }, + noise_reduction: Some(SessionNoiseReduction { + r#type: NoiseReductionType::NearField, + }), + transcription: Some(SessionInputAudioTranscription { + model: REALTIME_V2_INPUT_TRANSCRIPTION_MODEL.to_string(), + }), + turn_detection: Some(SessionTurnDetection { + r#type: TurnDetectionType::ServerVad, + interrupt_response: true, + create_response: true, + silence_duration_ms: 500, + }), + }, + output: Some(SessionAudioOutput { + format: Some(SessionAudioOutputFormat { + r#type: AudioFormatType::AudioPcm, + rate: REALTIME_AUDIO_SAMPLE_RATE, + }), + voice, + }), + }, + tools: Some(vec![ + SessionFunctionTool { + r#type: SessionToolType::Function, + name: REALTIME_V2_BACKGROUND_AGENT_TOOL_NAME.to_string(), + description: REALTIME_V2_BACKGROUND_AGENT_TOOL_DESCRIPTION.to_string(), + parameters: json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The user request to delegate to the background agent." + } + }, + "required": ["prompt"], + "additionalProperties": false + }), + }, + SessionFunctionTool { + r#type: SessionToolType::Function, + name: REALTIME_V2_SILENCE_TOOL_NAME.to_string(), + description: REALTIME_V2_SILENCE_TOOL_DESCRIPTION.to_string(), + parameters: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ]), + tool_choice: Some(REALTIME_V2_TOOL_CHOICE.to_string()), + }, + RealtimeSessionMode::Transcription => SessionUpdateSession { + id: None, + r#type: SessionType::Transcription, + model: None, + instructions: None, + output_modalities: None, + audio: SessionAudio { + input: SessionAudioInput { + format: SessionAudioFormat { + r#type: AudioFormatType::AudioPcm, + rate: REALTIME_AUDIO_SAMPLE_RATE, + }, + noise_reduction: None, + transcription: Some(SessionInputAudioTranscription { + model: REALTIME_V2_INPUT_TRANSCRIPTION_MODEL.to_string(), + }), + turn_detection: None, + }, + output: None, + }, + tools: None, + tool_choice: None, + }, + } +} + +fn output_modality_value(output_modality: RealtimeOutputModality) -> &'static str { + match output_modality { + RealtimeOutputModality::Text => REALTIME_V2_OUTPUT_MODALITY_TEXT, + RealtimeOutputModality::Audio => REALTIME_V2_OUTPUT_MODALITY_AUDIO, + } +} + +pub(super) fn websocket_intent() -> Option<&'static str> { + None +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/mod.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/mod.rs new file mode 100644 index 00000000000..1fb49b2436f --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/mod.rs @@ -0,0 +1,18 @@ +pub(crate) mod methods; +mod methods_common; +mod methods_v1; +mod methods_v2; +pub(crate) mod protocol; +mod protocol_common; +mod protocol_v1; +mod protocol_v2; + +pub use methods::RealtimeWebsocketClient; +pub use methods::RealtimeWebsocketConnection; +pub use methods::RealtimeWebsocketEvents; +pub use methods::RealtimeWebsocketWriter; +pub use methods_common::session_update_session_json; +pub use protocol::RealtimeEventParser; +pub use protocol::RealtimeOutputModality; +pub use protocol::RealtimeSessionConfig; +pub use protocol::RealtimeSessionMode; diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs new file mode 100644 index 00000000000..d689f6ea962 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -0,0 +1,229 @@ +use crate::endpoint::realtime_websocket::protocol_v1::parse_realtime_event_v1; +use crate::endpoint::realtime_websocket::protocol_v2::parse_realtime_event_v2; +pub use codex_protocol::protocol::RealtimeAudioFrame; +pub use codex_protocol::protocol::RealtimeEvent; +pub use codex_protocol::protocol::RealtimeOutputModality; +pub use codex_protocol::protocol::RealtimeTranscriptEntry; +pub use codex_protocol::protocol::RealtimeVoice; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RealtimeEventParser { + V1, + RealtimeV2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RealtimeSessionMode { + Conversational, + Transcription, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RealtimeSessionConfig { + pub instructions: String, + pub model: Option, + pub session_id: Option, + pub event_parser: RealtimeEventParser, + pub session_mode: RealtimeSessionMode, + pub output_modality: RealtimeOutputModality, + pub voice: RealtimeVoice, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub(super) enum RealtimeOutboundMessage { + #[serde(rename = "input_audio_buffer.append")] + InputAudioBufferAppend { audio: String }, + #[serde(rename = "conversation.handoff.append")] + ConversationHandoffAppend { + handoff_id: String, + output_text: String, + }, + #[serde(rename = "response.create")] + ResponseCreate, + #[serde(rename = "session.update")] + SessionUpdate { session: SessionUpdateSession }, + #[serde(rename = "conversation.item.create")] + ConversationItemCreate { item: ConversationItemPayload }, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionUpdateSession { + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) id: Option, + #[serde(rename = "type")] + pub(super) r#type: SessionType, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) output_modalities: Option>, + pub(super) audio: SessionAudio, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tool_choice: Option, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum SessionType { + Quicksilver, + Realtime, + Transcription, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudio { + pub(super) input: SessionAudioInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) output: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudioInput { + pub(super) format: SessionAudioFormat, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) noise_reduction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) transcription: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) turn_detection: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionInputAudioTranscription { + pub(super) model: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudioFormat { + #[serde(rename = "type")] + pub(super) r#type: AudioFormatType, + pub(super) rate: u32, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub(super) enum AudioFormatType { + #[serde(rename = "audio/pcm")] + AudioPcm, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudioOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) format: Option, + pub(super) voice: RealtimeVoice, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionNoiseReduction { + #[serde(rename = "type")] + pub(super) r#type: NoiseReductionType, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum NoiseReductionType { + NearField, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionTurnDetection { + #[serde(rename = "type")] + pub(super) r#type: TurnDetectionType, + pub(super) interrupt_response: bool, + pub(super) create_response: bool, + pub(super) silence_duration_ms: u32, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum TurnDetectionType { + ServerVad, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionAudioOutputFormat { + #[serde(rename = "type")] + pub(super) r#type: AudioFormatType, + pub(super) rate: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct ConversationMessageItem { + #[serde(rename = "type")] + pub(super) r#type: ConversationItemType, + pub(super) role: ConversationRole, + pub(super) content: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ConversationItemType { + Message, + FunctionCallOutput, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ConversationRole { + User, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub(super) enum ConversationItemPayload { + Message(ConversationMessageItem), + FunctionCallOutput(ConversationFunctionCallOutputItem), +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct ConversationFunctionCallOutputItem { + #[serde(rename = "type")] + pub(super) r#type: ConversationItemType, + pub(super) call_id: String, + pub(super) output: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct ConversationItemContent { + #[serde(rename = "type")] + pub(super) r#type: ConversationContentType, + pub(super) text: String, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ConversationContentType { + Text, + InputText, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionFunctionTool { + #[serde(rename = "type")] + pub(super) r#type: SessionToolType, + pub(super) name: String, + pub(super) description: String, + pub(super) parameters: Value, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum SessionToolType { + Function, +} + +pub(super) fn parse_realtime_event( + payload: &str, + event_parser: RealtimeEventParser, +) -> Option { + match event_parser { + RealtimeEventParser::V1 => parse_realtime_event_v1(payload), + RealtimeEventParser::RealtimeV2 => parse_realtime_event_v2(payload), + } +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_common.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_common.rs new file mode 100644 index 00000000000..2c96280672f --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_common.rs @@ -0,0 +1,83 @@ +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::RealtimeTranscriptDelta; +use codex_protocol::protocol::RealtimeTranscriptDone; +use serde_json::Value; +use tracing::debug; + +pub(super) fn parse_realtime_payload(payload: &str, parser_name: &str) -> Option<(Value, String)> { + let parsed: Value = match serde_json::from_str(payload) { + Ok(message) => message, + Err(err) => { + debug!("failed to parse {parser_name} event: {err}, data: {payload}"); + return None; + } + }; + + let message_type = match parsed.get("type").and_then(Value::as_str) { + Some(message_type) => message_type.to_string(), + None => { + debug!("received {parser_name} event without type field: {payload}"); + return None; + } + }; + + Some((parsed, message_type)) +} + +pub(super) fn parse_session_updated_event(parsed: &Value) -> Option { + let session_id = parsed + .get("session") + .and_then(Value::as_object) + .and_then(|session| session.get("id")) + .and_then(Value::as_str) + .map(str::to_string)?; + let instructions = parsed + .get("session") + .and_then(Value::as_object) + .and_then(|session| session.get("instructions")) + .and_then(Value::as_str) + .map(str::to_string); + Some(RealtimeEvent::SessionUpdated { + realtime_session_id: session_id, + instructions, + }) +} + +pub(super) fn parse_transcript_delta_event( + parsed: &Value, + field: &str, +) -> Option { + parsed + .get(field) + .and_then(Value::as_str) + .map(str::to_string) + .map(|delta| RealtimeTranscriptDelta { delta }) +} + +pub(super) fn parse_transcript_done_event( + parsed: &Value, + field: &str, +) -> Option { + parsed + .get(field) + .and_then(Value::as_str) + .map(str::to_string) + .map(|text| RealtimeTranscriptDone { text }) +} + +pub(super) fn parse_error_event(parsed: &Value) -> Option { + parsed + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + parsed + .get("error") + .and_then(Value::as_object) + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| parsed.get("error").map(ToString::to_string)) + .map(RealtimeEvent::Error) +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs new file mode 100644 index 00000000000..3c1d25aed75 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs @@ -0,0 +1,98 @@ +use crate::endpoint::realtime_websocket::protocol_common::parse_error_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_realtime_payload; +use crate::endpoint::realtime_websocket::protocol_common::parse_session_updated_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_delta_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_done_event; +use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::RealtimeHandoffRequested; +use serde_json::Value; +use tracing::debug; + +pub(super) fn parse_realtime_event_v1(payload: &str) -> Option { + let (parsed, message_type) = parse_realtime_payload(payload, "realtime v1")?; + match message_type.as_str() { + "session.updated" => parse_session_updated_event(&parsed), + "conversation.output_audio.delta" => { + let data = parsed + .get("delta") + .and_then(Value::as_str) + .or_else(|| parsed.get("data").and_then(Value::as_str)) + .map(str::to_string)?; + let sample_rate = parsed + .get("sample_rate") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok())?; + let num_channels = parsed + .get("channels") + .or_else(|| parsed.get("num_channels")) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok())?; + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel: parsed + .get("samples_per_channel") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + item_id: None, + })) + } + "conversation.input_transcript.delta" + | "conversation.item.input_audio_transcription.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta) + } + "conversation.item.input_audio_transcription.completed" => { + parse_transcript_done_event(&parsed, "transcript") + .map(RealtimeEvent::InputTranscriptDone) + } + "conversation.output_transcript.delta" + | "response.output_text.delta" + | "response.output_audio_transcript.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::OutputTranscriptDelta) + } + "response.output_audio_transcript.done" => { + parse_transcript_done_event(&parsed, "transcript") + .map(RealtimeEvent::OutputTranscriptDone) + } + "conversation.item.added" => parsed + .get("item") + .cloned() + .map(RealtimeEvent::ConversationItemAdded), + "conversation.item.done" => parse_conversation_item_done_event(&parsed), + "conversation.handoff.requested" => { + let handoff_id = parsed + .get("handoff_id") + .and_then(Value::as_str) + .map(str::to_string)?; + let item_id = parsed + .get("item_id") + .and_then(Value::as_str) + .map(str::to_string)?; + let input_transcript = parsed + .get("input_transcript") + .and_then(Value::as_str) + .map(str::to_string)?; + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id, + item_id, + input_transcript, + active_transcript: Vec::new(), + })) + } + "error" => parse_error_event(&parsed), + _ => { + debug!("received unsupported realtime v1 event type: {message_type}, data: {payload}"); + None + } + } +} + +fn parse_conversation_item_done_event(parsed: &Value) -> Option { + let item = parsed.get("item")?.as_object()?; + item.get("id") + .and_then(Value::as_str) + .map(str::to_string) + .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }) +} diff --git a/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs new file mode 100644 index 00000000000..ee0bba99461 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs @@ -0,0 +1,210 @@ +use crate::endpoint::realtime_websocket::protocol_common::parse_error_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_realtime_payload; +use crate::endpoint::realtime_websocket::protocol_common::parse_session_updated_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_delta_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_done_event; +use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::RealtimeHandoffRequested; +use codex_protocol::protocol::RealtimeInputAudioSpeechStarted; +use codex_protocol::protocol::RealtimeNoopRequested; +use codex_protocol::protocol::RealtimeResponseCancelled; +use codex_protocol::protocol::RealtimeResponseCreated; +use codex_protocol::protocol::RealtimeResponseDone; +use serde_json::Map as JsonMap; +use serde_json::Value; +use tracing::debug; + +const BACKGROUND_AGENT_TOOL_NAME: &str = "background_agent"; +const SILENCE_TOOL_NAME: &str = "remain_silent"; +const DEFAULT_AUDIO_SAMPLE_RATE: u32 = 24_000; +const DEFAULT_AUDIO_CHANNELS: u16 = 1; +const TOOL_ARGUMENT_KEYS: [&str; 5] = ["input_transcript", "input", "text", "prompt", "query"]; + +pub(super) fn parse_realtime_event_v2(payload: &str) -> Option { + let (parsed, message_type) = parse_realtime_payload(payload, "realtime v2")?; + + match message_type.as_str() { + "session.updated" => parse_session_updated_event(&parsed), + "response.output_audio.delta" | "response.audio.delta" => { + parse_output_audio_delta_event(&parsed) + } + "conversation.item.input_audio_transcription.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta) + } + "conversation.item.input_audio_transcription.completed" => { + parse_transcript_done_event(&parsed, "transcript") + .map(RealtimeEvent::InputTranscriptDone) + } + "response.output_text.delta" | "response.output_audio_transcript.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::OutputTranscriptDelta) + } + "response.output_text.done" => { + parse_transcript_done_event(&parsed, "text").map(RealtimeEvent::OutputTranscriptDone) + } + "response.output_audio_transcript.done" => { + parse_transcript_done_event(&parsed, "transcript") + .map(RealtimeEvent::OutputTranscriptDone) + } + "input_audio_buffer.speech_started" => Some(RealtimeEvent::InputAudioSpeechStarted( + RealtimeInputAudioSpeechStarted { + item_id: parsed + .get("item_id") + .and_then(Value::as_str) + .map(str::to_string), + }, + )), + "conversation.item.added" | "conversation.item.created" => parsed + .get("item") + .cloned() + .map(RealtimeEvent::ConversationItemAdded), + "conversation.item.done" => parse_conversation_item_done_event(&parsed), + "response.created" => Some(RealtimeEvent::ResponseCreated(RealtimeResponseCreated { + response_id: parse_response_event_response_id(&parsed), + })), + "response.cancelled" => Some(RealtimeEvent::ResponseCancelled( + RealtimeResponseCancelled { + response_id: parse_response_event_response_id(&parsed), + }, + )), + "response.done" => Some(RealtimeEvent::ResponseDone(RealtimeResponseDone { + response_id: parse_response_event_response_id(&parsed), + })), + "error" => parse_error_event(&parsed), + _ => { + debug!("received unsupported realtime v2 event type: {message_type}, data: {payload}"); + None + } + } +} + +fn parse_response_event_response_id(parsed: &Value) -> Option { + parsed + .get("response") + .and_then(Value::as_object) + .and_then(|response| response.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + parsed + .get("response_id") + .and_then(Value::as_str) + .map(str::to_string) + }) +} + +fn parse_output_audio_delta_event(parsed: &Value) -> Option { + let data = parsed + .get("delta") + .and_then(Value::as_str) + .map(str::to_string)?; + let sample_rate = parsed + .get("sample_rate") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(DEFAULT_AUDIO_SAMPLE_RATE); + let num_channels = parsed + .get("channels") + .or_else(|| parsed.get("num_channels")) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(DEFAULT_AUDIO_CHANNELS); + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel: parsed + .get("samples_per_channel") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + item_id: parsed + .get("item_id") + .and_then(Value::as_str) + .map(str::to_string), + })) +} + +fn parse_conversation_item_done_event(parsed: &Value) -> Option { + let item = parsed.get("item")?.as_object()?; + if let Some(handoff) = parse_handoff_requested_event(item) { + return Some(handoff); + } + if let Some(noop) = parse_noop_requested_event(item) { + return Some(noop); + } + + item.get("id") + .and_then(Value::as_str) + .map(str::to_string) + .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }) +} + +fn parse_handoff_requested_event(item: &JsonMap) -> Option { + let item_type = item.get("type").and_then(Value::as_str); + let item_name = item.get("name").and_then(Value::as_str); + if item_type != Some("function_call") || item_name != Some(BACKGROUND_AGENT_TOOL_NAME) { + return None; + } + + let call_id = item + .get("call_id") + .and_then(Value::as_str) + .or_else(|| item.get("id").and_then(Value::as_str))?; + let item_id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or(call_id) + .to_string(); + let arguments = item.get("arguments").and_then(Value::as_str).unwrap_or(""); + + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: call_id.to_string(), + item_id, + input_transcript: extract_input_transcript(arguments), + active_transcript: Vec::new(), + })) +} + +fn parse_noop_requested_event(item: &JsonMap) -> Option { + let item_type = item.get("type").and_then(Value::as_str); + let item_name = item.get("name").and_then(Value::as_str); + if item_type != Some("function_call") || item_name != Some(SILENCE_TOOL_NAME) { + return None; + } + + let call_id = item + .get("call_id") + .and_then(Value::as_str) + .or_else(|| item.get("id").and_then(Value::as_str))?; + let item_id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or(call_id) + .to_string(); + + Some(RealtimeEvent::NoopRequested(RealtimeNoopRequested { + call_id: call_id.to_string(), + item_id, + })) +} + +fn extract_input_transcript(arguments: &str) -> String { + if arguments.is_empty() { + return String::new(); + } + + if let Ok(arguments_json) = serde_json::from_str::(arguments) + && let Some(arguments_object) = arguments_json.as_object() + { + for key in TOOL_ARGUMENT_KEYS { + if let Some(value) = arguments_object.get(key).and_then(Value::as_str) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } + } + + arguments.to_string() +} diff --git a/code-rs/codex-api/src/endpoint/responses.rs b/code-rs/codex-api/src/endpoint/responses.rs new file mode 100644 index 00000000000..cc1be2846aa --- /dev/null +++ b/code-rs/codex-api/src/endpoint/responses.rs @@ -0,0 +1,153 @@ +use crate::auth::SharedAuthProvider; +use crate::common::ResponseStream; +use crate::common::ResponsesApiRequest; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::requests::Compression; +use crate::requests::attach_item_ids; +use crate::requests::headers::build_session_headers; +use crate::requests::headers::insert_header; +use crate::requests::headers::subagent_header; +use crate::sse::spawn_response_stream; +use crate::telemetry::SseTelemetry; +use codex_client::HttpTransport; +use codex_client::RequestCompression; +use codex_client::RequestTelemetry; +use codex_protocol::protocol::SessionSource; +use http::HeaderMap; +use http::HeaderValue; +use http::Method; +use serde_json::Value; +use std::sync::Arc; +use std::sync::OnceLock; +use tracing::instrument; + +pub struct ResponsesClient { + session: EndpointSession, + sse_telemetry: Option>, +} + +#[derive(Default)] +pub struct ResponsesOptions { + pub session_id: Option, + pub thread_id: Option, + pub session_source: Option, + pub extra_headers: HeaderMap, + pub compression: Compression, + pub turn_state: Option>>, +} + +impl ResponsesClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + sse_telemetry: None, + } + } + + pub fn with_telemetry( + self, + request: Option>, + sse: Option>, + ) -> Self { + Self { + session: self.session.with_request_telemetry(request), + sse_telemetry: sse, + } + } + + #[instrument( + name = "responses.stream_request", + level = "info", + skip_all, + fields( + transport = "responses_http", + http.method = "POST", + api.path = "responses" + ) + )] + pub async fn stream_request( + &self, + request: ResponsesApiRequest, + options: ResponsesOptions, + ) -> Result { + let ResponsesOptions { + session_id, + thread_id, + session_source, + extra_headers, + compression, + turn_state, + } = options; + + let mut body = serde_json::to_value(&request) + .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; + if request.store && self.session.provider().is_azure_responses_endpoint() { + attach_item_ids(&mut body, &request.input); + } + + let mut headers = extra_headers; + if let Some(ref thread_id) = thread_id { + insert_header(&mut headers, "x-client-request-id", thread_id); + } + headers.extend(build_session_headers(session_id, thread_id)); + if let Some(subagent) = subagent_header(&session_source) { + insert_header(&mut headers, "x-openai-subagent", &subagent); + } + + self.stream(body, headers, compression, turn_state).await + } + + fn path() -> &'static str { + "responses" + } + + #[instrument( + name = "responses.stream", + level = "info", + skip_all, + fields( + transport = "responses_http", + http.method = "POST", + api.path = "responses", + turn.has_state = turn_state.is_some() + ) + )] + pub async fn stream( + &self, + body: Value, + extra_headers: HeaderMap, + compression: Compression, + turn_state: Option>>, + ) -> Result { + let request_compression = match compression { + Compression::None => RequestCompression::None, + Compression::Zstd => RequestCompression::Zstd, + }; + + let stream_response = self + .session + .stream_with( + Method::POST, + Self::path(), + extra_headers, + Some(body), + |req| { + req.headers.insert( + http::header::ACCEPT, + HeaderValue::from_static("text/event-stream"), + ); + req.compression = request_compression; + }, + ) + .await?; + + Ok(spawn_response_stream( + stream_response, + self.session.provider().stream_idle_timeout, + self.sse_telemetry.clone(), + turn_state, + )) + } +} diff --git a/code-rs/codex-api/src/endpoint/responses_websocket.rs b/code-rs/codex-api/src/endpoint/responses_websocket.rs new file mode 100644 index 00000000000..c5a682b3283 --- /dev/null +++ b/code-rs/codex-api/src/endpoint/responses_websocket.rs @@ -0,0 +1,908 @@ +use crate::auth::SharedAuthProvider; +use crate::common::ResponseEvent; +use crate::common::ResponseProcessedWsRequest; +use crate::common::ResponseStream; +use crate::common::ResponsesWsRequest; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::rate_limits::parse_rate_limit_event; +use crate::sse::ResponsesStreamEvent; +use crate::sse::process_responses_event; +use crate::telemetry::WebsocketTelemetry; +use codex_client::TransportError; +use codex_client::maybe_build_rustls_client_config_with_custom_ca; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use futures::SinkExt; +use futures::StreamExt; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; +use http::StatusCode; +use serde::Deserialize; +use serde_json::Value; +use serde_json::map::Map as JsonMap; +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Instant; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::connect_async_tls_with_config; +use tokio_tungstenite::tungstenite::Error as WsError; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tracing::Instrument; +use tracing::Span; +use tracing::debug; +use tracing::error; +use tracing::info; +use tracing::instrument; +use tracing::trace; +use tungstenite::extensions::ExtensionsConfig; +use tungstenite::extensions::compression::deflate::DeflateConfig; +use tungstenite::protocol::WebSocketConfig; +use url::Url; + +struct WsStream { + tx_command: mpsc::Sender, + rx_message: mpsc::UnboundedReceiver>, + pump_task: tokio::task::JoinHandle<()>, +} + +enum WsCommand { + Send { + message: Message, + tx_result: oneshot::Sender>, + }, +} + +impl WsStream { + fn new(inner: WebSocketStream>) -> Self { + let (tx_command, mut rx_command) = mpsc::channel::(32); + let (tx_message, rx_message) = mpsc::unbounded_channel::>(); + + let pump_task = tokio::spawn(async move { + let mut inner = inner; + loop { + tokio::select! { + command = rx_command.recv() => { + let Some(command) = command else { + break; + }; + match command { + WsCommand::Send { message, tx_result } => { + let result = inner.send(message).await; + let should_break = result.is_err(); + let _ = tx_result.send(result); + if should_break { + break; + } + } + } + } + message = inner.next() => { + let Some(message) = message else { + break; + }; + match message { + Ok(Message::Ping(payload)) => { + if let Err(err) = inner.send(Message::Pong(payload)).await { + let _ = tx_message.send(Err(err)); + break; + } + } + Ok(Message::Pong(_)) => {} + Ok(message @ (Message::Text(_) + | Message::Binary(_) + | Message::Close(_) + | Message::Frame(_))) => { + let is_close = matches!(message, Message::Close(_)); + if tx_message.send(Ok(message)).is_err() { + break; + } + if is_close { + break; + } + } + Err(err) => { + let _ = tx_message.send(Err(err)); + break; + } + } + } + } + } + }); + + Self { + tx_command, + rx_message, + pump_task, + } + } + + async fn request( + &self, + make_command: impl FnOnce(oneshot::Sender>) -> WsCommand, + ) -> Result<(), WsError> { + let (tx_result, rx_result) = oneshot::channel(); + if self.tx_command.send(make_command(tx_result)).await.is_err() { + return Err(WsError::ConnectionClosed); + } + rx_result.await.unwrap_or(Err(WsError::ConnectionClosed)) + } + + async fn send(&self, message: Message) -> Result<(), WsError> { + self.request(|tx_result| WsCommand::Send { message, tx_result }) + .await + } + + async fn next(&mut self) -> Option> { + self.rx_message.recv().await + } +} + +impl Drop for WsStream { + fn drop(&mut self) { + self.pump_task.abort(); + } +} + +const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; +const X_MODELS_ETAG_HEADER: &str = "x-models-etag"; +const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; +const OPENAI_MODEL_HEADER: &str = "openai-model"; +const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE: &str = "websocket_connection_limit_reached"; +const WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE: &str = "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue."; + +pub struct ResponsesWebsocketConnection { + stream: Arc>>, + // TODO (pakrym): is this the right place for timeout? + idle_timeout: Duration, + server_reasoning_included: bool, + models_etag: Option, + server_model: Option, + telemetry: Option>, +} + +impl std::fmt::Debug for ResponsesWebsocketConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResponsesWebsocketConnection") + .field("stream", &"") + .field("idle_timeout", &self.idle_timeout) + .field("server_reasoning_included", &self.server_reasoning_included) + .field("models_etag", &self.models_etag) + .field("server_model", &self.server_model) + .field("telemetry", &self.telemetry.as_ref().map(|_| "")) + .finish() + } +} + +impl ResponsesWebsocketConnection { + fn new( + stream: WsStream, + idle_timeout: Duration, + server_reasoning_included: bool, + models_etag: Option, + server_model: Option, + telemetry: Option>, + ) -> Self { + Self { + stream: Arc::new(Mutex::new(Some(stream))), + idle_timeout, + server_reasoning_included, + models_etag, + server_model, + telemetry, + } + } + + pub async fn is_closed(&self) -> bool { + self.stream.lock().await.is_none() + } + + #[instrument( + name = "responses_websocket.send_response_processed", + level = "info", + skip_all, + fields(transport = "responses_websocket", api.path = "responses") + )] + #[expect( + clippy::await_holding_invalid_type, + reason = "the guard serializes exclusive use of the websocket while sending a request frame" + )] + pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { + let request = + ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); + let request_body = serde_json::to_value(&request).map_err(|err| { + ApiError::Stream(format!("failed to encode websocket request: {err}")) + })?; + + let mut guard = self.stream.lock().await; + let Some(ws_stream) = guard.as_mut() else { + return Err(ApiError::Stream( + "websocket connection is closed".to_string(), + )); + }; + + send_websocket_request( + ws_stream, + request_body, + self.idle_timeout, + self.telemetry.as_ref(), + /*connection_reused*/ true, + ) + .await + } + + #[instrument( + name = "responses_websocket.stream_request", + level = "info", + skip_all, + fields(transport = "responses_websocket", api.path = "responses") + )] + pub async fn stream_request( + &self, + request: ResponsesWsRequest, + connection_reused: bool, + ) -> Result { + let (tx_event, rx_event) = + mpsc::channel::>(1600); + let stream = Arc::clone(&self.stream); + let idle_timeout = self.idle_timeout; + let server_reasoning_included = self.server_reasoning_included; + let models_etag = self.models_etag.clone(); + let server_model = self.server_model.clone(); + let telemetry = self.telemetry.clone(); + let request_body = serde_json::to_value(&request).map_err(|err| { + ApiError::Stream(format!("failed to encode websocket request: {err}")) + })?; + + let current_span = Span::current(); + tokio::spawn( + #[expect( + clippy::await_holding_invalid_type, + reason = "the guard serializes exclusive use of the websocket stream for the lifetime of the response stream" + )] + async move { + if let Some(model) = server_model { + let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await; + } + if let Some(etag) = models_etag { + let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await; + } + if server_reasoning_included { + let _ = tx_event + .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) + .await; + } + let mut guard = stream.lock().await; + let result = { + let Some(ws_stream) = guard.as_mut() else { + let _ = tx_event + .send(Err(ApiError::Stream( + "websocket connection is closed".to_string(), + ))) + .await; + return; + }; + + run_websocket_response_stream( + ws_stream, + tx_event.clone(), + request_body, + idle_timeout, + telemetry, + connection_reused, + ) + .await + }; + + if let Err(err) = result { + // A terminal stream error should reach the caller immediately. Waiting for a + // graceful close handshake here can stall indefinitely and mask the error. + let failed_stream = guard.take(); + drop(guard); + drop(failed_stream); + let _ = tx_event.send(Err(err)).await; + } + } + .instrument(current_span), + ); + + Ok(ResponseStream { + rx_event, + upstream_request_id: None, + }) + } +} + +pub struct ResponsesWebsocketClient { + provider: Provider, + auth: SharedAuthProvider, +} + +impl ResponsesWebsocketClient { + pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self { + Self { provider, auth } + } + + #[instrument( + name = "responses_websocket.connect", + level = "info", + skip_all, + fields(transport = "responses_websocket", api.path = "responses") + )] + pub async fn connect( + &self, + extra_headers: HeaderMap, + default_headers: HeaderMap, + turn_state: Option>>, + telemetry: Option>, + ) -> Result { + let ws_url = self + .provider + .websocket_url_for_path("responses") + .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; + + let mut headers = + merge_request_headers(&self.provider.headers, extra_headers, default_headers); + self.auth.add_auth_headers(&mut headers); + + let (stream, server_reasoning_included, models_etag, server_model) = + connect_websocket(ws_url, headers, turn_state.clone()).await?; + Ok(ResponsesWebsocketConnection::new( + stream, + self.provider.stream_idle_timeout, + server_reasoning_included, + models_etag, + server_model, + telemetry, + )) + } +} + +fn merge_request_headers( + provider_headers: &HeaderMap, + extra_headers: HeaderMap, + default_headers: HeaderMap, +) -> HeaderMap { + let mut headers = provider_headers.clone(); + headers.extend(extra_headers); + for (name, value) in &default_headers { + if let http::header::Entry::Vacant(entry) = headers.entry(name) { + entry.insert(value.clone()); + } + } + headers +} + +async fn connect_websocket( + url: Url, + headers: HeaderMap, + turn_state: Option>>, +) -> Result<(WsStream, bool, Option, Option), ApiError> { + ensure_rustls_crypto_provider(); + info!("connecting to websocket: {url}"); + + let mut request = url + .as_str() + .into_client_request() + .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; + request.headers_mut().extend(headers); + + // Secure websocket traffic needs the same custom-CA policy as reqwest-based HTTPS traffic. + // If a Codex-specific CA bundle is configured, build an explicit rustls connector so this + // websocket path does not fall back to tungstenite's default native-roots-only behavior. + let connector = maybe_build_rustls_client_config_with_custom_ca() + .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))? + .map(tokio_tungstenite::Connector::Rustls); + + let response = connect_async_tls_with_config( + request, + Some(websocket_config()), + false, // `false` means "do not disable Nagle", which is tungstenite's recommended default. + connector, + ) + .await; + + let (stream, response) = match response { + Ok((stream, response)) => { + info!( + "successfully connected to websocket: {url}, headers: {:?}", + response.headers() + ); + (stream, response) + } + Err(err) => { + error!("failed to connect to websocket: {err}, url: {url}"); + return Err(map_ws_error(err, &url)); + } + }; + + let reasoning_included = response.headers().contains_key(X_REASONING_INCLUDED_HEADER); + let models_etag = response + .headers() + .get(X_MODELS_ETAG_HEADER) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + let server_model = response + .headers() + .get(OPENAI_MODEL_HEADER) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + if let Some(turn_state) = turn_state + && let Some(header_value) = response + .headers() + .get(X_CODEX_TURN_STATE_HEADER) + .and_then(|value| value.to_str().ok()) + { + let _ = turn_state.set(header_value.to_string()); + } + Ok(( + WsStream::new(stream), + reasoning_included, + models_etag, + server_model, + )) +} + +fn websocket_config() -> WebSocketConfig { + let mut extensions = ExtensionsConfig::default(); + extensions.permessage_deflate = Some(DeflateConfig::default()); + + let mut config = WebSocketConfig::default(); + config.extensions = extensions; + config +} + +fn map_ws_error(err: WsError, url: &Url) -> ApiError { + match err { + WsError::Http(response) => { + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .body() + .as_ref() + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()); + ApiError::Transport(TransportError::Http { + status, + url: Some(url.to_string()), + headers: Some(headers), + body, + }) + } + WsError::ConnectionClosed | WsError::AlreadyClosed => { + ApiError::Stream("websocket closed".to_string()) + } + WsError::Io(err) => ApiError::Transport(TransportError::Network(err.to_string())), + other => ApiError::Transport(TransportError::Network(other.to_string())), + } +} + +#[derive(Debug, Deserialize)] +struct WrappedWebsocketError { + code: Option, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct WrappedWebsocketErrorEvent { + #[serde(rename = "type")] + kind: String, + #[serde(alias = "status_code")] + status: Option, + #[serde(default)] + error: Option, + #[serde(default)] + headers: Option>, +} + +fn parse_wrapped_websocket_error_event(payload: &str) -> Option { + let event: WrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?; + if event.kind != "error" { + return None; + } + Some(event) +} + +fn map_wrapped_websocket_error_event( + event: WrappedWebsocketErrorEvent, + original_payload: String, +) -> Option { + let WrappedWebsocketErrorEvent { + status, + error, + headers, + .. + } = event; + + if let Some(error) = error.as_ref() + && let Some(code) = error.code.as_deref() + && code == WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE + { + return Some(ApiError::Retryable { + message: error + .message + .clone() + .unwrap_or_else(|| WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE.to_string()), + delay: None, + }); + } + + let status = StatusCode::from_u16(status?).ok()?; + if status.is_success() { + return None; + } + + Some(ApiError::Transport(TransportError::Http { + status, + url: None, + headers: headers.map(json_headers_to_http_headers), + body: Some(original_payload), + })) +} + +fn json_headers_to_http_headers(headers: JsonMap) -> HeaderMap { + let mut mapped = HeaderMap::new(); + for (name, value) in headers { + let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { + continue; + }; + let Some(header_value) = json_header_value(value) else { + continue; + }; + mapped.insert(header_name, header_value); + } + mapped +} + +fn json_header_value(value: Value) -> Option { + let value = match value { + Value::String(value) => value, + Value::Number(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + _ => return None, + }; + HeaderValue::from_str(&value).ok() +} + +async fn run_websocket_response_stream( + ws_stream: &mut WsStream, + tx_event: mpsc::Sender>, + request_body: Value, + idle_timeout: Duration, + telemetry: Option>, + connection_reused: bool, +) -> Result<(), ApiError> { + let mut last_server_model: Option = None; + send_websocket_request( + ws_stream, + request_body, + idle_timeout, + telemetry.as_ref(), + connection_reused, + ) + .await?; + + loop { + let poll_start = Instant::now(); + let response = tokio::time::timeout(idle_timeout, ws_stream.next()) + .await + .map_err(|_| ApiError::Stream("idle timeout waiting for websocket".into())); + if let Some(t) = telemetry.as_ref() { + t.on_ws_event(&response, poll_start.elapsed()); + } + let message = match response { + Ok(Some(Ok(msg))) => msg, + Ok(Some(Err(err))) => { + return Err(ApiError::Stream(err.to_string())); + } + Ok(None) => { + return Err(ApiError::Stream( + "stream closed before response.completed".into(), + )); + } + Err(err) => { + return Err(err); + } + }; + + match message { + Message::Text(text) => { + trace!("websocket event: {text}"); + if let Some(wrapped_error) = parse_wrapped_websocket_error_event(&text) + && let Some(error) = + map_wrapped_websocket_error_event(wrapped_error, text.to_string()) + { + return Err(error); + } + + let event = match serde_json::from_str::(&text) { + Ok(event) => event, + Err(err) => { + debug!("failed to parse websocket event: {err}, data: {text}"); + continue; + } + }; + let model_verifications = event.model_verifications(); + if event.kind() == "codex.rate_limits" { + if let Some(snapshot) = parse_rate_limit_event(&text) { + let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; + } + continue; + } + if let Some(model) = event.response_model() + && last_server_model.as_deref() != Some(model.as_str()) + { + let _ = tx_event + .send(Ok(ResponseEvent::ServerModel(model.clone()))) + .await; + last_server_model = Some(model); + } + if let Some(verifications) = model_verifications + && tx_event + .send(Ok(ResponseEvent::ModelVerifications(verifications))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } + match process_responses_event(event) { + Ok(Some(event)) => { + let is_completed = matches!(event, ResponseEvent::Completed { .. }); + let _ = tx_event.send(Ok(event)).await; + if is_completed { + break; + } + } + Ok(None) => {} + Err(error) => { + return Err(error.into_api_error()); + } + } + } + Message::Binary(_) => { + return Err(ApiError::Stream("unexpected binary websocket event".into())); + } + Message::Close(_) => { + return Err(ApiError::Stream( + "websocket closed by server before response.completed".into(), + )); + } + Message::Frame(_) => {} + Message::Ping(_) | Message::Pong(_) => {} + } + } + + Ok(()) +} + +async fn send_websocket_request( + ws_stream: &WsStream, + request_body: Value, + idle_timeout: Duration, + telemetry: Option<&Arc>, + connection_reused: bool, +) -> Result<(), ApiError> { + let request_text = match serde_json::to_string(&request_body) { + Ok(text) => text, + Err(err) => { + return Err(ApiError::Stream(format!( + "failed to encode websocket request: {err}" + ))); + } + }; + trace!("websocket request: {request_text}"); + + let request_start = Instant::now(); + let result = tokio::time::timeout( + idle_timeout, + ws_stream.send(Message::Text(request_text.into())), + ) + .await + .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) + .and_then(|result| { + result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) + }); + + if let Some(t) = telemetry.as_ref() { + t.on_ws_request( + request_start.elapsed(), + result.as_ref().err(), + connection_reused, + ); + } + + result?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn websocket_config_enables_permessage_deflate() { + let config = websocket_config(); + assert!(config.extensions.permessage_deflate.is_some()); + } + + #[test] + fn parse_wrapped_websocket_error_event_maps_to_transport_http() { + let payload = json!({ + "type": "error", + "status": 429, + "error": { + "type": "usage_limit_reached", + "message": "The usage limit has been reached", + "plan_type": "pro", + "resets_at": 1738888888 + }, + "headers": { + "x-codex-primary-used-percent": "100.0", + "x-codex-primary-window-minutes": 15 + } + }) + .to_string(); + + let wrapped_error = parse_wrapped_websocket_error_event(&payload) + .expect("expected websocket error payload to be parsed"); + let api_error = map_wrapped_websocket_error_event(wrapped_error, payload) + .expect("expected websocket error payload to map to ApiError"); + + let ApiError::Transport(TransportError::Http { + status, + headers, + body, + .. + }) = api_error + else { + panic!("expected ApiError::Transport(Http)"); + }; + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + let headers = headers.expect("expected headers"); + assert_eq!( + headers + .get("x-codex-primary-used-percent") + .and_then(|value| value.to_str().ok()), + Some("100.0") + ); + assert_eq!( + headers + .get("x-codex-primary-window-minutes") + .and_then(|value| value.to_str().ok()), + Some("15") + ); + let body = body.expect("expected body"); + assert!(body.contains("usage_limit_reached")); + assert!(body.contains("The usage limit has been reached")); + } + + #[test] + fn parse_wrapped_websocket_error_event_ignores_non_error_payloads() { + let payload = json!({ + "type": "response.created", + "response": { + "id": "resp-1" + } + }) + .to_string(); + + let wrapped_error = parse_wrapped_websocket_error_event(&payload); + assert!(wrapped_error.is_none()); + } + + #[test] + fn parse_wrapped_websocket_error_event_with_status_maps_invalid_request() { + let payload = json!({ + "type": "error", + "status": 400, + "error": { + "type": "invalid_request_error", + "message": "Model does not support image inputs" + } + }) + .to_string(); + + let wrapped_error = parse_wrapped_websocket_error_event(&payload) + .expect("expected websocket error payload to be parsed"); + let api_error = map_wrapped_websocket_error_event(wrapped_error, payload) + .expect("expected websocket error payload to map to ApiError"); + let ApiError::Transport(TransportError::Http { status, body, .. }) = api_error else { + panic!("expected ApiError::Transport(Http)"); + }; + assert_eq!(status, StatusCode::BAD_REQUEST); + let body = body.expect("expected body"); + assert!(body.contains("invalid_request_error")); + assert!(body.contains("Model does not support image inputs")); + } + + #[test] + fn parse_wrapped_websocket_error_event_with_connection_limit_maps_retryable() { + let payload = json!({ + "type": "error", + "status": 400, + "error": { + "type": "invalid_request_error", + "code": "websocket_connection_limit_reached", + "message": "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue." + } + }) + .to_string(); + + let wrapped_error = parse_wrapped_websocket_error_event(&payload) + .expect("expected websocket error payload to be parsed"); + let api_error = map_wrapped_websocket_error_event(wrapped_error, payload) + .expect("expected websocket error payload to map to ApiError"); + let ApiError::Retryable { message, delay } = api_error else { + panic!("expected ApiError::Retryable"); + }; + assert_eq!(message, WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE); + assert_eq!(delay, None); + } + + #[test] + fn parse_wrapped_websocket_error_event_without_status_is_not_mapped() { + let payload = json!({ + "type": "error", + "error": { + "type": "usage_limit_reached", + "message": "The usage limit has been reached" + }, + "headers": { + "x-codex-primary-used-percent": "100.0", + "x-codex-primary-window-minutes": 15 + } + }) + .to_string(); + + let wrapped_error = parse_wrapped_websocket_error_event(&payload) + .expect("expected websocket error payload to be parsed"); + let api_error = map_wrapped_websocket_error_event(wrapped_error, payload); + assert!(api_error.is_none()); + } + + #[test] + fn merge_request_headers_matches_http_precedence() { + let mut provider_headers = HeaderMap::new(); + provider_headers.insert( + "originator", + HeaderValue::from_static("provider-originator"), + ); + provider_headers.insert("x-priority", HeaderValue::from_static("provider")); + + let mut extra_headers = HeaderMap::new(); + extra_headers.insert("x-priority", HeaderValue::from_static("extra")); + + let mut default_headers = HeaderMap::new(); + default_headers.insert("originator", HeaderValue::from_static("default-originator")); + default_headers.insert("x-priority", HeaderValue::from_static("default")); + default_headers.insert("x-default-only", HeaderValue::from_static("default-only")); + + let merged = merge_request_headers(&provider_headers, extra_headers, default_headers); + + assert_eq!( + merged.get("originator"), + Some(&HeaderValue::from_static("provider-originator")) + ); + assert_eq!( + merged.get("x-priority"), + Some(&HeaderValue::from_static("extra")) + ); + assert_eq!( + merged.get("x-default-only"), + Some(&HeaderValue::from_static("default-only")) + ); + } +} diff --git a/code-rs/codex-api/src/endpoint/session.rs b/code-rs/codex-api/src/endpoint/session.rs new file mode 100644 index 00000000000..132c3abd90a --- /dev/null +++ b/code-rs/codex-api/src/endpoint/session.rs @@ -0,0 +1,154 @@ +use crate::auth::SharedAuthProvider; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::telemetry::run_with_request_telemetry; +use codex_client::HttpTransport; +use codex_client::Request; +use codex_client::RequestBody; +use codex_client::RequestTelemetry; +use codex_client::Response; +use codex_client::StreamResponse; +use codex_client::TransportError; +use http::HeaderMap; +use http::Method; +use serde_json::Value; +use std::sync::Arc; +use tracing::instrument; + +pub(crate) struct EndpointSession { + transport: T, + provider: Provider, + auth: SharedAuthProvider, + request_telemetry: Option>, +} + +impl EndpointSession { + pub(crate) fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + transport, + provider, + auth, + request_telemetry: None, + } + } + + pub(crate) fn with_request_telemetry( + mut self, + request: Option>, + ) -> Self { + self.request_telemetry = request; + self + } + + pub(crate) fn provider(&self) -> &Provider { + &self.provider + } + + fn make_request( + &self, + method: &Method, + path: &str, + extra_headers: &HeaderMap, + body: Option<&Value>, + ) -> Request { + let mut req = self.provider.build_request(method.clone(), path); + req.headers.extend(extra_headers.clone()); + if let Some(body) = body { + req.body = Some(RequestBody::Json(body.clone())); + } + req + } + + pub(crate) async fn execute( + &self, + method: Method, + path: &str, + extra_headers: HeaderMap, + body: Option, + ) -> Result { + self.execute_with(method, path, extra_headers, body, |_| {}) + .await + } + + #[instrument( + name = "endpoint_session.execute_with", + level = "info", + skip_all, + fields(http.method = %method, api.path = path) + )] + pub(crate) async fn execute_with( + &self, + method: Method, + path: &str, + extra_headers: HeaderMap, + body: Option, + configure: C, + ) -> Result + where + C: Fn(&mut Request), + { + let make_request = || { + let mut req = self.make_request(&method, path, &extra_headers, body.as_ref()); + configure(&mut req); + req + }; + + let response = run_with_request_telemetry( + self.provider.retry.to_policy(), + self.request_telemetry.clone(), + make_request, + |req| { + let auth = self.auth.clone(); + let transport = &self.transport; + async move { + let req = auth.apply_auth(req).await.map_err(TransportError::from)?; + transport.execute(req).await + } + }, + ) + .await?; + + Ok(response) + } + + #[instrument( + name = "endpoint_session.stream_with", + level = "info", + skip_all, + fields(http.method = %method, api.path = path) + )] + pub(crate) async fn stream_with( + &self, + method: Method, + path: &str, + extra_headers: HeaderMap, + body: Option, + configure: C, + ) -> Result + where + C: Fn(&mut Request), + { + let make_request = || { + let mut req = self.make_request(&method, path, &extra_headers, body.as_ref()); + configure(&mut req); + req + }; + + let stream = run_with_request_telemetry( + self.provider.retry.to_policy(), + self.request_telemetry.clone(), + make_request, + |req| { + let auth = self.auth.clone(); + let transport = &self.transport; + async move { + let req = auth.apply_auth(req).await.map_err(TransportError::from)?; + transport.stream(req).await + } + }, + ) + .await?; + + Ok(stream) + } +} diff --git a/code-rs/codex-api/src/error.rs b/code-rs/codex-api/src/error.rs new file mode 100644 index 00000000000..c6cb5fd4055 --- /dev/null +++ b/code-rs/codex-api/src/error.rs @@ -0,0 +1,40 @@ +use crate::rate_limits::RateLimitError; +use codex_client::TransportError; +use http::StatusCode; +use std::time::Duration; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error(transparent)] + Transport(#[from] TransportError), + #[error("api error {status}: {message}")] + Api { status: StatusCode, message: String }, + #[error("stream error: {0}")] + Stream(String), + #[error("context window exceeded")] + ContextWindowExceeded, + #[error("quota exceeded")] + QuotaExceeded, + #[error("usage not included")] + UsageNotIncluded, + #[error("retryable error: {message}")] + Retryable { + message: String, + delay: Option, + }, + #[error("rate limit: {0}")] + RateLimit(String), + #[error("invalid request: {message}")] + InvalidRequest { message: String }, + #[error("cyber policy: {message}")] + CyberPolicy { message: String }, + #[error("server overloaded")] + ServerOverloaded, +} + +impl From for ApiError { + fn from(err: RateLimitError) -> Self { + Self::RateLimit(err.to_string()) + } +} diff --git a/code-rs/codex-api/src/files.rs b/code-rs/codex-api/src/files.rs new file mode 100644 index 00000000000..d1e2840066d --- /dev/null +++ b/code-rs/codex-api/src/files.rs @@ -0,0 +1,380 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use crate::AuthProvider; +use codex_client::build_reqwest_client_with_custom_ca; +use reqwest::StatusCode; +use reqwest::header::CONTENT_LENGTH; +use serde::Deserialize; +use tokio::fs::File; +use tokio::time::Instant; +use tokio_util::io::ReaderStream; + +pub const OPENAI_FILE_URI_PREFIX: &str = "sediment://"; +pub const OPENAI_FILE_UPLOAD_LIMIT_BYTES: u64 = 512 * 1024 * 1024; + +const OPENAI_FILE_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const OPENAI_FILE_FINALIZE_TIMEOUT: Duration = Duration::from_secs(30); +const OPENAI_FILE_FINALIZE_RETRY_DELAY: Duration = Duration::from_millis(250); +const OPENAI_FILE_USE_CASE: &str = "codex"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UploadedOpenAiFile { + pub file_id: String, + pub uri: String, + pub download_url: String, + pub file_name: String, + pub file_size_bytes: u64, + pub mime_type: Option, + pub path: PathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum OpenAiFileError { + #[error("path `{path}` does not exist")] + MissingPath { path: PathBuf }, + #[error("path `{path}` is not a file")] + NotAFile { path: PathBuf }, + #[error("path `{path}` cannot be read: {source}")] + ReadFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error( + "file `{path}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes" + )] + FileTooLarge { + path: PathBuf, + size_bytes: u64, + limit_bytes: u64, + }, + #[error("failed to send OpenAI file request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + #[error("OpenAI file request to {url} failed with status {status}: {body}")] + UnexpectedStatus { + url: String, + status: StatusCode, + body: String, + }, + #[error("failed to parse OpenAI file response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + #[error("OpenAI file upload for `{file_id}` is not ready yet")] + UploadNotReady { file_id: String }, + #[error("OpenAI file upload for `{file_id}` failed: {message}")] + UploadFailed { file_id: String, message: String }, +} + +#[derive(Deserialize)] +struct CreateFileResponse { + file_id: String, + upload_url: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct DownloadLinkResponse { + status: String, + download_url: Option, + file_name: Option, + mime_type: Option, + error_message: Option, +} + +pub fn openai_file_uri(file_id: &str) -> String { + format!("{OPENAI_FILE_URI_PREFIX}{file_id}") +} + +pub async fn upload_local_file( + base_url: &str, + auth: &dyn AuthProvider, + path: &Path, +) -> Result { + let metadata = tokio::fs::metadata(path) + .await + .map_err(|source| match source.kind() { + std::io::ErrorKind::NotFound => OpenAiFileError::MissingPath { + path: path.to_path_buf(), + }, + _ => OpenAiFileError::ReadFile { + path: path.to_path_buf(), + source, + }, + })?; + if !metadata.is_file() { + return Err(OpenAiFileError::NotAFile { + path: path.to_path_buf(), + }); + } + if metadata.len() > OPENAI_FILE_UPLOAD_LIMIT_BYTES { + return Err(OpenAiFileError::FileTooLarge { + path: path.to_path_buf(), + size_bytes: metadata.len(), + limit_bytes: OPENAI_FILE_UPLOAD_LIMIT_BYTES, + }); + } + + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("file") + .to_string(); + let create_url = format!("{}/files", base_url.trim_end_matches('/')); + let create_response = authorized_request(auth, reqwest::Method::POST, &create_url) + .json(&serde_json::json!({ + "file_name": file_name, + "file_size": metadata.len(), + "use_case": OPENAI_FILE_USE_CASE, + })) + .send() + .await + .map_err(|source| OpenAiFileError::Request { + url: create_url.clone(), + source, + })?; + let create_status = create_response.status(); + let create_body = create_response.text().await.unwrap_or_default(); + if !create_status.is_success() { + return Err(OpenAiFileError::UnexpectedStatus { + url: create_url, + status: create_status, + body: create_body, + }); + } + let create_payload: CreateFileResponse = + serde_json::from_str(&create_body).map_err(|source| OpenAiFileError::Decode { + url: create_url.clone(), + source, + })?; + + let upload_file = File::open(path) + .await + .map_err(|source| OpenAiFileError::ReadFile { + path: path.to_path_buf(), + source, + })?; + let upload_response = build_reqwest_client() + .put(&create_payload.upload_url) + .timeout(OPENAI_FILE_REQUEST_TIMEOUT) + .header("x-ms-blob-type", "BlockBlob") + .header(CONTENT_LENGTH, metadata.len()) + .body(reqwest::Body::wrap_stream(ReaderStream::new(upload_file))) + .send() + .await + .map_err(|source| OpenAiFileError::Request { + url: create_payload.upload_url.clone(), + source, + })?; + let upload_status = upload_response.status(); + let upload_body = upload_response.text().await.unwrap_or_default(); + if !upload_status.is_success() { + return Err(OpenAiFileError::UnexpectedStatus { + url: create_payload.upload_url.clone(), + status: upload_status, + body: upload_body, + }); + } + + let finalize_url = format!( + "{}/files/{}/uploaded", + base_url.trim_end_matches('/'), + create_payload.file_id, + ); + let finalize_started_at = Instant::now(); + loop { + let finalize_response = authorized_request(auth, reqwest::Method::POST, &finalize_url) + .json(&serde_json::json!({})) + .send() + .await + .map_err(|source| OpenAiFileError::Request { + url: finalize_url.clone(), + source, + })?; + let finalize_status = finalize_response.status(); + let finalize_body = finalize_response.text().await.unwrap_or_default(); + if !finalize_status.is_success() { + return Err(OpenAiFileError::UnexpectedStatus { + url: finalize_url.clone(), + status: finalize_status, + body: finalize_body, + }); + } + let finalize_payload: DownloadLinkResponse = + serde_json::from_str(&finalize_body).map_err(|source| OpenAiFileError::Decode { + url: finalize_url.clone(), + source, + })?; + + match finalize_payload.status.as_str() { + "success" => { + return Ok(UploadedOpenAiFile { + file_id: create_payload.file_id.clone(), + uri: openai_file_uri(&create_payload.file_id), + download_url: finalize_payload.download_url.ok_or_else(|| { + OpenAiFileError::UploadFailed { + file_id: create_payload.file_id.clone(), + message: "missing download_url".to_string(), + } + })?, + file_name: finalize_payload.file_name.unwrap_or(file_name), + file_size_bytes: metadata.len(), + mime_type: finalize_payload.mime_type, + path: path.to_path_buf(), + }); + } + "retry" => { + if finalize_started_at.elapsed() >= OPENAI_FILE_FINALIZE_TIMEOUT { + return Err(OpenAiFileError::UploadNotReady { + file_id: create_payload.file_id, + }); + } + tokio::time::sleep(OPENAI_FILE_FINALIZE_RETRY_DELAY).await; + } + _ => { + return Err(OpenAiFileError::UploadFailed { + file_id: create_payload.file_id, + message: finalize_payload + .error_message + .unwrap_or_else(|| "upload finalization returned an error".to_string()), + }); + } + } + } +} + +fn authorized_request( + auth: &dyn AuthProvider, + method: reqwest::Method, + url: &str, +) -> reqwest::RequestBuilder { + let mut headers = http::HeaderMap::new(); + auth.add_auth_headers(&mut headers); + + let client = build_reqwest_client(); + client + .request(method, url) + .timeout(OPENAI_FILE_REQUEST_TIMEOUT) + .headers(headers) +} + +fn build_reqwest_client() -> reqwest::Client { + build_reqwest_client_with_custom_ca(reqwest::Client::builder()).unwrap_or_else(|error| { + tracing::warn!(error = %error, "failed to build OpenAI file upload client"); + reqwest::Client::new() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use reqwest::header::HeaderValue; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::Request; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_json; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + #[derive(Clone, Copy)] + struct ChatGptTestAuth; + + impl AuthProvider for ChatGptTestAuth { + fn add_auth_headers(&self, headers: &mut reqwest::header::HeaderMap) { + headers.insert( + reqwest::header::AUTHORIZATION, + HeaderValue::from_static("Bearer token"), + ); + headers.insert("ChatGPT-Account-ID", HeaderValue::from_static("account_id")); + } + } + + fn chatgpt_auth() -> ChatGptTestAuth { + ChatGptTestAuth + } + + fn base_url_for(server: &MockServer) -> String { + format!("{}/backend-api", server.uri()) + } + + #[tokio::test] + async fn upload_local_file_returns_canonical_uri() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/backend-api/files")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(serde_json::json!({ + "file_name": "hello.txt", + "file_size": 5, + "use_case": "codex", + }))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"file_id": "file_123", "upload_url": format!("{}/upload/file_123", server.uri())})), + ) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .and(header("content-length", "5")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + let finalize_attempts = Arc::new(AtomicUsize::new(0)); + let finalize_attempts_responder = Arc::clone(&finalize_attempts); + let download_url = format!("{}/download/file_123", server.uri()); + Mock::given(method("POST")) + .and(path("/backend-api/files/file_123/uploaded")) + .respond_with(move |_request: &Request| { + if finalize_attempts_responder.fetch_add(1, Ordering::SeqCst) == 0 { + return ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": "retry" + })); + } + + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": "success", + "download_url": download_url, + "file_name": "hello.txt", + "mime_type": "text/plain", + "file_size_bytes": 5 + })) + }) + .mount(&server) + .await; + + let base_url = base_url_for(&server); + let dir = TempDir::new().expect("temp dir"); + let path = dir.path().join("hello.txt"); + tokio::fs::write(&path, b"hello").await.expect("write file"); + + let uploaded = upload_local_file(&base_url, &chatgpt_auth(), &path) + .await + .expect("upload succeeds"); + + assert_eq!(uploaded.file_id, "file_123"); + assert_eq!(uploaded.uri, "sediment://file_123"); + assert_eq!( + uploaded.download_url, + format!("{}/download/file_123", server.uri()) + ); + assert_eq!(uploaded.file_name, "hello.txt"); + assert_eq!(uploaded.mime_type, Some("text/plain".to_string())); + assert_eq!(finalize_attempts.load(Ordering::SeqCst), 2); + } +} diff --git a/code-rs/codex-api/src/lib.rs b/code-rs/codex-api/src/lib.rs new file mode 100644 index 00000000000..e6f097db381 --- /dev/null +++ b/code-rs/codex-api/src/lib.rs @@ -0,0 +1,70 @@ +pub(crate) mod api_bridge; +pub(crate) mod auth; +pub(crate) mod common; +pub(crate) mod endpoint; +pub(crate) mod error; +pub(crate) mod files; +pub(crate) mod provider; +pub(crate) mod rate_limits; +pub(crate) mod requests; +pub(crate) mod sse; +pub(crate) mod telemetry; + +pub use crate::requests::headers::build_session_headers; +pub use codex_client::RequestTelemetry; +pub use codex_client::ReqwestTransport; +pub use codex_client::TransportError; + +pub use crate::api_bridge::map_api_error; +pub use crate::auth::AuthError; +pub use crate::auth::AuthHeaderTelemetry; +pub use crate::auth::AuthProvider; +pub use crate::auth::SharedAuthProvider; +pub use crate::auth::auth_header_telemetry; +pub use crate::common::CompactionInput; +pub use crate::common::MemorySummarizeInput; +pub use crate::common::MemorySummarizeOutput; +pub use crate::common::OpenAiVerbosity; +pub use crate::common::RawMemory; +pub use crate::common::RawMemoryMetadata; +pub use crate::common::Reasoning; +pub use crate::common::ResponseCreateWsRequest; +pub use crate::common::ResponseEvent; +pub use crate::common::ResponseProcessedWsRequest; +pub use crate::common::ResponseStream; +pub use crate::common::ResponsesApiRequest; +pub use crate::common::ResponsesWsRequest; +pub use crate::common::TextControls; +pub use crate::common::WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY; +pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; +pub use crate::common::create_text_param_for_request; +pub use crate::common::response_create_client_metadata; +pub use crate::endpoint::CompactClient; +pub use crate::endpoint::MemoriesClient; +pub use crate::endpoint::ModelsClient; +pub use crate::endpoint::RealtimeCallClient; +pub use crate::endpoint::RealtimeCallResponse; +pub use crate::endpoint::RealtimeEventParser; +pub use crate::endpoint::RealtimeOutputModality; +pub use crate::endpoint::RealtimeSessionConfig; +pub use crate::endpoint::RealtimeSessionMode; +pub use crate::endpoint::RealtimeWebsocketClient; +pub use crate::endpoint::RealtimeWebsocketConnection; +pub use crate::endpoint::RealtimeWebsocketEvents; +pub use crate::endpoint::RealtimeWebsocketWriter; +pub use crate::endpoint::ResponsesClient; +pub use crate::endpoint::ResponsesOptions; +pub use crate::endpoint::ResponsesWebsocketClient; +pub use crate::endpoint::ResponsesWebsocketConnection; +pub use crate::endpoint::session_update_session_json; +pub use crate::error::ApiError; +pub use crate::files::upload_local_file; +pub use crate::provider::Provider; +pub use crate::provider::RetryConfig; +pub use crate::provider::is_azure_responses_provider; +pub use crate::requests::Compression; +pub use crate::sse::stream_from_fixture; +pub use crate::telemetry::SseTelemetry; +pub use crate::telemetry::WebsocketTelemetry; +pub use codex_protocol::protocol::RealtimeAudioFrame; +pub use codex_protocol::protocol::RealtimeEvent; diff --git a/code-rs/codex-api/src/provider.rs b/code-rs/codex-api/src/provider.rs new file mode 100644 index 00000000000..45f2512dc39 --- /dev/null +++ b/code-rs/codex-api/src/provider.rs @@ -0,0 +1,169 @@ +use codex_client::Request; +use codex_client::RequestCompression; +use codex_client::RetryOn; +use codex_client::RetryPolicy; +use http::Method; +use http::header::HeaderMap; +use std::collections::HashMap; +use std::time::Duration; +use url::Url; + +/// High-level retry configuration for a provider. +/// +/// This is converted into a `RetryPolicy` used by `codex-client` to drive +/// transport-level retries for both unary and streaming calls. +#[derive(Debug, Clone)] +pub struct RetryConfig { + pub max_attempts: u64, + pub base_delay: Duration, + pub retry_429: bool, + pub retry_5xx: bool, + pub retry_transport: bool, +} + +impl RetryConfig { + pub fn to_policy(&self) -> RetryPolicy { + RetryPolicy { + max_attempts: self.max_attempts, + base_delay: self.base_delay, + retry_on: RetryOn { + retry_429: self.retry_429, + retry_5xx: self.retry_5xx, + retry_transport: self.retry_transport, + }, + } + } +} + +/// HTTP endpoint configuration used to talk to a concrete API deployment. +/// +/// Encapsulates base URL, default headers, query params, retry policy, and +/// stream idle timeout, plus helper methods for building requests. +#[derive(Debug, Clone)] +pub struct Provider { + pub name: String, + pub base_url: String, + pub query_params: Option>, + pub headers: HeaderMap, + pub retry: RetryConfig, + pub stream_idle_timeout: Duration, +} + +impl Provider { + pub fn url_for_path(&self, path: &str) -> String { + let base = self.base_url.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + let mut url = if path.is_empty() { + base.to_string() + } else { + format!("{base}/{path}") + }; + + if let Some(params) = &self.query_params + && !params.is_empty() + { + let qs = params + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&"); + url.push('?'); + url.push_str(&qs); + } + + url + } + + pub fn build_request(&self, method: Method, path: &str) -> Request { + Request { + method, + url: self.url_for_path(path), + headers: self.headers.clone(), + body: None, + compression: RequestCompression::None, + timeout: None, + } + } + + pub fn is_azure_responses_endpoint(&self) -> bool { + is_azure_responses_provider(&self.name, Some(&self.base_url)) + } + + pub fn websocket_url_for_path(&self, path: &str) -> Result { + let mut url = Url::parse(&self.url_for_path(path))?; + + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + "ws" | "wss" => return Ok(url), + _ => return Ok(url), + }; + let _ = url.set_scheme(scheme); + Ok(url) + } +} + +pub fn is_azure_responses_provider(name: &str, base_url: Option<&str>) -> bool { + if name.eq_ignore_ascii_case("azure") { + true + } else if let Some(base_url) = base_url { + matches_azure_responses_base_url(base_url) + } else { + false + } +} + +fn matches_azure_responses_base_url(base_url: &str) -> bool { + let base_url = base_url.to_ascii_lowercase(); + const AZURE_MARKERS: [&str; 6] = [ + "openai.azure.", + "cognitiveservices.azure.", + "aoai.azure.", + "azure-api.", + "azurefd.", + "windows.net/openai", + ]; + AZURE_MARKERS.iter().any(|marker| base_url.contains(marker)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_azure_responses_base_urls() { + let positive_cases = [ + "https://foo.openai.azure.com/openai", + "https://foo.openai.azure.us/openai/deployments/bar", + "https://foo.cognitiveservices.azure.cn/openai", + "https://foo.aoai.azure.com/openai", + "https://foo.openai.azure-api.net/openai", + "https://foo.z01.azurefd.net/", + ]; + + for base_url in positive_cases { + assert!( + is_azure_responses_provider("test", Some(base_url)), + "expected {base_url} to be detected as Azure" + ); + } + + assert!(is_azure_responses_provider( + "Azure", + Some("https://example.com") + )); + + let negative_cases = [ + "https://api.openai.com/v1", + "https://example.com/openai", + "https://myproxy.azurewebsites.net/openai", + ]; + + for base_url in negative_cases { + assert!( + !is_azure_responses_provider("test", Some(base_url)), + "expected {base_url} not to be detected as Azure" + ); + } + } +} diff --git a/code-rs/codex-api/src/rate_limits.rs b/code-rs/codex-api/src/rate_limits.rs new file mode 100644 index 00000000000..979500cdabc --- /dev/null +++ b/code-rs/codex-api/src/rate_limits.rs @@ -0,0 +1,368 @@ +use codex_protocol::account::PlanType; +use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; +use http::HeaderMap; +use serde::Deserialize; +use std::collections::BTreeSet; +use std::fmt::Display; + +#[derive(Debug)] +pub struct RateLimitError { + pub message: String, +} + +impl Display for RateLimitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +/// Parses the default Codex rate-limit header family into a `RateLimitSnapshot`. +pub fn parse_default_rate_limit(headers: &HeaderMap) -> Option { + parse_rate_limit_for_limit(headers, /*limit_id*/ None) +} + +/// Parses all known rate-limit header families into update records keyed by limit id. +pub fn parse_all_rate_limits(headers: &HeaderMap) -> Vec { + let mut snapshots = Vec::new(); + if let Some(snapshot) = parse_default_rate_limit(headers) { + snapshots.push(snapshot); + } + + let mut limit_ids: BTreeSet = BTreeSet::new(); + + for name in headers.keys() { + let header_name = name.as_str().to_ascii_lowercase(); + if let Some(limit_id) = header_name_to_limit_id(&header_name) + && limit_id != "codex" + { + limit_ids.insert(limit_id); + } + } + + snapshots.extend(limit_ids.into_iter().filter_map(|limit_id| { + let snapshot = parse_rate_limit_for_limit(headers, Some(limit_id.as_str()))?; + has_rate_limit_data(&snapshot).then_some(snapshot) + })); + + snapshots +} + +/// Parses rate-limit headers for the provided limit id. +/// +/// `limit_id` should match the server-provided metered limit id (e.g. `codex`, +/// `codex_other`). When omitted, this defaults to the legacy `codex` header family. +pub fn parse_rate_limit_for_limit( + headers: &HeaderMap, + limit_id: Option<&str>, +) -> Option { + let normalized_limit = limit_id + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or("codex") + .to_ascii_lowercase() + .replace('_', "-"); + let prefix = format!("x-{normalized_limit}"); + let primary = parse_rate_limit_window( + headers, + &format!("{prefix}-primary-used-percent"), + &format!("{prefix}-primary-window-minutes"), + &format!("{prefix}-primary-reset-at"), + ); + + let secondary = parse_rate_limit_window( + headers, + &format!("{prefix}-secondary-used-percent"), + &format!("{prefix}-secondary-window-minutes"), + &format!("{prefix}-secondary-reset-at"), + ); + + let normalized_limit_id = normalize_limit_id(normalized_limit); + let credits = parse_credits_snapshot(headers); + let limit_name_header = format!("{prefix}-limit-name"); + let parsed_limit_name = parse_header_str(headers, &limit_name_header) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(std::string::ToString::to_string); + + Some(RateLimitSnapshot { + limit_id: Some(normalized_limit_id), + limit_name: parsed_limit_name, + primary, + secondary, + credits, + plan_type: None, + rate_limit_reached_type: None, + }) +} + +#[derive(Debug, Deserialize)] +struct RateLimitEventWindow { + used_percent: f64, + window_minutes: Option, + reset_at: Option, +} + +#[derive(Debug, Deserialize)] +struct RateLimitEventDetails { + primary: Option, + secondary: Option, +} + +#[derive(Debug, Deserialize)] +struct RateLimitEventCredits { + has_credits: bool, + unlimited: bool, + balance: Option, +} + +#[derive(Debug, Deserialize)] +struct RateLimitEvent { + #[serde(rename = "type")] + kind: String, + plan_type: Option, + rate_limits: Option, + credits: Option, + metered_limit_name: Option, + limit_name: Option, +} + +pub fn parse_rate_limit_event(payload: &str) -> Option { + let event: RateLimitEvent = serde_json::from_str(payload).ok()?; + if event.kind != "codex.rate_limits" { + return None; + } + let (primary, secondary) = if let Some(details) = event.rate_limits.as_ref() { + ( + map_event_window(details.primary.as_ref()), + map_event_window(details.secondary.as_ref()), + ) + } else { + (None, None) + }; + let credits = event.credits.map(|credits| CreditsSnapshot { + has_credits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance, + }); + let limit_id = event + .metered_limit_name + .or(event.limit_name) + .map(normalize_limit_id); + Some(RateLimitSnapshot { + limit_id: Some(limit_id.unwrap_or_else(|| "codex".to_string())), + limit_name: None, + primary, + secondary, + credits, + plan_type: event.plan_type, + rate_limit_reached_type: None, + }) +} + +fn map_event_window(window: Option<&RateLimitEventWindow>) -> Option { + let window = window?; + Some(RateLimitWindow { + used_percent: window.used_percent, + window_minutes: window.window_minutes, + resets_at: window.reset_at, + }) +} + +/// Parses the bespoke Codex rate-limit headers into a `RateLimitSnapshot`. +pub fn parse_promo_message(headers: &HeaderMap) -> Option { + parse_header_str(headers, "x-codex-promo-message") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(std::string::ToString::to_string) +} + +fn parse_rate_limit_window( + headers: &HeaderMap, + used_percent_header: &str, + window_minutes_header: &str, + resets_at_header: &str, +) -> Option { + let used_percent: Option = parse_header_f64(headers, used_percent_header); + + used_percent.and_then(|used_percent| { + let window_minutes = parse_header_i64(headers, window_minutes_header); + let resets_at = parse_header_i64(headers, resets_at_header); + + let has_data = used_percent != 0.0 + || window_minutes.is_some_and(|minutes| minutes != 0) + || resets_at.is_some(); + + has_data.then_some(RateLimitWindow { + used_percent, + window_minutes, + resets_at, + }) + }) +} + +fn parse_credits_snapshot(headers: &HeaderMap) -> Option { + let has_credits = parse_header_bool(headers, "x-codex-credits-has-credits")?; + let unlimited = parse_header_bool(headers, "x-codex-credits-unlimited")?; + let balance = parse_header_str(headers, "x-codex-credits-balance") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(std::string::ToString::to_string); + Some(CreditsSnapshot { + has_credits, + unlimited, + balance, + }) +} + +fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)? + .parse::() + .ok() + .filter(|v| v.is_finite()) +} + +fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)?.parse::().ok() +} + +fn parse_header_bool(headers: &HeaderMap, name: &str) -> Option { + let raw = parse_header_str(headers, name)?; + if raw.eq_ignore_ascii_case("true") || raw == "1" { + Some(true) + } else if raw.eq_ignore_ascii_case("false") || raw == "0" { + Some(false) + } else { + None + } +} + +fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name)?.to_str().ok() +} + +fn has_rate_limit_data(snapshot: &RateLimitSnapshot) -> bool { + snapshot.primary.is_some() || snapshot.secondary.is_some() || snapshot.credits.is_some() +} + +fn header_name_to_limit_id(header_name: &str) -> Option { + let suffix = "-primary-used-percent"; + let prefix = header_name.strip_suffix(suffix)?; + let limit = prefix.strip_prefix("x-")?; + Some(normalize_limit_id(limit.to_string())) +} + +fn normalize_limit_id(name: impl Into) -> String { + name.into().trim().to_ascii_lowercase().replace('-', "_") +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderValue; + use pretty_assertions::assert_eq; + + #[test] + fn parse_rate_limit_for_limit_defaults_to_codex_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-codex-primary-used-percent", + HeaderValue::from_static("12.5"), + ); + headers.insert( + "x-codex-primary-window-minutes", + HeaderValue::from_static("60"), + ); + headers.insert( + "x-codex-primary-reset-at", + HeaderValue::from_static("1704069000"), + ); + + let snapshot = parse_rate_limit_for_limit(&headers, /*limit_id*/ None).expect("snapshot"); + assert_eq!(snapshot.limit_id.as_deref(), Some("codex")); + assert_eq!(snapshot.limit_name, None); + let primary = snapshot.primary.expect("primary"); + assert_eq!(primary.used_percent, 12.5); + assert_eq!(primary.window_minutes, Some(60)); + assert_eq!(primary.resets_at, Some(1704069000)); + } + + #[test] + fn parse_rate_limit_for_limit_reads_secondary_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-codex-secondary-primary-used-percent", + HeaderValue::from_static("80"), + ); + headers.insert( + "x-codex-secondary-primary-window-minutes", + HeaderValue::from_static("1440"), + ); + headers.insert( + "x-codex-secondary-primary-reset-at", + HeaderValue::from_static("1704074400"), + ); + + let snapshot = + parse_rate_limit_for_limit(&headers, Some("codex_secondary")).expect("snapshot"); + assert_eq!(snapshot.limit_id.as_deref(), Some("codex_secondary")); + assert_eq!(snapshot.limit_name, None); + let primary = snapshot.primary.expect("primary"); + assert_eq!(primary.used_percent, 80.0); + assert_eq!(primary.window_minutes, Some(1440)); + assert_eq!(primary.resets_at, Some(1704074400)); + assert_eq!(snapshot.secondary, None); + } + + #[test] + fn parse_rate_limit_for_limit_prefers_limit_name_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-codex-bengalfox-primary-used-percent", + HeaderValue::from_static("80"), + ); + headers.insert( + "x-codex-bengalfox-limit-name", + HeaderValue::from_static("gpt-5.2-codex-sonic"), + ); + + let snapshot = + parse_rate_limit_for_limit(&headers, Some("codex_bengalfox")).expect("snapshot"); + assert_eq!(snapshot.limit_id.as_deref(), Some("codex_bengalfox")); + assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-5.2-codex-sonic")); + } + + #[test] + fn parse_all_rate_limits_reads_all_limit_families() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-codex-primary-used-percent", + HeaderValue::from_static("12.5"), + ); + headers.insert( + "x-codex-secondary-primary-used-percent", + HeaderValue::from_static("80"), + ); + + let updates = parse_all_rate_limits(&headers); + assert_eq!(updates.len(), 2); + assert_eq!(updates[0].limit_id.as_deref(), Some("codex")); + assert_eq!(updates[1].limit_id.as_deref(), Some("codex_secondary")); + assert_eq!(updates[0].limit_name, None); + assert_eq!(updates[1].limit_name, None); + } + + #[test] + fn parse_all_rate_limits_includes_default_codex_snapshot() { + let headers = HeaderMap::new(); + + let updates = parse_all_rate_limits(&headers); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].limit_id.as_deref(), Some("codex")); + assert_eq!(updates[0].limit_name, None); + assert_eq!(updates[0].primary, None); + assert_eq!(updates[0].secondary, None); + assert_eq!(updates[0].credits, None); + } +} diff --git a/code-rs/codex-api/src/requests/headers.rs b/code-rs/codex-api/src/requests/headers.rs new file mode 100644 index 00000000000..d91d2a2bf18 --- /dev/null +++ b/code-rs/codex-api/src/requests/headers.rs @@ -0,0 +1,40 @@ +use codex_protocol::protocol::SessionSource; +use http::HeaderMap; +use http::HeaderValue; + +pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(id) = session_id { + insert_header(&mut headers, "session_id", &id); + } + if let Some(id) = thread_id { + insert_header(&mut headers, "thread_id", &id); + } + headers +} + +pub(crate) fn subagent_header(source: &Option) -> Option { + let SessionSource::SubAgent(sub) = source.as_ref()? else { + return None; + }; + match sub { + codex_protocol::protocol::SubAgentSource::Review => Some("review".to_string()), + codex_protocol::protocol::SubAgentSource::Compact => Some("compact".to_string()), + codex_protocol::protocol::SubAgentSource::MemoryConsolidation => { + Some("memory_consolidation".to_string()) + } + codex_protocol::protocol::SubAgentSource::ThreadSpawn { .. } => { + Some("collab_spawn".to_string()) + } + codex_protocol::protocol::SubAgentSource::Other(label) => Some(label.clone()), + } +} + +pub(crate) fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) { + if let (Ok(header_name), Ok(header_value)) = ( + name.parse::(), + HeaderValue::from_str(value), + ) { + headers.insert(header_name, header_value); + } +} diff --git a/code-rs/codex-api/src/requests/mod.rs b/code-rs/codex-api/src/requests/mod.rs new file mode 100644 index 00000000000..1c357b2a613 --- /dev/null +++ b/code-rs/codex-api/src/requests/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod headers; +pub(crate) mod responses; + +pub use responses::Compression; +pub(crate) use responses::attach_item_ids; diff --git a/code-rs/codex-api/src/requests/responses.rs b/code-rs/codex-api/src/requests/responses.rs new file mode 100644 index 00000000000..5f3e1ba8761 --- /dev/null +++ b/code-rs/codex-api/src/requests/responses.rs @@ -0,0 +1,37 @@ +use codex_protocol::models::ResponseItem; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Compression { + #[default] + None, + Zstd, +} + +pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { + let Some(input_value) = payload_json.get_mut("input") else { + return; + }; + let Value::Array(items) = input_value else { + return; + }; + + for (value, item) in items.iter_mut().zip(original_items.iter()) { + if let ResponseItem::Reasoning { id, .. } + | ResponseItem::Message { id: Some(id), .. } + | ResponseItem::WebSearchCall { id: Some(id), .. } + | ResponseItem::FunctionCall { id: Some(id), .. } + | ResponseItem::ToolSearchCall { id: Some(id), .. } + | ResponseItem::LocalShellCall { id: Some(id), .. } + | ResponseItem::CustomToolCall { id: Some(id), .. } = item + { + if id.is_empty() { + continue; + } + + if let Some(obj) = value.as_object_mut() { + obj.insert("id".to_string(), Value::String(id.clone())); + } + } + } +} diff --git a/code-rs/codex-api/src/sse/mod.rs b/code-rs/codex-api/src/sse/mod.rs new file mode 100644 index 00000000000..06b9855890e --- /dev/null +++ b/code-rs/codex-api/src/sse/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod responses; + +pub(crate) use responses::ResponsesStreamEvent; +pub(crate) use responses::process_responses_event; +pub use responses::spawn_response_stream; +pub use responses::stream_from_fixture; diff --git a/code-rs/codex-api/src/sse/responses.rs b/code-rs/codex-api/src/sse/responses.rs new file mode 100644 index 00000000000..4e949e43ae3 --- /dev/null +++ b/code-rs/codex-api/src/sse/responses.rs @@ -0,0 +1,1375 @@ +use crate::common::ResponseEvent; +use crate::common::ResponseStream; +use crate::error::ApiError; +use crate::rate_limits::parse_all_rate_limits; +use crate::telemetry::SseTelemetry; +use codex_client::ByteStream; +use codex_client::StreamResponse; +use codex_client::TransportError; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ModelVerification; +use codex_protocol::protocol::TokenUsage; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use futures::TryStreamExt; +use serde::Deserialize; +use serde_json::Value; +use std::io::BufRead; +use std::path::Path; +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio::time::timeout; +use tokio_util::io::ReaderStream; +use tracing::debug; +use tracing::trace; + +const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; +const OPENAI_MODEL_HEADER: &str = "openai-model"; +const REQUEST_ID_HEADER: &str = "x-request-id"; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; + +/// Streams SSE events from an on-disk fixture for tests. +pub fn stream_from_fixture( + path: impl AsRef, + idle_timeout: Duration, +) -> Result { + let file = + std::fs::File::open(path.as_ref()).map_err(|err| ApiError::Stream(err.to_string()))?; + let mut content = String::new(); + for line in std::io::BufReader::new(file).lines() { + let line = line.map_err(|err| ApiError::Stream(err.to_string()))?; + content.push_str(&line); + content.push_str("\n\n"); + } + + let reader = std::io::Cursor::new(content); + let stream = ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string())); + let (tx_event, rx_event) = mpsc::channel::>(1600); + tokio::spawn(process_sse( + Box::pin(stream), + tx_event, + idle_timeout, + /*telemetry*/ None, + )); + Ok(ResponseStream { + rx_event, + upstream_request_id: None, + }) +} + +pub fn spawn_response_stream( + stream_response: StreamResponse, + idle_timeout: Duration, + telemetry: Option>, + turn_state: Option>>, +) -> ResponseStream { + let rate_limit_snapshots = parse_all_rate_limits(&stream_response.headers); + let models_etag = stream_response + .headers + .get("X-Models-Etag") + .and_then(|v| v.to_str().ok()) + .map(ToString::to_string); + let server_model = stream_response + .headers + .get(OPENAI_MODEL_HEADER) + .and_then(|v| v.to_str().ok()) + .map(ToString::to_string); + let reasoning_included = stream_response + .headers + .get(X_REASONING_INCLUDED_HEADER) + .is_some(); + let upstream_request_id = stream_response + .headers + .get(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + if let Some(turn_state) = turn_state.as_ref() + && let Some(header_value) = stream_response + .headers + .get("x-codex-turn-state") + .and_then(|v| v.to_str().ok()) + { + let _ = turn_state.set(header_value.to_string()); + } + let (tx_event, rx_event) = mpsc::channel::>(1600); + tokio::spawn(async move { + if let Some(model) = server_model { + let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await; + } + for snapshot in rate_limit_snapshots { + let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; + } + if let Some(etag) = models_etag { + let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await; + } + if reasoning_included { + let _ = tx_event + .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) + .await; + } + process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await; + }); + + ResponseStream { + rx_event, + upstream_request_id, + } +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Error { + r#type: Option, + code: Option, + message: Option, + plan_type: Option, + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct ResponseCompleted { + id: String, + #[serde(default)] + usage: Option, + #[serde(default)] + end_turn: Option, +} + +#[derive(Debug, Deserialize)] +struct ResponseCompletedUsage { + input_tokens: i64, + input_tokens_details: Option, + output_tokens: i64, + output_tokens_details: Option, + total_tokens: i64, +} + +impl From for TokenUsage { + fn from(val: ResponseCompletedUsage) -> Self { + TokenUsage { + input_tokens: val.input_tokens, + cached_input_tokens: val + .input_tokens_details + .map(|d| d.cached_tokens) + .unwrap_or(0), + output_tokens: val.output_tokens, + reasoning_output_tokens: val + .output_tokens_details + .map(|d| d.reasoning_tokens) + .unwrap_or(0), + total_tokens: val.total_tokens, + } + } +} + +#[derive(Debug, Deserialize)] +struct ResponseCompletedInputTokensDetails { + cached_tokens: i64, +} + +#[derive(Debug, Deserialize)] +struct ResponseCompletedOutputTokensDetails { + reasoning_tokens: i64, +} + +#[derive(Deserialize, Debug)] +pub struct ResponsesStreamEvent { + #[serde(rename = "type")] + pub(crate) kind: String, + headers: Option, + metadata: Option, + response: Option, + item: Option, + item_id: Option, + call_id: Option, + delta: Option, + summary_index: Option, + content_index: Option, +} + +impl ResponsesStreamEvent { + pub fn kind(&self) -> &str { + &self.kind + } + + /// Returns the effective model reported by the server, if present. + /// + /// Precedence: + /// 1. `response.headers` for standard Responses stream events. + /// 2. top-level `headers` for websocket metadata events. + pub fn response_model(&self) -> Option { + let response_headers_model = self + .response + .as_ref() + .and_then(|response| response.get("headers")) + .and_then(header_openai_model_value_from_json); + + match response_headers_model { + Some(model) => Some(model), + None => self + .headers + .as_ref() + .and_then(header_openai_model_value_from_json), + } + } + + pub(crate) fn model_verifications(&self) -> Option> { + if self.kind() != "response.metadata" { + return None; + } + + self.metadata + .as_ref() + .and_then(|metadata| metadata.get("openai_verification_recommendation")) + .and_then(model_verifications_from_json_value) + } +} + +fn header_openai_model_value_from_json(value: &Value) -> Option { + let headers = value.as_object()?; + headers.iter().find_map(|(name, value)| { + if name.eq_ignore_ascii_case("openai-model") || name.eq_ignore_ascii_case("x-openai-model") + { + json_value_as_string(value) + } else { + None + } + }) +} + +fn model_verifications_from_json_value(value: &Value) -> Option> { + let verifications = value + .as_array() + .map(|items| { + let mut verifications = Vec::new(); + for verification in items + .iter() + .filter_map(Value::as_str) + .filter_map(parse_model_verification) + { + if !verifications.contains(&verification) { + verifications.push(verification); + } + } + verifications + }) + .unwrap_or_default(); + + if verifications.is_empty() { + None + } else { + Some(verifications) + } +} + +fn parse_model_verification(value: &str) -> Option { + match value { + TRUSTED_ACCESS_FOR_CYBER_VERIFICATION => Some(ModelVerification::TrustedAccessForCyber), + _ => None, + } +} + +fn json_value_as_string(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Array(items) => items.first().and_then(json_value_as_string), + _ => None, + } +} + +#[derive(Debug)] +pub enum ResponsesEventError { + Api(ApiError), +} + +impl ResponsesEventError { + pub fn into_api_error(self) -> ApiError { + match self { + Self::Api(error) => error, + } + } +} + +pub fn process_responses_event( + event: ResponsesStreamEvent, +) -> std::result::Result, ResponsesEventError> { + match event.kind.as_str() { + "response.output_item.done" => { + if let Some(item_val) = event.item { + if let Ok(item) = serde_json::from_value::(item_val) { + return Ok(Some(ResponseEvent::OutputItemDone(item))); + } + debug!("failed to parse ResponseItem from output_item.done"); + } + } + "response.output_text.delta" => { + if let Some(delta) = event.delta { + return Ok(Some(ResponseEvent::OutputTextDelta(delta))); + } + } + "response.custom_tool_call_input.delta" => { + if let (Some(delta), Some(item_id)) = + (event.delta, event.item_id.clone().or(event.call_id.clone())) + { + return Ok(Some(ResponseEvent::ToolCallInputDelta { + item_id, + call_id: event.call_id, + delta, + })); + } + } + "response.reasoning_summary_text.delta" => { + if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) { + return Ok(Some(ResponseEvent::ReasoningSummaryDelta { + delta, + summary_index, + })); + } + } + "response.reasoning_text.delta" => { + if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) { + return Ok(Some(ResponseEvent::ReasoningContentDelta { + delta, + content_index, + })); + } + } + "response.created" => { + if event.response.is_some() { + return Ok(Some(ResponseEvent::Created {})); + } + } + "response.failed" => { + if let Some(resp_val) = event.response { + let mut response_error = ApiError::Stream("response.failed event received".into()); + if let Some(error) = resp_val.get("error") + && let Ok(error) = serde_json::from_value::(error.clone()) + { + if is_context_window_error(&error) { + response_error = ApiError::ContextWindowExceeded; + } else if is_quota_exceeded_error(&error) { + response_error = ApiError::QuotaExceeded; + } else if is_usage_not_included(&error) { + response_error = ApiError::UsageNotIncluded; + } else if is_cyber_policy_error(&error) { + let message = cyber_policy_message(error.message); + response_error = ApiError::CyberPolicy { message }; + } else if is_invalid_prompt_error(&error) { + let message = error + .message + .unwrap_or_else(|| "Invalid request.".to_string()); + response_error = ApiError::InvalidRequest { message }; + } else if is_server_overloaded_error(&error) { + response_error = ApiError::ServerOverloaded; + } else { + let delay = try_parse_retry_after(&error); + let message = error.message.unwrap_or_default(); + response_error = ApiError::Retryable { message, delay }; + } + } + return Err(ResponsesEventError::Api(response_error)); + } + + return Err(ResponsesEventError::Api(ApiError::Stream( + "response.failed event received".into(), + ))); + } + "response.incomplete" => { + let reason = event.response.as_ref().and_then(|response| { + response + .get("incomplete_details") + .and_then(|details| details.get("reason")) + .and_then(Value::as_str) + }); + let reason = reason.unwrap_or("unknown"); + let message = format!("Incomplete response returned, reason: {reason}"); + return Err(ResponsesEventError::Api(ApiError::Stream(message))); + } + "response.completed" => { + if let Some(resp_val) = event.response { + match serde_json::from_value::(resp_val) { + Ok(resp) => { + return Ok(Some(ResponseEvent::Completed { + response_id: resp.id, + token_usage: resp.usage.map(Into::into), + end_turn: resp.end_turn, + })); + } + Err(err) => { + let error = format!("failed to parse ResponseCompleted: {err}"); + debug!("{error}"); + return Err(ResponsesEventError::Api(ApiError::Stream(error))); + } + } + } + } + "response.output_item.added" => { + if let Some(item_val) = event.item { + if let Ok(item) = serde_json::from_value::(item_val) { + return Ok(Some(ResponseEvent::OutputItemAdded(item))); + } + debug!("failed to parse ResponseItem from output_item.added"); + } + } + "response.reasoning_summary_part.added" => { + if let Some(summary_index) = event.summary_index { + return Ok(Some(ResponseEvent::ReasoningSummaryPartAdded { + summary_index, + })); + } + } + _ => { + trace!("unhandled responses event: {}", event.kind); + } + } + + Ok(None) +} + +pub async fn process_sse( + stream: ByteStream, + tx_event: mpsc::Sender>, + idle_timeout: Duration, + telemetry: Option>, +) { + let mut stream = stream.eventsource(); + let mut response_error: Option = None; + let mut last_server_model: Option = None; + + loop { + let start = Instant::now(); + let response = timeout(idle_timeout, stream.next()).await; + if let Some(t) = telemetry.as_ref() { + t.on_sse_poll(&response, start.elapsed()); + } + let sse = match response { + Ok(Some(Ok(sse))) => sse, + Ok(Some(Err(e))) => { + debug!("SSE Error: {e:#}"); + let _ = tx_event.send(Err(ApiError::Stream(e.to_string()))).await; + return; + } + Ok(None) => { + let error = response_error.unwrap_or(ApiError::Stream( + "stream closed before response.completed".into(), + )); + let _ = tx_event.send(Err(error)).await; + return; + } + Err(_) => { + let _ = tx_event + .send(Err(ApiError::Stream("idle timeout waiting for SSE".into()))) + .await; + return; + } + }; + + trace!("SSE event: {}", &sse.data); + + let event: ResponsesStreamEvent = match serde_json::from_str(&sse.data) { + Ok(event) => event, + Err(e) => { + debug!("Failed to parse SSE event: {e}, data: {}", &sse.data); + continue; + } + }; + let model_verifications = event.model_verifications(); + + if let Some(model) = event.response_model() + && last_server_model.as_deref() != Some(model.as_str()) + { + if tx_event + .send(Ok(ResponseEvent::ServerModel(model.clone()))) + .await + .is_err() + { + return; + } + last_server_model = Some(model); + } + if let Some(verifications) = model_verifications + && tx_event + .send(Ok(ResponseEvent::ModelVerifications(verifications))) + .await + .is_err() + { + return; + } + + match process_responses_event(event) { + Ok(Some(event)) => { + let is_completed = matches!(event, ResponseEvent::Completed { .. }); + if tx_event.send(Ok(event)).await.is_err() { + return; + } + if is_completed { + return; + } + } + Ok(None) => {} + Err(error) => { + response_error = Some(error.into_api_error()); + } + }; + } +} + +fn try_parse_retry_after(err: &Error) -> Option { + if err.code.as_deref() != Some("rate_limit_exceeded") { + return None; + } + + let re = rate_limit_regex(); + if let Some(message) = &err.message + && let Some(captures) = re.captures(message) + { + let seconds = captures.get(1); + let unit = captures.get(2); + + if let (Some(value), Some(unit)) = (seconds, unit) { + let value = value.as_str().parse::().ok()?; + let unit = unit.as_str().to_ascii_lowercase(); + + if unit == "s" || unit.starts_with("second") { + return Some(Duration::from_secs_f64(value)); + } else if unit == "ms" { + return Some(Duration::from_millis(value as u64)); + } + } + } + None +} + +fn is_context_window_error(error: &Error) -> bool { + error.code.as_deref() == Some("context_length_exceeded") +} + +fn is_quota_exceeded_error(error: &Error) -> bool { + error.code.as_deref() == Some("insufficient_quota") +} + +fn is_usage_not_included(error: &Error) -> bool { + error.code.as_deref() == Some("usage_not_included") +} + +fn is_invalid_prompt_error(error: &Error) -> bool { + error.code.as_deref() == Some("invalid_prompt") +} + +fn is_cyber_policy_error(error: &Error) -> bool { + error.code.as_deref() == Some("cyber_policy") +} + +fn is_server_overloaded_error(error: &Error) -> bool { + error.code.as_deref() == Some("server_is_overloaded") + || error.code.as_deref() == Some("slow_down") +} + +fn cyber_policy_fallback_message() -> String { + "This request has been flagged for possible cybersecurity risk.".to_string() +} + +fn cyber_policy_message(message: Option) -> String { + message + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(cyber_policy_fallback_message) +} + +fn rate_limit_regex() -> &'static regex_lite::Regex { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + #[expect(clippy::unwrap_used)] + RE.get_or_init(|| { + regex_lite::Regex::new(r"(?i)try again in\s*(\d+(?:\.\d+)?)\s*(s|ms|seconds?)").unwrap() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use bytes::Bytes; + use codex_client::StreamResponse; + use codex_protocol::models::MessagePhase; + use codex_protocol::models::ResponseItem; + use futures::stream; + use http::HeaderMap; + use http::HeaderValue; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use tokio::sync::mpsc; + use tokio_test::io::Builder as IoBuilder; + + async fn collect_events(chunks: &[&[u8]]) -> Vec> { + let mut builder = IoBuilder::new(); + for chunk in chunks { + builder.read(chunk); + } + + let reader = builder.build(); + let stream = + ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string())); + let (tx, mut rx) = mpsc::channel::>(16); + tokio::spawn(process_sse( + Box::pin(stream), + tx, + idle_timeout(), + /*telemetry*/ None, + )); + + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + events + } + + async fn run_sse(events: Vec) -> Vec { + let mut body = String::new(); + for e in events { + let kind = e + .get("type") + .and_then(|v| v.as_str()) + .expect("fixture event missing type"); + if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { + body.push_str(&format!("event: {kind}\n\n")); + } else { + body.push_str(&format!("event: {kind}\ndata: {e}\n\n")); + } + } + + let (tx, mut rx) = mpsc::channel::>(8); + let stream = ReaderStream::new(std::io::Cursor::new(body)) + .map_err(|err| TransportError::Network(err.to_string())); + tokio::spawn(process_sse( + Box::pin(stream), + tx, + idle_timeout(), + /*telemetry*/ None, + )); + + let mut out = Vec::new(); + while let Some(ev) = rx.recv().await { + out.push(ev.expect("channel closed")); + } + out + } + + fn idle_timeout() -> Duration { + Duration::from_millis(1000) + } + + #[tokio::test] + async fn parses_items_and_completed() { + let item1 = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello"}], + "phase": "commentary" + } + }) + .to_string(); + + let item2 = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "World"}] + } + }) + .to_string(); + + let completed = json!({ + "type": "response.completed", + "response": { "id": "resp1" } + }) + .to_string(); + + let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n"); + let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n"); + let sse3 = format!("event: response.completed\ndata: {completed}\n\n"); + + let events = collect_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await; + + assert_eq!(events.len(), 3); + + assert_matches!( + &events[0], + Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { + role, + phase: Some(MessagePhase::Commentary), + .. + })) if role == "assistant" + ); + + assert_matches!( + &events[1], + Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. })) + if role == "assistant" + ); + + match &events[2] { + Ok(ResponseEvent::Completed { + response_id, + token_usage, + end_turn, + }) => { + assert_eq!(response_id, "resp1"); + assert!(token_usage.is_none()); + assert!(end_turn.is_none()); + } + other => panic!("unexpected third event: {other:?}"), + } + } + + #[tokio::test] + async fn error_when_missing_completed() { + let item1 = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello"}] + } + }) + .to_string(); + + let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 2); + + assert_matches!(events[0], Ok(ResponseEvent::OutputItemDone(_))); + + match &events[1] { + Err(ApiError::Stream(msg)) => { + assert_eq!(msg, "stream closed before response.completed") + } + other => panic!("unexpected second event: {other:?}"), + } + } + + #[tokio::test] + async fn parses_tool_search_call_items() { + let events = run_sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "tool_search_call", + "call_id": "search-1", + "execution": "client", + "arguments": { + "query": "calendar create", + "limit": 1 + } + } + }), + json!({ + "type": "response.completed", + "response": { "id": "resp1" } + }), + ]) + .await; + + assert_eq!(events.len(), 2); + assert_matches!( + &events[0], + ResponseEvent::OutputItemDone(ResponseItem::ToolSearchCall { + call_id, + execution, + arguments, + .. + }) if call_id.as_deref() == Some("search-1") + && execution == "client" + && arguments == &json!({"query": "calendar create", "limit": 1}) + ); + } + + #[tokio::test] + async fn parses_tool_call_input_deltas() { + let events = run_sse(vec![ + json!({ + "type": "response.custom_tool_call_input.delta", + "item_id": "ctc_1", + "call_id": "call_1", + "delta": "*** Begin", + }), + json!({ + "type": "response.function_call_arguments.delta", + "item_id": "fc_1", + "delta": "{\"input\":\"", + }), + json!({ + "type": "response.completed", + "response": { "id": "resp1" } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::ToolCallInputDelta { + item_id, + call_id: Some(call_id), + delta, + } if item_id == "ctc_1" && call_id == "call_1" && delta == "*** Begin" + ); + assert_matches!(&events[1], ResponseEvent::Completed { .. }); + } + + #[tokio::test] + async fn emits_completed_without_stream_end() { + let completed = json!({ + "type": "response.completed", + "response": { "id": "resp1" } + }) + .to_string(); + + let sse1 = format!("event: response.completed\ndata: {completed}\n\n"); + let stream = stream::iter(vec![Ok(Bytes::from(sse1))]).chain(stream::pending()); + let stream: ByteStream = Box::pin(stream); + + let (tx, mut rx) = mpsc::channel::>(8); + tokio::spawn(process_sse( + stream, + tx, + idle_timeout(), + /*telemetry*/ None, + )); + + let events = tokio::time::timeout(Duration::from_millis(1000), async { + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + events + }) + .await + .expect("timed out collecting events"); + + assert_eq!(events.len(), 1); + match &events[0] { + Ok(ResponseEvent::Completed { + response_id, + token_usage, + end_turn, + }) => { + assert_eq!(response_id, "resp1"); + assert!(token_usage.is_none()); + assert!(end_turn.is_none()); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[tokio::test] + async fn error_when_error_event() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_689bcf18d7f08194bf3440ba62fe05d803fee0cdac429894","object":"response","created_at":1755041560,"status":"failed","background":false,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."}, "usage":null,"user":null,"metadata":{}}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::Retryable { message, delay }) => { + assert_eq!( + message, + "Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more." + ); + assert_eq!(*delay, Some(Duration::from_secs_f64(11.054))); + } + other => panic!("unexpected second event: {other:?}"), + } + } + + #[tokio::test] + async fn context_window_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_5c66275b97b9baef1ed95550adb3b7ec13b17aafd1d2f11b","object":"response","created_at":1759510079,"status":"failed","background":false,"error":{"code":"context_length_exceeded","message":"Your input exceeds the context window of this model. Please adjust your input and try again."},"usage":null,"user":null,"metadata":{}}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + assert_matches!(events[0], Err(ApiError::ContextWindowExceeded)); + } + + #[tokio::test] + async fn context_window_error_with_newline_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":4,"response":{"id":"resp_fatal_newline","object":"response","created_at":1759510080,"status":"failed","background":false,"error":{"code":"context_length_exceeded","message":"Your input exceeds the context window of this model. Please adjust your input and try\nagain."},"usage":null,"user":null,"metadata":{}}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + assert_matches!(events[0], Err(ApiError::ContextWindowExceeded)); + } + + #[tokio::test] + async fn quota_exceeded_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + assert_matches!(events[0], Err(ApiError::QuotaExceeded)); + } + + #[tokio::test] + async fn cyber_policy_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":"This request was flagged for cyber policy."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::CyberPolicy { message }) => { + assert_eq!(message, "This request was flagged for cyber policy."); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[tokio::test] + async fn cyber_policy_error_uses_fallback_for_empty_message() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":" "},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::CyberPolicy { message }) => { + assert_eq!( + message, + "This request has been flagged for possible cybersecurity risk." + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[tokio::test] + async fn invalid_prompt_without_type_is_invalid_request() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::InvalidRequest { message }) => { + assert_eq!( + message, + "Invalid prompt: we've limited access to this content for safety reasons." + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[tokio::test] + async fn table_driven_event_kinds() { + struct TestCase { + name: &'static str, + event: serde_json::Value, + expect_first: fn(&ResponseEvent) -> bool, + expected_len: usize, + } + + fn is_created(ev: &ResponseEvent) -> bool { + matches!(ev, ResponseEvent::Created) + } + fn is_output(ev: &ResponseEvent) -> bool { + matches!(ev, ResponseEvent::OutputItemDone(_)) + } + fn is_completed(ev: &ResponseEvent) -> bool { + matches!(ev, ResponseEvent::Completed { .. }) + } + + let completed = json!({ + "type": "response.completed", + "response": { + "id": "c", + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0 + }, + "output": [] + } + }); + + let cases = vec![ + TestCase { + name: "created", + event: json!({"type": "response.created", "response": {}}), + expect_first: is_created, + expected_len: 2, + }, + TestCase { + name: "output_item.done", + event: json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "hi"} + ] + } + }), + expect_first: is_output, + expected_len: 2, + }, + TestCase { + name: "unknown", + event: json!({"type": "response.new_tool_event"}), + expect_first: is_completed, + expected_len: 1, + }, + ]; + + for case in cases { + let mut evs = vec![case.event]; + evs.push(completed.clone()); + + let out = run_sse(evs).await; + assert_eq!(out.len(), case.expected_len, "case {}", case.name); + assert!( + (case.expect_first)(&out[0]), + "first event mismatch in case {}", + case.name + ); + } + } + + #[tokio::test] + async fn spawn_response_stream_emits_header_events() { + let mut headers = HeaderMap::new(); + headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req-1")); + headers.insert( + OPENAI_MODEL_HEADER, + HeaderValue::from_static(CYBER_RESTRICTED_MODEL_FOR_TESTS), + ); + let bytes = stream::iter(Vec::>::new()); + let stream_response = StreamResponse { + status: StatusCode::OK, + headers, + bytes: Box::pin(bytes), + }; + + let mut stream = spawn_response_stream( + stream_response, + idle_timeout(), + /*telemetry*/ None, + /*turn_state*/ None, + ); + assert_eq!(stream.upstream_request_id.as_deref(), Some("req-1")); + let event = stream + .rx_event + .recv() + .await + .expect("expected server model event") + .expect("expected ok event"); + match event { + ResponseEvent::ServerModel(model) => { + assert_eq!(model, CYBER_RESTRICTED_MODEL_FOR_TESTS); + } + other => panic!("expected server model event, got {other:?}"), + } + } + + #[tokio::test] + async fn spawn_response_stream_ignores_model_verification_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "openai-verification-recommendation", + HeaderValue::from_static(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION), + ); + let completed = json!({ + "type": "response.completed", + "response": { "id": "resp-1" } + }); + let sse = format!("event: response.completed\ndata: {completed}\n\n"); + let bytes = stream::iter(vec![Ok(Bytes::from(sse))]); + let stream_response = StreamResponse { + status: StatusCode::OK, + headers, + bytes: Box::pin(bytes), + }; + + let mut stream = spawn_response_stream( + stream_response, + idle_timeout(), + /*telemetry*/ None, + /*turn_state*/ None, + ); + let mut events = Vec::new(); + while let Some(event) = stream.rx_event.recv().await { + events.push(event.expect("expected ok event")); + } + + assert!( + !events + .iter() + .any(|event| matches!(event, ResponseEvent::ModelVerifications(_))) + ); + } + + #[tokio::test] + async fn process_sse_ignores_response_model_field_in_payload() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1", + "model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + }), + ]) + .await; + + assert_eq!(events.len(), 2); + assert_matches!(&events[0], ResponseEvent::Created); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + + #[tokio::test] + async fn process_sse_emits_server_model_from_response_headers_payload() { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp-1", + "headers": { + "OpenAI-Model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_eq!(events.len(), 3); + assert_matches!( + &events[0], + ResponseEvent::ServerModel(model) if model == CYBER_RESTRICTED_MODEL_FOR_TESTS + ); + assert_matches!(&events[1], ResponseEvent::Created); + assert_matches!( + &events[2], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + + #[tokio::test] + async fn process_sse_emits_model_verification_field() { + let events = run_sse(vec![ + json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION] + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::ModelVerifications(verifications) + if verifications == &vec![ModelVerification::TrustedAccessForCyber] + ); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + + #[test] + fn responses_stream_event_response_model_reads_top_level_headers() { + let ev: ResponsesStreamEvent = serde_json::from_value(json!({ + "type": "response.metadata", + "headers": { + "openai-model": CYBER_RESTRICTED_MODEL_FOR_TESTS, + } + })) + .expect("expected event to deserialize"); + + assert_eq!( + ev.response_model().as_deref(), + Some(CYBER_RESTRICTED_MODEL_FOR_TESTS) + ); + } + + #[test] + fn responses_stream_event_response_model_prefers_response_headers() { + let ev: ResponsesStreamEvent = serde_json::from_value(json!({ + "type": "response.created", + "headers": { + "openai-model": "top-level-model" + }, + "response": { + "id": "resp-1", + "headers": { + "openai-model": CYBER_RESTRICTED_MODEL_FOR_TESTS + } + } + })) + .expect("expected event to deserialize"); + + assert_eq!( + ev.response_model().as_deref(), + Some(CYBER_RESTRICTED_MODEL_FOR_TESTS) + ); + } + + #[test] + fn responses_stream_event_model_verification_reads_metadata_field() { + let event = json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION] + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!( + event.model_verifications(), + Some(vec![ModelVerification::TrustedAccessForCyber]) + ); + } + + #[test] + fn responses_stream_event_model_verification_ignores_unknown_field() { + let event = json!({ + "type": "response.metadata", + "metadata": { + "openai_verification_recommendation": ["unknown"] + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!(event.model_verifications(), None); + } + + #[test] + fn responses_stream_event_model_verification_ignores_non_array_field() { + let event = json!({ + "type": "response.metadata", + "metadata": { + "openai_verification_recommendation": TRUSTED_ACCESS_FOR_CYBER_VERIFICATION + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!(event.model_verifications(), None); + } + + #[test] + fn test_try_parse_retry_after() { + let err = Error { + r#type: None, + message: Some("Rate limit reached for gpt-5.1 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_at: None, + }; + + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_millis(28))); + } + + #[test] + fn test_try_parse_retry_after_no_delay() { + let err = Error { + r#type: None, + message: Some("Rate limit reached for gpt-5.1 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_at: None, + }; + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); + } + + #[test] + fn test_try_parse_retry_after_azure() { + let err = Error { + r#type: None, + message: Some("Rate limit exceeded. Try again in 35 seconds.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_at: None, + }; + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_secs(35))); + } + + const CYBER_RESTRICTED_MODEL_FOR_TESTS: &str = "gpt-5.3-codex"; +} diff --git a/code-rs/codex-api/src/telemetry.rs b/code-rs/codex-api/src/telemetry.rs new file mode 100644 index 00000000000..91918a65b92 --- /dev/null +++ b/code-rs/codex-api/src/telemetry.rs @@ -0,0 +1,98 @@ +use crate::error::ApiError; +use codex_client::Request; +use codex_client::RequestTelemetry; +use codex_client::Response; +use codex_client::RetryPolicy; +use codex_client::StreamResponse; +use codex_client::TransportError; +use codex_client::run_with_retry; +use http::StatusCode; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::Instant; +use tokio_tungstenite::tungstenite::Error; +use tokio_tungstenite::tungstenite::Message; + +/// Generic telemetry. +pub trait SseTelemetry: Send + Sync { + fn on_sse_poll( + &self, + result: &Result< + Option< + Result< + eventsource_stream::Event, + eventsource_stream::EventStreamError, + >, + >, + tokio::time::error::Elapsed, + >, + duration: Duration, + ); +} + +/// Telemetry for Responses WebSocket transport. +pub trait WebsocketTelemetry: Send + Sync { + fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>, connection_reused: bool); + + fn on_ws_event( + &self, + result: &Result>, ApiError>, + duration: Duration, + ); +} + +pub(crate) trait WithStatus { + fn status(&self) -> StatusCode; +} + +fn http_status(err: &TransportError) -> Option { + match err { + TransportError::Http { status, .. } => Some(*status), + _ => None, + } +} + +impl WithStatus for Response { + fn status(&self) -> StatusCode { + self.status + } +} + +impl WithStatus for StreamResponse { + fn status(&self) -> StatusCode { + self.status + } +} + +pub(crate) async fn run_with_request_telemetry( + policy: RetryPolicy, + telemetry: Option>, + make_request: impl FnMut() -> Request, + send: F, +) -> Result +where + T: WithStatus, + F: Clone + Fn(Request) -> Fut, + Fut: Future>, +{ + // Wraps `run_with_retry` to attach per-attempt request telemetry for both + // unary and streaming HTTP calls. + run_with_retry(policy, make_request, move |req, attempt| { + let telemetry = telemetry.clone(); + let send = send.clone(); + async move { + let start = Instant::now(); + let result = send(req).await; + if let Some(t) = telemetry.as_ref() { + let (status, err) = match &result { + Ok(resp) => (Some(resp.status()), None), + Err(err) => (http_status(err), Some(err)), + }; + t.on_request(attempt, status, err, start.elapsed()); + } + result + } + }) + .await +} diff --git a/code-rs/codex-api/tests/clients.rs b/code-rs/codex-api/tests/clients.rs new file mode 100644 index 00000000000..a2a29ba16d3 --- /dev/null +++ b/code-rs/codex-api/tests/clients.rs @@ -0,0 +1,499 @@ +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use bytes::Bytes; +use codex_api::ApiError; +use codex_api::AuthError; +use codex_api::AuthProvider; +use codex_api::Compression; +use codex_api::Provider; +use codex_api::ResponsesApiRequest; +use codex_api::ResponsesClient; +use codex_api::ResponsesOptions; +use codex_client::HttpTransport; +use codex_client::Request; +use codex_client::RequestBody; +use codex_client::Response; +use codex_client::StreamResponse; +use codex_client::TransportError; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use http::HeaderMap; +use http::HeaderValue; +use http::StatusCode; +use pretty_assertions::assert_eq; + +fn assert_path_ends_with(requests: &[Request], suffix: &str) { + assert_eq!(requests.len(), 1); + let url = &requests[0].url; + assert!( + url.ends_with(suffix), + "expected url to end with {suffix}, got {url}" + ); +} + +#[derive(Debug, Default, Clone)] +struct RecordingState { + stream_requests: Arc>>, +} + +impl RecordingState { + fn record(&self, req: Request) { + let mut guard = self + .stream_requests + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")); + guard.push(req); + } + + fn take_stream_requests(&self) -> Vec { + let mut guard = self + .stream_requests + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")); + std::mem::take(&mut *guard) + } +} + +#[derive(Clone)] +struct RecordingTransport { + state: RecordingState, +} + +impl RecordingTransport { + fn new(state: RecordingState) -> Self { + Self { state } + } +} + +#[async_trait] +impl HttpTransport for RecordingTransport { + async fn execute(&self, _req: Request) -> Result { + Err(TransportError::Build("execute should not run".to_string())) + } + + async fn stream(&self, req: Request) -> Result { + self.state.record(req); + + let stream = futures::stream::iter(Vec::>::new()); + Ok(StreamResponse { + status: StatusCode::OK, + headers: HeaderMap::new(), + bytes: Box::pin(stream), + }) + } +} + +#[derive(Clone, Default)] +struct NoAuth; + +impl AuthProvider for NoAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} +} + +#[derive(Clone)] +struct StaticAuth { + token: String, + account_id: String, +} + +impl StaticAuth { + fn new(token: &str, account_id: &str) -> Self { + Self { + token: token.to_string(), + account_id: account_id.to_string(), + } + } +} + +impl AuthProvider for StaticAuth { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + let token = &self.token; + if let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}")) { + headers.insert(http::header::AUTHORIZATION, header); + } + if let Ok(header) = HeaderValue::from_str(&self.account_id) { + headers.insert("ChatGPT-Account-ID", header); + } + } +} + +fn provider(name: &str) -> Provider { + Provider { + name: name.to_string(), + base_url: "https://example.com/v1".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: codex_api::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_millis(10), + } +} + +#[derive(Clone)] +struct FlakyTransport { + state: Arc>, +} + +impl Default for FlakyTransport { + fn default() -> Self { + Self::new() + } +} + +impl FlakyTransport { + fn new() -> Self { + Self { + state: Arc::new(Mutex::new(0)), + } + } + + fn attempts(&self) -> i64 { + *self + .state + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")) + } +} + +#[derive(Clone)] +struct FailsOnceAuth { + attempts: Arc>, + error: Arc, +} + +impl FailsOnceAuth { + fn transient() -> Self { + Self { + attempts: Arc::new(Mutex::new(0)), + error: Arc::new(AuthError::Transient( + "sts temporarily unavailable".to_string(), + )), + } + } + + fn build() -> Self { + Self { + attempts: Arc::new(Mutex::new(0)), + error: Arc::new(AuthError::Build("invalid auth configuration".to_string())), + } + } + + fn attempts(&self) -> i64 { + *self + .attempts + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")) + } +} + +#[async_trait] +impl AuthProvider for FailsOnceAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + + async fn apply_auth(&self, request: Request) -> Result { + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")); + *attempts += 1; + + if *attempts == 1 { + return match self.error.as_ref() { + AuthError::Build(message) => Err(AuthError::Build(message.clone())), + AuthError::Transient(message) => Err(AuthError::Transient(message.clone())), + }; + } + + Ok(request) + } +} + +#[async_trait] +impl HttpTransport for FlakyTransport { + async fn execute(&self, _req: Request) -> Result { + Err(TransportError::Build("execute should not run".to_string())) + } + + async fn stream(&self, _req: Request) -> Result { + let mut attempts = self + .state + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")); + *attempts += 1; + + if *attempts == 1 { + return Err(TransportError::Network("first attempt fails".to_string())); + } + + let stream = futures::stream::iter(vec![Ok(Bytes::from( + r#"event: message +data: {"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}]} + +"#, + ))]); + + Ok(StreamResponse { + status: StatusCode::OK, + headers: HeaderMap::new(), + bytes: Box::pin(stream), + }) + } +} + +#[tokio::test] +async fn responses_client_uses_responses_path() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); + + let body = serde_json::json!({ "echo": true }); + let _stream = client + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) + .await?; + + let requests = state.take_stream_requests(); + assert_path_ends_with(&requests, "/responses"); + Ok(()) +} + +#[tokio::test] +async fn streaming_client_adds_auth_headers() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let auth = Arc::new(StaticAuth::new("secret-token", "acct-1")); + let client = ResponsesClient::new(transport, provider("openai"), auth); + + let body = serde_json::json!({ "model": "gpt-test" }); + let _stream = client + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) + .await?; + + let requests = state.take_stream_requests(); + assert_eq!(requests.len(), 1); + let req = &requests[0]; + + let auth_header = req.headers.get(http::header::AUTHORIZATION); + assert!(auth_header.is_some(), "missing auth header"); + assert_eq!( + auth_header.unwrap().to_str().ok(), + Some("Bearer secret-token") + ); + + let account_header = req.headers.get("ChatGPT-Account-ID"); + assert!(account_header.is_some(), "missing account header"); + assert_eq!(account_header.unwrap().to_str().ok(), Some("acct-1")); + + let accept_header = req.headers.get(http::header::ACCEPT); + assert!(accept_header.is_some(), "missing Accept header"); + assert_eq!( + accept_header.unwrap().to_str().ok(), + Some("text/event-stream") + ); + Ok(()) +} + +#[tokio::test] +async fn streaming_client_retries_on_transport_error() -> Result<()> { + let transport = FlakyTransport::new(); + + let mut provider = provider("openai"); + provider.retry.max_attempts = 2; + + let request = ResponsesApiRequest { + model: "gpt-test".into(), + instructions: "Say hi".into(), + input: Vec::new(), + tools: Vec::new(), + tool_choice: "auto".into(), + parallel_tool_calls: false, + reasoning: None, + store: false, + stream: true, + include: Vec::new(), + service_tier: None, + prompt_cache_key: None, + text: None, + client_metadata: None, + }; + let client = ResponsesClient::new(transport.clone(), provider, Arc::new(NoAuth)); + + let _stream = client + .stream_request( + request, + ResponsesOptions { + compression: Compression::None, + ..Default::default() + }, + ) + .await?; + assert_eq!(transport.attempts(), 2); + Ok(()) +} + +#[tokio::test] +async fn streaming_client_retries_on_transient_auth_error() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let auth = FailsOnceAuth::transient(); + + let mut provider = provider("openai"); + provider.retry.max_attempts = 2; + + let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone())); + let body = serde_json::json!({ "model": "gpt-test" }); + let _stream = client + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) + .await?; + + assert_eq!(auth.attempts(), 2); + assert_eq!(state.take_stream_requests().len(), 1); + Ok(()) +} + +#[tokio::test] +async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let auth = FailsOnceAuth::build(); + + let mut provider = provider("openai"); + provider.retry.max_attempts = 2; + + let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone())); + let body = serde_json::json!({ "model": "gpt-test" }); + let result = client + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) + .await; + let err = match result { + Ok(_) => panic!("auth build errors should fail without retry"), + Err(err) => err, + }; + + assert!(matches!( + err, + ApiError::Transport(TransportError::Build(message)) + if message == "invalid auth configuration" + )); + assert_eq!(auth.attempts(), 1); + assert_eq!(state.take_stream_requests().len(), 0); + Ok(()) +} + +#[tokio::test] +async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth)); + + let request = ResponsesApiRequest { + model: "gpt-test".into(), + instructions: "Say hi".into(), + input: vec![ResponseItem::Message { + id: Some("msg_1".into()), + role: "user".into(), + content: vec![ContentItem::InputText { text: "hi".into() }], + phase: None, + }], + tools: Vec::new(), + tool_choice: "auto".into(), + parallel_tool_calls: false, + reasoning: None, + store: true, + stream: true, + include: Vec::new(), + service_tier: None, + prompt_cache_key: None, + text: None, + client_metadata: None, + }; + + let mut extra_headers = HeaderMap::new(); + extra_headers.insert("x-test-header", HeaderValue::from_static("present")); + let _stream = client + .stream_request( + request, + ResponsesOptions { + session_id: Some("sess_123".into()), + thread_id: Some("thread_123".into()), + session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)), + extra_headers, + compression: Compression::None, + turn_state: None, + }, + ) + .await?; + + let requests = state.take_stream_requests(); + assert_eq!(requests.len(), 1); + let req = &requests[0]; + + assert_eq!( + req.headers.get("session_id").and_then(|v| v.to_str().ok()), + Some("sess_123") + ); + assert_eq!( + req.headers.get("thread_id").and_then(|v| v.to_str().ok()), + Some("thread_123") + ); + assert_eq!( + req.headers + .get("x-client-request-id") + .and_then(|v| v.to_str().ok()), + Some("thread_123") + ); + assert_eq!( + req.headers + .get("x-openai-subagent") + .and_then(|v| v.to_str().ok()), + Some("review") + ); + assert_eq!( + req.headers + .get("x-test-header") + .and_then(|v| v.to_str().ok()), + Some("present") + ); + + let input_id = req + .body + .as_ref() + .and_then(RequestBody::json) + .and_then(|body| body.get("input")) + .and_then(|input| input.get(0)) + .and_then(|item| item.get("id")) + .and_then(|id| id.as_str()); + assert_eq!(input_id, Some("msg_1")); + + Ok(()) +} diff --git a/code-rs/codex-api/tests/models_integration.rs b/code-rs/codex-api/tests/models_integration.rs new file mode 100644 index 00000000000..d2b31180b90 --- /dev/null +++ b/code-rs/codex-api/tests/models_integration.rs @@ -0,0 +1,131 @@ +use codex_api::AuthProvider; +use codex_api::ModelsClient; +use codex_api::Provider; +use codex_api::RetryConfig; +use codex_client::ReqwestTransport; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::openai_models::default_input_modalities; +use http::HeaderMap; +use http::Method; +use std::sync::Arc; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[derive(Clone, Default)] +struct DummyAuth; + +impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} +} + +fn provider(base_url: &str) -> Provider { + Provider { + name: "test".to_string(), + base_url: base_url.to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: std::time::Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: std::time::Duration::from_secs(1), + } +} + +#[tokio::test] +async fn models_client_hits_models_endpoint() { + let server = MockServer::start().await; + let base_url = format!("{}/api/codex", server.uri()); + + let response = ModelsResponse { + models: vec![ModelInfo { + slug: "gpt-test".to_string(), + display_name: "gpt-test".to_string(), + description: Some("desc".to_string()), + default_reasoning_level: Some(ReasoningEffort::Medium), + supported_reasoning_levels: vec![ + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: ReasoningEffort::Low.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: ReasoningEffort::High.to_string(), + }, + ], + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 1, + additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), + upgrade: None, + base_instructions: "base instructions".to_string(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: ReasoningSummary::Auto, + support_verbosity: false, + default_verbosity: None, + availability_nux: None, + apply_patch_tool_type: None, + web_search_tool_type: Default::default(), + truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), + supports_parallel_tool_calls: false, + supports_image_detail_original: false, + context_window: Some(272_000), + max_context_window: None, + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + input_modalities: default_input_modalities(), + used_fallback_model_metadata: false, + supports_search_tool: false, + }], + }; + + Mock::given(method("GET")) + .and(path("/api/codex/models")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_json(&response), + ) + .mount(&server) + .await; + + let transport = ReqwestTransport::new(reqwest::Client::new()); + let client = ModelsClient::new(transport, provider(&base_url), Arc::new(DummyAuth)); + + let (models, _) = client + .list_models("0.1.0", HeaderMap::new()) + .await + .expect("models request should succeed"); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].slug, "gpt-test"); + + let received = server + .received_requests() + .await + .expect("should capture requests"); + assert_eq!(received.len(), 1); + assert_eq!(received[0].method, Method::GET.as_str()); + assert_eq!(received[0].url.path(), "/api/codex/models"); +} diff --git a/code-rs/codex-api/tests/realtime_websocket_e2e.rs b/code-rs/codex-api/tests/realtime_websocket_e2e.rs new file mode 100644 index 00000000000..cb9d7122f4b --- /dev/null +++ b/code-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -0,0 +1,633 @@ +use std::collections::HashMap; +use std::future::Future; +use std::time::Duration; + +use codex_api::Provider; +use codex_api::RealtimeAudioFrame; +use codex_api::RealtimeEvent; +use codex_api::RealtimeEventParser; +use codex_api::RealtimeOutputModality; +use codex_api::RealtimeSessionConfig; +use codex_api::RealtimeSessionMode; +use codex_api::RealtimeWebsocketClient; +use codex_api::RetryConfig; +use codex_protocol::protocol::RealtimeHandoffRequested; +use codex_protocol::protocol::RealtimeTranscriptDelta; +use codex_protocol::protocol::RealtimeTranscriptDone; +use codex_protocol::protocol::RealtimeTranscriptEntry; +use codex_protocol::protocol::RealtimeVoice; +use futures::SinkExt; +use futures::StreamExt; +use http::HeaderMap; +use serde_json::Value; +use serde_json::json; +use tokio::net::TcpListener; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; + +type RealtimeWsStream = tokio_tungstenite::WebSocketStream; + +async fn spawn_realtime_ws_server( + handler: Handler, +) -> (String, tokio::task::JoinHandle<()>) +where + Handler: FnOnce(RealtimeWsStream) -> Fut + Send + 'static, + Fut: Future + Send + 'static, +{ + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(err) => panic!("failed to bind test websocket listener: {err}"), + }; + let addr = match listener.local_addr() { + Ok(addr) => addr.to_string(), + Err(err) => panic!("failed to read local websocket listener address: {err}"), + }; + + let server = tokio::spawn(async move { + let (stream, _) = match listener.accept().await { + Ok(stream) => stream, + Err(err) => panic!("failed to accept test websocket connection: {err}"), + }; + let ws = match accept_async(stream).await { + Ok(ws) => ws, + Err(err) => panic!("failed to complete websocket handshake: {err}"), + }; + handler(ws).await; + }); + + (addr, server) +} + +fn test_provider(base_url: String) -> Provider { + Provider { + name: "test".to_string(), + base_url, + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + } +} + +#[tokio::test] +async fn realtime_ws_e2e_session_create_and_event_flow() { + let (addr, server) = spawn_realtime_ws_server(|mut ws: RealtimeWsStream| async move { + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("quicksilver".to_string()) + ); + assert_eq!( + first_json["session"]["instructions"], + Value::String("backend prompt".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["format"]["type"], + Value::String("audio/pcm".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["input"]["format"]["rate"], + Value::from(24_000) + ); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_mock", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); + + ws.send(Message::Text( + json!({ + "type": "conversation.output_audio.delta", + "delta": "AQID", + "sample_rate": 48000, + "channels": 1 + }) + .to_string() + .into(), + )) + .await + .expect("send audio out"); + }) + .await; + + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_mock".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection + .send_audio_frame(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 48000, + num_channels: 1, + samples_per_channel: Some(960), + item_id: None, + }) + .await + .expect("send audio"); + + let audio_event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + audio_event, + RealtimeEvent::AudioOut(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 48000, + num_channels: 1, + samples_per_channel: None, + item_id: None, + }) + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); +} + +#[tokio::test] +async fn realtime_ws_connect_webrtc_sideband_retries_join_until_server_is_available() { + let reserving_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = reserving_listener.local_addr().expect("local addr"); + drop(reserving_listener); + + let server = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let listener = TcpListener::bind(addr).await.expect("bind delayed server"); + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["instructions"], + Value::String("backend prompt".to_string()) + ); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_joined", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + }); + + let mut provider = test_provider(format!("http://{addr}")); + provider.retry.max_attempts = 1; + provider.retry.base_delay = Duration::from_millis(100); + + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect_webrtc_sideband( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Marin, + }, + "rtc_test", + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect on retry"); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_joined".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); +} + +#[tokio::test] +async fn realtime_ws_e2e_send_while_next_event_waits() { + let (addr, server) = spawn_realtime_ws_server(|mut ws: RealtimeWsStream| async move { + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_after_send", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + }) + .await; + + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let (send_result, next_result) = tokio::join!( + async { + tokio::time::timeout( + Duration::from_millis(200), + connection.send_audio_frame(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 48000, + num_channels: 1, + samples_per_channel: Some(960), + item_id: None, + }), + ) + .await + }, + connection.next_event() + ); + + send_result + .expect("send should not block on next_event") + .expect("send audio"); + let next_event = next_result.expect("next event").expect("event"); + assert_eq!( + next_event, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_after_send".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); +} + +#[tokio::test] +async fn realtime_ws_e2e_disconnected_emitted_once() { + let (addr, server) = spawn_realtime_ws_server(|mut ws: RealtimeWsStream| async move { + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + + ws.send(Message::Close(None)).await.expect("send close"); + }) + .await; + + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let first = connection.next_event().await.expect("next event"); + assert_eq!(first, None); + + let second = connection.next_event().await.expect("next event"); + assert_eq!(second, None); + + server.await.expect("server task"); +} + +#[tokio::test] +async fn realtime_ws_e2e_ignores_unknown_text_events() { + let (addr, server) = spawn_realtime_ws_server(|mut ws: RealtimeWsStream| async move { + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + + ws.send(Message::Text( + json!({ + "type": "response.created", + "response": {"id": "resp_unknown"} + }) + .to_string() + .into(), + )) + .await + .expect("send unknown event"); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_after_unknown", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + }) + .await; + + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::SessionUpdated { + realtime_session_id: "sess_after_unknown".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); +} + +#[tokio::test] +async fn realtime_ws_e2e_realtime_v2_parser_emits_handoff_requested() { + let (addr, server) = spawn_realtime_ws_server(|mut ws: RealtimeWsStream| async move { + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + + ws.send(Message::Text( + json!({ + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "delegate now" + }) + .to_string() + .into(), + )) + .await + .expect("send input transcript"); + + ws.send(Message::Text( + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "secret context" + }) + .to_string() + .into(), + )) + .await + .expect("send output transcript"); + + ws.send(Message::Text( + json!({ + "type": "conversation.item.created", + "item": { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "silent_delegate" + }] + } + }) + .to_string() + .into(), + )) + .await + .expect("send control item echo"); + + ws.send(Message::Text( + json!({ + "type": "conversation.item.done", + "item": { + "id": "item_123", + "type": "function_call", + "name": "background_agent", + "call_id": "call_123", + "arguments": "{\"prompt\":\"delegate now\"}" + } + }) + .to_string() + .into(), + )) + .await + .expect("send function call"); + }) + .await; + + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Conversational, + output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Marin, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone { + text: "delegate now".to_string() + }) + ); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { + delta: "secret context".to_string() + }) + ); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert!(matches!(event, RealtimeEvent::ConversationItemAdded(_))); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "call_123".to_string(), + item_id: "item_123".to_string(), + input_transcript: "delegate now".to_string(), + active_transcript: vec![ + RealtimeTranscriptEntry { + role: "user".to_string(), + text: "delegate now".to_string(), + }, + RealtimeTranscriptEntry { + role: "assistant".to_string(), + text: "secret context".to_string(), + }, + ], + }) + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); +} diff --git a/code-rs/codex-api/tests/sse_end_to_end.rs b/code-rs/codex-api/tests/sse_end_to_end.rs new file mode 100644 index 00000000000..bf880fefcf9 --- /dev/null +++ b/code-rs/codex-api/tests/sse_end_to_end.rs @@ -0,0 +1,171 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use bytes::Bytes; +use codex_api::AuthProvider; +use codex_api::Compression; +use codex_api::Provider; +use codex_api::ResponseEvent; +use codex_api::ResponsesClient; +use codex_client::HttpTransport; +use codex_client::Request; +use codex_client::Response; +use codex_client::StreamResponse; +use codex_client::TransportError; +use codex_protocol::models::ResponseItem; +use futures::StreamExt; +use http::HeaderMap; +use http::StatusCode; +use pretty_assertions::assert_eq; +use serde_json::Value; + +#[derive(Clone)] +struct FixtureSseTransport { + body: String, +} + +impl FixtureSseTransport { + fn new(body: String) -> Self { + Self { body } + } +} + +#[async_trait] +impl HttpTransport for FixtureSseTransport { + async fn execute(&self, _req: Request) -> Result { + Err(TransportError::Build("execute should not run".to_string())) + } + + async fn stream(&self, _req: Request) -> Result { + let stream = futures::stream::iter(vec![Ok::(Bytes::from( + self.body.clone(), + ))]); + Ok(StreamResponse { + status: StatusCode::OK, + headers: HeaderMap::new(), + bytes: Box::pin(stream), + }) + } +} + +#[derive(Clone, Default)] +struct NoAuth; + +impl AuthProvider for NoAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} +} + +fn provider(name: &str) -> Provider { + Provider { + name: name.to_string(), + base_url: "https://example.com/v1".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: codex_api::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_millis(50), + } +} + +fn build_responses_body(events: Vec) -> String { + let mut body = String::new(); + for e in events { + let kind = e + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("fixture event missing type in SSE fixture: {e}")); + if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { + body.push_str(&format!("event: {kind}\n\n")); + } else { + body.push_str(&format!("event: {kind}\ndata: {e}\n\n")); + } + } + body +} + +#[tokio::test] +async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> { + let item1 = serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello"}] + } + }); + + let item2 = serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "World"}] + } + }); + + let completed = serde_json::json!({ + "type": "response.completed", + "response": { "id": "resp1" } + }); + + let body = build_responses_body(vec![item1, item2, completed]); + let transport = FixtureSseTransport::new(body); + let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); + + let mut stream = client + .stream( + serde_json::json!({"echo": true}), + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) + .await?; + + let mut events = Vec::new(); + while let Some(ev) = stream.next().await { + events.push(ev?); + } + + let events: Vec = events + .into_iter() + .filter(|ev| !matches!(ev, ResponseEvent::RateLimits(_))) + .collect(); + + assert_eq!(events.len(), 3); + + match &events[0] { + ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }) => { + assert_eq!(role, "assistant"); + } + other => panic!("unexpected first event: {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }) => { + assert_eq!(role, "assistant"); + } + other => panic!("unexpected second event: {other:?}"), + } + + match &events[2] { + ResponseEvent::Completed { + response_id, + token_usage, + end_turn, + } => { + assert_eq!(response_id, "resp1"); + assert!(token_usage.is_none()); + assert!(end_turn.is_none()); + } + other => panic!("unexpected third event: {other:?}"), + } + + Ok(()) +} diff --git a/code-rs/codex-backend-openapi-models/BUILD.bazel b/code-rs/codex-backend-openapi-models/BUILD.bazel new file mode 100644 index 00000000000..e46cf0c3fd6 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "codex-backend-openapi-models", + crate_name = "codex_backend_openapi_models", +) diff --git a/code-rs/codex-backend-openapi-models/Cargo.toml b/code-rs/codex-backend-openapi-models/Cargo.toml new file mode 100644 index 00000000000..f6ff459b0f5 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codex-backend-openapi-models" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_backend_openapi_models" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +# Important: generated code often violates our workspace lints. +# Allow unwrap/expect in this crate so the workspace builds cleanly +# after models are regenerated. +# Lint overrides are applied in src/lib.rs via crate attributes + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_with = "3" + +[package.metadata.cargo-shear] +ignored = ["serde_with"] diff --git a/code-rs/code-backend-openapi-models/src/lib.rs b/code-rs/codex-backend-openapi-models/src/lib.rs similarity index 100% rename from code-rs/code-backend-openapi-models/src/lib.rs rename to code-rs/codex-backend-openapi-models/src/lib.rs diff --git a/code-rs/codex-backend-openapi-models/src/models/additional_rate_limit_details.rs b/code-rs/codex-backend-openapi-models/src/models/additional_rate_limit_details.rs new file mode 100644 index 00000000000..d89e6561419 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/src/models/additional_rate_limit_details.rs @@ -0,0 +1,38 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AdditionalRateLimitDetails { + #[serde(rename = "limit_name")] + pub limit_name: String, + #[serde(rename = "metered_feature")] + pub metered_feature: String, + #[serde( + rename = "rate_limit", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub rate_limit: Option>>, +} + +impl AdditionalRateLimitDetails { + pub fn new(limit_name: String, metered_feature: String) -> AdditionalRateLimitDetails { + AdditionalRateLimitDetails { + limit_name, + metered_feature, + rate_limit: None, + } + } +} diff --git a/code-rs/code-backend-openapi-models/src/models/code_task_details_response.rs b/code-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs similarity index 100% rename from code-rs/code-backend-openapi-models/src/models/code_task_details_response.rs rename to code-rs/codex-backend-openapi-models/src/models/code_task_details_response.rs diff --git a/code-rs/code-backend-openapi-models/src/models/config_file_response.rs b/code-rs/codex-backend-openapi-models/src/models/config_file_response.rs similarity index 96% rename from code-rs/code-backend-openapi-models/src/models/config_file_response.rs rename to code-rs/codex-backend-openapi-models/src/models/config_file_response.rs index d71959977d3..2e22cb58fe6 100644 --- a/code-rs/code-backend-openapi-models/src/models/config_file_response.rs +++ b/code-rs/codex-backend-openapi-models/src/models/config_file_response.rs @@ -1,7 +1,7 @@ /* - * code-backend + * codex-backend * - * code-backend + * codex-backend * * The version of the OpenAPI document: 0.0.1 * diff --git a/code-rs/codex-backend-openapi-models/src/models/credit_status_details.rs b/code-rs/codex-backend-openapi-models/src/models/credit_status_details.rs new file mode 100644 index 00000000000..b62b88d7159 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/src/models/credit_status_details.rs @@ -0,0 +1,52 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CreditStatusDetails { + #[serde(rename = "has_credits")] + pub has_credits: bool, + #[serde(rename = "unlimited")] + pub unlimited: bool, + #[serde( + rename = "balance", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub balance: Option>, + #[serde( + rename = "approx_local_messages", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub approx_local_messages: Option>>, + #[serde( + rename = "approx_cloud_messages", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub approx_cloud_messages: Option>>, +} + +impl CreditStatusDetails { + pub fn new(has_credits: bool, unlimited: bool) -> CreditStatusDetails { + CreditStatusDetails { + has_credits, + unlimited, + balance: None, + approx_local_messages: None, + approx_cloud_messages: None, + } + } +} diff --git a/code-rs/code-backend-openapi-models/src/models/external_pull_request_response.rs b/code-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs similarity index 84% rename from code-rs/code-backend-openapi-models/src/models/external_pull_request_response.rs rename to code-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs index 5339da3f698..92b56db2ca9 100644 --- a/code-rs/code-backend-openapi-models/src/models/external_pull_request_response.rs +++ b/code-rs/codex-backend-openapi-models/src/models/external_pull_request_response.rs @@ -20,8 +20,8 @@ pub struct ExternalPullRequestResponse { pub assistant_turn_id: String, #[serde(rename = "pull_request")] pub pull_request: Box, - #[serde(rename = "code_updated_sha", skip_serializing_if = "Option::is_none")] - pub code_updated_sha: Option, + #[serde(rename = "codex_updated_sha", skip_serializing_if = "Option::is_none")] + pub codex_updated_sha: Option, } impl ExternalPullRequestResponse { @@ -34,7 +34,7 @@ impl ExternalPullRequestResponse { id, assistant_turn_id, pull_request: Box::new(pull_request), - code_updated_sha: None, + codex_updated_sha: None, } } } diff --git a/code-rs/code-backend-openapi-models/src/models/git_pull_request.rs b/code-rs/codex-backend-openapi-models/src/models/git_pull_request.rs similarity index 100% rename from code-rs/code-backend-openapi-models/src/models/git_pull_request.rs rename to code-rs/codex-backend-openapi-models/src/models/git_pull_request.rs diff --git a/code-rs/codex-backend-openapi-models/src/models/mod.rs b/code-rs/codex-backend-openapi-models/src/models/mod.rs new file mode 100644 index 00000000000..c881822b375 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/src/models/mod.rs @@ -0,0 +1,46 @@ +// Curated minimal export list for current workspace usage. +// NOTE: This file was previously auto-generated by the OpenAPI generator. +// Currently export only the types referenced by the workspace +// The process for this will change + +// Config +pub(crate) mod config_file_response; +pub use self::config_file_response::ConfigFileResponse; + +// Cloud Tasks +pub(crate) mod code_task_details_response; +pub use self::code_task_details_response::CodeTaskDetailsResponse; + +pub(crate) mod task_response; +pub use self::task_response::TaskResponse; + +pub(crate) mod external_pull_request_response; +pub use self::external_pull_request_response::ExternalPullRequestResponse; + +pub(crate) mod git_pull_request; +pub use self::git_pull_request::GitPullRequest; + +pub(crate) mod task_list_item; +pub use self::task_list_item::TaskListItem; + +pub(crate) mod paginated_list_task_list_item_; +pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem; + +// Rate Limits +pub(crate) mod additional_rate_limit_details; +pub use self::additional_rate_limit_details::AdditionalRateLimitDetails; + +pub(crate) mod rate_limit_status_payload; +pub use self::rate_limit_status_payload::PlanType; +pub use self::rate_limit_status_payload::RateLimitReachedKind; +pub use self::rate_limit_status_payload::RateLimitReachedType; +pub use self::rate_limit_status_payload::RateLimitStatusPayload; + +pub(crate) mod rate_limit_status_details; +pub use self::rate_limit_status_details::RateLimitStatusDetails; + +pub(crate) mod rate_limit_window_snapshot; +pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot; + +pub(crate) mod credit_status_details; +pub use self::credit_status_details::CreditStatusDetails; diff --git a/code-rs/code-backend-openapi-models/src/models/paginated_list_task_list_item_.rs b/code-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs similarity index 100% rename from code-rs/code-backend-openapi-models/src/models/paginated_list_task_list_item_.rs rename to code-rs/codex-backend-openapi-models/src/models/paginated_list_task_list_item_.rs diff --git a/code-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs b/code-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs new file mode 100644 index 00000000000..ca9fdfe2406 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs @@ -0,0 +1,46 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitStatusDetails { + #[serde(rename = "allowed")] + pub allowed: bool, + #[serde(rename = "limit_reached")] + pub limit_reached: bool, + #[serde( + rename = "primary_window", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub primary_window: Option>>, + #[serde( + rename = "secondary_window", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub secondary_window: Option>>, +} + +impl RateLimitStatusDetails { + pub fn new(allowed: bool, limit_reached: bool) -> RateLimitStatusDetails { + RateLimitStatusDetails { + allowed, + limit_reached, + primary_window: None, + secondary_window: None, + } + } +} diff --git a/code-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/code-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs new file mode 100644 index 00000000000..56d4a8ee104 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -0,0 +1,125 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitStatusPayload { + #[serde(rename = "plan_type")] + pub plan_type: PlanType, + #[serde( + rename = "rate_limit", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub rate_limit: Option>>, + #[serde( + rename = "credits", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub credits: Option>>, + #[serde( + rename = "additional_rate_limits", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub additional_rate_limits: Option>>, + #[serde( + rename = "rate_limit_reached_type", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub rate_limit_reached_type: Option>, +} + +impl RateLimitStatusPayload { + pub fn new(plan_type: PlanType) -> RateLimitStatusPayload { + RateLimitStatusPayload { + plan_type, + rate_limit: None, + credits: None, + additional_rate_limits: None, + rate_limit_reached_type: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitReachedType { + #[serde(rename = "type")] + pub kind: RateLimitReachedKind, +} + +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, +)] +pub enum RateLimitReachedKind { + #[serde(rename = "rate_limit_reached")] + RateLimitReached, + #[serde(rename = "workspace_owner_credits_depleted")] + WorkspaceOwnerCreditsDepleted, + #[serde(rename = "workspace_member_credits_depleted")] + WorkspaceMemberCreditsDepleted, + #[serde(rename = "workspace_owner_usage_limit_reached")] + WorkspaceOwnerUsageLimitReached, + #[serde(rename = "workspace_member_usage_limit_reached")] + WorkspaceMemberUsageLimitReached, + #[serde(rename = "unknown", other)] + #[default] + Unknown, +} + +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default, +)] +pub enum PlanType { + #[serde(rename = "guest")] + #[default] + Guest, + #[serde(rename = "free")] + Free, + #[serde(rename = "go")] + Go, + #[serde(rename = "plus")] + Plus, + #[serde(rename = "pro")] + Pro, + #[serde(rename = "prolite")] + ProLite, + #[serde(rename = "free_workspace")] + FreeWorkspace, + #[serde(rename = "team")] + Team, + #[serde(rename = "self_serve_business_usage_based")] + SelfServeBusinessUsageBased, + #[serde(rename = "business")] + Business, + #[serde(rename = "enterprise_cbp_usage_based")] + EnterpriseCbpUsageBased, + #[serde(rename = "education")] + Education, + #[serde(rename = "quorum")] + Quorum, + #[serde(rename = "k12")] + K12, + #[serde(rename = "enterprise")] + Enterprise, + #[serde(rename = "edu")] + Edu, + #[serde(rename = "unknown", other)] + Unknown, +} diff --git a/code-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs b/code-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs new file mode 100644 index 00000000000..b2a6c0c2285 --- /dev/null +++ b/code-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs @@ -0,0 +1,39 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitWindowSnapshot { + #[serde(rename = "used_percent")] + pub used_percent: i32, + #[serde(rename = "limit_window_seconds")] + pub limit_window_seconds: i32, + #[serde(rename = "reset_after_seconds")] + pub reset_after_seconds: i32, + #[serde(rename = "reset_at")] + pub reset_at: i32, +} + +impl RateLimitWindowSnapshot { + pub fn new( + used_percent: i32, + limit_window_seconds: i32, + reset_after_seconds: i32, + reset_at: i32, + ) -> RateLimitWindowSnapshot { + RateLimitWindowSnapshot { + used_percent, + limit_window_seconds, + reset_after_seconds, + reset_at, + } + } +} diff --git a/code-rs/code-backend-openapi-models/src/models/task_list_item.rs b/code-rs/codex-backend-openapi-models/src/models/task_list_item.rs similarity index 100% rename from code-rs/code-backend-openapi-models/src/models/task_list_item.rs rename to code-rs/codex-backend-openapi-models/src/models/task_list_item.rs diff --git a/code-rs/code-backend-openapi-models/src/models/task_response.rs b/code-rs/codex-backend-openapi-models/src/models/task_response.rs similarity index 100% rename from code-rs/code-backend-openapi-models/src/models/task_response.rs rename to code-rs/codex-backend-openapi-models/src/models/task_response.rs diff --git a/code-rs/codex-client/BUILD.bazel b/code-rs/codex-client/BUILD.bazel new file mode 100644 index 00000000000..b1b1ef765c9 --- /dev/null +++ b/code-rs/codex-client/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "codex-client", + crate_name = "codex_client", + compile_data = glob(["tests/fixtures/**"]), +) diff --git a/code-rs/codex-client/Cargo.toml b/code-rs/codex-client/Cargo.toml new file mode 100644 index 00000000000..184505eb559 --- /dev/null +++ b/code-rs/codex-client/Cargo.toml @@ -0,0 +1,40 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-client" +version.workspace = true + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +eventsource-stream = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +opentelemetry = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] } +rustls = { workspace = true } +rustls-native-certs = { workspace = true } +rustls-pki-types = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] } +tracing = { workspace = true } +tracing-opentelemetry = { workspace = true } +codex-utils-rustls-provider = { workspace = true } +zstd = { workspace = true } + +[lints] +workspace = true + +[dev-dependencies] +codex-utils-cargo-bin = { workspace = true } +opentelemetry_sdk = { workspace = true } +pretty_assertions = { workspace = true } +rcgen = { workspace = true } +tempfile = { workspace = true } +tracing-subscriber = { workspace = true } + +[lib] +doctest = false diff --git a/code-rs/codex-client/README.md b/code-rs/codex-client/README.md new file mode 100644 index 00000000000..045ee7b3437 --- /dev/null +++ b/code-rs/codex-client/README.md @@ -0,0 +1,8 @@ +# codex-client + +Generic transport layer that wraps HTTP requests, retries, and streaming primitives without any Codex/OpenAI awareness. + +- Defines `HttpTransport` and a default `ReqwestTransport` plus thin `Request`/`Response` types. +- Provides retry utilities (`RetryPolicy`, `RetryOn`, `run_with_retry`, `backoff`) that callers plug into for unary and streaming calls. +- Supplies the `sse_stream` helper to turn byte streams into raw SSE `data:` frames with idle timeouts and surfaced stream errors. +- Consumed by higher-level crates like `codex-api`; it stays neutral on endpoints, headers, or API-specific error shapes. diff --git a/code-rs/codex-client/src/bin/custom_ca_probe.rs b/code-rs/codex-client/src/bin/custom_ca_probe.rs new file mode 100644 index 00000000000..81f5ba9bc2b --- /dev/null +++ b/code-rs/codex-client/src/bin/custom_ca_probe.rs @@ -0,0 +1,100 @@ +//! Helper binary for exercising shared custom CA environment handling in tests. +//! +//! The shared reqwest client honors `CODEX_CA_CERTIFICATE` and `SSL_CERT_FILE`, but those +//! environment variables are process-global and unsafe to mutate in parallel test execution. This +//! probe keeps the behavior under test while letting integration tests (`tests/ca_env.rs`) set +//! env vars per-process, proving: +//! +//! - env precedence is respected, +//! - multi-cert PEM bundles load, +//! - error messages guide users when CA files are invalid. +//! - optional HTTPS probes can complete a request through the constructed client. +//! +//! The detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`. +//! This binary exists so the tests can exercise +//! [`codex_client::build_reqwest_client_for_subprocess_tests`] in a separate process without +//! duplicating client-construction logic. + +use std::env; +use std::process; +use std::time::Duration; + +const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13"; +const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY"; +const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL"; + +fn main() { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("failed to create probe runtime: {error}"); + process::exit(1); + } + }; + + match runtime.block_on(run_probe()) { + Ok(()) => println!("ok"), + Err(error) => { + eprintln!("{error}"); + process::exit(1); + } + } +} + +async fn run_probe() -> Result<(), String> { + let proxy_url = env::var(PROBE_PROXY_ENV).ok(); + let target_url = env::var(PROBE_URL_ENV).ok(); + let mut builder = reqwest::Client::builder(); + if target_url.is_some() { + builder = builder.timeout(Duration::from_secs(5)); + } + if env::var_os(PROBE_TLS13_ENV).is_some() { + builder = builder.min_tls_version(reqwest::tls::Version::TLS_1_3); + } + + let client = build_probe_client(builder, proxy_url.as_deref())?; + if let Some(url) = target_url { + post_probe_request(&client, &url).await?; + } + Ok(()) +} + +fn build_probe_client( + builder: reqwest::ClientBuilder, + proxy_url: Option<&str>, +) -> Result { + if let Some(proxy_url) = proxy_url { + let proxy = reqwest::Proxy::https(proxy_url) + .map_err(|error| format!("failed to configure probe proxy {proxy_url}: {error}"))?; + return codex_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy)) + .map_err(|error| error.to_string()); + } + + codex_client::build_reqwest_client_for_subprocess_tests(builder) + .map_err(|error| error.to_string()) +} + +async fn post_probe_request(client: &reqwest::Client, url: &str) -> Result<(), String> { + let response = client + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("grant_type=authorization_code&code=test") + .send() + .await + .map_err(|error| format!("probe request failed: {error:?}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("failed to read probe response body: {error}"))?; + if !status.is_success() { + return Err(format!("probe request returned {status}: {body}")); + } + if body != "ok" { + return Err(format!("probe response body mismatch: {body}")); + } + Ok(()) +} diff --git a/code-rs/codex-client/src/chatgpt_cloudflare_cookies.rs b/code-rs/codex-client/src/chatgpt_cloudflare_cookies.rs new file mode 100644 index 00000000000..c5f4bbd4eb1 --- /dev/null +++ b/code-rs/codex-client/src/chatgpt_cloudflare_cookies.rs @@ -0,0 +1,264 @@ +use std::sync::Arc; +use std::sync::LazyLock; + +use reqwest::cookie::CookieStore; +use reqwest::cookie::Jar; +use reqwest::header::HeaderValue; + +use crate::chatgpt_hosts::is_allowed_chatgpt_host; + +// WARNING: this store is process-global and may be shared across auth contexts. +// It must only ever contain Cloudflare infrastructure cookies. Never extend this +// store to persist ChatGPT account, session, auth, or other user-specific cookie +// data. +static SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE: LazyLock> = + LazyLock::new(|| Arc::new(ChatGptCloudflareCookieStore::default())); + +#[derive(Debug, Default)] +struct ChatGptCloudflareCookieStore { + jar: Jar, +} + +impl CookieStore for ChatGptCloudflareCookieStore { + fn set_cookies( + &self, + cookie_headers: &mut dyn Iterator, + url: &reqwest::Url, + ) { + if !is_chatgpt_cookie_url(url) { + return; + } + + let mut cloudflare_cookie_headers = + cookie_headers.filter(|header| is_allowed_cloudflare_set_cookie_header(header)); + self.jar.set_cookies(&mut cloudflare_cookie_headers, url); + } + + fn cookies(&self, url: &reqwest::Url) -> Option { + if is_chatgpt_cookie_url(url) { + self.jar.cookies(url).and_then(only_cloudflare_cookies) + } else { + None + } + } +} + +/// Adds the process-local ChatGPT Cloudflare cookie jar used by Codex HTTP clients. +/// +/// WARNING: this jar is global within the process. It is only acceptable because it hardcodes a +/// small allowlist of Cloudflare cookie names and refuses all other ChatGPT cookies. Do not store +/// ChatGPT account, session, auth, or other user-specific cookies here. If a future caller needs +/// those cookies, the store must be scoped to the auth/session owner instead of shared globally. +pub fn with_chatgpt_cloudflare_cookie_store( + builder: reqwest::ClientBuilder, +) -> reqwest::ClientBuilder { + builder.cookie_provider(Arc::clone(&SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE)) +} + +fn is_chatgpt_cookie_url(url: &reqwest::Url) -> bool { + match url.scheme() { + "https" => {} + _ => return false, + } + + let Some(host) = url.host_str() else { + return false; + }; + + is_allowed_chatgpt_host(host) +} + +fn is_allowed_cloudflare_set_cookie_header(header: &HeaderValue) -> bool { + header + .to_str() + .ok() + .and_then(set_cookie_name) + .is_some_and(is_allowed_cloudflare_cookie_name) +} + +fn set_cookie_name(header: &str) -> Option<&str> { + let (name, _) = header.split_once('=')?; + let name = name.trim(); + (!name.is_empty()).then_some(name) +} + +fn only_cloudflare_cookies(header: HeaderValue) -> Option { + let header = header.to_str().ok()?; + let cookies = header + .split(';') + .filter_map(|cookie| { + let cookie = cookie.trim(); + let name = cookie.split_once('=')?.0.trim(); + is_allowed_cloudflare_cookie_name(name).then_some(cookie) + }) + .collect::>() + .join("; "); + + if cookies.is_empty() { + None + } else { + HeaderValue::from_str(&cookies).ok() + } +} + +fn is_allowed_cloudflare_cookie_name(name: &str) -> bool { + // Keep this allowlist aligned with Cloudflare's documented service cookies: + // https://developers.cloudflare.com/fundamentals/reference/policies-compliances/cloudflare-cookies/ + matches!( + name, + "__cf_bm" + | "__cflb" + | "__cfruid" + | "__cfseq" + | "__cfwaitingroom" + | "_cfuvid" + | "cf_clearance" + | "cf_ob_info" + | "cf_use_ob" + ) || name.starts_with("cf_chl_") +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use reqwest::cookie::CookieStore; + + #[test] + fn stores_and_returns_cloudflare_cookies_for_chatgpt_hosts() { + let store = ChatGptCloudflareCookieStore::default(); + let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap(); + let cfuvid = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly"); + let clearance = + HeaderValue::from_static("cf_clearance=clearance; Path=/; Secure; HttpOnly"); + + store.set_cookies(&mut [&cfuvid, &clearance].into_iter(), &url); + + let mut cookies = store + .cookies(&url) + .and_then(|value| value.to_str().ok().map(str::to_string)) + .map(|header| { + header + .split("; ") + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + cookies.sort(); + assert_eq!( + cookies, + vec![ + "_cfuvid=visitor".to_string(), + "cf_clearance=clearance".to_string() + ] + ); + } + + #[test] + fn ignores_non_chatgpt_cookies() { + let store = ChatGptCloudflareCookieStore::default(); + let url = reqwest::Url::parse("https://api.openai.com/v1/responses").unwrap(); + let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly"); + + store.set_cookies(&mut std::iter::once(&set_cookie), &url); + + assert_eq!(store.cookies(&url), None); + } + + #[test] + fn ignores_non_cloudflare_cookies_for_chatgpt_hosts() { + let store = ChatGptCloudflareCookieStore::default(); + let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap(); + let set_cookie = HeaderValue::from_static( + "__Secure-next-auth.session-token=secret; Path=/; Secure; HttpOnly", + ); + + store.set_cookies(&mut std::iter::once(&set_cookie), &url); + + assert_eq!(store.cookies(&url), None); + } + + #[test] + fn ignores_mixed_non_cloudflare_cookies_for_chatgpt_hosts() { + let store = ChatGptCloudflareCookieStore::default(); + let url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap(); + let cfuvid = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly"); + let account_cookie = + HeaderValue::from_static("chatgpt_session=secret; Path=/; Secure; HttpOnly"); + + store.set_cookies(&mut [&cfuvid, &account_cookie].into_iter(), &url); + + assert_eq!( + store + .cookies(&url) + .and_then(|value| value.to_str().ok().map(str::to_string)), + Some("_cfuvid=visitor".to_string()) + ); + } + + #[test] + fn does_not_return_chatgpt_cloudflare_cookies_for_other_hosts() { + let store = ChatGptCloudflareCookieStore::default(); + let chatgpt_url = + reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap(); + let api_url = reqwest::Url::parse("https://api.openai.com/v1/responses").unwrap(); + let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly"); + + store.set_cookies(&mut std::iter::once(&set_cookie), &chatgpt_url); + + assert_eq!(store.cookies(&api_url), None); + } + + #[test] + fn rejects_plain_http_chatgpt_cookie_urls() { + let store = ChatGptCloudflareCookieStore::default(); + let http_url = reqwest::Url::parse("http://chatgpt.com/backend-api/codex/responses") + .expect("URL should parse"); + let https_url = reqwest::Url::parse("https://chatgpt.com/backend-api/codex/responses") + .expect("URL should parse"); + let set_cookie = HeaderValue::from_static("_cfuvid=visitor; Path=/; Secure; HttpOnly"); + + store.set_cookies(&mut std::iter::once(&set_cookie), &http_url); + + assert_eq!(store.cookies(&http_url), None); + assert_eq!(store.cookies(&https_url), None); + } + + #[test] + fn only_allows_https_urls() { + let url = reqwest::Url::parse("http://chatgpt.com/backend-api/codex/responses").unwrap(); + + assert!(!is_chatgpt_cookie_url(&url)); + + let url = reqwest::Url::parse("wss://chatgpt.com/backend-api/codex/responses").unwrap(); + + assert!(!is_chatgpt_cookie_url(&url)); + } + + #[test] + fn allows_only_known_cloudflare_cookie_names() { + for name in [ + "__cf_bm", + "__cflb", + "__cfruid", + "__cfseq", + "__cfwaitingroom", + "_cfuvid", + "cf_clearance", + "cf_ob_info", + "cf_use_ob", + "cf_chl_rc_i", + ] { + assert!(is_allowed_cloudflare_cookie_name(name)); + } + + for name in [ + "__Secure-next-auth.session-token", + "chatgpt_session", + "oai-auth-token", + "not_cf_clearance", + ] { + assert!(!is_allowed_cloudflare_cookie_name(name)); + } + } +} diff --git a/code-rs/codex-client/src/chatgpt_hosts.rs b/code-rs/codex-client/src/chatgpt_hosts.rs new file mode 100644 index 00000000000..dd0b99589ca --- /dev/null +++ b/code-rs/codex-client/src/chatgpt_hosts.rs @@ -0,0 +1,39 @@ +/// Returns whether `host` is one of the ChatGPT hosts Codex is allowed to treat +/// as first-party ChatGPT traffic. +pub fn is_allowed_chatgpt_host(host: &str) -> bool { + const EXACT_HOSTS: &[&str] = &["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"]; + const SUBDOMAIN_SUFFIXES: &[&str] = &[".chatgpt.com", ".chatgpt-staging.com"]; + + EXACT_HOSTS.contains(&host) + || SUBDOMAIN_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_chatgpt_hosts_without_suffix_tricks() { + for host in [ + "chatgpt.com", + "foo.chatgpt.com", + "staging.chatgpt.com", + "chat.openai.com", + "chatgpt-staging.com", + "api.chatgpt-staging.com", + ] { + assert!(is_allowed_chatgpt_host(host)); + } + + for host in [ + "evilchatgpt.com", + "chatgpt.com.evil.example", + "api.openai.com", + "foo.chat.openai.com", + ] { + assert!(!is_allowed_chatgpt_host(host)); + } + } +} diff --git a/code-rs/codex-client/src/custom_ca.rs b/code-rs/codex-client/src/custom_ca.rs new file mode 100644 index 00000000000..7a8b2f27bdf --- /dev/null +++ b/code-rs/codex-client/src/custom_ca.rs @@ -0,0 +1,797 @@ +//! Custom CA handling for Codex outbound HTTP and websocket clients. +//! +//! Codex constructs outbound reqwest clients and secure websocket connections in a few crates, but +//! they all need the same trust-store policy when enterprise proxies or gateways intercept TLS. +//! This module centralizes that policy so callers can start from an ordinary +//! `reqwest::ClientBuilder` or rustls client config, layer in custom CA support, and either get +//! back a configured transport or a user-facing error that explains how to fix a misconfigured CA +//! bundle. +//! +//! The module intentionally has a narrow responsibility: +//! +//! - read CA material from `CODEX_CA_CERTIFICATE`, falling back to `SSL_CERT_FILE` +//! - normalize PEM variants that show up in real deployments, including OpenSSL-style +//! `TRUSTED CERTIFICATE` labels and bundles that also contain CRLs +//! - return user-facing errors that explain how to fix misconfigured CA files +//! +//! Its production contract is narrow: produce a transport configuration whose root store contains +//! every parseable certificate block from the configured PEM bundle, or fail early with a precise +//! error before the caller starts network traffic. +//! +//! In this module's test setup, a hermetic test is one whose result depends only on the CA file +//! and environment variables that the test chose for itself. That matters here because the normal +//! reqwest client-construction path is not hermetic enough for environment-sensitive tests: +//! +//! - on macOS seatbelt runs, `reqwest::Client::builder().build()` can panic inside +//! `system-configuration` while probing platform proxy settings, which means the process can die +//! before the custom-CA code reports success or a structured error. That matters in practice +//! because Codex itself commonly runs spawned test processes under seatbelt, so this is not just +//! a hypothetical CI edge case. +//! - child processes inherit CA-related environment variables by default, which lets developer +//! shell state or CI configuration affect a test unless the test scrubs those variables first +//! +//! The tests in this crate therefore stay split across two layers: +//! +//! - unit tests in this module cover env-selection logic without constructing a real client +//! - subprocess integration tests under `tests/` cover real client construction through +//! [`build_reqwest_client_for_subprocess_tests`], which disables reqwest proxy autodetection so +//! the tests can observe custom-CA success and failure directly, including one TLS handshake +//! through a local HTTPS server +//! - those subprocess tests also scrub inherited CA environment variables before launch so their +//! result depends only on the test fixtures and env vars set by the test itself + +use std::env; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use rustls::ClientConfig; +use rustls::RootCertStore; +use rustls_pki_types::CertificateDer; +use rustls_pki_types::pem::PemObject; +use rustls_pki_types::pem::SectionKind; +use rustls_pki_types::pem::{self}; +use thiserror::Error; +use tracing::info; +use tracing::warn; + +pub const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +pub const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; +const CA_CERT_HINT: &str = "If you set CODEX_CA_CERTIFICATE or SSL_CERT_FILE, ensure it points to a PEM file containing one or more CERTIFICATE blocks, or unset it to use system roots."; +type PemSection = (SectionKind, Vec); + +/// Describes why a transport using shared custom CA support could not be constructed. +/// +/// These failure modes apply to both reqwest client construction and websocket TLS +/// configuration. A build can fail because the configured CA file could not be read, could not be +/// parsed as certificates, contained certs that the target TLS stack refused to register, or +/// because the final reqwest client builder failed. Callers that do not care about the +/// distinction can rely on the `From for io::Error` conversion. +#[derive(Debug, Error)] +pub enum BuildCustomCaTransportError { + /// Reading the selected CA file from disk failed before any PEM parsing could happen. + #[error( + "Failed to read CA certificate file {} selected by {}: {source}. {hint}", + path.display(), + source_env, + hint = CA_CERT_HINT + )] + ReadCaFile { + source_env: &'static str, + path: PathBuf, + source: io::Error, + }, + + /// The selected CA file was readable, but did not produce usable certificate material. + #[error( + "Failed to load CA certificates from {} selected by {}: {detail}. {hint}", + path.display(), + source_env, + hint = CA_CERT_HINT + )] + InvalidCaFile { + source_env: &'static str, + path: PathBuf, + detail: String, + }, + + /// One parsed certificate block could not be registered with the reqwest client builder. + #[error( + "Failed to parse certificate #{certificate_index} from {} selected by {}: {source}. {hint}", + path.display(), + source_env, + hint = CA_CERT_HINT + )] + RegisterCertificate { + source_env: &'static str, + path: PathBuf, + certificate_index: usize, + source: reqwest::Error, + }, + + /// Reqwest rejected the final client configuration after a custom CA bundle was loaded. + #[error( + "Failed to build HTTP client while using CA bundle from {} ({}): {source}", + source_env, + path.display() + )] + BuildClientWithCustomCa { + source_env: &'static str, + path: PathBuf, + #[source] + source: reqwest::Error, + }, + + /// Reqwest rejected the final client configuration while using only system roots. + #[error("Failed to build HTTP client while using system root certificates: {0}")] + BuildClientWithSystemRoots(#[source] reqwest::Error), + + /// One parsed certificate block could not be registered with the websocket TLS root store. + #[error( + "Failed to register certificate #{certificate_index} from {} selected by {} in rustls root store: {source}. {hint}", + path.display(), + source_env, + hint = CA_CERT_HINT + )] + RegisterRustlsCertificate { + source_env: &'static str, + path: PathBuf, + certificate_index: usize, + source: rustls::Error, + }, +} + +impl From for io::Error { + fn from(error: BuildCustomCaTransportError) -> Self { + match error { + BuildCustomCaTransportError::ReadCaFile { ref source, .. } => { + io::Error::new(source.kind(), error) + } + BuildCustomCaTransportError::InvalidCaFile { .. } + | BuildCustomCaTransportError::RegisterCertificate { .. } + | BuildCustomCaTransportError::RegisterRustlsCertificate { .. } => { + io::Error::new(io::ErrorKind::InvalidData, error) + } + BuildCustomCaTransportError::BuildClientWithCustomCa { .. } + | BuildCustomCaTransportError::BuildClientWithSystemRoots(_) => io::Error::other(error), + } + } +} + +/// Builds a reqwest client that honors Codex custom CA environment variables. +/// +/// Callers supply the baseline builder configuration they need, and this helper layers in custom +/// CA handling before finally constructing the client. `CODEX_CA_CERTIFICATE` takes precedence +/// over `SSL_CERT_FILE`, and empty values for either are treated as unset so callers do not +/// accidentally turn `VAR=""` into a bogus path lookup. +/// +/// Callers that build a raw `reqwest::Client` directly bypass this policy entirely. That is an +/// easy mistake to make when adding a new outbound Codex HTTP path, and the resulting bug only +/// shows up in environments where a proxy or gateway requires a custom root CA. +/// +/// # Errors +/// +/// Returns a [`BuildCustomCaTransportError`] when the configured CA file is unreadable, +/// malformed, or contains a certificate block that `reqwest` cannot register as a root. +pub fn build_reqwest_client_with_custom_ca( + builder: reqwest::ClientBuilder, +) -> Result { + build_reqwest_client_with_env(&ProcessEnv, builder) +} + +/// Builds a rustls client config when a Codex custom CA bundle is configured. +/// +/// This is the websocket-facing sibling of [`build_reqwest_client_with_custom_ca`]. When +/// `CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE` selects a CA bundle, the returned config starts from +/// the platform native roots and then adds the configured custom CA certificates. When no custom +/// CA env var is set, this returns `Ok(None)` so websocket callers can keep using their ordinary +/// default connector path. +/// +/// Callers that let tungstenite build its default TLS connector directly bypass this policy +/// entirely. That bug only shows up in environments where secure websocket traffic needs the same +/// enterprise root CA bundle as HTTPS traffic. +pub fn maybe_build_rustls_client_config_with_custom_ca() +-> Result>, BuildCustomCaTransportError> { + maybe_build_rustls_client_config_with_env(&ProcessEnv) +} + +/// Builds a reqwest client for spawned subprocess tests that exercise CA behavior. +/// +/// This is the test-only client-construction path used by the subprocess coverage in `tests/`. +/// The module-level docs explain the hermeticity problem in full; this helper only addresses the +/// reqwest proxy-discovery panic side of that problem by disabling proxy autodetection. The tests +/// still scrub inherited CA environment variables themselves. Normal production callers should use +/// [`build_reqwest_client_with_custom_ca`] so test-only proxy behavior does not leak into +/// ordinary client construction. +pub fn build_reqwest_client_for_subprocess_tests( + builder: reqwest::ClientBuilder, +) -> Result { + build_reqwest_client_with_env(&ProcessEnv, builder.no_proxy()) +} + +fn maybe_build_rustls_client_config_with_env( + env_source: &dyn EnvSource, +) -> Result>, BuildCustomCaTransportError> { + let Some(bundle) = env_source.configured_ca_bundle() else { + return Ok(None); + }; + + ensure_rustls_crypto_provider(); + + // Start from the platform roots so websocket callers keep the same baseline trust behavior + // they would get from tungstenite's default rustls connector, then layer in the Codex custom + // CA bundle on top when configured. + let mut root_store = RootCertStore::empty(); + let rustls_native_certs::CertificateResult { certs, errors, .. } = + rustls_native_certs::load_native_certs(); + if !errors.is_empty() { + warn!( + native_root_error_count = errors.len(), + "encountered errors while loading native root certificates" + ); + } + let _ = root_store.add_parsable_certificates(certs); + + let certificates = bundle.load_certificates()?; + for (idx, cert) in certificates.into_iter().enumerate() { + if let Err(source) = root_store.add(cert) { + warn!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + certificate_index = idx + 1, + error = %source, + "failed to register CA certificate in rustls root store" + ); + return Err(BuildCustomCaTransportError::RegisterRustlsCertificate { + source_env: bundle.source_env, + path: bundle.path.clone(), + certificate_index: idx + 1, + source, + }); + } + } + + Ok(Some(Arc::new( + ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(), + ))) +} + +/// Builds a reqwest client using an injected environment source and reqwest builder. +/// +/// This exists so tests can exercise precedence behavior deterministically without mutating the +/// real process environment. It selects the CA bundle, delegates file parsing to +/// [`ConfiguredCaBundle::load_certificates`], preserves the caller's chosen `reqwest` builder +/// configuration, forces rustls when a custom CA is configured, and finally registers each parsed +/// certificate with that builder. +fn build_reqwest_client_with_env( + env_source: &dyn EnvSource, + mut builder: reqwest::ClientBuilder, +) -> Result { + if let Some(bundle) = env_source.configured_ca_bundle() { + ensure_rustls_crypto_provider(); + info!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + "building HTTP client with rustls backend for custom CA bundle" + ); + builder = builder.use_rustls_tls(); + + let certificates = bundle.load_certificates()?; + + for (idx, cert) in certificates.iter().enumerate() { + let certificate = match reqwest::Certificate::from_der(cert.as_ref()) { + Ok(certificate) => certificate, + Err(source) => { + warn!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + certificate_index = idx + 1, + error = %source, + "failed to register CA certificate" + ); + return Err(BuildCustomCaTransportError::RegisterCertificate { + source_env: bundle.source_env, + path: bundle.path.clone(), + certificate_index: idx + 1, + source, + }); + } + }; + builder = builder.add_root_certificate(certificate); + } + return match builder.build() { + Ok(client) => Ok(client), + Err(source) => { + warn!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + error = %source, + "failed to build client after loading custom CA bundle" + ); + Err(BuildCustomCaTransportError::BuildClientWithCustomCa { + source_env: bundle.source_env, + path: bundle.path.clone(), + source, + }) + } + }; + } + + info!( + codex_ca_certificate_configured = false, + ssl_cert_file_configured = false, + "using system root certificates because no CA override environment variable was selected" + ); + + match builder.build() { + Ok(client) => Ok(client), + Err(source) => { + warn!( + error = %source, + "failed to build client while using system root certificates" + ); + Err(BuildCustomCaTransportError::BuildClientWithSystemRoots( + source, + )) + } + } +} + +/// Abstracts environment access so tests can cover precedence rules without mutating process-wide +/// variables. +trait EnvSource { + /// Returns the environment variable value for `key`, if this source considers it set. + /// + /// Implementations should return `None` for absent values and may also collapse unreadable + /// process-environment states into `None`, because the custom CA logic treats both cases as + /// "no override configured". Callers build precedence and empty-string handling on top of this + /// method, so implementations should not trim or normalize the returned string. + fn var(&self, key: &str) -> Option; + + /// Returns a non-empty environment variable value interpreted as a filesystem path. + /// + /// Empty strings are treated as unset because presence here acts as a boolean "custom CA + /// override requested" signal. This keeps the precedence logic from treating `VAR=""` as an + /// attempt to open the current working directory or some other platform-specific oddity once + /// it is converted into a path. + fn non_empty_path(&self, key: &str) -> Option { + self.var(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + } + + /// Returns the configured CA bundle and which environment variable selected it. + /// + /// `CODEX_CA_CERTIFICATE` wins over `SSL_CERT_FILE` because it is the Codex-specific override. + /// Keeping the winning variable name with the path lets later logging explain not only which + /// file was used but also why that file was chosen. + fn configured_ca_bundle(&self) -> Option { + self.non_empty_path(CODEX_CA_CERT_ENV) + .map(|path| ConfiguredCaBundle { + source_env: CODEX_CA_CERT_ENV, + path, + }) + .or_else(|| { + self.non_empty_path(SSL_CERT_FILE_ENV) + .map(|path| ConfiguredCaBundle { + source_env: SSL_CERT_FILE_ENV, + path, + }) + }) + } +} + +/// Reads CA configuration from the real process environment. +/// +/// This is the production `EnvSource` implementation used by +/// [`build_reqwest_client_with_custom_ca`]. Tests substitute in-memory env maps so they can +/// exercise precedence and empty-value behavior without mutating process-global variables. +struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn var(&self, key: &str) -> Option { + env::var(key).ok() + } +} + +/// Identifies the CA bundle selected for a client and the policy decision that selected it. +/// +/// This is the concrete output of the environment-precedence logic. Callers use `source_env` for +/// logging and diagnostics, while `path` is the bundle that will actually be loaded. +struct ConfiguredCaBundle { + /// The environment variable that won the precedence check for this bundle. + source_env: &'static str, + /// The filesystem path that should be read as PEM certificate input. + path: PathBuf, +} + +impl ConfiguredCaBundle { + /// Loads certificates from this selected CA bundle. + /// + /// The bundle already represents the output of environment-precedence selection, so this is + /// the natural point where the file-loading phase begins. The method owns the high-level + /// success/failure logs for that phase and keeps the source env and path together for lower- + /// level parsing and error shaping. + fn load_certificates( + &self, + ) -> Result>, BuildCustomCaTransportError> { + match self.parse_certificates() { + Ok(certificates) => { + info!( + source_env = self.source_env, + ca_path = %self.path.display(), + certificate_count = certificates.len(), + "loaded certificates from custom CA bundle" + ); + Ok(certificates) + } + Err(error) => { + warn!( + source_env = self.source_env, + ca_path = %self.path.display(), + error = %error, + "failed to load custom CA bundle" + ); + Err(error) + } + } + } + + /// Loads every certificate block from a PEM file intended for Codex CA overrides. + /// + /// This accepts a few common real-world variants so Codex behaves like other CA-aware tooling: + /// leading comments are preserved, `TRUSTED CERTIFICATE` labels are normalized to standard + /// certificate labels, and embedded CRLs are ignored when they are well-formed enough for the + /// section iterator to classify them. + fn parse_certificates( + &self, + ) -> Result>, BuildCustomCaTransportError> { + let pem_data = self.read_pem_data()?; + let normalized_pem = NormalizedPem::from_pem_data(self.source_env, &self.path, &pem_data); + + let mut certificates = Vec::new(); + let mut logged_crl_presence = false; + for section_result in normalized_pem.sections() { + // Known limitation: if `rustls-pki-types` fails while parsing a malformed CRL section, + // that error is reported here before we can classify the block as ignorable. A bundle + // containing valid certificates plus a malformed `X509 CRL` therefore still fails to + // load today, even though well-formed CRLs are ignored. + let (section_kind, der) = match section_result { + Ok(section) => section, + Err(error) => return Err(self.pem_parse_error(&error)), + }; + match section_kind { + SectionKind::Certificate => { + // Standard CERTIFICATE blocks already decode to the exact DER bytes reqwest + // wants. Only OpenSSL TRUSTED CERTIFICATE blocks need trimming to drop any + // trailing X509_AUX trust metadata before registration. + let cert_der = normalized_pem.certificate_der(&der).ok_or_else(|| { + self.invalid_ca_file( + "failed to extract certificate data from TRUSTED CERTIFICATE: invalid DER length", + ) + })?; + certificates.push(CertificateDer::from(cert_der.to_vec())); + } + SectionKind::Crl => { + if !logged_crl_presence { + info!( + source_env = self.source_env, + ca_path = %self.path.display(), + "ignoring X509 CRL entries found in custom CA bundle" + ); + logged_crl_presence = true; + } + } + _ => {} + } + } + + if certificates.is_empty() { + return Err(self.pem_parse_error(&pem::Error::NoItemsFound)); + } + + Ok(certificates) + } + + /// Reads the CA bundle bytes while preserving the original filesystem error kind. + /// + /// The caller wants a user-facing error that includes the bundle path and remediation hint, but + /// higher-level surfaces still benefit from distinguishing "not found" from other I/O + /// failures. This helper keeps both pieces together. + fn read_pem_data(&self) -> Result, BuildCustomCaTransportError> { + fs::read(&self.path).map_err(|source| BuildCustomCaTransportError::ReadCaFile { + source_env: self.source_env, + path: self.path.clone(), + source, + }) + } + + /// Rewrites PEM parsing failures into user-facing configuration errors. + /// + /// The underlying parser knows whether the file was empty, malformed, or contained unsupported + /// PEM content, but callers need a message that also points them back to the relevant + /// environment variables and the expected remediation. + fn pem_parse_error(&self, error: &pem::Error) -> BuildCustomCaTransportError { + let detail = match error { + pem::Error::NoItemsFound => "no certificates found in PEM file".to_string(), + _ => format!("failed to parse PEM file: {error}"), + }; + + self.invalid_ca_file(detail) + } + + /// Creates an invalid-CA error tied to this file path. + /// + /// Most parse-time failures in this module eventually collapse to "the configured CA bundle is + /// not usable", but the detailed reason still matters for operator debugging. Centralizing that + /// formatting keeps the path and hint text consistent across the different parser branches. + fn invalid_ca_file(&self, detail: impl std::fmt::Display) -> BuildCustomCaTransportError { + BuildCustomCaTransportError::InvalidCaFile { + source_env: self.source_env, + path: self.path.clone(), + detail: detail.to_string(), + } + } +} + +/// The PEM text shape after OpenSSL compatibility normalization. +/// +/// `Standard` means the input already used ordinary PEM certificate labels. `TrustedCertificate` +/// means the input used OpenSSL's `TRUSTED CERTIFICATE` labels, so callers must also be prepared +/// to trim trailing `X509_AUX` bytes from decoded certificate sections. +enum NormalizedPem { + /// PEM contents that already used ordinary `CERTIFICATE` labels. + Standard(String), + /// PEM contents rewritten from OpenSSL `TRUSTED CERTIFICATE` labels to `CERTIFICATE`. + TrustedCertificate(String), +} + +impl NormalizedPem { + /// Normalizes PEM text from a CA bundle into the label shape this module expects. + /// + /// Codex only needs certificate DER bytes to seed `reqwest`'s root store, but operators may + /// point it at CA files that came from OpenSSL tooling rather than from a minimal certificate + /// bundle. OpenSSL's `TRUSTED CERTIFICATE` form is one such variant: it is still certificate + /// material, but it uses a different PEM label and may carry auxiliary trust metadata that + /// this crate does not consume. This constructor rewrites only the PEM labels so the mixed- + /// section parser can keep treating the file as certificate input. The rustls ecosystem does + /// not currently accept `TRUSTED CERTIFICATE` as a standard certificate label upstream, so + /// this remains a local compatibility shim rather than behavior delegated to + /// `rustls-pki-types`. + /// + /// See also: + /// - rustls/pemfile issue #52, closed as not planned, documenting that + /// `BEGIN TRUSTED CERTIFICATE` blocks are ignored upstream: + /// + /// - OpenSSL `x509 -trustout`, which emits `TRUSTED CERTIFICATE` PEM blocks: + /// + /// - OpenSSL PEM readers, which document that plain `PEM_read_bio_X509()` discards auxiliary + /// trust settings: + /// + /// - `openssl s_server`, a real OpenSSL-based server/test tool that operates in this + /// ecosystem: + /// + fn from_pem_data(source_env: &'static str, path: &Path, pem_data: &[u8]) -> Self { + let pem = String::from_utf8_lossy(pem_data); + if pem.contains("TRUSTED CERTIFICATE") { + info!( + source_env, + ca_path = %path.display(), + "normalizing OpenSSL TRUSTED CERTIFICATE labels in custom CA bundle" + ); + Self::TrustedCertificate( + pem.replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE") + .replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"), + ) + } else { + Self::Standard(pem.into_owned()) + } + } + + /// Returns the normalized PEM contents regardless of the label shape that produced them. + fn contents(&self) -> &str { + match self { + Self::Standard(contents) | Self::TrustedCertificate(contents) => contents, + } + } + + /// Iterates over every recognized PEM section in this normalized PEM text. + /// + /// `rustls-pki-types` exposes mixed-section parsing through a `PemObject` implementation on the + /// `(SectionKind, Vec)` tuple. Keeping that type-directed API here lets callers iterate in + /// terms of normalized sections rather than trait plumbing. + fn sections(&self) -> impl Iterator> + '_ { + PemSection::pem_slice_iter(self.contents().as_bytes()) + } + + /// Returns the certificate DER bytes for one parsed PEM certificate section. + /// + /// Standard PEM certificates already decode to the exact DER bytes `reqwest` wants. OpenSSL + /// `TRUSTED CERTIFICATE` sections may append `X509_AUX` bytes after the certificate, so those + /// sections need to be trimmed down to their first DER object before registration. + fn certificate_der<'a>(&self, der: &'a [u8]) -> Option<&'a [u8]> { + match self { + Self::Standard(_) => Some(der), + Self::TrustedCertificate(_) => first_der_item(der), + } + } +} + +/// Returns the first DER-encoded ASN.1 object in `der`, ignoring any trailing OpenSSL metadata. +/// +/// A PEM `CERTIFICATE` block usually decodes to exactly one DER blob: the certificate itself. +/// OpenSSL's `TRUSTED CERTIFICATE` variant is different. It starts with that same certificate +/// blob, but may append extra `X509_AUX` bytes after it to describe OpenSSL-specific trust +/// settings. `reqwest::Certificate::from_der` only understands the certificate object, not those +/// trailing OpenSSL extensions. +/// +/// This helper therefore asks a narrower question than "is this a valid certificate?": where does +/// the first top-level DER object end? If that boundary can be found, the caller keeps only that +/// prefix and discards the trailing trust metadata. If it cannot be found, the input is treated as +/// malformed CA data. +fn first_der_item(der: &[u8]) -> Option<&[u8]> { + der_item_length(der).map(|length| &der[..length]) +} + +/// Returns the byte length of the first DER item in `der`. +/// +/// DER is a binary encoding for ASN.1 objects. Each object begins with: +/// +/// - a tag byte describing what kind of object follows +/// - one or more length bytes describing how many content bytes belong to that object +/// - the content bytes themselves +/// +/// For this module, the important fact is that a certificate is stored as one complete top-level +/// DER object. Once we know that object's declared length, we know exactly where the certificate +/// ends and where any trailing OpenSSL `X509_AUX` data begins. +/// +/// This helper intentionally parses only that outer length field. It does not validate the inner +/// certificate structure, the meaning of the tag, or every nested ASN.1 value. That narrower scope +/// is deliberate: the caller only needs a safe slice boundary for the leading certificate object +/// before handing those bytes to `reqwest`, which performs the real certificate parsing. +/// +/// The implementation supports the DER length forms needed here: +/// +/// - short form, where the length is stored directly in the second byte +/// - long form, where the second byte says how many following bytes make up the length value +/// +/// Indefinite lengths are rejected because DER does not permit them, and any declared length that +/// would run past the end of the input is treated as malformed. +fn der_item_length(der: &[u8]) -> Option { + let &length_octet = der.get(1)?; + if length_octet & 0x80 == 0 { + return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len()); + } + + let length_octets = usize::from(length_octet & 0x7f); + if length_octets == 0 { + return None; + } + + let length_start = 2usize; + let length_end = length_start.checked_add(length_octets)?; + let length_bytes = der.get(length_start..length_end)?; + let mut content_length = 0usize; + for &byte in length_bytes { + content_length = content_length + .checked_mul(256)? + .checked_add(usize::from(byte))?; + } + + length_end + .checked_add(content_length) + .filter(|length| *length <= der.len()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + use super::BuildCustomCaTransportError; + use super::CODEX_CA_CERT_ENV; + use super::EnvSource; + use super::SSL_CERT_FILE_ENV; + use super::maybe_build_rustls_client_config_with_env; + + const TEST_CERT: &str = include_str!("../tests/fixtures/test-ca.pem"); + + struct MapEnv { + values: HashMap, + } + + impl EnvSource for MapEnv { + fn var(&self, key: &str) -> Option { + self.values.get(key).cloned() + } + } + + fn map_env(pairs: &[(&str, &str)]) -> MapEnv { + MapEnv { + values: pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(), + } + } + + fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf { + let path = temp_dir.path().join(name); + fs::write(&path, contents).unwrap_or_else(|error| { + panic!("write cert fixture failed for {}: {error}", path.display()) + }); + path + } + + #[test] + fn ca_path_prefers_codex_env() { + let env = map_env(&[ + (CODEX_CA_CERT_ENV, "/tmp/codex.pem"), + (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"), + ]); + + assert_eq!( + env.configured_ca_bundle().map(|bundle| bundle.path), + Some(PathBuf::from("/tmp/codex.pem")) + ); + } + + #[test] + fn ca_path_falls_back_to_ssl_cert_file() { + let env = map_env(&[(SSL_CERT_FILE_ENV, "/tmp/fallback.pem")]); + + assert_eq!( + env.configured_ca_bundle().map(|bundle| bundle.path), + Some(PathBuf::from("/tmp/fallback.pem")) + ); + } + + #[test] + fn ca_path_ignores_empty_values() { + let env = map_env(&[ + (CODEX_CA_CERT_ENV, ""), + (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"), + ]); + + assert_eq!( + env.configured_ca_bundle().map(|bundle| bundle.path), + Some(PathBuf::from("/tmp/fallback.pem")) + ); + } + + #[test] + fn rustls_config_uses_custom_ca_bundle_when_configured() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT); + let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_string_lossy().as_ref())]); + + let config = maybe_build_rustls_client_config_with_env(&env) + .expect("rustls config") + .expect("custom CA config should be present"); + + assert!(config.enable_sni); + } + + #[test] + fn rustls_config_reports_invalid_ca_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "empty.pem", ""); + let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_string_lossy().as_ref())]); + + let error = maybe_build_rustls_client_config_with_env(&env).expect_err("invalid CA"); + + assert!(matches!( + error, + BuildCustomCaTransportError::InvalidCaFile { .. } + )); + } +} diff --git a/code-rs/codex-client/src/default_client.rs b/code-rs/codex-client/src/default_client.rs new file mode 100644 index 00000000000..56b3ce4b163 --- /dev/null +++ b/code-rs/codex-client/src/default_client.rs @@ -0,0 +1,218 @@ +use http::Error as HttpError; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; +use opentelemetry::global; +use opentelemetry::propagation::Injector; +use reqwest::IntoUrl; +use reqwest::Method; +use reqwest::Response; +use serde::Serialize; +use std::fmt::Display; +use std::time::Duration; +use tracing::Span; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +#[derive(Clone, Debug)] +pub struct CodexHttpClient { + inner: reqwest::Client, +} + +impl CodexHttpClient { + pub fn new(inner: reqwest::Client) -> Self { + Self { inner } + } + + pub fn get(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::GET, url) + } + + pub fn post(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::POST, url) + } + + pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + let url_str = url.as_str().to_string(); + CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + } +} + +#[must_use = "requests are not sent unless `send` is awaited"] +#[derive(Debug)] +pub struct CodexRequestBuilder { + builder: reqwest::RequestBuilder, + method: Method, + url: String, +} + +impl CodexRequestBuilder { + fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { + Self { + builder, + method, + url, + } + } + + fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { + Self { + builder: f(self.builder), + method: self.method, + url: self.url, + } + } + + pub fn headers(self, headers: HeaderMap) -> Self { + self.map(|builder| builder.headers(headers)) + } + + pub fn header(self, key: K, value: V) -> Self + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + self.map(|builder| builder.header(key, value)) + } + + pub fn bearer_auth(self, token: T) -> Self + where + T: Display, + { + self.map(|builder| builder.bearer_auth(token)) + } + + pub fn timeout(self, timeout: Duration) -> Self { + self.map(|builder| builder.timeout(timeout)) + } + + pub fn json(self, value: &T) -> Self + where + T: ?Sized + Serialize, + { + self.map(|builder| builder.json(value)) + } + + pub fn body(self, body: B) -> Self + where + B: Into, + { + self.map(|builder| builder.body(body)) + } + + pub async fn send(self) -> Result { + let headers = trace_headers(); + + match self.builder.headers(headers).send().await { + Ok(response) => { + tracing::debug!( + method = %self.method, + url = %self.url, + status = %response.status(), + headers = ?response.headers(), + version = ?response.version(), + "Request completed" + ); + + Ok(response) + } + Err(error) => { + let status = error.status(); + tracing::debug!( + method = %self.method, + url = %self.url, + status = status.map(|s| s.as_u16()), + error = %error, + "Request failed" + ); + Err(error) + } + } + } +} + +struct HeaderMapInjector<'a>(&'a mut HeaderMap); + +impl<'a> Injector for HeaderMapInjector<'a> { + fn set(&mut self, key: &str, value: String) { + if let (Ok(name), Ok(val)) = ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_str(&value), + ) { + self.0.insert(name, val); + } + } +} + +fn trace_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + global::get_text_map_propagator(|prop| { + prop.inject_context( + &Span::current().context(), + &mut HeaderMapInjector(&mut headers), + ); + }); + headers +} + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::propagation::Extractor; + use opentelemetry::propagation::TextMapPropagator; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TracerProvider; + use opentelemetry_sdk::propagation::TraceContextPropagator; + use opentelemetry_sdk::trace::SdkTracerProvider; + use tracing::trace_span; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + #[test] + fn inject_trace_headers_uses_current_span_context() { + global::set_text_map_propagator(TraceContextPropagator::new()); + + let provider = SdkTracerProvider::builder().build(); + let tracer = provider.tracer("test-tracer"); + let subscriber = + tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)); + let _guard = subscriber.set_default(); + + let span = trace_span!("client_request"); + let _entered = span.enter(); + let span_context = span.context().span().span_context().clone(); + + let headers = trace_headers(); + + let extractor = HeaderMapExtractor(&headers); + let extracted = TraceContextPropagator::new().extract(&extractor); + let extracted_span = extracted.span(); + let extracted_context = extracted_span.span_context(); + + assert!(extracted_context.is_valid()); + assert_eq!(extracted_context.trace_id(), span_context.trace_id()); + assert_eq!(extracted_context.span_id(), span_context.span_id()); + } + + struct HeaderMapExtractor<'a>(&'a HeaderMap); + + impl<'a> Extractor for HeaderMapExtractor<'a> { + fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).and_then(|value| value.to_str().ok()) + } + + fn keys(&self) -> Vec<&str> { + self.0.keys().map(HeaderName::as_str).collect() + } + } +} diff --git a/code-rs/codex-client/src/error.rs b/code-rs/codex-client/src/error.rs new file mode 100644 index 00000000000..fa2bfb4f797 --- /dev/null +++ b/code-rs/codex-client/src/error.rs @@ -0,0 +1,30 @@ +use http::HeaderMap; +use http::StatusCode; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TransportError { + #[error("http {status}: {body:?}")] + Http { + status: StatusCode, + url: Option, + headers: Option, + body: Option, + }, + #[error("retry limit reached")] + RetryLimit, + #[error("timeout")] + Timeout, + #[error("network error: {0}")] + Network(String), + #[error("request build error: {0}")] + Build(String), +} + +#[derive(Debug, Error)] +pub enum StreamError { + #[error("stream failed: {0}")] + Stream(String), + #[error("timeout")] + Timeout, +} diff --git a/code-rs/codex-client/src/lib.rs b/code-rs/codex-client/src/lib.rs new file mode 100644 index 00000000000..0f503fb3e21 --- /dev/null +++ b/code-rs/codex-client/src/lib.rs @@ -0,0 +1,42 @@ +mod chatgpt_cloudflare_cookies; +mod chatgpt_hosts; +mod custom_ca; +mod default_client; +mod error; +mod request; +mod retry; +mod sse; +mod telemetry; +mod transport; + +pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; +pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; +pub use crate::custom_ca::BuildCustomCaTransportError; +/// Test-only subprocess hook for custom CA coverage. +/// +/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It +/// is hidden from normal docs because ordinary callers should use +/// [`build_reqwest_client_with_custom_ca`] instead. +#[doc(hidden)] +pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests; +pub use crate::custom_ca::build_reqwest_client_with_custom_ca; +pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; +pub use crate::default_client::CodexHttpClient; +pub use crate::default_client::CodexRequestBuilder; +pub use crate::error::StreamError; +pub use crate::error::TransportError; +pub use crate::request::PreparedRequestBody; +pub use crate::request::Request; +pub use crate::request::RequestBody; +pub use crate::request::RequestCompression; +pub use crate::request::Response; +pub use crate::retry::RetryOn; +pub use crate::retry::RetryPolicy; +pub use crate::retry::backoff; +pub use crate::retry::run_with_retry; +pub use crate::sse::sse_stream; +pub use crate::telemetry::RequestTelemetry; +pub use crate::transport::ByteStream; +pub use crate::transport::HttpTransport; +pub use crate::transport::ReqwestTransport; +pub use crate::transport::StreamResponse; diff --git a/code-rs/codex-client/src/request.rs b/code-rs/codex-client/src/request.rs new file mode 100644 index 00000000000..5fc076627f3 --- /dev/null +++ b/code-rs/codex-client/src/request.rs @@ -0,0 +1,215 @@ +use bytes::Bytes; +use http::Method; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderValue; +use serde::Serialize; +use serde_json::Value; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RequestCompression { + #[default] + None, + Zstd, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RequestBody { + Json(Value), + Raw(Bytes), +} + +impl RequestBody { + pub fn json(&self) -> Option<&Value> { + match self { + Self::Json(value) => Some(value), + Self::Raw(_) => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedRequestBody { + pub headers: HeaderMap, + pub body: Option, +} + +impl PreparedRequestBody { + pub fn body_bytes(&self) -> Bytes { + self.body.clone().unwrap_or_default() + } +} + +#[derive(Debug, Clone)] +pub struct Request { + pub method: Method, + pub url: String, + pub headers: HeaderMap, + pub body: Option, + pub compression: RequestCompression, + pub timeout: Option, +} + +impl Request { + pub fn new(method: Method, url: String) -> Self { + Self { + method, + url, + headers: HeaderMap::new(), + body: None, + compression: RequestCompression::None, + timeout: None, + } + } + + pub fn with_json(mut self, body: &T) -> Self { + self.body = serde_json::to_value(body).ok().map(RequestBody::Json); + self + } + + pub fn with_raw_body(mut self, body: impl Into) -> Self { + self.body = Some(RequestBody::Raw(body.into())); + self + } + + pub fn with_compression(mut self, compression: RequestCompression) -> Self { + self.compression = compression; + self + } + + /// Convert the request body into the exact bytes that will be sent. + /// + /// Auth schemes such as AWS SigV4 need to sign the final body bytes, including + /// compression and content headers. Calling this method does not mutate the + /// request. + pub fn prepare_body_for_send(&self) -> Result { + let mut headers = self.headers.clone(); + match self.body.as_ref() { + Some(RequestBody::Raw(raw_body)) => { + if self.compression != RequestCompression::None { + return Err("request compression cannot be used with raw bodies".to_string()); + } + Ok(PreparedRequestBody { + headers, + body: Some(raw_body.clone()), + }) + } + Some(RequestBody::Json(body)) => { + let json = serde_json::to_vec(&body).map_err(|err| err.to_string())?; + let bytes = if self.compression != RequestCompression::None { + if headers.contains_key(http::header::CONTENT_ENCODING) { + return Err( + "request compression was requested but content-encoding is already set" + .to_string(), + ); + } + + let pre_compression_bytes = json.len(); + let compression_start = std::time::Instant::now(); + let (compressed, content_encoding) = match self.compression { + RequestCompression::None => unreachable!("guarded by compression != None"), + RequestCompression::Zstd => ( + zstd::stream::encode_all(std::io::Cursor::new(json), 3) + .map_err(|err| err.to_string())?, + HeaderValue::from_static("zstd"), + ), + }; + let post_compression_bytes = compressed.len(); + let compression_duration = compression_start.elapsed(); + + headers.insert(http::header::CONTENT_ENCODING, content_encoding); + + tracing::debug!( + pre_compression_bytes, + post_compression_bytes, + compression_duration_ms = compression_duration.as_millis(), + "Compressed request body with zstd" + ); + + compressed + } else { + json + }; + + if !headers.contains_key(http::header::CONTENT_TYPE) { + headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + } + + Ok(PreparedRequestBody { + headers, + body: Some(Bytes::from(bytes)), + }) + } + None => Ok(PreparedRequestBody { + headers, + body: None, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderValue; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn prepare_body_for_send_serializes_json_and_sets_content_type() { + let request = Request::new(Method::POST, "https://example.com/v1/responses".to_string()) + .with_json(&json!({"model": "test-model"})); + + let prepared = request + .prepare_body_for_send() + .expect("body should prepare"); + + assert_eq!( + prepared.body, + Some(Bytes::from_static(br#"{"model":"test-model"}"#)) + ); + assert_eq!( + prepared + .headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert_eq!( + request.body, + Some(RequestBody::Json(json!({"model": "test-model"}))) + ); + assert_eq!(request.compression, RequestCompression::None); + } + + #[test] + fn prepare_body_for_send_rejects_existing_content_encoding_when_compressing() { + let mut request = + Request::new(Method::POST, "https://example.com/v1/responses".to_string()) + .with_json(&json!({"model": "test-model"})) + .with_compression(RequestCompression::Zstd); + request.headers.insert( + http::header::CONTENT_ENCODING, + HeaderValue::from_static("gzip"), + ); + + let err = request + .prepare_body_for_send() + .expect_err("conflicting content-encoding should fail"); + + assert_eq!( + err, + "request compression was requested but content-encoding is already set" + ); + } +} + +#[derive(Debug, Clone)] +pub struct Response { + pub status: http::StatusCode, + pub headers: HeaderMap, + pub body: Bytes, +} diff --git a/code-rs/codex-client/src/retry.rs b/code-rs/codex-client/src/retry.rs new file mode 100644 index 00000000000..c7bdd34b1ef --- /dev/null +++ b/code-rs/codex-client/src/retry.rs @@ -0,0 +1,73 @@ +use crate::error::TransportError; +use crate::request::Request; +use rand::Rng; +use std::future::Future; +use std::time::Duration; +use tokio::time::sleep; + +#[derive(Debug, Clone)] +pub struct RetryPolicy { + pub max_attempts: u64, + pub base_delay: Duration, + pub retry_on: RetryOn, +} + +#[derive(Debug, Clone)] +pub struct RetryOn { + pub retry_429: bool, + pub retry_5xx: bool, + pub retry_transport: bool, +} + +impl RetryOn { + pub fn should_retry(&self, err: &TransportError, attempt: u64, max_attempts: u64) -> bool { + if attempt >= max_attempts { + return false; + } + match err { + TransportError::Http { status, .. } => { + (self.retry_429 && status.as_u16() == 429) + || (self.retry_5xx && status.is_server_error()) + } + TransportError::Timeout | TransportError::Network(_) => self.retry_transport, + _ => false, + } + } +} + +pub fn backoff(base: Duration, attempt: u64) -> Duration { + if attempt == 0 { + return base; + } + let exp = 2u64.saturating_pow(attempt as u32 - 1); + let millis = base.as_millis() as u64; + let raw = millis.saturating_mul(exp); + let jitter: f64 = rand::rng().random_range(0.9..1.1); + Duration::from_millis((raw as f64 * jitter) as u64) +} + +pub async fn run_with_retry( + policy: RetryPolicy, + mut make_req: impl FnMut() -> Request, + op: F, +) -> Result +where + F: Fn(Request, u64) -> Fut, + Fut: Future>, +{ + for attempt in 0..=policy.max_attempts { + let req = make_req(); + match op(req, attempt).await { + Ok(resp) => return Ok(resp), + Err(err) + if policy + .retry_on + .should_retry(&err, attempt, policy.max_attempts) => + { + sleep(backoff(policy.base_delay, attempt + 1)).await; + } + Err(err) => return Err(err), + } + } + Err(TransportError::RetryLimit) +} diff --git a/code-rs/codex-client/src/sse.rs b/code-rs/codex-client/src/sse.rs new file mode 100644 index 00000000000..f3aba3a2c59 --- /dev/null +++ b/code-rs/codex-client/src/sse.rs @@ -0,0 +1,48 @@ +use crate::error::StreamError; +use crate::transport::ByteStream; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio::time::timeout; + +/// Minimal SSE helper that forwards raw `data:` frames as UTF-8 strings. +/// +/// Errors and idle timeouts are sent as `Err(StreamError)` before the task exits. +pub fn sse_stream( + stream: ByteStream, + idle_timeout: Duration, + tx: mpsc::Sender>, +) { + tokio::spawn(async move { + let mut stream = stream + .map(|res| res.map_err(|e| StreamError::Stream(e.to_string()))) + .eventsource(); + + loop { + match timeout(idle_timeout, stream.next()).await { + Ok(Some(Ok(ev))) => { + if tx.send(Ok(ev.data.clone())).await.is_err() { + return; + } + } + Ok(Some(Err(e))) => { + let _ = tx.send(Err(StreamError::Stream(e.to_string()))).await; + return; + } + Ok(None) => { + let _ = tx + .send(Err(StreamError::Stream( + "stream closed before completion".into(), + ))) + .await; + return; + } + Err(_) => { + let _ = tx.send(Err(StreamError::Timeout)).await; + return; + } + } + } + }); +} diff --git a/code-rs/codex-client/src/telemetry.rs b/code-rs/codex-client/src/telemetry.rs new file mode 100644 index 00000000000..457d47f4fca --- /dev/null +++ b/code-rs/codex-client/src/telemetry.rs @@ -0,0 +1,14 @@ +use crate::error::TransportError; +use http::StatusCode; +use std::time::Duration; + +/// API specific telemetry. +pub trait RequestTelemetry: Send + Sync { + fn on_request( + &self, + attempt: u64, + status: Option, + error: Option<&TransportError>, + duration: Duration, + ); +} diff --git a/code-rs/codex-client/src/transport.rs b/code-rs/codex-client/src/transport.rs new file mode 100644 index 00000000000..4ed062c483b --- /dev/null +++ b/code-rs/codex-client/src/transport.rs @@ -0,0 +1,156 @@ +use crate::default_client::CodexHttpClient; +use crate::default_client::CodexRequestBuilder; +use crate::error::TransportError; +use crate::request::Request; +use crate::request::RequestBody; +use crate::request::Response; +use async_trait::async_trait; +use bytes::Bytes; +use futures::StreamExt; +use futures::stream::BoxStream; +use http::HeaderMap; +use http::Method; +use http::StatusCode; +use tracing::Level; +use tracing::enabled; +use tracing::trace; + +pub type ByteStream = BoxStream<'static, Result>; + +pub struct StreamResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub bytes: ByteStream, +} + +#[async_trait] +pub trait HttpTransport: Send + Sync { + async fn execute(&self, req: Request) -> Result; + async fn stream(&self, req: Request) -> Result; +} + +#[derive(Clone, Debug)] +pub struct ReqwestTransport { + client: CodexHttpClient, +} + +impl ReqwestTransport { + pub fn new(client: reqwest::Client) -> Self { + Self { + client: CodexHttpClient::new(client), + } + } + + fn build(&self, req: Request) -> Result { + let prepared = req.prepare_body_for_send().map_err(TransportError::Build)?; + + let Request { + method, + url, + headers: _, + body: _, + compression: _, + timeout, + } = req; + + let mut builder = self.client.request( + Method::from_bytes(method.as_str().as_bytes()).unwrap_or(Method::GET), + &url, + ); + + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + + builder = builder.headers(prepared.headers); + if let Some(body) = prepared.body { + builder = builder.body(body); + } + Ok(builder) + } + + fn map_error(err: reqwest::Error) -> TransportError { + if err.is_timeout() { + TransportError::Timeout + } else { + TransportError::Network(err.to_string()) + } + } +} + +fn request_body_for_trace(req: &Request) -> String { + match req.body.as_ref() { + Some(RequestBody::Json(body)) => body.to_string(), + Some(RequestBody::Raw(body)) => format!("", body.len()), + None => String::new(), + } +} + +#[async_trait] +impl HttpTransport for ReqwestTransport { + async fn execute(&self, req: Request) -> Result { + if enabled!(Level::TRACE) { + trace!( + "{} to {}: {}", + req.method, + req.url, + request_body_for_trace(&req) + ); + } + + let url = req.url.clone(); + let builder = self.build(req)?; + let resp = builder.send().await.map_err(Self::map_error)?; + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp.bytes().await.map_err(Self::map_error)?; + if !status.is_success() { + let body = String::from_utf8(bytes.to_vec()).ok(); + return Err(TransportError::Http { + status, + url: Some(url), + headers: Some(headers), + body, + }); + } + Ok(Response { + status, + headers, + body: bytes, + }) + } + + async fn stream(&self, req: Request) -> Result { + if enabled!(Level::TRACE) { + trace!( + "{} to {}: {}", + req.method, + req.url, + request_body_for_trace(&req) + ); + } + + let url = req.url.clone(); + let builder = self.build(req)?; + let resp = builder.send().await.map_err(Self::map_error)?; + let status = resp.status(); + let headers = resp.headers().clone(); + if !status.is_success() { + let body = resp.text().await.ok(); + return Err(TransportError::Http { + status, + url: Some(url), + headers: Some(headers), + body, + }); + } + let stream = resp + .bytes_stream() + .map(|result| result.map_err(Self::map_error)); + Ok(StreamResponse { + status, + headers, + bytes: Box::pin(stream), + }) + } +} diff --git a/code-rs/codex-client/tests/ca_env.rs b/code-rs/codex-client/tests/ca_env.rs new file mode 100644 index 00000000000..6a3a0e0caf3 --- /dev/null +++ b/code-rs/codex-client/tests/ca_env.rs @@ -0,0 +1,548 @@ +//! Subprocess coverage for custom CA behavior that must build a real reqwest client. +//! +//! These tests intentionally run through `custom_ca_probe` and +//! `build_reqwest_client_for_subprocess_tests` instead of calling the helper in-process. The +//! detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`; these +//! tests add the process-level half of that contract by scrubbing inherited CA environment +//! variables before each subprocess launch. Most assertions here cover CA file selection, PEM +//! parsing, and user-facing errors. The HTTPS probes go further and perform real POSTs against +//! locally generated certificates, including through a TLS-intercepting CONNECT proxy. + +use codex_utils_cargo_bin::cargo_bin; +use rcgen::BasicConstraints; +use rcgen::CertificateParams; +use rcgen::CertifiedIssuer; +use rcgen::DistinguishedName; +use rcgen::DnType; +use rcgen::ExtendedKeyUsagePurpose; +use rcgen::IsCa; +use rcgen::KeyPair; +use rcgen::KeyUsagePurpose; +use rcgen::PKCS_ECDSA_P256_SHA256; +use rustls_pki_types::CertificateDer; +use rustls_pki_types::PrivateKeyDer; +use std::fs; +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::TcpListener; +use std::net::TcpStream; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; +use std::time::Instant; +use tempfile::TempDir; + +const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY"; +const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13"; +const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL"; +const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; +const PROXY_ENV_VARS: &[&str] = &[ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", +]; + +const TEST_CERT_1: &str = include_str!("fixtures/test-ca.pem"); +const TEST_CERT_2: &str = include_str!("fixtures/test-intermediate.pem"); +const TRUSTED_TEST_CERT: &str = include_str!("fixtures/test-ca-trusted.pem"); + +struct Tls13Material { + ca_cert_pem: String, + server_cert: CertificateDer<'static>, + server_key: PrivateKeyDer<'static>, +} + +struct Tls13TestServer { + ca_cert_pem: String, + request_rx: mpsc::Receiver>, + url: String, +} + +struct PlainHttpOrigin { + request_rx: mpsc::Receiver>, + url: String, +} + +struct TlsInterceptingProxy { + ca_cert_pem: String, + request_rx: mpsc::Receiver>, + url: String, +} + +fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf { + let path = temp_dir.path().join(name); + fs::write(&path, contents).unwrap_or_else(|error| { + panic!("write cert fixture failed for {}: {error}", path.display()) + }); + path +} + +fn probe_command() -> Command { + let mut cmd = Command::new( + cargo_bin("custom_ca_probe") + .unwrap_or_else(|error| panic!("failed to locate custom_ca_probe: {error}")), + ); + // `Command` inherits the parent environment by default, so scrub CA-related variables first or + // these tests can accidentally pass/fail based on the developer shell or CI runner. + cmd.env_remove(CODEX_CA_CERT_ENV); + cmd.env_remove(PROBE_PROXY_ENV); + cmd.env_remove(PROBE_TLS13_ENV); + cmd.env_remove(PROBE_URL_ENV); + cmd.env_remove(SSL_CERT_FILE_ENV); + for env_var in PROXY_ENV_VARS { + cmd.env_remove(env_var); + } + cmd +} + +fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output { + let mut cmd = probe_command(); + for (key, value) in envs { + cmd.env(key, value); + } + cmd.output() + .unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}")) +} + +fn run_probe_posting_to_tls13_server(envs: &[(&str, &Path)], url: &str) -> std::process::Output { + let mut cmd = probe_command(); + for (key, value) in envs { + cmd.env(key, value); + } + cmd.env(PROBE_TLS13_ENV, "1"); + cmd.env(PROBE_URL_ENV, url); + cmd.output() + .unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}")) +} + +fn run_probe_posting_through_tls_intercepting_proxy( + envs: &[(&str, &Path)], + url: &str, + proxy_url: &str, +) -> std::process::Output { + let mut cmd = probe_command(); + for (key, value) in envs { + cmd.env(key, value); + } + cmd.env(PROBE_PROXY_ENV, proxy_url); + cmd.env(PROBE_TLS13_ENV, "1"); + cmd.env(PROBE_URL_ENV, url); + cmd.output() + .unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}")) +} + +fn spawn_tls13_test_server() -> Tls13TestServer { + codex_utils_rustls_provider::ensure_rustls_crypto_provider(); + let material = generate_tls13_material(); + let listener = TcpListener::bind(("127.0.0.1", 0)) + .unwrap_or_else(|error| panic!("bind TLS test server: {error}")); + listener + .set_nonblocking(true) + .unwrap_or_else(|error| panic!("set TLS test server nonblocking: {error}")); + let port = listener + .local_addr() + .unwrap_or_else(|error| panic!("TLS test server addr: {error}")) + .port(); + let config = Arc::new( + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(vec![material.server_cert], material.server_key) + .unwrap_or_else(|error| panic!("TLS 1.3 server config: {error}")), + ); + let (request_tx, request_rx) = mpsc::channel(); + + thread::spawn(move || { + let result = accept_tls13_request(listener, config); + let _ = request_tx.send(result.map_err(|error| error.to_string())); + }); + + Tls13TestServer { + ca_cert_pem: material.ca_cert_pem, + request_rx, + url: format!("https://127.0.0.1:{port}/oauth/token"), + } +} + +fn spawn_plain_http_origin() -> PlainHttpOrigin { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .unwrap_or_else(|error| panic!("bind plain HTTP origin: {error}")); + listener + .set_nonblocking(true) + .unwrap_or_else(|error| panic!("set plain HTTP origin nonblocking: {error}")); + let port = listener + .local_addr() + .unwrap_or_else(|error| panic!("plain HTTP origin addr: {error}")) + .port(); + let (request_tx, request_rx) = mpsc::channel(); + + thread::spawn(move || { + let result = accept_plain_http_origin_request(listener); + let _ = request_tx.send(result.map_err(|error| error.to_string())); + }); + + PlainHttpOrigin { + request_rx, + url: format!("https://127.0.0.1:{port}/oauth/token"), + } +} + +fn spawn_tls_intercepting_proxy() -> TlsInterceptingProxy { + codex_utils_rustls_provider::ensure_rustls_crypto_provider(); + let material = generate_tls13_material(); + let listener = TcpListener::bind(("127.0.0.1", 0)) + .unwrap_or_else(|error| panic!("bind TLS intercepting proxy: {error}")); + listener + .set_nonblocking(true) + .unwrap_or_else(|error| panic!("set TLS intercepting proxy nonblocking: {error}")); + let port = listener + .local_addr() + .unwrap_or_else(|error| panic!("TLS intercepting proxy addr: {error}")) + .port(); + let config = Arc::new( + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(vec![material.server_cert], material.server_key) + .unwrap_or_else(|error| panic!("TLS intercepting proxy config: {error}")), + ); + let (request_tx, request_rx) = mpsc::channel(); + + thread::spawn(move || { + let result = accept_tls_intercepting_proxy_request(listener, config); + let _ = request_tx.send(result.map_err(|error| error.to_string())); + }); + + TlsInterceptingProxy { + ca_cert_pem: material.ca_cert_pem, + request_rx, + url: format!("http://127.0.0.1:{port}"), + } +} + +fn generate_tls13_material() -> Tls13Material { + let mut ca_params = CertificateParams::default(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut ca_distinguished_name = DistinguishedName::new(); + ca_distinguished_name.push(DnType::CommonName, "codex test CA"); + ca_params.distinguished_name = ca_distinguished_name; + let ca_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .unwrap_or_else(|error| panic!("generate test CA key pair: {error}")); + let ca = CertifiedIssuer::self_signed(ca_params, ca_key_pair) + .unwrap_or_else(|error| panic!("generate test CA certificate: {error}")); + + let mut server_params = + CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap_or_else(|error| panic!("create test server certificate params: {error}")); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + server_params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let server_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .unwrap_or_else(|error| panic!("generate test server key pair: {error}")); + let server_cert = server_params + .signed_by(&server_key_pair, &ca) + .unwrap_or_else(|error| panic!("generate test server certificate: {error}")); + + Tls13Material { + ca_cert_pem: ca.pem(), + server_cert: server_cert.der().clone(), + server_key: PrivateKeyDer::from(server_key_pair), + } +} + +fn accept_plain_http_origin_request(listener: TcpListener) -> io::Result { + let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let request = read_http_message(&mut stream)?; + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; + stream.flush()?; + Ok(request) +} + +fn accept_tls13_request( + listener: TcpListener, + config: Arc, +) -> io::Result { + let stream = accept_with_timeout(listener, Duration::from_secs(5))?; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?; + let mut tls = rustls::StreamOwned::new(connection, stream); + let request = read_http_message(&mut tls)?; + tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; + tls.flush()?; + Ok(request) +} + +fn accept_tls_intercepting_proxy_request( + listener: TcpListener, + config: Arc, +) -> io::Result { + let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let connect_request = read_http_message(&mut stream)?; + let origin_authority = connect_authority_from_request(&connect_request)?; + stream.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?; + stream.flush()?; + + let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?; + let mut tls = rustls::StreamOwned::new(connection, stream); + let request = read_http_message(&mut tls)?; + + let mut origin = TcpStream::connect(origin_authority.as_str())?; + origin.set_read_timeout(Some(Duration::from_secs(5)))?; + origin.set_write_timeout(Some(Duration::from_secs(5)))?; + origin.write_all(request.as_bytes())?; + origin.flush()?; + let response = read_http_message(&mut origin)?; + + tls.write_all(response.as_bytes())?; + tls.flush()?; + Ok(request) +} + +fn connect_authority_from_request(request: &str) -> io::Result { + let request_line = request + .lines() + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty CONNECT request"))?; + let mut parts = request_line.split_whitespace(); + match (parts.next(), parts.next(), parts.next()) { + (Some("CONNECT"), Some(authority), Some(_version)) => Ok(authority.to_string()), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid CONNECT request line: {request_line}"), + )), + } +} + +fn accept_with_timeout(listener: TcpListener, timeout: Duration) -> io::Result { + let deadline = Instant::now() + timeout; + loop { + match listener.accept() { + Ok((stream, _)) => return Ok(stream), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "timed out waiting for TLS test client", + )); + } + thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(error), + } + } +} + +fn read_http_message(stream: &mut impl Read) -> io::Result { + let mut buffer = Vec::new(); + let mut chunk = [0; 1024]; + loop { + let bytes_read = stream.read(&mut chunk)?; + if bytes_read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..bytes_read]); + if let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { + let body_start = header_end + 4; + let headers = String::from_utf8_lossy(&buffer[..body_start]); + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find_map(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if buffer.len() >= body_start + content_length { + break; + } + } + } + Ok(String::from_utf8_lossy(&buffer).into_owned()) +} + +fn assert_token_exchange_request(request: &str) { + assert!( + request.starts_with("POST /oauth/token HTTP/1.1"), + "unexpected request:\n{request}" + ); + assert!( + request.contains("grant_type=authorization_code&code=test"), + "unexpected request body:\n{request}" + ); +} + +#[test] +fn uses_codex_ca_cert_env() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn falls_back_to_ssl_cert_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ssl.pem", TEST_CERT_1); + + let output = run_probe(&[(SSL_CERT_FILE_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn prefers_codex_ca_cert_over_ssl_cert_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1); + let bad_path = write_cert_file(&temp_dir, "bad.pem", ""); + + let output = run_probe(&[ + (CODEX_CA_CERT_ENV, cert_path.as_path()), + (SSL_CERT_FILE_ENV, bad_path.as_path()), + ]); + + assert!(output.status.success()); +} + +#[test] +fn handles_multi_certificate_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let bundle = format!("{TEST_CERT_1}\n{TEST_CERT_2}"); + let cert_path = write_cert_file(&temp_dir, "bundle.pem", &bundle); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn posts_to_tls13_server_using_custom_ca_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let server = spawn_tls13_test_server(); + let cert_path = write_cert_file(&temp_dir, "tls-ca.pem", &server.ca_cert_pem); + + let output = + run_probe_posting_to_tls13_server(&[(CODEX_CA_CERT_ENV, cert_path.as_path())], &server.url); + let server_result = server.request_rx.recv_timeout(Duration::from_secs(5)); + + assert!( + output.status.success(), + "custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nserver:\n{server_result:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let request = server_result + .expect("TLS test server should report a request") + .expect("TLS test server should accept the probe request"); + assert_token_exchange_request(&request); +} + +#[test] +fn posts_to_token_origin_through_tls_intercepting_proxy_with_custom_ca_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let origin = spawn_plain_http_origin(); + let proxy = spawn_tls_intercepting_proxy(); + let cert_path = write_cert_file(&temp_dir, "proxy-ca.pem", &proxy.ca_cert_pem); + + let output = run_probe_posting_through_tls_intercepting_proxy( + &[(CODEX_CA_CERT_ENV, cert_path.as_path())], + &origin.url, + &proxy.url, + ); + let proxy_result = proxy.request_rx.recv_timeout(Duration::from_secs(5)); + let origin_result = origin.request_rx.recv_timeout(Duration::from_secs(5)); + + assert!( + output.status.success(), + "custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nproxy:\n{proxy_result:?}\norigin:\n{origin_result:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let proxy_request = proxy_result + .expect("TLS intercepting proxy should report a request") + .expect("TLS intercepting proxy should accept the probe request"); + let origin_request = origin_result + .expect("plain HTTP origin should report a request") + .expect("plain HTTP origin should accept the forwarded request"); + assert_token_exchange_request(&proxy_request); + assert_token_exchange_request(&origin_request); +} + +#[test] +fn rejects_empty_pem_file_with_hint() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "empty.pem", ""); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("no certificates found in PEM file")); + assert!(stderr.contains("CODEX_CA_CERTIFICATE")); + assert!(stderr.contains("SSL_CERT_FILE")); +} + +#[test] +fn rejects_malformed_pem_with_hint() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file( + &temp_dir, + "malformed.pem", + "-----BEGIN CERTIFICATE-----\nMIIBroken", + ); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("failed to parse PEM file")); + assert!(stderr.contains("CODEX_CA_CERTIFICATE")); + assert!(stderr.contains("SSL_CERT_FILE")); +} + +#[test] +fn accepts_openssl_trusted_certificate() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "trusted.pem", TRUSTED_TEST_CERT); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn accepts_bundle_with_crl() { + let temp_dir = TempDir::new().expect("tempdir"); + let crl = "-----BEGIN X509 CRL-----\nMIIC\n-----END X509 CRL-----"; + let bundle = format!("{TEST_CERT_1}\n{crl}"); + let cert_path = write_cert_file(&temp_dir, "bundle_crl.pem", &bundle); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} diff --git a/code-rs/codex-client/tests/fixtures/test-ca-trusted.pem b/code-rs/codex-client/tests/fixtures/test-ca-trusted.pem new file mode 100644 index 00000000000..0b394ce84fe --- /dev/null +++ b/code-rs/codex-client/tests/fixtures/test-ca-trusted.pem @@ -0,0 +1,25 @@ +# Test-only OpenSSL trusted-certificate fixture generated from test-ca.pem with +# `openssl x509 -addtrust serverAuth -trustout`. +# The extra trailing bytes model the OpenSSL X509_AUX data that follows the +# certificate DER in real TRUSTED CERTIFICATE bundles. +# This fixture exists to validate the X509_AUX trimming path against a real +# OpenSSL-generated artifact, not just label normalization. +-----BEGIN TRUSTED CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky +MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF +7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH +twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko +ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l +kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM +gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6 +sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57 +7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB +TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd +S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7 +zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO +2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13 +CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+ +SprtRUBjlWzjMAwwCgYIKwYBBQUHAwE= +-----END TRUSTED CERTIFICATE----- diff --git a/code-rs/codex-client/tests/fixtures/test-ca.pem b/code-rs/codex-client/tests/fixtures/test-ca.pem new file mode 100644 index 00000000000..7c9a9883c81 --- /dev/null +++ b/code-rs/codex-client/tests/fixtures/test-ca.pem @@ -0,0 +1,21 @@ +# Test-only self-signed CA fixture used for single-certificate loading. +# These tests only verify PEM parsing and root-certificate registration, not a TLS handshake. +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky +MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF +7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH +twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko +ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l +kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM +gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6 +sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57 +7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB +TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd +S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7 +zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO +2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13 +CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+ +SprtRUBjlWzj +-----END CERTIFICATE----- diff --git a/code-rs/codex-client/tests/fixtures/test-intermediate.pem b/code-rs/codex-client/tests/fixtures/test-intermediate.pem new file mode 100644 index 00000000000..f29e69d63fd --- /dev/null +++ b/code-rs/codex-client/tests/fixtures/test-intermediate.pem @@ -0,0 +1,21 @@ +# Second valid test-only certificate used for multi-certificate bundle coverage. +# It is intentionally distinct from test-ca.pem; chain validation is not part of these tests. +-----BEGIN CERTIFICATE----- +MIIDGTCCAgGgAwIBAgIUWxlcvHzwITWAHWHbKMFUTgeDmjwwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRdGVzdC1pbnRlcm1lZGlhdGUwHhcNMjUxMTE5MTU1MDIz +WhcNMjYxMTE5MTU1MDIzWjAcMRowGAYDVQQDDBF0ZXN0LWludGVybWVkaWF0ZTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANq7xbeYpC2GaXANqD1nLk0t +j9j2sOk6e7DqTapxnIUijS7z4DF0Vo1xHM07wK1m+wsB/t9CubNYRvtn6hrIzx7K +jjlmvxo4/YluwO1EDMQWZAXkaY2O28ESKVx7QLfBPYAc4bf/5B4Nmt6KX5sQyyyH +2qTfzVBUCAl3sI+Ydd3mx7NOye1yNNkCNqyK3Hj45F1JuH8NZxcb4OlKssZhMlD+ +EQx4G46AzKE9Ho8AqlQvg/tiWrMHRluw7zolMJ/AXzedAXedNIrX4fCOmZwcTkA1 +a8eLPP8oM9VFrr67a7on6p4zPqugUEQ4fawp7A5KqSjUAVCt1FXmn2V8N8V6W/sC +AwEAAaNTMFEwHQYDVR0OBBYEFBEwRwW0gm3IjhLw1U3eOAvR0r6SMB8GA1UdIwQY +MBaAFBEwRwW0gm3IjhLw1U3eOAvR0r6SMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAB2fjAlpevK42Odv8XUEgV6VWlEP9HAmkRvugW9hjhzx1Iz9 +Vh/l9VcxL7PcqdpyGH+BIRvQIMokcYF5TXzf/KV1T2y56U8AWaSd2/xSjYNWwkgE +TLE5V+H/YDKzvTe58UrOaxa5N3URscQL9f+ZKworODmfMlkJ1mlREK130ZMlBexB +p9w5wo1M1fjx76Rqzq9MkpwBSbIO2zx/8+qy4BAH23MPGW+9OOnnq2DiIX3qUu1v +hnjYOxYpCB28MZEJmqsjFJQQ9RF+Te4U2/oknVcf8lZIMJ2ZBOwt2zg8RqCtM52/ +IbATwYj77wg3CFLFKcDYs3tdUqpiniabKcf6zAs= +-----END CERTIFICATE----- diff --git a/code-rs/codex-experimental-api-macros/BUILD.bazel b/code-rs/codex-experimental-api-macros/BUILD.bazel index 896488303b5..370a4ed8c56 100644 --- a/code-rs/codex-experimental-api-macros/BUILD.bazel +++ b/code-rs/codex-experimental-api-macros/BUILD.bazel @@ -2,6 +2,6 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "codex-experimental-api-macros", - crate_name = "code_experimental_api_macros", + crate_name = "codex_experimental_api_macros", proc_macro = True, ) diff --git a/code-rs/codex-experimental-api-macros/Cargo.toml b/code-rs/codex-experimental-api-macros/Cargo.toml index 1db799cfc6b..2e148a21d78 100644 --- a/code-rs/codex-experimental-api-macros/Cargo.toml +++ b/code-rs/codex-experimental-api-macros/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true [lib] proc-macro = true +test = false +doctest = false [dependencies] proc-macro2 = "1" @@ -14,4 +16,3 @@ syn = { version = "2", features = ["full", "extra-traits"] } [lints] workspace = true - diff --git a/code-rs/codex-experimental-api-macros/src/lib.rs b/code-rs/codex-experimental-api-macros/src/lib.rs index 1b6590dcfed..2bca0190eaa 100644 --- a/code-rs/codex-experimental-api-macros/src/lib.rs +++ b/code-rs/codex-experimental-api-macros/src/lib.rs @@ -37,9 +37,8 @@ fn derive_for_struct(input: &DeriveInput, data: &DataStruct) -> TokenStream { let mut experimental_fields = Vec::new(); let mut registrations = Vec::new(); for field in &named.named { - let reason = experimental_reason(&field.attrs); - if let Some(reason) = reason { - let expr = experimental_presence_expr(field, false); + if let Some(reason) = experimental_reason(&field.attrs) { + let expr = experimental_presence_expr(field, /*tuple_struct*/ false); checks.push(quote! { if #expr { return Some(#reason); @@ -65,6 +64,17 @@ fn derive_for_struct(input: &DeriveInput, data: &DataStruct) -> TokenStream { } }); } + } else if has_nested_experimental(field) { + let Some(ident) = field.ident.as_ref() else { + continue; + }; + checks.push(quote! { + if let Some(reason) = + crate::experimental_api::ExperimentalApi::experimental_reason(&self.#ident) + { + return Some(reason); + } + }); } } (checks, experimental_fields, registrations) @@ -74,8 +84,7 @@ fn derive_for_struct(input: &DeriveInput, data: &DataStruct) -> TokenStream { let mut experimental_fields = Vec::new(); let mut registrations = Vec::new(); for (index, field) in unnamed.unnamed.iter().enumerate() { - let reason = experimental_reason(&field.attrs); - if let Some(reason) = reason { + if let Some(reason) = experimental_reason(&field.attrs) { let expr = index_presence_expr(index, &field.ty); checks.push(quote! { if #expr { @@ -100,6 +109,15 @@ fn derive_for_struct(input: &DeriveInput, data: &DataStruct) -> TokenStream { } } }); + } else if has_nested_experimental(field) { + let index = syn::Index::from(index); + checks.push(quote! { + if let Some(reason) = + crate::experimental_api::ExperimentalApi::experimental_reason(&self.#index) + { + return Some(reason); + } + }); } } (checks, experimental_fields, registrations) @@ -175,12 +193,30 @@ fn derive_for_enum(input: &DeriveInput, data: &DataEnum) -> TokenStream { } fn experimental_reason(attrs: &[Attribute]) -> Option { - let attr = attrs - .iter() - .find(|attr| attr.path().is_ident("experimental"))?; + attrs.iter().find_map(experimental_reason_attr) +} + +fn experimental_reason_attr(attr: &Attribute) -> Option { + if !attr.path().is_ident("experimental") { + return None; + } + attr.parse_args::().ok() } +fn has_nested_experimental(field: &Field) -> bool { + field.attrs.iter().any(experimental_nested_attr) +} + +fn experimental_nested_attr(attr: &Attribute) -> bool { + if !attr.path().is_ident("experimental") { + return false; + } + + attr.parse_args::() + .is_ok_and(|ident| ident == "nested") +} + fn field_serialized_name(field: &Field) -> Option { let ident = field.ident.as_ref()?; let name = ident.to_string(); @@ -225,11 +261,8 @@ fn presence_expr_for_access( access: proc_macro2::TokenStream, ty: &Type, ) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; + if option_inner(ty).is_some() { + return quote! { #access.is_some() }; } if is_vec_like(ty) || is_map_like(ty) { return quote! { !#access.is_empty() }; @@ -240,22 +273,6 @@ fn presence_expr_for_access( quote! { true } } -fn presence_expr_for_ref(access: proc_macro2::TokenStream, ty: &Type) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; - } - if is_vec_like(ty) || is_map_like(ty) { - return quote! { !#access.is_empty() }; - } - if is_bool(ty) { - return quote! { *#access }; - } - quote! { true } -} - fn option_inner(ty: &Type) -> Option<&Type> { let Type::Path(type_path) = ty else { return None; @@ -291,4 +308,3 @@ fn type_last_ident(ty: &Type) -> Option { }; type_path.path.segments.last().map(|seg| seg.ident.clone()) } - diff --git a/code-rs/codex-mcp/BUILD.bazel b/code-rs/codex-mcp/BUILD.bazel new file mode 100644 index 00000000000..fbae63201a7 --- /dev/null +++ b/code-rs/codex-mcp/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "codex-mcp", + crate_name = "codex_mcp", +) diff --git a/code-rs/codex-mcp/Cargo.toml b/code-rs/codex-mcp/Cargo.toml new file mode 100644 index 00000000000..ed51cd5bbed --- /dev/null +++ b/code-rs/codex-mcp/Cargo.toml @@ -0,0 +1,45 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-mcp" +version.workspace = true + +[lib] +name = "codex_mcp" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-channel = { workspace = true } +codex-async-utils = { workspace = true } +codex-api = { workspace = true } +codex-builtin-mcps = { workspace = true } +codex-config = { workspace = true } +codex-exec-server = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-otel = { workspace = true } +codex-plugin = { workspace = true } +codex-protocol = { workspace = true } +codex-rmcp-client = { workspace = true } +codex-utils-plugins = { workspace = true } +futures = { workspace = true } +regex-lite = { workspace = true } +rmcp = { workspace = true, default-features = false, features = ["base64", "macros", "schemars", "server"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +rmcp = { workspace = true, default-features = false, features = ["base64", "macros", "schemars", "server"] } +tempfile = { workspace = true } diff --git a/code-rs/codex-mcp/src/auth_elicitation.rs b/code-rs/codex-mcp/src/auth_elicitation.rs new file mode 100644 index 00000000000..77c7b78c555 --- /dev/null +++ b/code-rs/codex-mcp/src/auth_elicitation.rs @@ -0,0 +1,347 @@ +//! Auth elicitation helpers. +//! +//! This module owns protocol-neutral auth elicitation parsing and payload shaping. +//! Session orchestration stays in `codex-core`. + +use codex_protocol::mcp::CallToolResult; +use serde::Serialize; + +pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason"; +pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexAppsConnectorAuthFailure { + pub connector_id: String, + pub connector_name: String, + pub install_url: String, + pub auth_reason: Option, + pub link_id: Option, + pub error_code: Option, + pub error_http_status_code: Option, + pub error_action: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitation { + pub meta: serde_json::Value, + pub message: String, + pub url: String, + pub elicitation_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitationPlan { + pub auth_failure: CodexAppsConnectorAuthFailure, + pub elicitation: CodexAppsAuthElicitation, +} + +#[derive(Serialize)] +struct CodexAppsConnectorAuthFailureMeta<'a> { + is_auth_failure: bool, + connector_id: &'a str, + connector_name: &'a str, + install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + auth_reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + link_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_http_status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_action: Option<&'a str>, +} + +pub fn connector_auth_failure_from_tool_result( + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + if result.is_error != Some(true) { + return None; + } + + let auth_failure = result + .meta + .as_ref()? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let connector_id = connector_id + .map(str::trim) + .filter(|connector_id| !connector_id.is_empty())?; + if let Some(auth_failure_connector_id) = + string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + && auth_failure_connector_id != connector_id + { + return None; + } + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(connector_id) + .to_string(); + + Some(CodexAppsConnectorAuthFailure { + connector_id: connector_id.to_string(), + connector_name, + install_url: install_url?, + auth_reason: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY, + ), + link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY), + error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY), + error_http_status_code: auth_failure + .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY) + .and_then(serde_json::Value::as_i64), + error_action: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY, + ), + }) +} + +pub fn build_auth_elicitation_plan( + call_id: &str, + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + let auth_failure = + connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?; + let elicitation = build_auth_elicitation(call_id, &auth_failure); + Some(CodexAppsAuthElicitationPlan { + auth_failure, + elicitation, + }) +} + +pub fn build_auth_elicitation( + call_id: &str, + auth_failure: &CodexAppsConnectorAuthFailure, +) -> CodexAppsAuthElicitation { + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta { + is_auth_failure: true, + connector_id: &auth_failure.connector_id, + connector_name: &auth_failure.connector_name, + install_url: &auth_failure.install_url, + auth_reason: auth_failure.auth_reason.as_deref(), + link_id: auth_failure.link_id.as_deref(), + error_code: auth_failure.error_code.as_deref(), + error_http_status_code: auth_failure.error_http_status_code, + error_action: auth_failure.error_action.as_deref(), + }, + }, + }), + message: auth_elicitation_message(auth_failure), + url: auth_failure.install_url.clone(), + elicitation_id: auth_elicitation_id(call_id), + } +} + +pub fn auth_elicitation_completed_result( + auth_failure: &CodexAppsConnectorAuthFailure, + meta: Option, +) -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": format!( + "Authentication for {} was requested and accepted. Retry this tool call now.", + auth_failure.connector_name + ), + })], + structured_content: None, + is_error: Some(true), + meta, + } +} + +pub fn auth_elicitation_id(call_id: &str) -> String { + format!("codex_apps_auth_{call_id}") +} + +fn string_auth_failure_field( + auth_failure: &serde_json::Map, + key: &str, +) -> Option { + auth_failure + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String { + match auth_failure.auth_reason.as_deref() { + Some("oauth_upgrade_required") => format!( + "Reconnect {} on ChatGPT to grant the permissions needed for this request.", + auth_failure.connector_name + ), + Some("reauthentication_required") => format!( + "Reconnect {} on ChatGPT to restore access for this request.", + auth_failure.connector_name + ), + Some("missing_link") => format!( + "Sign in to {} on ChatGPT to use it in Codex.", + auth_failure.connector_name + ), + _ => format!( + "Sign in to {} on ChatGPT to continue.", + auth_failure.connector_name + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Untrusted Calendar", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } + } + + #[test] + fn parses_auth_failure_from_trusted_connector_metadata() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + Some(CodexAppsConnectorAuthFailure { + connector_id: "connector_calendar".to_string(), + connector_name: "Google Calendar".to_string(), + install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar" + .to_string(), + auth_reason: Some("reauthentication_required".to_string()), + link_id: Some("link_123".to_string()), + error_code: Some("UNAUTHORIZED".to_string()), + error_http_status_code: Some(401), + error_action: Some("TRIGGER_REAUTHENTICATION".to_string()), + }) + ); + } + + #[test] + fn rejects_missing_or_mismatched_connector_ids() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + /*connector_id*/ None, + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + None + ); + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_drive"), + Some("Google Drive"), + Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()), + ), + None + ); + } + + #[test] + fn builds_url_elicitation_payload() { + let auth_failure = connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth failure"); + + assert_eq!( + build_auth_elicitation("call_123", &auth_failure), + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Google Calendar", + "install_url": + "https://chatgpt.com/apps/google-calendar/connector_calendar", + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + }), + message: "Reconnect Google Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + ); + } + + #[test] + fn builds_auth_elicitation_plan() { + let plan = build_auth_elicitation_plan( + "call_123", + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth elicitation plan"); + + assert_eq!(plan.auth_failure.connector_name, "Google Calendar"); + assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123"); + } +} diff --git a/code-rs/codex-mcp/src/builtin.rs b/code-rs/codex-mcp/src/builtin.rs new file mode 100644 index 00000000000..9441b644ddd --- /dev/null +++ b/code-rs/codex-mcp/src/builtin.rs @@ -0,0 +1,39 @@ +use std::io; +use std::path::PathBuf; + +use codex_builtin_mcps::BuiltinMcpServer; +use codex_rmcp_client::InProcessTransportFactory; +use futures::FutureExt; +use futures::future::BoxFuture; + +#[derive(Clone)] +pub(crate) struct BuiltinMcpServerFactory { + server: BuiltinMcpServer, + codex_home: PathBuf, +} + +impl BuiltinMcpServerFactory { + pub(crate) fn new(server: BuiltinMcpServer, codex_home: PathBuf) -> Self { + Self { server, codex_home } + } +} + +impl InProcessTransportFactory for BuiltinMcpServerFactory { + fn open(&self) -> BoxFuture<'static, io::Result> { + let server = self.server; + let codex_home = self.codex_home.clone(); + async move { + let (client_transport, server_transport) = tokio::io::duplex(64 * 1024); + tokio::spawn(async move { + if let Err(err) = server.serve(&codex_home, server_transport).await { + tracing::warn!( + server = server.name(), + "built-in MCP server exited: {err:#}" + ); + } + }); + Ok(client_transport) + } + .boxed() + } +} diff --git a/code-rs/codex-mcp/src/codex_apps.rs b/code-rs/codex-mcp/src/codex_apps.rs new file mode 100644 index 00000000000..81643e66656 --- /dev/null +++ b/code-rs/codex-mcp/src/codex_apps.rs @@ -0,0 +1,247 @@ +//! Codex Apps support for the built-in apps MCP server. +//! +//! This module owns the pieces that are unique to ChatGPT-hosted app +//! connectors: cache scoping by authenticated user, disk cache reads/writes, +//! connector allow-list filtering, and the normalization that turns app +//! connector/tool metadata into model-visible MCP callable names. + +use std::path::PathBuf; +use std::time::Instant; + +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::runtime::emit_duration; +use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; +use crate::tools::ToolInfo; +use codex_login::CodexAuth; +use codex_utils_plugins::mcp_connector::is_connector_id_allowed; +use codex_utils_plugins::mcp_connector::sanitize_name; +use serde::Deserialize; +use serde::Serialize; +use sha1::Digest; +use sha1::Sha1; + +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 2; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodexAppsToolsCacheKey { + pub(crate) account_id: Option, + pub(crate) chatgpt_user_id: Option, + pub(crate) is_workspace_account: bool, +} + +pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey { + CodexAppsToolsCacheKey { + account_id: auth.and_then(CodexAuth::get_account_id), + chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), + is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account), + } +} + +#[derive(Clone)] +pub(crate) struct CodexAppsToolsCacheContext { + pub(crate) codex_home: PathBuf, + pub(crate) user_key: CodexAppsToolsCacheKey, +} + +impl CodexAppsToolsCacheContext { + pub(crate) fn cache_path(&self) -> PathBuf { + let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); + let user_key_hash = sha1_hex(&user_key_json); + self.codex_home + .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(format!("{user_key_hash}.json")) + } +} + +pub(crate) enum CachedCodexAppsToolsLoad { + Hit(Vec), + Missing, + Invalid, +} + +pub(crate) fn normalize_codex_apps_tool_title( + server_name: &str, + connector_name: Option<&str>, + value: &str, +) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return value.to_string(); + } + + let Some(connector_name) = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return value.to_string(); + }; + + let prefix = format!("{connector_name}_"); + if let Some(stripped) = value.strip_prefix(&prefix) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + value.to_string() +} + +pub(crate) fn normalize_codex_apps_callable_name( + server_name: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, +) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return tool_name.to_string(); + } + + let tool_name = sanitize_name(tool_name); + + if let Some(connector_name) = connector_name + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_name) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + if let Some(connector_id) = connector_id + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_id) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + tool_name +} + +pub(crate) fn normalize_codex_apps_callable_namespace( + server_name: &str, + connector_name: Option<&str>, +) -> String { + if server_name == CODEX_APPS_MCP_SERVER_NAME + && let Some(connector_name) = connector_name + { + format!("mcp__{}__{}", server_name, sanitize_name(connector_name)) + } else { + format!("mcp__{server_name}__") + } +} + +pub(crate) fn write_cached_codex_apps_tools_if_needed( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, + tools: &[ToolInfo], +) { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return; + } + + if let Some(cache_context) = cache_context { + let cache_write_start = Instant::now(); + write_cached_codex_apps_tools(cache_context, tools); + emit_duration( + MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, + cache_write_start.elapsed(), + &[], + ); + } +} + +pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option> { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + let cache_context = cache_context?; + + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, + } +} + +#[cfg(test)] +pub(crate) fn read_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> Option> { + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, + } +} + +pub(crate) fn load_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> CachedCodexAppsToolsLoad { + let cache_path = cache_context.cache_path(); + let bytes = match std::fs::read(cache_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return CachedCodexAppsToolsLoad::Missing; + } + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) { + Ok(cache) => cache, + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { + return CachedCodexAppsToolsLoad::Invalid; + } + CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) +} + +pub(crate) fn write_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, + tools: &[ToolInfo], +) { + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); + let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { + schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + tools, + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + +pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { + tools + .into_iter() + .filter(|tool| { + tool.connector_id + .as_deref() + .is_none_or(is_connector_id_allowed) + }) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsToolsDiskCache { + schema_version: u8, + tools: Vec, +} + +const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; + +fn sha1_hex(s: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(s.as_bytes()); + let sha1 = hasher.finalize(); + format!("{sha1:x}") +} diff --git a/code-rs/codex-mcp/src/connection_manager.rs b/code-rs/codex-mcp/src/connection_manager.rs new file mode 100644 index 00000000000..e02b6094b39 --- /dev/null +++ b/code-rs/codex-mcp/src/connection_manager.rs @@ -0,0 +1,769 @@ +//! Aggregates MCP server connections for Codex. +//! +//! [`McpConnectionManager`] owns the set of running async RMCP clients keyed by +//! MCP server name. It coordinates startup status events, keeps server origin +//! metadata, aggregates tools/resources/templates across servers, routes tool +//! calls to the right client, and exposes the public manager API used by +//! `codex-core`. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use crate::McpAuthStatusEntry; +use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::CodexAppsToolsCacheKey; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::elicitation::ElicitationRequestManager; +use crate::elicitation::ElicitationReviewerHandle; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::ToolPluginProvenance; +use crate::rmcp_client::AsyncManagedClient; +use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT; +use crate::rmcp_client::MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC; +use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC; +use crate::rmcp_client::ManagedClient; +use crate::rmcp_client::StartupOutcomeError; +use crate::rmcp_client::list_tools_for_client_uncached; +use crate::runtime::McpRuntimeEnvironment; +use crate::runtime::emit_duration; +use crate::server::EffectiveMcpServer; +use crate::server::McpServerMetadata; +use crate::tools::ToolInfo; +use crate::tools::filter_tools; +use crate::tools::normalize_tools_for_model; +use crate::tools::tool_with_model_visible_input_schema; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use async_channel::Sender; +use codex_config::Constrained; +use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_login::CodexAuth; +use codex_protocol::ToolName; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::McpStartupCompleteEvent; +use codex_protocol::protocol::McpStartupFailure; +use codex_protocol::protocol::McpStartupStatus; +use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_rmcp_client::ElicitationResponse; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; +use rmcp::model::PaginatedRequestParams; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use rmcp::model::RequestId; +use rmcp::model::Resource; +use rmcp::model::ResourceTemplate; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::instrument; +use tracing::warn; + +/// A thin wrapper around a set of running [`RmcpClient`] instances. +pub struct McpConnectionManager { + clients: HashMap, + server_metadata: HashMap, + host_owned_codex_apps_enabled: bool, + elicitation_requests: ElicitationRequestManager, + startup_cancellation_token: CancellationToken, +} + +impl McpConnectionManager { + pub fn new_uninitialized( + approval_policy: &Constrained, + permission_profile: &Constrained, + ) -> Self { + Self { + clients: HashMap::new(), + server_metadata: HashMap::new(), + host_owned_codex_apps_enabled: false, + elicitation_requests: ElicitationRequestManager::new( + approval_policy.value(), + permission_profile.get().clone(), + /*reviewer*/ None, + ), + startup_cancellation_token: CancellationToken::new(), + } + } + + pub fn has_servers(&self) -> bool { + !self.clients.is_empty() + } + + /// Drain all MCP clients from this manager and return a future that stops + /// them and terminates their stdio server processes. + pub fn begin_shutdown(&mut self) -> impl std::future::Future + Send + 'static { + self.startup_cancellation_token.cancel(); + let clients = std::mem::take(&mut self.clients); + self.server_metadata.clear(); + async move { + for client in clients.into_values() { + client.shutdown().await; + } + } + } + + /// Stop all MCP clients owned by this manager and terminate stdio server processes. + pub async fn shutdown(&mut self) { + self.begin_shutdown().await; + } + + pub fn server_origin(&self, server_name: &str) -> Option<&str> { + self.server_metadata + .get(server_name) + .and_then(|metadata| metadata.origin.as_ref()) + .map(super::server::McpServerOrigin::as_str) + } + + pub fn server_pollutes_memory(&self, server_name: &str) -> bool { + self.server_metadata + .get(server_name) + .is_none_or(|metadata| metadata.pollutes_memory) + } + + pub fn parallel_tool_call_server_names(&self) -> HashSet { + self.server_metadata + .iter() + .filter_map(|(name, metadata)| { + metadata + .supports_parallel_tool_calls + .then_some(name.clone()) + }) + .collect() + } + + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { + self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME + } + + pub fn set_approval_policy(&self, approval_policy: &Constrained) { + if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { + *policy = approval_policy.value(); + } + } + + pub fn set_permission_profile(&self, permission_profile: PermissionProfile) { + if let Ok(mut profile) = self.elicitation_requests.permission_profile.lock() { + *profile = permission_profile; + } + } + + pub fn elicitations_auto_deny(&self) -> bool { + self.elicitation_requests.auto_deny() + } + + pub fn set_elicitations_auto_deny(&self, auto_deny: bool) { + self.elicitation_requests.set_auto_deny(auto_deny); + } + + #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] + pub async fn new( + mcp_servers: &HashMap, + store_mode: OAuthCredentialsStoreMode, + auth_entries: HashMap, + approval_policy: &Constrained, + submit_id: String, + tx_event: Sender, + initial_permission_profile: PermissionProfile, + runtime_environment: McpRuntimeEnvironment, + codex_home: PathBuf, + codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + host_owned_codex_apps_enabled: bool, + tool_plugin_provenance: ToolPluginProvenance, + auth: Option<&CodexAuth>, + elicitation_reviewer: Option, + ) -> (Self, CancellationToken) { + let cancel_token = CancellationToken::new(); + let mut clients = HashMap::new(); + let mut server_metadata = HashMap::new(); + let mut join_set = JoinSet::new(); + let elicitation_requests = ElicitationRequestManager::new( + approval_policy.value(), + initial_permission_profile, + elicitation_reviewer, + ); + let tool_plugin_provenance = Arc::new(tool_plugin_provenance); + let startup_submit_id = submit_id.clone(); + let codex_apps_auth_provider = auth + .filter(|auth| auth.uses_codex_backend()) + .map(codex_model_provider::auth_provider_from_auth); + let mcp_servers = mcp_servers.clone(); + for (server_name, server) in mcp_servers + .into_iter() + .filter(|(_, server)| server.enabled()) + { + server_metadata.insert(server_name.clone(), McpServerMetadata::from(&server)); + let cancel_token = cancel_token.child_token(); + let _ = emit_update( + startup_submit_id.as_str(), + &tx_event, + McpStartupUpdateEvent { + server: server_name.clone(), + status: McpStartupStatus::Starting, + }, + ) + .await; + let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { + Some(CodexAppsToolsCacheContext { + codex_home: codex_home.clone(), + user_key: codex_apps_tools_cache_key.clone(), + }) + } else { + None + }; + let uses_env_bearer_token = + server + .configured_config() + .is_some_and(|config| match &config.transport { + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var, + .. + } => bearer_token_env_var.is_some(), + McpServerTransportConfig::Stdio { .. } => false, + }); + let runtime_auth_provider = + if server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token { + codex_apps_auth_provider.clone() + } else { + None + }; + let async_managed_client = AsyncManagedClient::new( + server_name.clone(), + server, + store_mode, + cancel_token.clone(), + tx_event.clone(), + elicitation_requests.clone(), + codex_apps_tools_cache_context, + Arc::clone(&tool_plugin_provenance), + runtime_environment.clone(), + codex_home.clone(), + runtime_auth_provider, + ); + clients.insert(server_name.clone(), async_managed_client.clone()); + let tx_event = tx_event.clone(); + let submit_id = startup_submit_id.clone(); + let auth_entry = auth_entries.get(&server_name).cloned(); + join_set.spawn(async move { + let mut outcome = async_managed_client.client().await; + if cancel_token.is_cancelled() { + outcome = Err(StartupOutcomeError::Cancelled); + } + let status = match &outcome { + Ok(_) => McpStartupStatus::Ready, + Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled, + Err(error) => { + let error_str = mcp_init_error_display( + server_name.as_str(), + auth_entry.as_ref(), + error, + ); + McpStartupStatus::Failed { error: error_str } + } + }; + + let _ = emit_update( + submit_id.as_str(), + &tx_event, + McpStartupUpdateEvent { + server: server_name.clone(), + status, + }, + ) + .await; + + (server_name, outcome) + }); + } + let manager = Self { + clients, + server_metadata, + host_owned_codex_apps_enabled, + elicitation_requests: elicitation_requests.clone(), + startup_cancellation_token: cancel_token.clone(), + }; + tokio::spawn(async move { + let outcomes = join_set.join_all().await; + let mut summary = McpStartupCompleteEvent::default(); + for (server_name, outcome) in outcomes { + match outcome { + Ok(_) => summary.ready.push(server_name), + Err(StartupOutcomeError::Cancelled) => summary.cancelled.push(server_name), + Err(StartupOutcomeError::Failed { error }) => { + summary.failed.push(McpStartupFailure { + server: server_name, + error, + }) + } + } + } + let _ = tx_event + .send(Event { + id: startup_submit_id, + msg: EventMsg::McpStartupComplete(summary), + }) + .await; + }); + (manager, cancel_token) + } + + pub async fn resolve_elicitation( + &self, + server_name: String, + id: RequestId, + response: ElicitationResponse, + ) -> Result<()> { + self.elicitation_requests + .resolve(server_name, id, response) + .await + } + + pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { + let Some(async_managed_client) = self.clients.get(server_name) else { + return false; + }; + + match tokio::time::timeout(timeout, async_managed_client.client()).await { + Ok(Ok(_)) => true, + Ok(Err(_)) | Err(_) => false, + } + } + + pub async fn required_startup_failures( + &self, + required_servers: &[String], + ) -> Vec { + let mut failures = Vec::new(); + for server_name in required_servers { + let Some(async_managed_client) = self.clients.get(server_name).cloned() else { + failures.push(McpStartupFailure { + server: server_name.clone(), + error: format!("required MCP server `{server_name}` was not initialized"), + }); + continue; + }; + + match async_managed_client.client().await { + Ok(_) => {} + Err(error) => failures.push(McpStartupFailure { + server: server_name.clone(), + error: startup_outcome_error_message(error), + }), + } + } + failures + } + + /// Returns all tools with model-visible names normalized. + #[instrument(level = "trace", skip_all)] + pub async fn list_all_tools(&self) -> Vec { + let mut tools = Vec::new(); + for managed_client in self.clients.values() { + let Some(server_tools) = managed_client.listed_tools().await else { + continue; + }; + tools.extend(server_tools); + } + normalize_tools_for_model(tools) + } + + /// Force-refresh codex apps tools by bypassing the in-process cache. + /// + /// On success, the refreshed tools replace the cache contents and the + /// latest filtered tools are returned directly to the caller. On + /// failure, the existing cache remains unchanged. + pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { + let managed_client = self + .clients + .get(CODEX_APPS_MCP_SERVER_NAME) + .ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))? + .client() + .await + .context("failed to get client")?; + + let list_start = Instant::now(); + let fetch_start = Instant::now(); + let tools = list_tools_for_client_uncached( + CODEX_APPS_MCP_SERVER_NAME, + &managed_client.client, + managed_client.tool_timeout, + managed_client.server_instructions.as_deref(), + ) + .await + .with_context(|| { + format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'") + })?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + managed_client.codex_apps_tools_cache_context.as_ref(), + &tools, + ); + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); + let tools = filter_tools(tools, &managed_client.tool_filter) + .into_iter() + .map(|mut tool| { + tool.tool = tool_with_model_visible_input_schema(&tool.tool); + tool + }); + Ok(normalize_tools_for_model(tools)) + } + + /// Returns a single map that contains all resources. Each key is the + /// server name and the value is a vector of resources. + pub async fn list_all_resources(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + let clients_snapshot = &self.clients; + + for (server_name, async_managed_client) in clients_snapshot { + let server_name = server_name.clone(); + let Ok(managed_client) = async_managed_client.client().await else { + continue; + }; + let timeout = managed_client.tool_timeout; + let client = managed_client.client.clone(); + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor.as_ref().map(|next| PaginatedRequestParams { + meta: None, + cursor: Some(next.clone()), + }); + let response = match client.list_resources(params, timeout).await { + Ok(result) => result, + Err(err) => return (server_name, Err(err)), + }; + + collected.extend(response.resources); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name, + Err(anyhow!("resources/list returned duplicate cursor")), + ); + } + cursor = Some(next); + } + None => return (server_name, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(resources))) => { + aggregated.insert(server_name, resources); + } + Ok((server_name, Err(err))) => { + warn!("Failed to list resources for MCP server '{server_name}': {err:#}"); + } + Err(err) => { + warn!("Task panic when listing resources for MCP server: {err:#}"); + } + } + } + + aggregated + } + + /// Returns a single map that contains all resource templates. Each key is the + /// server name and the value is a vector of resource templates. + pub async fn list_all_resource_templates(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + let clients_snapshot = &self.clients; + + for (server_name, async_managed_client) in clients_snapshot { + let server_name_cloned = server_name.clone(); + let Ok(managed_client) = async_managed_client.client().await else { + continue; + }; + let client = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor.as_ref().map(|next| PaginatedRequestParams { + meta: None, + cursor: Some(next.clone()), + }); + let response = match client.list_resource_templates(params, timeout).await { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resource_templates); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!( + "resources/templates/list returned duplicate cursor" + )), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(templates))) => { + aggregated.insert(server_name, templates); + } + Ok((server_name, Err(err))) => { + warn!( + "Failed to list resource templates for MCP server '{server_name}': {err:#}" + ); + } + Err(err) => { + warn!("Task panic when listing resource templates for MCP server: {err:#}"); + } + } + } + + aggregated + } + + /// Invoke the tool indicated by the (server, tool) pair. + pub async fn call_tool( + &self, + server: &str, + tool: &str, + arguments: Option, + meta: Option, + ) -> Result { + let client = self.client_by_name(server).await?; + if !client.tool_filter.allows(tool) { + return Err(anyhow!( + "tool '{tool}' is disabled for MCP server '{server}'" + )); + } + + let result: rmcp::model::CallToolResult = client + .client + .call_tool(tool.to_string(), arguments, meta, client.tool_timeout) + .await + .with_context(|| format!("tool call failed for `{server}/{tool}`"))?; + + let content = result + .content + .into_iter() + .map(|content| { + serde_json::to_value(content) + .unwrap_or_else(|_| serde_json::Value::String("".to_string())) + }) + .collect(); + + Ok(CallToolResult { + content, + structured_content: result.structured_content, + is_error: result.is_error, + meta: result.meta.and_then(|meta| serde_json::to_value(meta).ok()), + }) + } + + pub async fn server_supports_sandbox_state_meta_capability( + &self, + server: &str, + ) -> Result { + Ok(self + .client_by_name(server) + .await? + .server_supports_sandbox_state_meta_capability) + } + + /// List resources from the specified server. + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self.client_by_name(server).await?; + let timeout = managed.tool_timeout; + + managed + .client + .list_resources(params, timeout) + .await + .with_context(|| format!("resources/list failed for `{server}`")) + } + + /// List resource templates from the specified server. + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self.client_by_name(server).await?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resource_templates(params, timeout) + .await + .with_context(|| format!("resources/templates/list failed for `{server}`")) + } + + /// Read a resource from the specified server. + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> Result { + let managed = self.client_by_name(server).await?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + let uri = params.uri.clone(); + + client + .read_resource(params, timeout) + .await + .with_context(|| format!("resources/read failed for `{server}` ({uri})")) + } + + pub async fn resolve_tool_info(&self, tool_name: &ToolName) -> Option { + let all_tools = self.list_all_tools().await; + all_tools + .into_iter() + .find(|tool| tool.canonical_tool_name() == *tool_name) + } + + async fn client_by_name(&self, name: &str) -> Result { + self.clients + .get(name) + .ok_or_else(|| anyhow!("unknown MCP server '{name}'"))? + .client() + .await + .context("failed to get client") + } +} + +impl Drop for McpConnectionManager { + fn drop(&mut self) { + self.startup_cancellation_token.cancel(); + self.clients.clear(); + } +} + +async fn emit_update( + submit_id: &str, + tx_event: &Sender, + update: McpStartupUpdateEvent, +) -> Result<(), async_channel::SendError> { + tx_event + .send(Event { + id: submit_id.to_string(), + msg: EventMsg::McpStartupUpdate(update), + }) + .await +} + +fn mcp_init_error_display( + server_name: &str, + entry: Option<&McpAuthStatusEntry>, + err: &StartupOutcomeError, +) -> String { + if let Some(McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + .. + }) = entry.and_then(|entry| entry.config.as_ref().map(|config| &config.transport)) + && url == "https://api.githubcopilot.com/mcp/" + && bearer_token_env_var.is_none() + && http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true) + { + format!( + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" + ) + } else if is_mcp_client_auth_required_error(err) { + format!( + "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." + ) + } else if is_mcp_client_startup_timeout_error(err) { + let startup_timeout_secs = match entry { + Some(entry) => match entry + .config + .as_ref() + .and_then(|config| config.startup_timeout_sec) + { + Some(timeout) => timeout, + None => DEFAULT_STARTUP_TIMEOUT, + }, + None => DEFAULT_STARTUP_TIMEOUT, + } + .as_secs(); + format!( + "MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX" + ) + } else { + format!("MCP client for `{server_name}` failed to start: {err:#}") + } +} + +fn startup_outcome_error_message(error: StartupOutcomeError) -> String { + match error { + StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(), + StartupOutcomeError::Failed { error } => error, + } +} + +fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool { + match error { + StartupOutcomeError::Failed { error } => error.contains("Auth required"), + _ => false, + } +} + +fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool { + match error { + StartupOutcomeError::Failed { error } => { + error.contains("request timed out") + || error.contains("timed out handshaking with MCP server") + } + _ => false, + } +} + +#[cfg(test)] +#[path = "connection_manager_tests.rs"] +mod tests; diff --git a/code-rs/codex-mcp/src/connection_manager_tests.rs b/code-rs/codex-mcp/src/connection_manager_tests.rs new file mode 100644 index 00000000000..4835bc57054 --- /dev/null +++ b/code-rs/codex-mcp/src/connection_manager_tests.rs @@ -0,0 +1,955 @@ +use super::*; +use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION; +use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; +use crate::codex_apps::read_cached_codex_apps_tools; +use crate::codex_apps::write_cached_codex_apps_tools; +use crate::declared_openai_file_input_param_names; +use crate::elicitation::ElicitationRequestManager; +use crate::elicitation::elicitation_is_rejected_by_policy; +use crate::rmcp_client::AsyncManagedClient; +use crate::rmcp_client::ManagedClient; +use crate::rmcp_client::StartupOutcomeError; +use crate::rmcp_client::elicitation_capability_for_server; +use crate::tools::ToolFilter; +use crate::tools::ToolInfo; +use crate::tools::filter_tools; +use crate::tools::normalize_tools_for_model; +use crate::tools::tool_with_model_visible_input_schema; +use codex_config::Constrained; +use codex_config::McpServerConfig; +use codex_protocol::ToolName; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::GranularApprovalConfig; +use codex_protocol::protocol::McpAuthStatus; +use futures::FutureExt; +use pretty_assertions::assert_eq; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::ElicitationCapability; +use rmcp::model::JsonObject; +use rmcp::model::Meta; +use rmcp::model::NumberOrString; +use rmcp::model::Tool; +use std::collections::HashSet; +use std::sync::Arc; +use tempfile::tempdir; + +fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { + let tool_namespace = format!("mcp__{server_name}__"); + ToolInfo { + server_name: server_name.to_string(), + callable_name: tool_name.to_string(), + callable_namespace: tool_namespace, + namespace_description: None, + tool: Tool { + name: tool_name.to_string().into(), + title: None, + description: Some(format!("Test tool: {tool_name}").into()), + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + } +} + +fn create_test_tool_with_connector( + server_name: &str, + tool_name: &str, + connector_id: &str, + connector_name: Option<&str>, +) -> ToolInfo { + let mut tool = create_test_tool(server_name, tool_name); + tool.connector_id = Some(connector_id.to_string()); + tool.connector_name = connector_name.map(ToOwned::to_owned); + tool +} + +fn create_codex_apps_tools_cache_context( + codex_home: PathBuf, + account_id: Option<&str>, + chatgpt_user_id: Option<&str>, +) -> CodexAppsToolsCacheContext { + CodexAppsToolsCacheContext { + codex_home, + user_key: CodexAppsToolsCacheKey { + account_id: account_id.map(ToOwned::to_owned), + chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), + is_workspace_account: false, + }, + } +} + +fn model_tool_names(tools: &[ToolInfo]) -> HashSet { + tools + .iter() + .map(ToolInfo::canonical_tool_name) + .collect::>() +} + +#[test] +fn declared_openai_file_fields_treat_names_literally() { + let meta = serde_json::json!({ + "openai/fileParams": ["file", "input_file", "attachments"] + }); + let meta = meta.as_object().expect("meta object"); + + assert_eq!( + declared_openai_file_input_param_names(Some(meta)), + vec![ + "file".to_string(), + "input_file".to_string(), + "attachments".to_string(), + ] + ); +} + +#[test] +fn tool_with_model_visible_input_schema_masks_file_params() { + let mut tool = create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "upload").tool; + tool.input_schema = Arc::new( + serde_json::json!({ + "type": "object", + "properties": { + "file": { + "type": "object", + "description": "Original file payload." + }, + "files": { + "type": "array", + "items": {"type": "object"} + } + } + }) + .as_object() + .expect("object") + .clone(), + ); + tool.meta = Some(Meta( + serde_json::json!({ + "openai/fileParams": ["file", "files"] + }) + .as_object() + .expect("object") + .clone(), + )); + + let tool = tool_with_model_visible_input_schema(&tool); + + assert_eq!( + *tool.input_schema, + serde_json::json!({ + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Original file payload. This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here." + }, + "files": { + "type": "array", + "items": {"type": "string"}, + "description": "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here." + } + } + }) + .as_object() + .expect("object") + .clone() + ); +} + +#[test] +fn tool_with_model_visible_input_schema_leaves_tools_without_file_params_unchanged() { + let original_tool = create_test_tool("custom", "upload").tool; + + let tool = tool_with_model_visible_input_schema(&original_tool); + + assert_eq!(tool, original_tool); +} + +#[test] +fn elicitation_granular_policy_defaults_to_prompting() { + assert!(!elicitation_is_rejected_by_policy( + AskForApproval::OnFailure + )); + assert!(!elicitation_is_rejected_by_policy( + AskForApproval::OnRequest + )); + assert!(!elicitation_is_rejected_by_policy( + AskForApproval::UnlessTrusted + )); + assert!(elicitation_is_rejected_by_policy(AskForApproval::Granular( + GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: false, + } + ))); +} + +#[test] +fn elicitation_granular_policy_respects_never_and_config() { + assert!(elicitation_is_rejected_by_policy(AskForApproval::Never)); + assert!(elicitation_is_rejected_by_policy(AskForApproval::Granular( + GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: false, + } + ))); +} + +#[tokio::test] +async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { + let manager = ElicitationRequestManager::new( + AskForApproval::Never, + PermissionProfile::Disabled, + /*reviewer*/ None, + ); + let (tx_event, _rx_event) = async_channel::bounded(1); + let sender = manager.make_sender("server".to_string(), tx_event); + + let response = sender( + NumberOrString::Number(1), + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Confirm?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + ) + .await + .expect("elicitation should auto accept"); + + assert_eq!( + response, + ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + } + ); +} + +#[tokio::test] +async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fields() { + let manager = ElicitationRequestManager::new( + AskForApproval::Never, + PermissionProfile::Disabled, + /*reviewer*/ None, + ); + let (tx_event, _rx_event) = async_channel::bounded(1); + let sender = manager.make_sender("server".to_string(), tx_event); + + let response = sender( + NumberOrString::Number(1), + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "What should I say?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .required_property( + "message", + rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), + ) + .build() + .expect("schema should build"), + }, + ) + .await + .expect("elicitation should auto decline"); + + assert_eq!( + response, + ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + } + ); +} + +#[test] +fn test_normalize_tools_short_non_duplicated_names() { + let tools = vec![ + create_test_tool("server1", "tool1"), + create_test_tool("server1", "tool2"), + ]; + + let model_tools = normalize_tools_for_model(tools); + + assert_eq!( + model_tool_names(&model_tools), + HashSet::from([ + ToolName::namespaced("mcp__server1__", "tool1"), + ToolName::namespaced("mcp__server1__", "tool2") + ]) + ); +} + +#[test] +fn test_normalize_tools_duplicated_names_skipped() { + let tools = vec![ + create_test_tool("server1", "duplicate_tool"), + create_test_tool("server1", "duplicate_tool"), + ]; + + let model_tools = normalize_tools_for_model(tools); + + // Only the first tool should remain, the second is skipped + assert_eq!( + model_tool_names(&model_tools), + HashSet::from([ToolName::namespaced("mcp__server1__", "duplicate_tool")]) + ); +} + +#[test] +fn test_normalize_tools_long_names_same_server() { + let server_name = "my_server"; + + let tools = vec![ + create_test_tool( + server_name, + "extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits", + ), + create_test_tool( + server_name, + "yet_another_extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits", + ), + ]; + + let model_tools = normalize_tools_for_model(tools); + + assert_eq!(model_tools.len(), 2); + + let names = model_tool_names(&model_tools); + + assert!(names.iter().all(|name| name.display().len() == 64)); + assert!( + names + .iter() + .all(|name| name.namespace.as_deref() == Some("mcp__my_server__")) + ); + assert!( + names.iter().all(|name| name + .display() + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_')), + "model-visible names must be code-mode compatible: {names:?}" + ); +} + +#[test] +fn test_normalize_tools_sanitizes_invalid_characters() { + let tools = vec![create_test_tool("server.one", "tool.two-three")]; + + let model_tools = normalize_tools_for_model(tools); + + assert_eq!(model_tools.len(), 1); + let tool = model_tools.into_iter().next().expect("one tool"); + let model_name = tool.canonical_tool_name(); + assert_eq!( + model_name, + ToolName::namespaced("mcp__server_one__", "tool_two_three") + ); + assert_eq!( + format!("{}{}", tool.callable_namespace, tool.callable_name), + model_name.display() + ); + + // The callable parts are sanitized for model-visible tool calls, but the raw + // MCP name is preserved for the actual MCP call. + assert_eq!(tool.server_name, "server.one"); + assert_eq!(tool.callable_namespace, "mcp__server_one__"); + assert_eq!(tool.callable_name, "tool_two_three"); + assert_eq!(tool.tool.name, "tool.two-three"); + + assert!( + model_name + .display() + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'), + "model-visible name must be code-mode compatible: {model_name:?}" + ); +} + +#[test] +fn test_normalize_tools_keeps_hyphenated_mcp_tools_callable() { + let tools = vec![create_test_tool("music-studio", "get-strudel-guide")]; + + let model_tools = normalize_tools_for_model(tools); + + assert_eq!(model_tools.len(), 1); + let tool = model_tools.into_iter().next().expect("one tool"); + assert_eq!( + tool.canonical_tool_name(), + ToolName::namespaced("mcp__music_studio__", "get_strudel_guide") + ); + assert_eq!(tool.callable_namespace, "mcp__music_studio__"); + assert_eq!(tool.callable_name, "get_strudel_guide"); + assert_eq!(tool.tool.name, "get-strudel-guide"); +} + +#[test] +fn test_normalize_tools_disambiguates_sanitized_namespace_collisions() { + let tools = vec![ + create_test_tool("basic-server", "lookup"), + create_test_tool("basic_server", "query"), + ]; + + let model_tools = normalize_tools_for_model(tools); + + assert_eq!(model_tools.len(), 2); + let mut namespaces = model_tools + .iter() + .map(|tool| tool.callable_namespace.as_str()) + .collect::>(); + namespaces.sort(); + namespaces.dedup(); + assert_eq!(namespaces.len(), 2); + + let raw_servers = model_tools + .iter() + .map(|tool| tool.server_name.as_str()) + .collect::>(); + assert_eq!(raw_servers, HashSet::from(["basic-server", "basic_server"])); + let model_names = model_tool_names(&model_tools); + assert!( + model_names.iter().all(|name| name + .display() + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_')), + "model-visible names must be code-mode compatible: {model_names:?}" + ); +} + +#[test] +fn test_normalize_tools_disambiguates_sanitized_tool_name_collisions() { + let tools = vec![ + create_test_tool("server", "tool-name"), + create_test_tool("server", "tool_name"), + ]; + + let model_tools = normalize_tools_for_model(tools); + + assert_eq!(model_tools.len(), 2); + let raw_tool_names = model_tools + .iter() + .map(|tool| tool.tool.name.to_string()) + .collect::>(); + assert_eq!( + raw_tool_names, + HashSet::from(["tool-name".to_string(), "tool_name".to_string()]) + ); + let callable_tool_names = model_tools + .iter() + .map(|tool| tool.callable_name.as_str()) + .collect::>(); + assert_eq!(callable_tool_names.len(), 2); +} + +#[test] +fn tool_filter_allows_by_default() { + let filter = ToolFilter::default(); + + assert!(filter.allows("any")); +} + +#[test] +fn tool_filter_applies_enabled_list() { + let filter = ToolFilter { + enabled: Some(HashSet::from(["allowed".to_string()])), + disabled: HashSet::new(), + }; + + assert!(filter.allows("allowed")); + assert!(!filter.allows("denied")); +} + +#[test] +fn tool_filter_applies_disabled_list() { + let filter = ToolFilter { + enabled: None, + disabled: HashSet::from(["blocked".to_string()]), + }; + + assert!(!filter.allows("blocked")); + assert!(filter.allows("open")); +} + +#[test] +fn tool_filter_applies_enabled_then_disabled() { + let filter = ToolFilter { + enabled: Some(HashSet::from(["keep".to_string(), "remove".to_string()])), + disabled: HashSet::from(["remove".to_string()]), + }; + + assert!(filter.allows("keep")); + assert!(!filter.allows("remove")); + assert!(!filter.allows("unknown")); +} + +#[test] +fn filter_tools_applies_per_server_filters() { + let server1_tools = vec![ + create_test_tool("server1", "tool_a"), + create_test_tool("server1", "tool_b"), + ]; + let server2_tools = vec![create_test_tool("server2", "tool_a")]; + let server1_filter = ToolFilter { + enabled: Some(HashSet::from(["tool_a".to_string(), "tool_b".to_string()])), + disabled: HashSet::from(["tool_b".to_string()]), + }; + let server2_filter = ToolFilter { + enabled: None, + disabled: HashSet::from(["tool_a".to_string()]), + }; + + let filtered: Vec<_> = filter_tools(server1_tools, &server1_filter) + .into_iter() + .chain(filter_tools(server2_tools, &server2_filter)) + .collect(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].server_name, "server1"); + assert_eq!(filtered[0].callable_name, "tool_a"); +} + +#[test] +fn codex_apps_tools_cache_is_overwritten_by_last_write() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + + write_cached_codex_apps_tools(&cache_context, &tools_gateway_1); + let cached_gateway_1 = + read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for first write"); + assert_eq!(cached_gateway_1[0].callable_name, "one"); + + write_cached_codex_apps_tools(&cache_context, &tools_gateway_2); + let cached_gateway_2 = + read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for second write"); + assert_eq!(cached_gateway_2[0].callable_name, "two"); +} + +#[test] +fn codex_apps_tools_cache_is_scoped_per_user() { + let codex_home = tempdir().expect("tempdir"); + let cache_context_user_1 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_context_user_2 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-two"), + Some("user-two"), + ); + let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + + write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1); + write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2); + + let read_user_1 = + read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one"); + let read_user_2 = + read_cached_codex_apps_tools(&cache_context_user_2).expect("cache entry for user two"); + + assert_eq!(read_user_1[0].callable_name, "one"); + assert_eq!(read_user_2[0].callable_name, "two"); + assert_ne!( + cache_context_user_1.cache_path(), + cache_context_user_2.cache_path(), + "each user should get an isolated cache file" + ); +} + +#[test] +fn codex_apps_tools_cache_filters_disallowed_connectors() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools = vec![ + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "blocked_tool", + "connector_openai_hidden", + Some("Hidden"), + ), + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "allowed_tool", + "calendar", + Some("Calendar"), + ), + ]; + + write_cached_codex_apps_tools(&cache_context, &tools); + let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); + + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].callable_name, "allowed_tool"); + assert_eq!(cached[0].connector_id.as_deref(), Some("calendar")); +} + +#[test] +fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION + 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); +} + +#[test] +fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(cache_path, b"{not json").expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); +} + +#[test] +fn startup_cached_codex_apps_tools_loads_from_disk_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cached_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )]; + write_cached_codex_apps_tools(&cache_context, &cached_tools); + + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); + let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(startup_tools[0].callable_name, "calendar_search"); +} + +#[tokio::test] +async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(startup_tools), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .iter() + .find(|tool| { + tool.canonical_tool_name() + == ToolName::namespaced("mcp__codex_apps__", "calendar_create_event") + }) + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.callable_name, "calendar_create_event"); +} + +#[tokio::test] +async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() { + let startup_tools = vec![create_test_tool("rmcp", "echo")]; + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + manager.clients.insert( + "rmcp".to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(startup_tools), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let tool = manager + .resolve_tool_info(&ToolName::namespaced("mcp__rmcp__", "echo")) + .await + .expect("split MCP tool namespace and name should resolve"); + + let expected = ("rmcp", "mcp__rmcp__", "echo", "echo"); + assert_eq!( + ( + tool.server_name.as_str(), + tool.callable_namespace.as_str(), + tool.callable_name.as_str(), + tool.tool.name.as_ref(), + ), + expected + ); +} + +#[tokio::test] +async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + assert!(timeout_result.is_err()); +} + +#[tokio::test] +async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(Vec::new()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + let tools = timeout_result.expect("cache-hit startup snapshot should not block"); + assert!(tools.is_empty()); +} + +#[tokio::test] +async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let failed_client = futures::future::ready::>(Err( + StartupOutcomeError::Failed { + error: "startup failed".to_string(), + }, + )) + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: failed_client, + startup_snapshot: Some(startup_tools), + startup_complete, + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .iter() + .find(|tool| { + tool.canonical_tool_name() + == ToolName::namespaced("mcp__codex_apps__", "calendar_create_event") + }) + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.callable_name, "calendar_create_event"); +} + +#[test] +fn elicitation_capability_uses_2025_06_18_shape_for_all_servers() { + for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] { + let capability = elicitation_capability_for_server(server_name); + assert_eq!(capability, Some(ElicitationCapability::default())); + assert_eq!( + serde_json::to_value(capability).expect("serialize elicitation capability"), + serde_json::json!({}) + ); + } +} + +#[test] +fn mcp_init_error_display_prompts_for_github_pat() { + let server_name = "github"; + let entry = McpAuthStatusEntry { + config: Some(McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://api.githubcopilot.com/mcp/".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }), + auth_status: McpAuthStatus::Unsupported, + }; + let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into(); + + let display = mcp_init_error_display(server_name, Some(&entry), &err); + + let expected = format!( + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" + ); + + assert_eq!(expected, display); +} + +#[test] +fn mcp_init_error_display_prompts_for_login_when_auth_required() { + let server_name = "example"; + let err: StartupOutcomeError = anyhow::anyhow!("Auth required for server").into(); + + let display = mcp_init_error_display(server_name, /*entry*/ None, &err); + + let expected = format!( + "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." + ); + + assert_eq!(expected, display); +} + +#[test] +fn mcp_init_error_display_reports_generic_errors() { + let server_name = "custom"; + let entry = McpAuthStatusEntry { + config: Some(McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com".to_string(), + bearer_token_env_var: Some("TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }), + auth_status: McpAuthStatus::Unsupported, + }; + let err: StartupOutcomeError = anyhow::anyhow!("boom").into(); + + let display = mcp_init_error_display(server_name, Some(&entry), &err); + + let expected = format!("MCP client for `{server_name}` failed to start: {err:#}"); + + assert_eq!(expected, display); +} + +#[test] +fn mcp_init_error_display_includes_startup_timeout_hint() { + let server_name = "slow"; + let err: StartupOutcomeError = anyhow::anyhow!("request timed out").into(); + + let display = mcp_init_error_display(server_name, /*entry*/ None, &err); + + assert_eq!( + "MCP client for `slow` timed out after 30 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX", + display + ); +} diff --git a/code-rs/codex-mcp/src/elicitation.rs b/code-rs/codex-mcp/src/elicitation.rs new file mode 100644 index 00000000000..a51cd7c6235 --- /dev/null +++ b/code-rs/codex-mcp/src/elicitation.rs @@ -0,0 +1,256 @@ +//! MCP elicitation request tracking and policy handling. +//! +//! RMCP clients call into this module when a server asks Codex to elicit data +//! from the user. It decides whether the request can be automatically accepted, +//! must be declined by policy, or should be surfaced as a Codex protocol event +//! and later resolved through the stored responder. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; + +use crate::mcp::McpPermissionPromptAutoApproveContext; +use crate::mcp::mcp_permission_prompt_is_auto_approved; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use async_channel::Sender; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::mcp::RequestId as ProtocolRequestId; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_rmcp_client::ElicitationResponse; +use codex_rmcp_client::SendElicitation; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::RequestId; +use tokio::sync::Mutex; +use tokio::sync::oneshot; + +#[derive(Debug, Clone)] +pub struct ElicitationReviewRequest { + pub server_name: String, + pub request_id: RequestId, + pub elicitation: CreateElicitationRequestParams, +} + +pub trait ElicitationReviewer: Send + Sync { + fn review( + &self, + request: ElicitationReviewRequest, + ) -> BoxFuture<'static, Result>>; +} + +pub type ElicitationReviewerHandle = Arc; + +#[derive(Clone)] +pub(crate) struct ElicitationRequestManager { + requests: Arc>, + pub(crate) approval_policy: Arc>, + pub(crate) permission_profile: Arc>, + auto_deny: Arc>, + reviewer: Option, +} + +impl ElicitationRequestManager { + pub(crate) fn new( + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + reviewer: Option, + ) -> Self { + Self { + requests: Arc::new(Mutex::new(HashMap::new())), + approval_policy: Arc::new(StdMutex::new(approval_policy)), + permission_profile: Arc::new(StdMutex::new(permission_profile)), + auto_deny: Arc::new(StdMutex::new(false)), + reviewer, + } + } + + pub(crate) fn auto_deny(&self) -> bool { + self.auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false) + } + + pub(crate) fn set_auto_deny(&self, auto_deny: bool) { + if let Ok(mut current) = self.auto_deny.lock() { + *current = auto_deny; + } + } + + pub(crate) async fn resolve( + &self, + server_name: String, + id: RequestId, + response: ElicitationResponse, + ) -> Result<()> { + self.requests + .lock() + .await + .remove(&(server_name, id)) + .ok_or_else(|| anyhow!("elicitation request not found"))? + .send(response) + .map_err(|e| anyhow!("failed to send elicitation response: {e:?}")) + } + + pub(crate) fn make_sender( + &self, + server_name: String, + tx_event: Sender, + ) -> SendElicitation { + let elicitation_requests = self.requests.clone(); + let approval_policy = self.approval_policy.clone(); + let permission_profile = self.permission_profile.clone(); + let auto_deny = self.auto_deny.clone(); + let reviewer = self.reviewer.clone(); + Box::new(move |id, elicitation| { + let elicitation_requests = elicitation_requests.clone(); + let tx_event = tx_event.clone(); + let server_name = server_name.clone(); + let approval_policy = approval_policy.clone(); + let permission_profile = permission_profile.clone(); + let auto_deny = auto_deny.clone(); + let reviewer = reviewer.clone(); + async move { + let auto_deny = auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false); + if auto_deny { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + } + + let approval_policy = approval_policy + .lock() + .map(|policy| *policy) + .unwrap_or(AskForApproval::Never); + let permission_profile = permission_profile + .lock() + .map(|profile| profile.clone()) + .unwrap_or_default(); + if mcp_permission_prompt_is_auto_approved( + approval_policy, + &permission_profile, + McpPermissionPromptAutoApproveContext::default(), + ) && can_auto_accept_elicitation(&elicitation) + { + return Ok(ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + }); + } + + if elicitation_is_rejected_by_policy(approval_policy) { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + } + + if let Some(reviewer) = reviewer.as_ref() { + let request = ElicitationReviewRequest { + server_name: server_name.clone(), + request_id: id.clone(), + elicitation: elicitation.clone(), + }; + if let Some(response) = reviewer.review(request).await? { + return Ok(response); + } + } + + let request = match elicitation { + CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + } => ElicitationRequest::Form { + meta: meta + .map(serde_json::to_value) + .transpose() + .context("failed to serialize MCP elicitation metadata")?, + message, + requested_schema: serde_json::to_value(requested_schema) + .context("failed to serialize MCP elicitation schema")?, + }, + CreateElicitationRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + } => ElicitationRequest::Url { + meta: meta + .map(serde_json::to_value) + .transpose() + .context("failed to serialize MCP elicitation metadata")?, + message, + url, + elicitation_id, + }, + }; + let (tx, rx) = oneshot::channel(); + { + let mut lock = elicitation_requests.lock().await; + lock.insert((server_name.clone(), id.clone()), tx); + } + let _ = tx_event + .send(Event { + id: "mcp_elicitation_request".to_string(), + msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: None, + server_name, + id: match id.clone() { + rmcp::model::NumberOrString::String(value) => { + ProtocolRequestId::String(value.to_string()) + } + rmcp::model::NumberOrString::Number(value) => { + ProtocolRequestId::Integer(value) + } + }, + request, + }), + }) + .await; + rx.await + .context("elicitation request channel closed unexpectedly") + } + .boxed() + }) + } +} + +pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool { + match approval_policy { + AskForApproval::Never => true, + AskForApproval::OnFailure => false, + AskForApproval::OnRequest => false, + AskForApproval::UnlessTrusted => false, + AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(), + } +} + +type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; + +fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool { + match elicitation { + CreateElicitationRequestParams::FormElicitationParams { + requested_schema, .. + } => { + // Auto-accept confirm/approval elicitations without schema requirements. + requested_schema.properties.is_empty() + } + CreateElicitationRequestParams::UrlElicitationParams { .. } => false, + } +} diff --git a/code-rs/codex-mcp/src/lib.rs b/code-rs/codex-mcp/src/lib.rs new file mode 100644 index 00000000000..19197996974 --- /dev/null +++ b/code-rs/codex-mcp/src/lib.rs @@ -0,0 +1,68 @@ +pub use connection_manager::McpConnectionManager; +pub use elicitation::ElicitationReviewRequest; +pub use elicitation::ElicitationReviewer; +pub use elicitation::ElicitationReviewerHandle; +pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY; +pub use runtime::McpRuntimeEnvironment; +pub use runtime::SandboxState; +pub use tools::ToolInfo; + +pub use mcp::CODEX_APPS_MCP_SERVER_NAME; +pub use mcp::McpConfig; +pub use mcp::ToolPluginProvenance; +pub use server::EffectiveMcpServer; + +pub use auth_elicitation::CodexAppsAuthElicitation; +pub use auth_elicitation::CodexAppsAuthElicitationPlan; +pub use auth_elicitation::CodexAppsConnectorAuthFailure; +pub use auth_elicitation::MCP_TOOL_CODEX_APPS_META_KEY; +pub use auth_elicitation::auth_elicitation_completed_result; +pub use auth_elicitation::auth_elicitation_id; +pub use auth_elicitation::build_auth_elicitation; +pub use auth_elicitation::build_auth_elicitation_plan; +pub use auth_elicitation::connector_auth_failure_from_tool_result; +pub use codex_apps::CodexAppsToolsCacheKey; +pub use codex_apps::codex_apps_tools_cache_key; +pub use codex_builtin_mcps::BuiltinMcpServer; +pub use codex_builtin_mcps::BuiltinMcpServerOptions; +pub use codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME; +pub use codex_builtin_mcps::enabled_builtin_mcp_servers; + +pub use mcp::configured_mcp_servers; +pub use mcp::effective_mcp_servers; +pub use mcp::effective_mcp_servers_from_configured; +pub use mcp::host_owned_codex_apps_enabled; +pub use mcp::tool_plugin_provenance; +pub use mcp::with_codex_apps_mcp; + +pub use mcp::McpServerStatusSnapshot; +pub use mcp::McpSnapshotDetail; +pub use mcp::collect_mcp_server_status_snapshot_with_detail; +pub use mcp::read_mcp_resource; + +pub use mcp::McpAuthStatusEntry; +pub use mcp::McpOAuthLoginConfig; +pub use mcp::McpOAuthLoginSupport; +pub use mcp::McpOAuthScopesSource; +pub use mcp::ResolvedMcpOAuthScopes; +pub use mcp::compute_auth_statuses; +pub use mcp::discover_supported_scopes; +pub use mcp::oauth_login_support; +pub use mcp::resolve_oauth_scopes; +pub use mcp::should_retry_without_scopes; + +pub use mcp::McpPermissionPromptAutoApproveContext; +pub use mcp::mcp_permission_prompt_is_auto_approved; +pub use mcp::qualified_mcp_tool_name_prefix; +pub use tools::declared_openai_file_input_param_names; + +pub(crate) mod auth_elicitation; +pub(crate) mod builtin; +pub(crate) mod codex_apps; +pub(crate) mod connection_manager; +pub(crate) mod elicitation; +pub(crate) mod mcp; +pub(crate) mod rmcp_client; +pub(crate) mod runtime; +pub(crate) mod server; +pub(crate) mod tools; diff --git a/code-rs/codex-mcp/src/mcp/auth.rs b/code-rs/codex-mcp/src/mcp/auth.rs new file mode 100644 index 00000000000..12f832f9e99 --- /dev/null +++ b/code-rs/codex-mcp/src/mcp/auth.rs @@ -0,0 +1,331 @@ +use std::collections::HashMap; + +use anyhow::Result; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_login::CodexAuth; +use codex_protocol::protocol::McpAuthStatus; +use codex_rmcp_client::OAuthProviderError; +use codex_rmcp_client::determine_streamable_http_auth_status; +use codex_rmcp_client::discover_streamable_http_oauth; +use futures::future::join_all; +use tracing::warn; + +use crate::server::EffectiveMcpServer; + +use super::CODEX_APPS_MCP_SERVER_NAME; + +#[derive(Debug, Clone)] +pub struct McpOAuthLoginConfig { + pub url: String, + pub http_headers: Option>, + pub env_http_headers: Option>, + pub discovered_scopes: Option>, +} + +#[derive(Debug)] +pub enum McpOAuthLoginSupport { + Supported(McpOAuthLoginConfig), + Unsupported, + Unknown(anyhow::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpOAuthScopesSource { + Explicit, + Configured, + Discovered, + Empty, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedMcpOAuthScopes { + pub scopes: Vec, + pub source: McpOAuthScopesSource, +} + +#[derive(Debug, Clone)] +pub struct McpAuthStatusEntry { + pub config: Option, + pub auth_status: McpAuthStatus, +} + +pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport { + let McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } = transport + else { + return McpOAuthLoginSupport::Unsupported; + }; + + if bearer_token_env_var.is_some() { + return McpOAuthLoginSupport::Unsupported; + } + + match discover_streamable_http_oauth(url, http_headers.clone(), env_http_headers.clone()).await + { + Ok(Some(discovery)) => McpOAuthLoginSupport::Supported(McpOAuthLoginConfig { + url: url.clone(), + http_headers: http_headers.clone(), + env_http_headers: env_http_headers.clone(), + discovered_scopes: discovery.scopes_supported, + }), + Ok(None) => McpOAuthLoginSupport::Unsupported, + Err(err) => McpOAuthLoginSupport::Unknown(err), + } +} + +pub async fn discover_supported_scopes( + transport: &McpServerTransportConfig, +) -> Option> { + match oauth_login_support(transport).await { + McpOAuthLoginSupport::Supported(config) => config.discovered_scopes, + McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None, + } +} + +pub fn resolve_oauth_scopes( + explicit_scopes: Option>, + configured_scopes: Option>, + discovered_scopes: Option>, +) -> ResolvedMcpOAuthScopes { + if let Some(scopes) = explicit_scopes { + return ResolvedMcpOAuthScopes { + scopes, + source: McpOAuthScopesSource::Explicit, + }; + } + + if let Some(scopes) = configured_scopes { + return ResolvedMcpOAuthScopes { + scopes, + source: McpOAuthScopesSource::Configured, + }; + } + + if let Some(scopes) = discovered_scopes + && !scopes.is_empty() + { + return ResolvedMcpOAuthScopes { + scopes, + source: McpOAuthScopesSource::Discovered, + }; + } + + ResolvedMcpOAuthScopes { + scopes: Vec::new(), + source: McpOAuthScopesSource::Empty, + } +} + +pub fn should_retry_without_scopes(scopes: &ResolvedMcpOAuthScopes, error: &anyhow::Error) -> bool { + scopes.source == McpOAuthScopesSource::Discovered + && error.downcast_ref::().is_some() +} + +pub async fn compute_auth_statuses<'a, I>( + servers: I, + store_mode: OAuthCredentialsStoreMode, + auth: Option<&CodexAuth>, +) -> HashMap +where + I: IntoIterator, +{ + let futures = servers.into_iter().map(|(name, server)| { + let name = name.clone(); + let config = server.configured_config().cloned(); + let has_runtime_auth = name == CODEX_APPS_MCP_SERVER_NAME + && auth.is_some_and(CodexAuth::uses_codex_backend) + && config.as_ref().is_some_and(|config| { + matches!( + &config.transport, + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var: None, + .. + } + ) + }); + async move { + let auth_status = match config.as_ref() { + Some(config) => { + match compute_auth_status(&name, config, store_mode, has_runtime_auth).await { + Ok(status) => status, + Err(error) => { + warn!( + "failed to determine auth status for MCP server `{name}`: {error:?}" + ); + McpAuthStatus::Unsupported + } + } + } + None => McpAuthStatus::Unsupported, + }; + let entry = McpAuthStatusEntry { + config, + auth_status, + }; + (name, entry) + } + }); + + join_all(futures).await.into_iter().collect() +} + +async fn compute_auth_status( + server_name: &str, + config: &McpServerConfig, + store_mode: OAuthCredentialsStoreMode, + has_runtime_auth: bool, +) -> Result { + if !config.enabled { + return Ok(McpAuthStatus::Unsupported); + } + + if has_runtime_auth { + return Ok(McpAuthStatus::BearerToken); + } + + match &config.transport { + McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported), + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + determine_streamable_http_auth_status( + server_name, + url, + bearer_token_env_var.as_deref(), + http_headers.clone(), + env_http_headers.clone(), + store_mode, + ) + .await + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + use pretty_assertions::assert_eq; + + use super::McpOAuthScopesSource; + use super::OAuthProviderError; + use super::ResolvedMcpOAuthScopes; + use super::resolve_oauth_scopes; + use super::should_retry_without_scopes; + + #[test] + fn resolve_oauth_scopes_prefers_explicit() { + let resolved = resolve_oauth_scopes( + Some(vec!["explicit".to_string()]), + Some(vec!["configured".to_string()]), + Some(vec!["discovered".to_string()]), + ); + + assert_eq!( + resolved, + ResolvedMcpOAuthScopes { + scopes: vec!["explicit".to_string()], + source: McpOAuthScopesSource::Explicit, + } + ); + } + + #[test] + fn resolve_oauth_scopes_prefers_configured_over_discovered() { + let resolved = resolve_oauth_scopes( + /*explicit_scopes*/ None, + Some(vec!["configured".to_string()]), + Some(vec!["discovered".to_string()]), + ); + + assert_eq!( + resolved, + ResolvedMcpOAuthScopes { + scopes: vec!["configured".to_string()], + source: McpOAuthScopesSource::Configured, + } + ); + } + + #[test] + fn resolve_oauth_scopes_uses_discovered_when_needed() { + let resolved = resolve_oauth_scopes( + /*explicit_scopes*/ None, + /*configured_scopes*/ None, + Some(vec!["discovered".to_string()]), + ); + + assert_eq!( + resolved, + ResolvedMcpOAuthScopes { + scopes: vec!["discovered".to_string()], + source: McpOAuthScopesSource::Discovered, + } + ); + } + + #[test] + fn resolve_oauth_scopes_preserves_explicitly_empty_configured_scopes() { + let resolved = resolve_oauth_scopes( + /*explicit_scopes*/ None, + Some(Vec::new()), + Some(vec!["ignored".into()]), + ); + + assert_eq!( + resolved, + ResolvedMcpOAuthScopes { + scopes: Vec::new(), + source: McpOAuthScopesSource::Configured, + } + ); + } + + #[test] + fn resolve_oauth_scopes_falls_back_to_empty() { + let resolved = resolve_oauth_scopes( + /*explicit_scopes*/ None, /*configured_scopes*/ None, + /*discovered_scopes*/ None, + ); + + assert_eq!( + resolved, + ResolvedMcpOAuthScopes { + scopes: Vec::new(), + source: McpOAuthScopesSource::Empty, + } + ); + } + + #[test] + fn should_retry_without_scopes_only_for_discovered_provider_errors() { + let discovered = ResolvedMcpOAuthScopes { + scopes: vec!["scope".to_string()], + source: McpOAuthScopesSource::Discovered, + }; + let provider_error = anyhow!(OAuthProviderError::new( + Some("invalid_scope".to_string()), + Some("scope rejected".to_string()), + )); + + assert!(should_retry_without_scopes(&discovered, &provider_error)); + + let configured = ResolvedMcpOAuthScopes { + scopes: vec!["scope".to_string()], + source: McpOAuthScopesSource::Configured, + }; + assert!(!should_retry_without_scopes(&configured, &provider_error)); + assert!(!should_retry_without_scopes( + &discovered, + &anyhow!("timed out waiting for OAuth callback"), + )); + } +} diff --git a/code-rs/codex-mcp/src/mcp/mod.rs b/code-rs/codex-mcp/src/mcp/mod.rs new file mode 100644 index 00000000000..71f79b9b4a6 --- /dev/null +++ b/code-rs/codex-mcp/src/mcp/mod.rs @@ -0,0 +1,608 @@ +pub use auth::McpAuthStatusEntry; +pub use auth::McpOAuthLoginConfig; +pub use auth::McpOAuthLoginSupport; +pub use auth::McpOAuthScopesSource; +pub use auth::ResolvedMcpOAuthScopes; +pub use auth::compute_auth_statuses; +pub use auth::discover_supported_scopes; +pub use auth::oauth_login_support; +pub use auth::resolve_oauth_scopes; +pub use auth::should_retry_without_scopes; + +pub(crate) mod auth; + +use std::collections::HashMap; +use std::env; +use std::path::PathBuf; +use std::time::Duration; + +use async_channel::unbounded; +use codex_config::Constrained; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_config::types::AppToolApproval; +use codex_config::types::ApprovalsReviewer; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_login::CodexAuth; +use codex_plugin::PluginCapabilitySummary; +use codex_protocol::mcp::Resource; +use codex_protocol::mcp::ResourceTemplate; +use codex_protocol::mcp::Tool; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::McpAuthStatus; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use serde_json::Value; + +use crate::codex_apps::codex_apps_tools_cache_key; +use crate::connection_manager::McpConnectionManager; +use crate::runtime::McpRuntimeEnvironment; +use crate::server::EffectiveMcpServer; + +pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; +const MCP_TOOL_NAME_PREFIX: &str = "mcp"; +const MCP_TOOL_NAME_DELIMITER: &str = "__"; +const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum McpSnapshotDetail { + #[default] + Full, + ToolsAndAuthOnly, +} + +impl McpSnapshotDetail { + fn include_resources(self) -> bool { + matches!(self, Self::Full) + } +} + +pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String { + sanitize_responses_api_tool_name(&format!( + "{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}" + )) +} + +/// Returns true when MCP permission prompts should resolve as approved instead +/// of being shown to the user. +pub fn mcp_permission_prompt_is_auto_approved( + approval_policy: AskForApproval, + permission_profile: &PermissionProfile, + context: McpPermissionPromptAutoApproveContext, +) -> bool { + if context.tool_approval_mode == Some(AppToolApproval::Approve) { + return true; + } + + if approval_policy != AskForApproval::Never { + return false; + } + + match permission_profile { + PermissionProfile::Disabled | PermissionProfile::External { .. } => true, + PermissionProfile::Managed { file_system, .. } => { + file_system.to_sandbox_policy().has_full_disk_write_access() + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct McpPermissionPromptAutoApproveContext { + pub approvals_reviewer: Option, + pub tool_approval_mode: Option, +} + +/// MCP runtime settings derived from `codex_core::config::Config`. +/// +/// This struct should contain only long-lived configuration values that the +/// `codex-mcp` crate needs to construct server transports, enforce MCP +/// approval/sandbox policy, locate OAuth state, and merge plugin-provided MCP +/// servers. Request-scoped or auth-scoped state should not be stored here; +/// thread those values explicitly into runtime entry points such as +/// [`effective_mcp_servers`] and snapshot collection helpers so config objects +/// do not go stale when auth changes. +#[derive(Debug, Clone)] +pub struct McpConfig { + /// Base URL for ChatGPT-hosted app MCP servers, copied from the root config. + pub chatgpt_base_url: String, + /// Optional path override for the built-in apps MCP server. + pub apps_mcp_path_override: Option, + /// Codex home directory used for MCP OAuth state and app-tool cache files. + pub codex_home: PathBuf, + /// Preferred credential store for MCP OAuth tokens. + pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode, + /// Optional fixed localhost callback port for MCP OAuth login. + pub mcp_oauth_callback_port: Option, + /// Optional OAuth redirect URI override for MCP login. + pub mcp_oauth_callback_url: Option, + /// Whether skill MCP dependency installation prompts are enabled. + pub skill_mcp_dependency_install_enabled: bool, + /// Approval policy used for MCP tool calls and MCP elicitation requests. + pub approval_policy: Constrained, + /// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution. + pub codex_linux_sandbox_exe: Option, + /// Whether to use legacy Landlock behavior in the MCP sandbox state. + pub use_legacy_landlock: bool, + /// Whether the app MCP integration is enabled by config. + /// + /// ChatGPT auth is checked separately at runtime before the built-in apps + /// MCP server is added. + pub apps_enabled: bool, + /// Config-backed MCP servers keyed by server name. + /// + /// Product-owned built-ins and runtime-only additions are merged later by + /// [`effective_mcp_servers`]. + pub configured_mcp_servers: HashMap, + /// Product-owned built-ins enabled for this runtime config. + pub builtin_mcp_servers: Vec, + /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. + pub plugin_capability_summaries: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ToolPluginProvenance { + plugin_display_names_by_connector_id: HashMap>, + plugin_display_names_by_mcp_server_name: HashMap>, +} + +impl ToolPluginProvenance { + pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] { + self.plugin_display_names_by_connector_id + .get(connector_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] { + self.plugin_display_names_by_mcp_server_name + .get(server_name) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self { + let mut tool_plugin_provenance = Self::default(); + for plugin in capability_summaries { + for connector_id in &plugin.app_connector_ids { + tool_plugin_provenance + .plugin_display_names_by_connector_id + .entry(connector_id.0.clone()) + .or_default() + .push(plugin.display_name.clone()); + } + + for server_name in &plugin.mcp_server_names { + tool_plugin_provenance + .plugin_display_names_by_mcp_server_name + .entry(server_name.clone()) + .or_default() + .push(plugin.display_name.clone()); + } + } + + for plugin_names in tool_plugin_provenance + .plugin_display_names_by_connector_id + .values_mut() + .chain( + tool_plugin_provenance + .plugin_display_names_by_mcp_server_name + .values_mut(), + ) + { + plugin_names.sort_unstable(); + plugin_names.dedup(); + } + + tool_plugin_provenance + } +} + +pub fn with_codex_apps_mcp( + mut servers: HashMap, + auth: Option<&CodexAuth>, + config: &McpConfig, +) -> HashMap { + if host_owned_codex_apps_enabled(config, auth) { + servers.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + EffectiveMcpServer::configured(codex_apps_mcp_server_config(config)), + ); + } else { + servers.remove(CODEX_APPS_MCP_SERVER_NAME); + } + servers +} + +pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool { + config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) +} + +pub fn configured_mcp_servers(config: &McpConfig) -> HashMap { + config.configured_mcp_servers.clone() +} + +pub fn effective_mcp_servers( + config: &McpConfig, + auth: Option<&CodexAuth>, +) -> HashMap { + effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth) +} + +pub fn effective_mcp_servers_from_configured( + configured_servers: HashMap, + config: &McpConfig, + auth: Option<&CodexAuth>, +) -> HashMap { + let mut servers = configured_servers + .into_iter() + .map(|(name, server)| (name, EffectiveMcpServer::configured(server))) + .collect::>(); + for builtin_server in &config.builtin_mcp_servers { + servers.insert( + builtin_server.name().to_string(), + EffectiveMcpServer::builtin(*builtin_server), + ); + } + with_codex_apps_mcp(servers, auth, config) +} + +pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance { + ToolPluginProvenance::from_capability_summaries(&config.plugin_capability_summaries) +} + +pub async fn read_mcp_resource( + config: &McpConfig, + auth: Option<&CodexAuth>, + runtime_environment: McpRuntimeEnvironment, + server: &str, + uri: &str, +) -> anyhow::Result { + let mut mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); + mcp_servers.retain(|name, _| name == server); + let auth_statuses = compute_auth_statuses( + mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + auth, + ) + .await; + let (tx_event, rx_event) = unbounded(); + drop(rx_event); + let (manager, cancel_token) = McpConnectionManager::new( + &mcp_servers, + config.mcp_oauth_credentials_store_mode, + auth_statuses, + &config.approval_policy, + String::new(), + tx_event, + PermissionProfile::default(), + runtime_environment, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, + tool_plugin_provenance(config), + auth, + /*elicitation_reviewer*/ None, + ) + .await; + + let result = manager + .read_resource( + server, + ReadResourceRequestParams { + meta: None, + uri: uri.to_string(), + }, + ) + .await; + cancel_token.cancel(); + result +} + +#[derive(Debug, Clone)] +pub struct McpServerStatusSnapshot { + pub tools_by_server: HashMap>, + pub resources: HashMap>, + pub resource_templates: HashMap>, + pub auth_statuses: HashMap, +} + +pub async fn collect_mcp_server_status_snapshot_with_detail( + config: &McpConfig, + auth: Option<&CodexAuth>, + submit_id: String, + runtime_environment: McpRuntimeEnvironment, + detail: McpSnapshotDetail, +) -> McpServerStatusSnapshot { + let mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); + let tool_plugin_provenance = tool_plugin_provenance(config); + if mcp_servers.is_empty() { + return McpServerStatusSnapshot { + tools_by_server: HashMap::new(), + resources: HashMap::new(), + resource_templates: HashMap::new(), + auth_statuses: HashMap::new(), + }; + } + + let auth_status_entries = compute_auth_statuses( + mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + auth, + ) + .await; + + let (tx_event, rx_event) = unbounded(); + drop(rx_event); + + let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( + &mcp_servers, + config.mcp_oauth_credentials_store_mode, + auth_status_entries.clone(), + &config.approval_policy, + submit_id, + tx_event, + PermissionProfile::default(), + runtime_environment, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, + tool_plugin_provenance, + auth, + /*elicitation_reviewer*/ None, + ) + .await; + + let snapshot = collect_mcp_server_status_snapshot_from_manager( + &mcp_connection_manager, + auth_status_entries, + detail, + ) + .await; + + cancel_token.cancel(); + + snapshot +} + +pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { + codex_apps_mcp_url_for_base_url( + &config.chatgpt_base_url, + config.apps_mcp_path_override.as_deref(), + ) +} + +/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. +/// MCP server/tool names are user-controlled, so sanitize the fully-qualified +/// name we expose to the model by replacing any disallowed character with `_`. +pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String { + let mut sanitized = String::with_capacity(name.len()); + for c in name.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + sanitized.push(c); + } else { + sanitized.push('_'); + } + } + + if sanitized.is_empty() { + "_".to_string() + } else { + sanitized + } +} + +fn codex_apps_mcp_bearer_token_env_var() -> Option { + match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) { + Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), + Ok(_) => None, + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), + } +} + +fn normalize_codex_apps_base_url(base_url: &str) -> String { + let mut base_url = base_url.trim_end_matches('/').to_string(); + 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"); + } + base_url +} + +fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Option<&str>) -> String { + let base_url = normalize_codex_apps_base_url(base_url); + let (base_url, default_path) = if base_url.contains("/backend-api") { + (base_url, "wham/apps") + } else if base_url.contains("/api/codex") { + (base_url, "apps") + } else { + (format!("{base_url}/api/codex"), "apps") + }; + let path = apps_mcp_path_override + .unwrap_or(default_path) + .trim_start_matches('/'); + format!("{base_url}/{path}") +} + +fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig { + let url = codex_apps_mcp_url(config); + + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(), + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(30)), + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + } +} + +fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option { + match serde_json::to_value(tool) { + Ok(value) => match Tool::from_mcp_value(value) { + Ok(tool) => Some(tool), + Err(err) => { + tracing::warn!("Failed to convert MCP tool '{name}': {err}"); + None + } + }, + Err(err) => { + tracing::warn!("Failed to serialize MCP tool '{name}': {err}"); + None + } + } +} + +fn auth_statuses_from_entries( + auth_status_entries: &HashMap, +) -> HashMap { + auth_status_entries + .iter() + .map(|(name, entry)| (name.clone(), entry.auth_status)) + .collect::>() +} + +fn convert_mcp_resources( + resources: HashMap>, +) -> HashMap> { + resources + .into_iter() + .map(|(name, resources)| { + let resources = resources + .into_iter() + .filter_map(|resource| match serde_json::to_value(resource) { + Ok(value) => match Resource::from_mcp_value(value.clone()) { + Ok(resource) => Some(resource), + Err(err) => { + let (uri, resource_name) = match value { + Value::Object(obj) => ( + obj.get("uri") + .and_then(|v| v.as_str().map(ToString::to_string)), + obj.get("name") + .and_then(|v| v.as_str().map(ToString::to_string)), + ), + _ => (None, None), + }; + + tracing::warn!( + "Failed to convert MCP resource (uri={uri:?}, name={resource_name:?}): {err}" + ); + None + } + }, + Err(err) => { + tracing::warn!("Failed to serialize MCP resource: {err}"); + None + } + }) + .collect::>(); + (name, resources) + }) + .collect::>() +} + +fn convert_mcp_resource_templates( + resource_templates: HashMap>, +) -> HashMap> { + resource_templates + .into_iter() + .map(|(name, templates)| { + let templates = templates + .into_iter() + .filter_map(|template| match serde_json::to_value(template) { + Ok(value) => match ResourceTemplate::from_mcp_value(value.clone()) { + Ok(template) => Some(template), + Err(err) => { + let (uri_template, template_name) = match value { + Value::Object(obj) => ( + obj.get("uriTemplate") + .or_else(|| obj.get("uri_template")) + .and_then(|v| v.as_str().map(ToString::to_string)), + obj.get("name") + .and_then(|v| v.as_str().map(ToString::to_string)), + ), + _ => (None, None), + }; + + tracing::warn!( + "Failed to convert MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}" + ); + None + } + }, + Err(err) => { + tracing::warn!("Failed to serialize MCP resource template: {err}"); + None + } + }) + .collect::>(); + (name, templates) + }) + .collect::>() +} + +async fn collect_mcp_server_status_snapshot_from_manager( + mcp_connection_manager: &McpConnectionManager, + auth_status_entries: HashMap, + detail: McpSnapshotDetail, +) -> McpServerStatusSnapshot { + let (tools, resources, resource_templates) = tokio::join!( + mcp_connection_manager.list_all_tools(), + async { + if detail.include_resources() { + mcp_connection_manager.list_all_resources().await + } else { + HashMap::new() + } + }, + async { + if detail.include_resources() { + mcp_connection_manager.list_all_resource_templates().await + } else { + HashMap::new() + } + }, + ); + + let mut tools_by_server = HashMap::>::new(); + for tool_info in tools { + let raw_tool_name = tool_info.tool.name.to_string(); + let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else { + continue; + }; + let tool_name = tool.name.clone(); + tools_by_server + .entry(tool_info.server_name) + .or_default() + .insert(tool_name, tool); + } + + McpServerStatusSnapshot { + tools_by_server, + resources: convert_mcp_resources(resources), + resource_templates: convert_mcp_resource_templates(resource_templates), + auth_statuses: auth_statuses_from_entries(&auth_status_entries), + } +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/code-rs/codex-mcp/src/mcp/mod_tests.rs b/code-rs/codex-mcp/src/mcp/mod_tests.rs new file mode 100644 index 00000000000..491341c3e92 --- /dev/null +++ b/code-rs/codex-mcp/src/mcp/mod_tests.rs @@ -0,0 +1,399 @@ +use super::*; +use codex_config::Constrained; +use codex_config::types::AppToolApproval; +use codex_config::types::ApprovalsReviewer; +use codex_login::CodexAuth; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +fn test_mcp_config(codex_home: PathBuf) -> McpConfig { + McpConfig { + chatgpt_base_url: "https://chatgpt.com".to_string(), + apps_mcp_path_override: None, + codex_home, + mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), + mcp_oauth_callback_port: None, + mcp_oauth_callback_url: None, + skill_mcp_dependency_install_enabled: true, + approval_policy: Constrained::allow_any(AskForApproval::OnFailure), + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + apps_enabled: false, + configured_mcp_servers: HashMap::new(), + builtin_mcp_servers: Vec::new(), + plugin_capability_summaries: Vec::new(), + } +} + +#[test] +fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() { + assert_eq!( + qualified_mcp_tool_name_prefix("Some-Server"), + "mcp__Some_Server__".to_string() + ); +} + +#[test] +fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() { + assert!(mcp_permission_prompt_is_auto_approved( + AskForApproval::Never, + &PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Enabled, + }, + McpPermissionPromptAutoApproveContext::default(), + )); + assert!(mcp_permission_prompt_is_auto_approved( + AskForApproval::Never, + &PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Restricted, + }, + McpPermissionPromptAutoApproveContext::default(), + )); + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::Never, + &PermissionProfile::read_only(), + McpPermissionPromptAutoApproveContext::default(), + )); + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::OnRequest, + &PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Enabled, + }, + McpPermissionPromptAutoApproveContext::default(), + )); +} + +#[test] +fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { + for approval_policy in [ + AskForApproval::UnlessTrusted, + AskForApproval::OnFailure, + AskForApproval::OnRequest, + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + AskForApproval::Never, + ] { + assert!(mcp_permission_prompt_is_auto_approved( + approval_policy, + &PermissionProfile::read_only(), + McpPermissionPromptAutoApproveContext { + approvals_reviewer: Some(ApprovalsReviewer::User), + tool_approval_mode: Some(AppToolApproval::Approve), + }, + )); + } + + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::OnRequest, + &PermissionProfile::read_only(), + McpPermissionPromptAutoApproveContext { + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + tool_approval_mode: Some(AppToolApproval::Auto), + }, + )); +} + +#[test] +fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::OnRequest, + &PermissionProfile::read_only(), + McpPermissionPromptAutoApproveContext { + approvals_reviewer: Some(ApprovalsReviewer::User), + tool_approval_mode: Some(AppToolApproval::Auto), + }, + )); +} + +#[test] +fn tool_plugin_provenance_collects_app_and_mcp_sources() { + let provenance = ToolPluginProvenance::from_capability_summaries(&[ + PluginCapabilitySummary { + display_name: "alpha-plugin".to_string(), + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + mcp_server_names: vec!["alpha".to_string()], + ..PluginCapabilitySummary::default() + }, + PluginCapabilitySummary { + display_name: "beta-plugin".to_string(), + app_connector_ids: vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ], + mcp_server_names: vec!["beta".to_string()], + ..PluginCapabilitySummary::default() + }, + ]); + + assert_eq!( + provenance, + ToolPluginProvenance { + plugin_display_names_by_connector_id: HashMap::from([ + ( + "connector_example".to_string(), + vec!["alpha-plugin".to_string(), "beta-plugin".to_string()], + ), + ( + "connector_gmail".to_string(), + vec!["beta-plugin".to_string()], + ), + ]), + plugin_display_names_by_mcp_server_name: HashMap::from([ + ("alpha".to_string(), vec!["alpha-plugin".to_string()]), + ("beta".to_string(), vec!["beta-plugin".to_string()]), + ]), + } + ); +} + +#[test] +fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() { + assert_eq!( + codex_apps_mcp_url_for_base_url( + "https://chatgpt.com/backend-api", + /*apps_mcp_path_override*/ None, + ), + "https://chatgpt.com/backend-api/wham/apps" + ); + assert_eq!( + codex_apps_mcp_url_for_base_url( + "https://chat.openai.com", + /*apps_mcp_path_override*/ None, + ), + "https://chat.openai.com/backend-api/wham/apps" + ); + assert_eq!( + codex_apps_mcp_url_for_base_url( + "http://localhost:8080/api/codex", + /*apps_mcp_path_override*/ None, + ), + "http://localhost:8080/api/codex/apps" + ); + assert_eq!( + codex_apps_mcp_url_for_base_url( + "http://localhost:8080", + /*apps_mcp_path_override*/ None, + ), + "http://localhost:8080/api/codex/apps" + ); +} + +#[test] +fn codex_apps_mcp_url_uses_legacy_codex_apps_path() { + let config = test_mcp_config(PathBuf::from("/tmp")); + + assert_eq!( + codex_apps_mcp_url(&config), + "https://chatgpt.com/backend-api/wham/apps" + ); +} + +#[test] +fn codex_apps_server_config_uses_legacy_codex_apps_path() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let mut servers = with_codex_apps_mcp(HashMap::new(), /*auth*/ None, &config); + assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME)); + + config.apps_enabled = true; + + servers = with_codex_apps_mcp(servers, Some(&auth), &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let config = server + .configured_config() + .expect("codex apps should use configured transport"); + let url = match &config.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => url, + _ => panic!("expected streamable http transport for codex apps"), + }; + + assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); +} + +#[test] +fn codex_apps_server_config_uses_configured_apps_mcp_path_override() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + config.apps_mcp_path_override = Some("/custom/mcp".to_string()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let config = server + .configured_config() + .expect("codex apps should use configured transport"); + let url = match &config.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => url, + _ => panic!("expected streamable http transport for codex apps"), + }; + + assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp"); +} + +#[tokio::test] +async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let mut config = test_mcp_config(codex_home.path().to_path_buf()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + config.configured_mcp_servers.insert( + "sample".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://user.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }, + ); + config.configured_mcp_servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://docs.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }, + ); + + let effective = effective_mcp_servers(&config, Some(&auth)); + + let sample = effective.get("sample").expect("user server should exist"); + let docs = effective + .get("docs") + .expect("configured server should exist"); + let codex_apps = effective + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps server should exist"); + + let sample = sample + .configured_config() + .expect("configured server should retain transport"); + let docs = docs + .configured_config() + .expect("configured server should retain transport"); + let codex_apps = codex_apps + .configured_config() + .expect("codex apps should use configured transport"); + + match &sample.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://user.example/mcp"); + } + other => panic!("expected streamable http transport, got {other:?}"), + } + match &docs.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://docs.example/mcp"); + } + other => panic!("expected streamable http transport, got {other:?}"), + } + match &codex_apps.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); + } + other => panic!("expected streamable http transport, got {other:?}"), + } +} + +#[test] +fn effective_mcp_servers_preserve_builtin_runtime_shape() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + config.builtin_mcp_servers = vec![codex_builtin_mcps::BuiltinMcpServer::Memories]; + + let effective = effective_mcp_servers(&config, /*auth*/ None); + let memories = effective + .get(codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME) + .expect("memories server should exist"); + + assert!(!crate::server::McpServerMetadata::from(memories).pollutes_memory); + assert!(matches!( + memories.launch(), + crate::server::McpServerLaunch::Builtin(codex_builtin_mcps::BuiltinMcpServer::Memories) + )); +} + +#[tokio::test] +async fn builtin_memories_server_runs_in_process() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let mut config = test_mcp_config(codex_home.path().to_path_buf()); + config.builtin_mcp_servers = vec![codex_builtin_mcps::BuiltinMcpServer::Memories]; + + let snapshot = collect_mcp_server_status_snapshot_with_detail( + &config, + /*auth*/ None, + "builtin-memories-test".to_string(), + McpRuntimeEnvironment::new( + Arc::new(codex_exec_server::Environment::default_for_tests()), + codex_home.path().to_path_buf(), + ), + McpSnapshotDetail::ToolsAndAuthOnly, + ) + .await; + + let tools = snapshot + .tools_by_server + .get(codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME) + .expect("memories tools should be listed"); + assert_eq!( + tools + .keys() + .cloned() + .collect::>(), + ["list".to_string(), "read".to_string(), "search".to_string()] + .into_iter() + .collect() + ); +} diff --git a/code-rs/codex-mcp/src/rmcp_client.rs b/code-rs/codex-mcp/src/rmcp_client.rs new file mode 100644 index 00000000000..c9a8ca8c339 --- /dev/null +++ b/code-rs/codex-mcp/src/rmcp_client.rs @@ -0,0 +1,773 @@ +//! RMCP client lifecycle for MCP server connections. +//! +//! This module owns startup of individual RMCP clients: building the transport, +//! initializing the server, listing raw tools, applying per-server tool filters, +//! and exposing cached startup snapshots while a client is still connecting. +//! Higher-level aggregation and resource/tool APIs live in +//! [`crate::connection_manager`]. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::env; +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; + +use crate::builtin::BuiltinMcpServerFactory; +use crate::codex_apps::CachedCodexAppsToolsLoad; +use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::filter_disallowed_codex_apps_tools; +use crate::codex_apps::load_cached_codex_apps_tools; +use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; +use crate::codex_apps::normalize_codex_apps_callable_name; +use crate::codex_apps::normalize_codex_apps_callable_namespace; +use crate::codex_apps::normalize_codex_apps_tool_title; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::elicitation::ElicitationRequestManager; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::ToolPluginProvenance; +use crate::runtime::McpRuntimeEnvironment; +use crate::runtime::emit_duration; +use crate::server::EffectiveMcpServer; +use crate::server::McpServerLaunch; +use crate::tools::ToolFilter; +use crate::tools::ToolInfo; +use crate::tools::filter_tools; +use crate::tools::tool_with_model_visible_input_schema; +use anyhow::Result; +use anyhow::anyhow; +use async_channel::Sender; +use codex_api::SharedAuthProvider; +use codex_async_utils::CancelErr; +use codex_async_utils::OrCancelExt; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::HttpClient; +use codex_exec_server::ReqwestHttpClient; +use codex_protocol::protocol::Event; +use codex_rmcp_client::ExecutorStdioServerLauncher; +use codex_rmcp_client::InProcessTransportFactory; +use codex_rmcp_client::LocalStdioServerLauncher; +use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::StdioServerLauncher; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use rmcp::model::ClientCapabilities; +use rmcp::model::ElicitationCapability; +use rmcp::model::Implementation; +use rmcp::model::InitializeRequestParams; +use rmcp::model::ProtocolVersion; +use rmcp::model::Tool as RmcpTool; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +/// MCP server capability indicating that Codex should include [`SandboxState`] +/// in tool-call request `_meta` under this key. +pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; + +pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; +pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = + "codex.mcp.tools.fetch_uncached.duration_ms"; +pub(crate) const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120); + +const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[ + "connector_id", + "connector_name", + "connector_display_name", + "connector_description", + "connectorDescription", +]; + +#[derive(Clone)] +pub(crate) struct ManagedClient { + pub(crate) client: Arc, + pub(crate) tools: Vec, + pub(crate) tool_filter: ToolFilter, + pub(crate) tool_timeout: Option, + pub(crate) server_instructions: Option, + pub(crate) server_supports_sandbox_state_meta_capability: bool, + pub(crate) codex_apps_tools_cache_context: Option, +} + +impl ManagedClient { + fn listed_tools(&self) -> Vec { + let total_start = Instant::now(); + if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref() + && let CachedCodexAppsToolsLoad::Hit(tools) = + load_cached_codex_apps_tools(cache_context) + { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "hit")], + ); + return filter_tools(tools, &self.tool_filter); + } + + if self.codex_apps_tools_cache_context.is_some() { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "miss")], + ); + } + + self.tools.clone() + } +} + +#[derive(Clone)] +pub(crate) struct AsyncManagedClient { + pub(crate) client: Shared>>, + pub(crate) startup_snapshot: Option>, + pub(crate) startup_complete: Arc, + pub(crate) tool_plugin_provenance: Arc, + pub(crate) cancel_token: CancellationToken, +} + +impl AsyncManagedClient { + // Keep this constructor flat so the startup inputs remain readable at the + // single call site instead of introducing a one-off params wrapper. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + server_name: String, + server: EffectiveMcpServer, + store_mode: OAuthCredentialsStoreMode, + cancel_token: CancellationToken, + tx_event: Sender, + elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, + tool_plugin_provenance: Arc, + runtime_environment: McpRuntimeEnvironment, + codex_home: PathBuf, + runtime_auth_provider: Option, + ) -> Self { + let tool_filter = server + .configured_config() + .map(ToolFilter::from_config) + .unwrap_or_default(); + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ) + .map(|tools| filter_tools(tools, &tool_filter)); + let startup_tool_filter = tool_filter; + let startup_complete = Arc::new(AtomicBool::new(false)); + let startup_complete_for_fut = Arc::clone(&startup_complete); + let cancel_token_for_fut = cancel_token.clone(); + let fut = async move { + let outcome = match async { + if let Err(error) = validate_mcp_server_name(&server_name) { + return Err(error.into()); + } + + let client = Arc::new( + make_rmcp_client( + &server_name, + server.clone(), + store_mode, + runtime_environment, + codex_home, + runtime_auth_provider, + ) + .await?, + ); + start_server_task( + server_name, + client, + StartServerTaskParams { + startup_timeout: server + .configured_config() + .and_then(|config| config.startup_timeout_sec) + .or(Some(DEFAULT_STARTUP_TIMEOUT)), + tool_timeout: server + .configured_config() + .and_then(|config| config.tool_timeout_sec) + .unwrap_or(DEFAULT_TOOL_TIMEOUT), + tool_filter: startup_tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + }, + ) + .await + } + .or_cancel(&cancel_token_for_fut) + .await + { + Ok(result) => result, + Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), + }; + + startup_complete_for_fut.store(true, Ordering::Release); + outcome + }; + let client = fut.boxed().shared(); + if startup_snapshot.is_some() { + let startup_task = client.clone(); + tokio::spawn(async move { + let _ = startup_task.await; + }); + } + + Self { + client, + startup_snapshot, + startup_complete, + tool_plugin_provenance, + cancel_token, + } + } + + pub(crate) async fn client(&self) -> Result { + self.client.clone().await + } + + pub(crate) async fn shutdown(&self) { + self.cancel_token.cancel(); + match self.client().await { + Ok(client) => client.client.shutdown().await, + Err(StartupOutcomeError::Cancelled) => {} + Err(error) => { + warn!("failed to initialize MCP client during shutdown: {error:#}"); + } + } + } + + fn startup_snapshot_while_initializing(&self) -> Option> { + if !self.startup_complete.load(Ordering::Acquire) { + return self.startup_snapshot.clone(); + } + None + } + + pub(crate) async fn listed_tools(&self) -> Option> { + let annotate_tools = |tools: Vec| { + let mut tools = tools; + for tool in &mut tools { + if tool.server_name == CODEX_APPS_MCP_SERVER_NAME { + tool.tool = tool_with_model_visible_input_schema(&tool.tool); + } + + let plugin_names = match tool.connector_id.as_deref() { + Some(connector_id) => self + .tool_plugin_provenance + .plugin_display_names_for_connector_id(connector_id), + None => self + .tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), + }; + tool.plugin_display_names = plugin_names.to_vec(); + + if plugin_names.is_empty() { + continue; + } + + let plugin_source_note = if plugin_names.len() == 1 { + format!("This tool is part of plugin `{}`.", plugin_names[0]) + } else { + format!( + "This tool is part of plugins {}.", + plugin_names + .iter() + .map(|plugin_name| format!("`{plugin_name}`")) + .collect::>() + .join(", ") + ) + }; + let description = tool + .tool + .description + .as_deref() + .map(str::trim) + .unwrap_or(""); + let annotated_description = if description.is_empty() { + plugin_source_note + } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { + format!("{description} {plugin_source_note}") + } else { + format!("{description}. {plugin_source_note}") + }; + tool.tool.description = Some(Cow::Owned(annotated_description)); + } + tools + }; + + // Keep cache payloads raw; plugin provenance is resolved per-session at read time. + let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + Some(startup_tools) + } else { + match self.client().await { + Ok(client) => Some(client.listed_tools()), + Err(_) => self.startup_snapshot.clone(), + } + }; + tools.map(annotate_tools) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub(crate) enum StartupOutcomeError { + #[error("MCP startup cancelled")] + Cancelled, + // We can't store the original error here because anyhow::Error doesn't implement + // `Clone`. + #[error("MCP startup failed: {error}")] + Failed { error: String }, +} + +impl From for StartupOutcomeError { + fn from(error: anyhow::Error) -> Self { + Self::Failed { + error: error.to_string(), + } + } +} + +pub(crate) fn elicitation_capability_for_server( + _server_name: &str, +) -> Option { + // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities + // indicates this should be an empty object. + Some(ElicitationCapability::default()) +} + +pub(crate) async fn list_tools_for_client_uncached( + server_name: &str, + client: &Arc, + timeout: Option, + server_instructions: Option<&str>, +) -> Result> { + let resp = client + .list_tools_with_connector_ids(/*params*/ None, timeout) + .await?; + let tools = resp + .tools + .into_iter() + .map(|tool| { + let mut tool_def = tool.tool; + let (connector_id, connector_name, connector_description) = + sanitize_tool_connector_metadata( + server_name, + &mut tool_def, + tool.connector_id, + tool.connector_name, + tool.connector_description, + ); + let callable_name = normalize_codex_apps_callable_name( + server_name, + &tool_def.name, + connector_id.as_deref(), + connector_name.as_deref(), + ); + let callable_namespace = + normalize_codex_apps_callable_namespace(server_name, connector_name.as_deref()); + if let Some(title) = tool_def.title.as_deref() { + let normalized_title = + normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title); + if tool_def.title.as_deref() != Some(normalized_title.as_str()) { + tool_def.title = Some(normalized_title); + } + } + let has_connector_metadata = connector_id.is_some() + || connector_name.is_some() + || connector_description.is_some(); + let namespace_description = if has_connector_metadata { + connector_description + } else { + server_instructions.map(str::to_string) + }; + ToolInfo { + server_name: server_name.to_owned(), + callable_name, + callable_namespace, + namespace_description, + tool: tool_def, + connector_id, + connector_name, + plugin_display_names: Vec::new(), + } + }) + .collect(); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + return Ok(filter_disallowed_codex_apps_tools(tools)); + } + Ok(tools) +} + +fn sanitize_tool_connector_metadata( + server_name: &str, + tool: &mut RmcpTool, + connector_id: Option, + connector_name: Option, + connector_description: Option, +) -> (Option, Option, Option) { + if server_name == CODEX_APPS_MCP_SERVER_NAME { + return (connector_id, connector_name, connector_description); + } + + strip_untrusted_connector_meta(tool); + (None, None, None) +} + +fn strip_untrusted_connector_meta(tool: &mut RmcpTool) { + if let Some(meta) = tool.meta.as_mut() { + meta.retain(|key, _| !is_untrusted_connector_meta_key(key)); + } +} + +fn is_untrusted_connector_meta_key(key: &str) -> bool { + UNTRUSTED_CONNECTOR_META_KEYS.contains(&key) +} + +fn resolve_bearer_token( + server_name: &str, + bearer_token_env_var: Option<&str>, +) -> Result> { + let Some(env_var) = bearer_token_env_var else { + return Ok(None); + }; + + match env::var(env_var) { + Ok(value) => { + if value.is_empty() { + Err(anyhow!( + "Environment variable {env_var} for MCP server '{server_name}' is empty" + )) + } else { + Ok(Some(value)) + } + } + Err(env::VarError::NotPresent) => Err(anyhow!( + "Environment variable {env_var} for MCP server '{server_name}' is not set" + )), + Err(env::VarError::NotUnicode(_)) => Err(anyhow!( + "Environment variable {env_var} for MCP server '{server_name}' contains invalid Unicode" + )), + } +} + +fn validate_mcp_server_name(server_name: &str) -> Result<()> { + let re = regex_lite::Regex::new(r"^[a-zA-Z0-9_-]+$")?; + if !re.is_match(server_name) { + return Err(anyhow!( + "Invalid MCP server name '{server_name}': must match pattern {pattern}", + pattern = re.as_str() + )); + } + Ok(()) +} + +async fn start_server_task( + server_name: String, + client: Arc, + params: StartServerTaskParams, +) -> Result { + let StartServerTaskParams { + startup_timeout, + tool_timeout, + tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + } = params; + let elicitation = elicitation_capability_for_server(&server_name); + let params = InitializeRequestParams { + meta: None, + capabilities: ClientCapabilities { + experimental: None, + extensions: None, + roots: None, + sampling: None, + elicitation, + tasks: None, + }, + client_info: Implementation { + name: "codex-mcp-client".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + title: Some("Codex".into()), + description: None, + icons: None, + website_url: None, + }, + protocol_version: ProtocolVersion::V_2025_06_18, + }; + + let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); + + let initialize_result = client + .initialize(params, startup_timeout, send_elicitation) + .await + .map_err(StartupOutcomeError::from)?; + + let server_supports_sandbox_state_meta_capability = initialize_result + .capabilities + .experimental + .as_ref() + .and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY)) + .is_some(); + let list_start = Instant::now(); + let fetch_start = Instant::now(); + let tools = list_tools_for_client_uncached( + &server_name, + &client, + startup_timeout, + initialize_result.instructions.as_deref(), + ) + .await + .map_err(StartupOutcomeError::from)?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + write_cached_codex_apps_tools_if_needed( + &server_name, + codex_apps_tools_cache_context.as_ref(), + &tools, + ); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); + } + let tools = filter_tools(tools, &tool_filter); + + let managed = ManagedClient { + client: Arc::clone(&client), + tools, + tool_timeout: Some(tool_timeout), + tool_filter, + server_instructions: initialize_result.instructions, + server_supports_sandbox_state_meta_capability, + codex_apps_tools_cache_context, + }; + + Ok(managed) +} + +struct StartServerTaskParams { + startup_timeout: Option, // TODO: cancel_token should handle this. + tool_timeout: Duration, + tool_filter: ToolFilter, + tx_event: Sender, + elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, +} + +async fn make_rmcp_client( + server_name: &str, + server: EffectiveMcpServer, + store_mode: OAuthCredentialsStoreMode, + runtime_environment: McpRuntimeEnvironment, + codex_home: PathBuf, + runtime_auth_provider: Option, +) -> Result { + let config = match server.launch() { + McpServerLaunch::Configured(config) => config.as_ref().clone(), + McpServerLaunch::Builtin(builtin_server) => { + let factory: Arc = + Arc::new(BuiltinMcpServerFactory::new(*builtin_server, codex_home)); + return RmcpClient::new_in_process_client(factory) + .await + .map_err(|err| StartupOutcomeError::from(anyhow!(err))); + } + }; + let McpServerConfig { + transport, + experimental_environment, + .. + } = config; + let remote_environment = match experimental_environment.as_deref() { + None | Some("local") => false, + Some("remote") => { + if !runtime_environment.environment().is_remote() { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server `{server_name}` requires a remote environment" + ))); + } + true + } + Some(environment) => { + return Err(StartupOutcomeError::from(anyhow!( + "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" + ))); + } + }; + + match transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + let command_os: OsString = command.into(); + let args_os: Vec = args.into_iter().map(Into::into).collect(); + let env_os = env.map(|env| { + env.into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect::>() + }); + let launcher = if remote_environment { + Arc::new(ExecutorStdioServerLauncher::new( + runtime_environment.environment().get_exec_backend(), + runtime_environment.fallback_cwd(), + )) + } else { + Arc::new(LocalStdioServerLauncher::new( + runtime_environment.fallback_cwd(), + )) as Arc + }; + + // `RmcpClient` always sees a launched MCP stdio server. The + // launcher hides whether that means a local child process or an + // executor process whose stdin/stdout bytes cross the process API. + RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher) + .await + .map_err(|err| StartupOutcomeError::from(anyhow!(err))) + } + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + bearer_token_env_var, + } => { + let http_client: Arc = if remote_environment { + runtime_environment.environment().get_http_client() + } else { + Arc::new(ReqwestHttpClient) + }; + let resolved_bearer_token = + match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) { + Ok(token) => token, + Err(error) => return Err(error.into()), + }; + RmcpClient::new_streamable_http_client( + server_name, + &url, + resolved_bearer_token, + http_headers, + env_http_headers, + store_mode, + http_client, + runtime_auth_provider, + ) + .await + .map_err(StartupOutcomeError::from) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::JsonObject; + use rmcp::model::Meta; + + fn tool_with_connector_meta() -> RmcpTool { + RmcpTool { + name: "capture_file_upload".to_string().into(), + title: None, + description: Some("test tool".to_string().into()), + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: Some(Meta( + serde_json::json!({ + "connector_id": "connector_gmail", + "connector_name": "Gmail", + "connector_display_name": "Gmail", + "connector_description": "Mail connector", + "connectorDescription": "Mail connector", + "connectorFutureField": "future connector metadata", + "CONNECTOR_UPPERCASE": "uppercase connector metadata", + "openai/fileParams": ["file"], + "custom": "kept" + }) + .as_object() + .expect("object") + .clone(), + )), + } + } + + #[test] + fn custom_mcp_connector_metadata_is_stripped() { + let mut tool = tool_with_connector_meta(); + + let (connector_id, connector_name, connector_description) = + sanitize_tool_connector_metadata( + "minimaltest", + &mut tool, + Some("connector_gmail".to_string()), + Some("Gmail".to_string()), + Some("Mail connector".to_string()), + ); + + assert_eq!(connector_id, None); + assert_eq!(connector_name, None); + assert_eq!(connector_description, None); + + let meta = tool.meta.as_ref().expect("meta"); + for key in [ + "connector_id", + "connector_name", + "connector_display_name", + "connector_description", + "connectorDescription", + ] { + assert!(!meta.0.contains_key(key), "{key} should be stripped"); + } + assert!(meta.0.contains_key("connectorFutureField")); + assert!(meta.0.contains_key("CONNECTOR_UPPERCASE")); + assert!(meta.0.contains_key("openai/fileParams")); + assert_eq!( + meta.0.get("custom").and_then(|value| value.as_str()), + Some("kept") + ); + } + + #[test] + fn codex_apps_connector_metadata_is_preserved() { + let mut tool = tool_with_connector_meta(); + + let (connector_id, connector_name, connector_description) = + sanitize_tool_connector_metadata( + CODEX_APPS_MCP_SERVER_NAME, + &mut tool, + Some("connector_gmail".to_string()), + Some("Gmail".to_string()), + Some("Mail connector".to_string()), + ); + + assert_eq!(connector_id.as_deref(), Some("connector_gmail")); + assert_eq!(connector_name.as_deref(), Some("Gmail")); + assert_eq!(connector_description.as_deref(), Some("Mail connector")); + + let meta = tool.meta.as_ref().expect("meta"); + for key in [ + "connector_id", + "connector_name", + "connector_display_name", + "connector_description", + "connectorDescription", + "connectorFutureField", + "CONNECTOR_UPPERCASE", + ] { + assert!(meta.0.contains_key(key), "{key} should be preserved"); + } + } +} diff --git a/code-rs/codex-mcp/src/runtime.rs b/code-rs/codex-mcp/src/runtime.rs new file mode 100644 index 00000000000..4284c96ff61 --- /dev/null +++ b/code-rs/codex-mcp/src/runtime.rs @@ -0,0 +1,66 @@ +//! Runtime support for Model Context Protocol (MCP) servers. +//! +//! This module contains data that describes the runtime environment in which MCP +//! servers execute, plus the sandbox state payload sent to capable servers and a +//! tiny shared metrics helper. Transport startup and orchestration live in +//! [`crate::rmcp_client`] and [`crate::connection_manager`]. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use codex_exec_server::Environment; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::SandboxPolicy; + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, + pub sandbox_policy: SandboxPolicy, + pub codex_linux_sandbox_exe: Option, + pub sandbox_cwd: PathBuf, + #[serde(default)] + pub use_legacy_landlock: bool, +} + +/// Runtime placement information used when starting MCP server transports. +/// +/// `McpConfig` describes what servers exist. This value describes where those +/// servers should run for the current caller. Keep it explicit at manager +/// construction time so status/snapshot paths and real sessions make the same +/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is +/// used when a stdio server omits `cwd` and the launcher needs a concrete +/// process working directory. +#[derive(Clone)] +pub struct McpRuntimeEnvironment { + environment: Arc, + fallback_cwd: PathBuf, +} + +impl McpRuntimeEnvironment { + pub fn new(environment: Arc, fallback_cwd: PathBuf) -> Self { + Self { + environment, + fallback_cwd, + } + } + + pub(crate) fn environment(&self) -> Arc { + Arc::clone(&self.environment) + } + + pub(crate) fn fallback_cwd(&self) -> PathBuf { + self.fallback_cwd.clone() + } +} + +pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { + if let Some(metrics) = codex_otel::global() { + let _ = metrics.record_duration(metric, duration, tags); + } +} diff --git a/code-rs/codex-mcp/src/server.rs b/code-rs/codex-mcp/src/server.rs new file mode 100644 index 00000000000..a57aceccb95 --- /dev/null +++ b/code-rs/codex-mcp/src/server.rs @@ -0,0 +1,108 @@ +use codex_builtin_mcps::BuiltinMcpServer; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; + +/// The runtime launch strategy for an effective MCP server. +#[derive(Debug, Clone)] +pub(crate) enum McpServerLaunch { + Configured(Box), + Builtin(BuiltinMcpServer), +} + +/// MCP server after product-owned runtime additions have been applied. +#[derive(Debug, Clone)] +pub struct EffectiveMcpServer { + launch: McpServerLaunch, +} + +impl EffectiveMcpServer { + pub fn configured(config: McpServerConfig) -> Self { + Self { + launch: McpServerLaunch::Configured(Box::new(config)), + } + } + + pub fn builtin(server: BuiltinMcpServer) -> Self { + Self { + launch: McpServerLaunch::Builtin(server), + } + } + + pub(crate) fn launch(&self) -> &McpServerLaunch { + &self.launch + } + + pub fn configured_config(&self) -> Option<&McpServerConfig> { + match &self.launch { + McpServerLaunch::Configured(config) => Some(config.as_ref()), + McpServerLaunch::Builtin(_) => None, + } + } + + pub fn enabled(&self) -> bool { + match &self.launch { + McpServerLaunch::Configured(config) => config.enabled, + McpServerLaunch::Builtin(_) => true, + } + } + + pub fn required(&self) -> bool { + match &self.launch { + McpServerLaunch::Configured(config) => config.required, + McpServerLaunch::Builtin(_) => false, + } + } +} + +/// Transport origin retained for metrics and diagnostics after server launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum McpServerOrigin { + InProcess, + Stdio, + StreamableHttp(String), +} + +impl McpServerOrigin { + pub fn as_str(&self) -> &str { + match self { + Self::InProcess => "in_process", + Self::Stdio => "stdio", + Self::StreamableHttp(origin) => origin, + } + } + + fn from_transport(transport: &McpServerTransportConfig) -> Option { + match transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + let parsed = url::Url::parse(url).ok()?; + Some(Self::StreamableHttp(parsed.origin().ascii_serialization())) + } + McpServerTransportConfig::Stdio { .. } => Some(Self::Stdio), + } + } +} + +/// Semantic metadata that must survive after the server is launched. +#[derive(Debug, Clone)] +pub(crate) struct McpServerMetadata { + pub pollutes_memory: bool, + pub origin: Option, + pub supports_parallel_tool_calls: bool, +} + +impl From<&EffectiveMcpServer> for McpServerMetadata { + fn from(server: &EffectiveMcpServer) -> Self { + match server.launch() { + McpServerLaunch::Configured(config) => Self { + pollutes_memory: true, + origin: McpServerOrigin::from_transport(&config.transport), + supports_parallel_tool_calls: config.supports_parallel_tool_calls, + }, + McpServerLaunch::Builtin(server) => Self { + pollutes_memory: server.pollutes_memory(), + origin: Some(McpServerOrigin::InProcess), + supports_parallel_tool_calls: server.supports_parallel_tool_calls(), + }, + } + } +} diff --git a/code-rs/codex-mcp/src/tools.rs b/code-rs/codex-mcp/src/tools.rs new file mode 100644 index 00000000000..cb3d8babae6 --- /dev/null +++ b/code-rs/codex-mcp/src/tools.rs @@ -0,0 +1,369 @@ +//! MCP tool metadata, filtering, schema shaping, and name normalization. +//! +//! Raw MCP tool identities must be preserved for protocol calls, while +//! model-visible tool names must be sanitized, deduplicated, and kept within API +//! limits. This module owns that translation as well as the shared [`ToolInfo`] +//! type and helpers that adjust tool schemas before exposing them to the model. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +use codex_config::McpServerConfig; +use codex_protocol::ToolName; +use rmcp::model::Tool; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value as JsonValue; +use sha1::Digest; +use sha1::Sha1; +use tracing::warn; + +use crate::mcp::sanitize_responses_api_tool_name; + +pub(crate) const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = + "codex.mcp.tools.cache_write.duration_ms"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInfo { + /// Raw MCP server name used for routing the tool call. + pub server_name: String, + /// Model-visible tool name used in Responses API tool declarations. + #[serde(rename = "tool_name", alias = "callable_name")] + pub callable_name: String, + /// Model-visible namespace used for deferred tool loading. + #[serde(rename = "tool_namespace", alias = "callable_namespace")] + pub callable_namespace: String, + /// Model-visible namespace description. + // Keep the old serialized field name readable for cached ToolInfo values. + #[serde(default, alias = "connector_description")] + pub namespace_description: Option, + /// Raw MCP tool definition; `tool.name` is sent back to the MCP server. + pub tool: Tool, + pub connector_id: Option, + pub connector_name: Option, + #[serde(default)] + pub plugin_display_names: Vec, +} + +impl ToolInfo { + pub fn canonical_tool_name(&self) -> ToolName { + ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone()) + } +} + +pub fn declared_openai_file_input_param_names( + meta: Option<&Map>, +) -> Vec { + let Some(meta) = meta else { + return Vec::new(); + }; + + meta.get(META_OPENAI_FILE_PARAMS) + .and_then(JsonValue::as_array) + .into_iter() + .flatten() + .filter_map(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +/// A tool is allowed to be used if both are true: +/// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled. +/// 2. The tool is not explicitly disabled. +#[derive(Default, Clone)] +pub(crate) struct ToolFilter { + pub(crate) enabled: Option>, + pub(crate) disabled: HashSet, +} + +impl ToolFilter { + pub(crate) fn from_config(cfg: &McpServerConfig) -> Self { + let enabled = cfg + .enabled_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect::>()); + let disabled = cfg + .disabled_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect::>()) + .unwrap_or_default(); + + Self { enabled, disabled } + } + + pub(crate) fn allows(&self, tool_name: &str) -> bool { + if let Some(enabled) = &self.enabled + && !enabled.contains(tool_name) + { + return false; + } + + !self.disabled.contains(tool_name) + } +} + +/// Returns the model-visible view of a tool while preserving the raw metadata +/// used by execution. Keep cache entries raw and call this at manager return +/// boundaries. +pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { + let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); + if file_params.is_empty() { + return tool.clone(); + } + + let mut tool = tool.clone(); + let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone()); + mask_input_schema_for_file_path_params(&mut input_schema, &file_params); + if let JsonValue::Object(input_schema) = input_schema { + tool.input_schema = Arc::new(input_schema); + } + tool +} + +pub(crate) fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { + tools + .into_iter() + .filter(|tool| filter.allows(&tool.tool.name)) + .collect() +} + +/// Returns MCP tools with model-visible names normalized. +/// +/// Raw MCP server/tool names are kept on each [`ToolInfo`] for protocol calls, while +/// `callable_namespace` / `callable_name` are sanitized and, when necessary, hashed so +/// every model-visible name is unique and <= 64 bytes. +pub(crate) fn normalize_tools_for_model(tools: I) -> Vec +where + I: IntoIterator, +{ + let mut seen_raw_names = HashSet::new(); + let mut candidates = Vec::new(); + for tool in tools { + let raw_namespace_identity = format!( + "{}\0{}\0{}", + tool.server_name, + tool.callable_namespace, + tool.connector_id.as_deref().unwrap_or_default() + ); + let raw_tool_identity = format!( + "{}\0{}\0{}", + raw_namespace_identity, tool.callable_name, tool.tool.name + ); + if !seen_raw_names.insert(raw_tool_identity.clone()) { + warn!("skipping duplicated tool {}", tool.tool.name); + continue; + } + + candidates.push(CallableToolCandidate { + callable_namespace: sanitize_responses_api_tool_name(&tool.callable_namespace), + callable_name: sanitize_responses_api_tool_name(&tool.callable_name), + raw_namespace_identity, + raw_tool_identity, + tool, + }); + } + + let mut namespace_identities_by_base = HashMap::>::new(); + for candidate in &candidates { + namespace_identities_by_base + .entry(candidate.callable_namespace.clone()) + .or_default() + .insert(candidate.raw_namespace_identity.clone()); + } + let colliding_namespaces = namespace_identities_by_base + .into_iter() + .filter_map(|(namespace, identities)| (identities.len() > 1).then_some(namespace)) + .collect::>(); + for candidate in &mut candidates { + if colliding_namespaces.contains(&candidate.callable_namespace) { + candidate.callable_namespace = append_namespace_hash_suffix( + &candidate.callable_namespace, + &candidate.raw_namespace_identity, + ); + } + } + + let mut tool_identities_by_base = HashMap::<(String, String), HashSet>::new(); + for candidate in &candidates { + tool_identities_by_base + .entry(( + candidate.callable_namespace.clone(), + candidate.callable_name.clone(), + )) + .or_default() + .insert(candidate.raw_tool_identity.clone()); + } + let colliding_tools = tool_identities_by_base + .into_iter() + .filter_map(|(key, identities)| (identities.len() > 1).then_some(key)) + .collect::>(); + for candidate in &mut candidates { + if colliding_tools.contains(&( + candidate.callable_namespace.clone(), + candidate.callable_name.clone(), + )) { + candidate.callable_name = + append_hash_suffix(&candidate.callable_name, &candidate.raw_tool_identity); + } + } + + candidates.sort_by(|left, right| left.raw_tool_identity.cmp(&right.raw_tool_identity)); + + let mut used_names = HashSet::new(); + let mut model_tools = Vec::new(); + for mut candidate in candidates { + let (callable_namespace, callable_name) = unique_callable_parts( + &candidate.callable_namespace, + &candidate.callable_name, + &candidate.raw_tool_identity, + &mut used_names, + ); + candidate.tool.callable_namespace = callable_namespace; + candidate.tool.callable_name = callable_name; + model_tools.push(candidate.tool); + } + model_tools +} + +#[derive(Debug)] +struct CallableToolCandidate { + tool: ToolInfo, + raw_namespace_identity: String, + raw_tool_identity: String, + callable_namespace: String, + callable_name: String, +} + +const MCP_TOOL_NAME_DELIMITER: &str = "__"; +const MAX_TOOL_NAME_LENGTH: usize = 64; +const CALLABLE_NAME_HASH_LEN: usize = 12; +const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; + +fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_params: &[String]) { + let Some(properties) = input_schema + .as_object_mut() + .and_then(|schema| schema.get_mut("properties")) + .and_then(JsonValue::as_object_mut) + else { + return; + }; + + for field_name in file_params { + let Some(property_schema) = properties.get_mut(field_name) else { + continue; + }; + mask_input_property_schema(property_schema); + } +} + +fn mask_input_property_schema(schema: &mut JsonValue) { + let Some(object) = schema.as_object_mut() else { + return; + }; + + let mut description = object + .get("description") + .and_then(JsonValue::as_str) + .map(str::to_string) + .unwrap_or_default(); + let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."; + if description.is_empty() { + description = guidance.to_string(); + } else if !description.contains(guidance) { + description = format!("{description} {guidance}"); + } + + let is_array = object.get("type").and_then(JsonValue::as_str) == Some("array") + || object.get("items").is_some(); + object.clear(); + object.insert("description".to_string(), JsonValue::String(description)); + if is_array { + object.insert("type".to_string(), JsonValue::String("array".to_string())); + object.insert("items".to_string(), serde_json::json!({ "type": "string" })); + } else { + object.insert("type".to_string(), JsonValue::String("string".to_string())); + } +} + +fn sha1_hex(s: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(s.as_bytes()); + let sha1 = hasher.finalize(); + format!("{sha1:x}") +} + +fn callable_name_hash_suffix(raw_identity: &str) -> String { + let hash = sha1_hex(raw_identity); + format!("_{}", &hash[..CALLABLE_NAME_HASH_LEN]) +} + +fn append_hash_suffix(value: &str, raw_identity: &str) -> String { + format!("{value}{}", callable_name_hash_suffix(raw_identity)) +} + +fn append_namespace_hash_suffix(namespace: &str, raw_identity: &str) -> String { + if let Some(namespace) = namespace.strip_suffix(MCP_TOOL_NAME_DELIMITER) { + format!( + "{}{}{}", + namespace, + callable_name_hash_suffix(raw_identity), + MCP_TOOL_NAME_DELIMITER + ) + } else { + append_hash_suffix(namespace, raw_identity) + } +} + +fn truncate_name(value: &str, max_len: usize) -> String { + value.chars().take(max_len).collect() +} + +fn fit_callable_parts_with_hash( + namespace: &str, + tool_name: &str, + raw_identity: &str, +) -> (String, String) { + let suffix = callable_name_hash_suffix(raw_identity); + let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len()); + if max_tool_len >= suffix.len() { + let prefix_len = max_tool_len - suffix.len(); + return ( + namespace.to_string(), + format!("{}{}", truncate_name(tool_name, prefix_len), suffix), + ); + } + + let max_namespace_len = MAX_TOOL_NAME_LENGTH - suffix.len(); + (truncate_name(namespace, max_namespace_len), suffix) +} + +fn unique_callable_parts( + namespace: &str, + tool_name: &str, + raw_identity: &str, + used_names: &mut HashSet, +) -> (String, String) { + let model_name = format!("{namespace}{tool_name}"); + if model_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) { + return (namespace.to_string(), tool_name.to_string()); + } + + let mut attempt = 0_u32; + loop { + let hash_input = if attempt == 0 { + raw_identity.to_string() + } else { + format!("{raw_identity}\0{attempt}") + }; + let (namespace, tool_name) = + fit_callable_parts_with_hash(namespace, tool_name, &hash_input); + let model_name = format!("{namespace}{tool_name}"); + if used_names.insert(model_name) { + return (namespace, tool_name); + } + attempt = attempt.saturating_add(1); + } +} diff --git a/code-rs/collaboration-mode-templates/BUILD.bazel b/code-rs/collaboration-mode-templates/BUILD.bazel new file mode 100644 index 00000000000..0fbc86ec835 --- /dev/null +++ b/code-rs/collaboration-mode-templates/BUILD.bazel @@ -0,0 +1,12 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "collaboration-mode-templates", + crate_name = "codex_collaboration_mode_templates", + compile_data = glob(["templates/*.md"]), +) + +exports_files( + glob(["templates/*.md"]), + visibility = ["//visibility:public"], +) diff --git a/code-rs/collaboration-mode-templates/Cargo.toml b/code-rs/collaboration-mode-templates/Cargo.toml new file mode 100644 index 00000000000..2c17b1fd2ad --- /dev/null +++ b/code-rs/collaboration-mode-templates/Cargo.toml @@ -0,0 +1,14 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-collaboration-mode-templates" +version.workspace = true + +[lib] +doctest = false +name = "codex_collaboration_mode_templates" +path = "src/lib.rs" +test = false + +[lints] +workspace = true diff --git a/code-rs/collaboration-mode-templates/src/lib.rs b/code-rs/collaboration-mode-templates/src/lib.rs new file mode 100644 index 00000000000..345990db0b2 --- /dev/null +++ b/code-rs/collaboration-mode-templates/src/lib.rs @@ -0,0 +1,4 @@ +pub const PLAN: &str = include_str!("../templates/plan.md"); +pub const DEFAULT: &str = include_str!("../templates/default.md"); +pub const EXECUTE: &str = include_str!("../templates/execute.md"); +pub const PAIR_PROGRAMMING: &str = include_str!("../templates/pair_programming.md"); diff --git a/code-rs/collaboration-mode-templates/templates/default.md b/code-rs/collaboration-mode-templates/templates/default.md new file mode 100644 index 00000000000..715982c396b --- /dev/null +++ b/code-rs/collaboration-mode-templates/templates/default.md @@ -0,0 +1,11 @@ +# Collaboration Mode: Default + +You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. + +Your active mode changes only when new developer instructions with a different `...` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are {{KNOWN_MODE_NAMES}}. + +## request_user_input availability + +Use the `request_user_input` tool only when it is listed in the available tools for this turn. + +In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. diff --git a/code-rs/collaboration-mode-templates/templates/execute.md b/code-rs/collaboration-mode-templates/templates/execute.md new file mode 100644 index 00000000000..c57878242de --- /dev/null +++ b/code-rs/collaboration-mode-templates/templates/execute.md @@ -0,0 +1,45 @@ +# Collaboration Style: Execute +You execute on a well-specified task independently and report progress. + +You do not collaborate on decisions in this mode. You execute end-to-end. +You make reasonable assumptions when the user hasn't specified something, and you proceed without asking questions. + +## Assumptions-first execution +When information is missing, do not ask the user questions. +Instead: +- Make a sensible assumption. +- Clearly state the assumption in the final message (briefly). +- Continue executing. + +Group assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. +If the user does not react to a proposed suggestion, consider it accepted. + +## Execution principles +*Think out loud.* Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. Avoid design lectures or exhaustive option lists. + +*Use reasonable assumptions.* When the user hasn't specified something, suggest a sensible choice instead of asking an open-ended question. Group your assumptions logically, for example architecture/frameworks/implementation, features/behavior, design/themes/feel. Clearly label suggestions as provisional. Share reasoning when it helps the user evaluate tradeoffs. Keep explanations short and grounded in consequences. They should be easy to accept or override. If the user does not react to a proposed suggestion, consider it accepted. + +Example: "There are a few viable ways to structure this. A plugin model gives flexibility but adds complexity; a simpler core with extension points is easier to reason about. Given what you've said about your team's size, I'd lean towards the latter." +Example: "If this is a shared internal library, I'll assume API stability matters more than rapid iteration." + +*Think ahead.* What else might the user need? How will the user test and understand what you did? Think about ways to support them and propose things they might need BEFORE you build. Offer at least one suggestion you came up with by thinking ahead. +Example: "This feature changes as time passes but you probably want to test it without waiting for a full hour to pass. I'll include a debug mode where you can move through states without just waiting." + +*Be mindful of time.* The user is right here with you. Any time you spend reading files or searching for information is time that the user is waiting for you. Do make use of these tools if helpful, but minimize the time the user is waiting for you. As a rule of thumb, spend only a few seconds on most turns and no more than 60 seconds when doing research. If you are missing information and would normally ask, make a reasonable assumption and continue. +Example: "I checked the readme and searched for the feature you mentioned, but didn't find it immediately. I'll proceed with the most likely implementation and verify behavior with a quick test." + +## Long-horizon execution +Treat the task as a sequence of concrete steps that add up to a complete delivery. +- Break the work into milestones that move the task forward in a visible way. +- Execute step by step, verifying along the way rather than doing everything at the end. +- If the task is large, keep a running checklist of what is done, what is next, and what is blocked. +- Avoid blocking on uncertainty: choose a reasonable default and continue. + +## Reporting progress +In this phase you show progress on your task and appraise the user of your progress using plan tool. +- Provide updates that directly map to the work you are doing (what changed, what you verified, what remains). +- If something fails, report what failed, what you tried, and what you will do next. +- When you finish, summarize what you delivered and how the user can validate it. + +## Executing +Once you start working, you should execute independently. Your job is to deliver the task and report progress. diff --git a/code-rs/collaboration-mode-templates/templates/pair_programming.md b/code-rs/collaboration-mode-templates/templates/pair_programming.md new file mode 100644 index 00000000000..1297129b1de --- /dev/null +++ b/code-rs/collaboration-mode-templates/templates/pair_programming.md @@ -0,0 +1,7 @@ +# Collaboration Style: Pair Programming + +## Build together as you go +You treat collaboration as pairing by default. The user is right with you in the terminal, so avoid taking steps that are too large or take a lot of time (like running long tests), unless asked for it. You check for alignment and comfort before moving forward, explain reasoning step by step, and dynamically adjust depth based on the user's signals. There is no need to ask multiple rounds of questions—build as you go. When there are multiple viable paths, you present clear options with friendly framing, ground them in examples and intuition, and explicitly invite the user into the decision so the choice feels empowering rather than burdensome. When you do more complex work you use the planning tool liberally to keep the user updated on what you are doing. + +## Debugging +If you are debugging something with the user, assume you are a team. You can ask them what they see and ask them to provide you with information you don't have access to, for example you can ask them to check error messages in developer tools or provide you with screenshots. diff --git a/code-rs/collaboration-mode-templates/templates/plan.md b/code-rs/collaboration-mode-templates/templates/plan.md new file mode 100644 index 00000000000..8a1a934f83b --- /dev/null +++ b/code-rs/collaboration-mode-templates/templates/plan.md @@ -0,0 +1,128 @@ +# Plan Mode (Conversational) + +You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. + +## Mode rules (strict) + +You are in **Plan Mode** until a developer message explicitly ends it. + +Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. + +## Plan Mode vs update_plan tool + +Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a `` block. + +Separately, `update_plan` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use `update_plan` in Plan mode, it will return an error. + +## Execution vs. mutation in Plan Mode + +You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. + +### Allowed (non-mutating, plan-improving) + +Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: + +* Reading or searching files, configs, schemas, types, manifests, and docs +* Static analysis, inspection, and repo exploration +* Dry-run style commands when they do not edit repo-tracked files +* Tests, builds, or checks that may write to caches or build artifacts (for example, `target/`, `.cache/`, or snapshots) so long as they do not edit repo-tracked files + +### Not allowed (mutating, plan-executing) + +Actions that implement the plan or change repo-tracked state. Examples: + +* Editing or writing files +* Running formatters or linters that rewrite files +* Applying patches, migrations, or codegen that updates repo-tracked files +* Side-effectful commands whose purpose is to carry out the plan rather than refine it + +When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. + +## PHASE 1 — Ground in the environment (explore first, ask second) + +Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. + +Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. + +Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. + +Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. + +## PHASE 2 — Intent chat (what they actually want) + +* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. +* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet—ask. + +## PHASE 3 — Implementation chat (what/how we’ll build) + +* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. + +## Asking questions + +Critical rules: + +* Strongly prefer using the `request_user_input` tool to ask any questions. +* Offer only meaningful multiple‑choice options; don’t include filler choices that are obviously wrong or irrelevant. +* In rare cases where an unavoidable, important question can’t be expressed with reasonable multiple‑choice options (due to extreme ambiguity), you may ask it directly without the tool. + +You SHOULD ask many questions, but each question must: + +* materially change the spec/plan, OR +* confirm/lock an assumption, OR +* choose between meaningful tradeoffs. +* not be answerable by non-mutating commands. + +Use the `request_user_input` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. + +## Two kinds of unknowns (treat differently) + +1. **Discoverable facts** (repo/system truth): explore first. + + * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). + * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. + * If asking, present concrete candidates (paths/service names) + recommend one. + * Never ask questions you can answer from your environment (e.g., “where is this struct”). + +2. **Preferences/tradeoffs** (not discoverable): ask early. + + * These are intent or implementation preferences that cannot be derived from exploration. + * Provide 2–4 mutually exclusive options + a recommended default. + * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. + +## Finalization rule + +Only output the final plan when it is decision complete and leaves no decisions to the implementer. + +When you present the official plan, wrap it in a `` block so the client can render it specially: + +1) The opening tag must be on its own line. +2) Start the plan content on the next line (no text on the same line as the tag). +3) The closing tag must be on its own line. +4) Use Markdown inside the block. +5) Keep the tags exactly as `` and `` (do not translate or rename them), even if the plan content is in another language. + +Example: + + +plan content + + +plan content should be human and agent digestible. The final plan must be plan-only, concise by default, and include: + +* A clear title +* A brief summary section +* Important changes or additions to public APIs/interfaces/types +* Test cases and scenarios +* Explicit assumptions and defaults chosen where needed + +When possible, prefer a compact structure with 3-5 short sections, usually: Summary, Key Changes or Implementation Changes, Test Plan, and Assumptions. Do not include a separate Scope section unless scope boundaries are genuinely important to avoid mistakes. + +Prefer grouped implementation bullets by subsystem or behavior over file-by-file inventories. Mention files only when needed to disambiguate a non-obvious change, and avoid naming more than 3 paths unless extra specificity is necessary to prevent mistakes. Prefer behavior-level descriptions over symbol-by-symbol removal lists. For v1 feature-addition plans, do not invent detailed schema, validation, precedence, fallback, or wire-shape policy unless the request establishes it or it is needed to prevent a concrete implementation mistake; prefer the intended capability and minimum interface/behavior changes. + +Keep bullets short and avoid explanatory sub-bullets unless they are needed to prevent ambiguity. Prefer the minimum detail needed for implementation safety, not exhaustive coverage. Within each section, compress related changes into a few high-signal bullets and omit branch-by-branch logic, repeated invariants, and long lists of unaffected behavior unless they are necessary to prevent a likely implementation mistake. Avoid repeated repo facts and irrelevant edge-case or rollout detail. For straightforward refactors, keep the plan to a compact summary, key edits, tests, and assumptions. If the user asks for more detail, then expand. + +Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a `` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. + +Only produce at most one `` block per turn, and only when you are presenting a complete spec. + +If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. diff --git a/code-rs/common/Cargo.toml b/code-rs/common/Cargo.toml deleted file mode 100644 index aa08a583de5..00000000000 --- a/code-rs/common/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -edition = "2024" -name = "code-common" -version = { workspace = true } - -[lints] -workspace = true - -[dependencies] -clap = { workspace = true, features = ["derive", "wrap_help"], optional = true } -code-core = { workspace = true } -code-protocol = { workspace = true } -code-app-server-protocol = { workspace = true } -once_cell = { workspace = true } -serde = { workspace = true, optional = true } -toml = { workspace = true, optional = true } - -[features] -# Separate feature so that `clap` is not a mandatory dependency. -cli = ["clap", "serde", "toml"] -elapsed = [] -sandbox_summary = [] diff --git a/code-rs/common/README.md b/code-rs/common/README.md deleted file mode 100644 index 9d5d4151260..00000000000 --- a/code-rs/common/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# codex-common - -This crate is designed for utilities that need to be shared across other crates in the workspace, but should not go in `core`. - -For narrow utility features, the pattern is to add introduce a new feature under `[features]` in `Cargo.toml` and then gate it with `#[cfg]` in `lib.rs`, as appropriate. diff --git a/code-rs/common/src/approval_presets.rs b/code-rs/common/src/approval_presets.rs deleted file mode 100644 index 20305d2990f..00000000000 --- a/code-rs/common/src/approval_presets.rs +++ /dev/null @@ -1,46 +0,0 @@ -use code_core::protocol::AskForApproval; -use code_core::protocol::SandboxPolicy; - -/// A simple preset pairing an approval policy with a sandbox policy. -#[derive(Debug, Clone)] -pub struct ApprovalPreset { - /// Stable identifier for the preset. - pub id: &'static str, - /// Display label shown in UIs. - pub label: &'static str, - /// Short human description shown next to the label in UIs. - pub description: &'static str, - /// Approval policy to apply. - pub approval: AskForApproval, - /// Sandbox policy to apply. - pub sandbox: SandboxPolicy, -} - -/// Built-in list of approval presets that pair approval and sandbox policy. -/// -/// Keep this UI-agnostic so it can be reused by both TUI and MCP server. -pub fn builtin_approval_presets() -> Vec { - vec![ - ApprovalPreset { - id: "read-only", - label: "Read Only", - description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network", - approval: AskForApproval::OnRequest, - sandbox: SandboxPolicy::ReadOnly, - }, - ApprovalPreset { - id: "auto", - label: "Auto", - description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network", - approval: AskForApproval::OnRequest, - sandbox: SandboxPolicy::new_workspace_write_policy(), - }, - ApprovalPreset { - id: "full-access", - label: "Full Access", - description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution", - approval: AskForApproval::Never, - sandbox: SandboxPolicy::DangerFullAccess, - }, - ] -} diff --git a/code-rs/common/src/config_summary.rs b/code-rs/common/src/config_summary.rs deleted file mode 100644 index b9057def887..00000000000 --- a/code-rs/common/src/config_summary.rs +++ /dev/null @@ -1,29 +0,0 @@ -use code_core::WireApi; -use code_core::config::Config; - -use crate::sandbox_summary::summarize_sandbox_policy; - -/// Build a list of key/value pairs summarizing the effective configuration. -pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { - let mut entries = vec![ - ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), - ("provider", config.model_provider_id.clone()), - ("approval", config.approval_policy.to_string()), - ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), - ]; - if config.model_provider.wire_api == WireApi::Responses - && config.model_family.supports_reasoning_summaries - { - entries.push(( - "reasoning effort", - config.model_reasoning_effort.to_string(), - )); - entries.push(( - "reasoning summaries", - config.model_reasoning_summary.to_string(), - )); - } - - entries -} diff --git a/code-rs/common/src/elapsed.rs b/code-rs/common/src/elapsed.rs deleted file mode 100644 index 8c6c0344aa9..00000000000 --- a/code-rs/common/src/elapsed.rs +++ /dev/null @@ -1,168 +0,0 @@ -use std::time::Duration; -use std::time::Instant; - -/// Returns a string representing the elapsed time since `start_time`. -pub fn format_elapsed(start_time: Instant) -> String { - format_duration(start_time.elapsed()) -} - -/// Convert a [`std::time::Duration`] into a human-readable, compact string. -/// -/// Formatting rules: -/// * < 1 s -> "{milli}ms" -/// * < 60 s -> "{sec}s" -/// * < 60 m -> "{min}m {sec:02}s" -/// * < 24 h -> "{hour}h {minute:02}m" (rounded to the nearest minute) -/// * >= 24 h -> "{day}d {hour:02}h" (rounded to the nearest hour) -pub fn format_duration(duration: Duration) -> String { - let millis = duration.as_millis(); - if millis < 1_000 { - return format!("{millis}ms"); - } - - let secs = duration.as_secs(); - if secs < 60 { - return format!("{secs}s"); - } - - if secs < 3_600 { - return format_minutes_seconds(duration); - } - - if secs < 86_400 { - return format_hours_minutes(duration); - } - - format_days_hours(duration) -} - -fn format_minutes_seconds(duration: Duration) -> String { - let total_seconds = duration.as_secs(); - let minutes = total_seconds / 60; - let seconds = total_seconds % 60; - format!("{minutes}m {seconds:02}s") -} - -fn format_hours_minutes(duration: Duration) -> String { - let total_hours_f = duration.as_secs_f64() / 3_600.0; - let mut hours = total_hours_f.floor() as u64; - let mut minutes = ((total_hours_f - hours as f64) * 60.0).round() as u64; - - if minutes == 60 { - minutes = 0; - hours += 1; - } - - if hours >= 24 { - return format_days_hours(duration); - } - - format!("{hours}h {minutes:02}m") -} - -fn format_days_hours(duration: Duration) -> String { - let total_hours_f = duration.as_secs_f64() / 3_600.0; - let mut days = (total_hours_f / 24.0).floor() as u64; - let mut hours = (total_hours_f - days as f64 * 24.0).round() as u64; - - if hours == 24 { - hours = 0; - days += 1; - } - - format!("{days}d {hours:02}h") -} - -/// Format a duration as a zero-padded digital clock string. -/// -/// * < 1 h -> "{mm}:{ss}" -/// * >= 1 h -> "{hh}:{mm}:{ss}" -pub fn format_duration_digital(duration: Duration) -> String { - let total_seconds = duration.as_secs(); - let hours = total_seconds / 3_600; - let minutes = (total_seconds % 3_600) / 60; - let seconds = total_seconds % 60; - - if hours == 0 { - return format!("{minutes:02}:{seconds:02}"); - } - - format!("{hours:02}:{minutes:02}:{seconds:02}") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_duration_subsecond() { - // Durations < 1s should be rendered in milliseconds with no decimals. - let dur = Duration::from_millis(250); - assert_eq!(format_duration(dur), "250ms"); - - // Exactly zero should still work. - let dur_zero = Duration::from_millis(0); - assert_eq!(format_duration(dur_zero), "0ms"); - } - - #[test] - fn test_format_duration_seconds() { - // Durations between 1s (inclusive) and 60s (exclusive) should be - // printed with whole seconds and no decimal places. - let dur = Duration::from_millis(1_500); // 1.5s - assert_eq!(format_duration(dur), "1s"); - - // Values just shy of the next second truncate to the lower bound. - let dur2 = Duration::from_millis(59_999); - assert_eq!(format_duration(dur2), "59s"); - } - - #[test] - fn test_format_duration_minutes() { - // Durations ≥ 1 minute should be printed mmss. - let dur = Duration::from_millis(75_000); // 1m15s - assert_eq!(format_duration(dur), "1m 15s"); - - let dur_exact = Duration::from_millis(60_000); // 1m0s - assert_eq!(format_duration(dur_exact), "1m 00s"); - - let dur_long = Duration::from_millis(3_601_000); - assert_eq!(format_duration(dur_long), "1h 00m"); - } - - #[test] - fn test_format_duration_one_hour_has_space() { - let dur_hour = Duration::from_millis(3_600_000); - assert_eq!(format_duration(dur_hour), "1h 00m"); - } - - #[test] - fn test_format_duration_hours_rounds_minutes() { - let dur = Duration::from_secs(4 * 3_600 + 58 * 60 + 40); - assert_eq!(format_duration(dur), "4h 59m"); - } - - #[test] - fn test_format_duration_days_rounds_hours() { - let dur = Duration::from_secs(2 * 86_400 + 11 * 3_600 + 45 * 60); - assert_eq!(format_duration(dur), "2d 12h"); - } - - #[test] - fn test_format_duration_digital_under_minute() { - let dur = Duration::from_secs(5); - assert_eq!(format_duration_digital(dur), "00:05"); - } - - #[test] - fn test_format_duration_digital_under_hour() { - let dur = Duration::from_secs(5 * 60 + 3); - assert_eq!(format_duration_digital(dur), "05:03"); - } - - #[test] - fn test_format_duration_digital_over_hour() { - let dur = Duration::from_secs(2 * 3_600 + 7 * 60 + 9); - assert_eq!(format_duration_digital(dur), "02:07:09"); - } -} diff --git a/code-rs/common/src/fuzzy_match.rs b/code-rs/common/src/fuzzy_match.rs deleted file mode 100644 index 836848d6a4f..00000000000 --- a/code-rs/common/src/fuzzy_match.rs +++ /dev/null @@ -1,177 +0,0 @@ -/// Simple case-insensitive subsequence matcher used for fuzzy filtering. -/// -/// Returns the indices (character positions) of the matched characters in the -/// ORIGINAL `haystack` string and a score where smaller is better. -/// -/// Unicode correctness: we perform the match on a lowercased copy of the -/// haystack and needle but maintain a mapping from each character in the -/// lowercased haystack back to the original character index in `haystack`. -/// This ensures the returned indices can be safely used with -/// `str::chars().enumerate()` consumers for highlighting, even when -/// lowercasing expands certain characters (e.g., ß → ss, İ → i̇). -pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec, i32)> { - if needle.is_empty() { - return Some((Vec::new(), i32::MAX)); - } - - let mut lowered_chars: Vec = Vec::new(); - let mut lowered_to_orig_char_idx: Vec = Vec::new(); - for (orig_idx, ch) in haystack.chars().enumerate() { - for lc in ch.to_lowercase() { - lowered_chars.push(lc); - lowered_to_orig_char_idx.push(orig_idx); - } - } - - let lowered_needle: Vec = needle.to_lowercase().chars().collect(); - - let mut result_orig_indices: Vec = Vec::with_capacity(lowered_needle.len()); - let mut last_lower_pos: Option = None; - let mut cur = 0usize; - for &nc in lowered_needle.iter() { - let mut found_at: Option = None; - while cur < lowered_chars.len() { - if lowered_chars[cur] == nc { - found_at = Some(cur); - cur += 1; - break; - } - cur += 1; - } - let pos = found_at?; - result_orig_indices.push(lowered_to_orig_char_idx[pos]); - last_lower_pos = Some(pos); - } - - let first_lower_pos = if result_orig_indices.is_empty() { - 0usize - } else { - let target_orig = result_orig_indices[0]; - lowered_to_orig_char_idx - .iter() - .position(|&oi| oi == target_orig) - .unwrap_or(0) - }; - // last defaults to first for single-hit; score = extra span between first/last hit - // minus needle len (≥0). - // Strongly reward prefix matches by subtracting 100 when the first hit is at index 0. - let last_lower_pos = last_lower_pos.unwrap_or(first_lower_pos); - let window = - (last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32); - let mut score = window.max(0); - if first_lower_pos == 0 { - score -= 100; - } - - result_orig_indices.sort_unstable(); - result_orig_indices.dedup(); - Some((result_orig_indices, score)) -} - -/// Convenience wrapper to get only the indices for a fuzzy match. -pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option> { - fuzzy_match(haystack, needle).map(|(mut idx, _)| { - idx.sort_unstable(); - idx.dedup(); - idx - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ascii_basic_indices() { - let (idx, score) = match fuzzy_match("hello", "hl") { - Some(v) => v, - None => panic!("expected a match"), - }; - assert_eq!(idx, vec![0, 2]); - // 'h' at 0, 'l' at 2 -> window 1; start-of-string bonus applies (-100) - assert_eq!(score, -99); - } - - #[test] - fn unicode_dotted_i_istanbul_highlighting() { - let (idx, score) = match fuzzy_match("İstanbul", "is") { - Some(v) => v, - None => panic!("expected a match"), - }; - assert_eq!(idx, vec![0, 1]); - // Matches at lowered positions 0 and 2 -> window 1; start-of-string bonus applies - assert_eq!(score, -99); - } - - #[test] - fn unicode_german_sharp_s_casefold() { - assert!(fuzzy_match("straße", "strasse").is_none()); - } - - #[test] - fn prefer_contiguous_match_over_spread() { - let (_idx_a, score_a) = match fuzzy_match("abc", "abc") { - Some(v) => v, - None => panic!("expected a match"), - }; - let (_idx_b, score_b) = match fuzzy_match("a-b-c", "abc") { - Some(v) => v, - None => panic!("expected a match"), - }; - // Contiguous window -> 0; start-of-string bonus -> -100 - assert_eq!(score_a, -100); - // Spread over 5 chars for 3-letter needle -> window 2; with bonus -> -98 - assert_eq!(score_b, -98); - assert!(score_a < score_b); - } - - #[test] - fn start_of_string_bonus_applies() { - let (_idx_a, score_a) = match fuzzy_match("file_name", "file") { - Some(v) => v, - None => panic!("expected a match"), - }; - let (_idx_b, score_b) = match fuzzy_match("my_file_name", "file") { - Some(v) => v, - None => panic!("expected a match"), - }; - // Start-of-string contiguous -> window 0; bonus -> -100 - assert_eq!(score_a, -100); - // Non-prefix contiguous -> window 0; no bonus -> 0 - assert_eq!(score_b, 0); - assert!(score_a < score_b); - } - - #[test] - fn empty_needle_matches_with_max_score_and_no_indices() { - let (idx, score) = match fuzzy_match("anything", "") { - Some(v) => v, - None => panic!("empty needle should match"), - }; - assert!(idx.is_empty()); - assert_eq!(score, i32::MAX); - } - - #[test] - fn case_insensitive_matching_basic() { - let (idx, score) = match fuzzy_match("FooBar", "foO") { - Some(v) => v, - None => panic!("expected a match"), - }; - assert_eq!(idx, vec![0, 1, 2]); - // Contiguous prefix match (case-insensitive) -> window 0 with bonus - assert_eq!(score, -100); - } - - #[test] - fn indices_are_deduped_for_multichar_lowercase_expansion() { - let needle = "\u{0069}\u{0307}"; // "i" + combining dot above - let (idx, score) = match fuzzy_match("İ", needle) { - Some(v) => v, - None => panic!("expected a match"), - }; - assert_eq!(idx, vec![0]); - // Lowercasing 'İ' expands to two chars; contiguous prefix -> window 0 with bonus - assert_eq!(score, -100); - } -} diff --git a/code-rs/common/src/lib.rs b/code-rs/common/src/lib.rs deleted file mode 100644 index f35b63f46ba..00000000000 --- a/code-rs/common/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -#[cfg(feature = "cli")] -mod approval_mode_cli_arg; - -#[cfg(feature = "elapsed")] -pub mod elapsed; - -#[cfg(feature = "cli")] -pub use approval_mode_cli_arg::ApprovalModeCliArg; - -#[cfg(feature = "cli")] -mod sandbox_mode_cli_arg; - -#[cfg(feature = "cli")] -pub use sandbox_mode_cli_arg::SandboxModeCliArg; - -#[cfg(any(feature = "cli", test))] -mod config_override; - -#[cfg(feature = "cli")] -pub use config_override::CliConfigOverrides; - -mod sandbox_summary; - -#[cfg(feature = "sandbox_summary")] -pub use sandbox_summary::summarize_sandbox_policy; - -mod config_summary; -pub use config_summary::create_config_summary_entries; -// Shared fuzzy matcher (used by TUI selection popups and other UI filtering) -pub mod fuzzy_match; -// Shared model presets used by TUI and MCP server -pub mod model_presets; -// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server -// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy. -pub mod approval_presets; diff --git a/code-rs/common/src/model_presets.rs b/code-rs/common/src/model_presets.rs deleted file mode 100644 index ae404cc515b..00000000000 --- a/code-rs/common/src/model_presets.rs +++ /dev/null @@ -1,655 +0,0 @@ -use std::collections::HashMap; - -use code_app_server_protocol::AuthMode; -use code_core::config_types::TextVerbosity as TextVerbosityConfig; -use code_core::protocol_config_types::ReasoningEffort; -use once_cell::sync::Lazy; - -pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt"; -pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str = - "hide_gpt-5.1-codex-max_migration_prompt"; -pub const HIDE_GPT_5_2_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_2_migration_prompt"; -pub const HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_2_codex_migration_prompt"; - -/// A reasoning effort option surfaced for a model. -#[derive(Debug, Clone)] -pub struct ReasoningEffortPreset { - pub effort: ReasoningEffort, - pub description: String, -} - -#[derive(Debug, Clone)] -pub struct ModelUpgrade { - pub id: String, - pub reasoning_effort_mapping: Option>, - pub migration_config_key: String, -} - -/// Metadata describing a Code-supported model. -#[derive(Debug, Clone)] -pub struct ModelPreset { - pub id: String, - pub model: String, - pub display_name: String, - pub description: String, - pub default_reasoning_effort: ReasoningEffort, - pub supported_reasoning_efforts: Vec, - pub supported_text_verbosity: &'static [TextVerbosityConfig], - pub is_default: bool, - pub upgrade: Option, - pub pro_only: bool, - pub show_in_picker: bool, -} - -const ALL_TEXT_VERBOSITY: &[TextVerbosityConfig] = &[ - TextVerbosityConfig::Low, - TextVerbosityConfig::Medium, - TextVerbosityConfig::High, -]; - -static PRESETS: Lazy> = Lazy::new(|| { - vec![ - ModelPreset { - id: "gpt-5.5".to_string(), - model: "gpt-5.5".to_string(), - display_name: "GPT-5.5".to_string(), - description: "Frontier model for complex coding, research, and real-world work." - .to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Greater reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: true, - upgrade: None, - pro_only: false, - show_in_picker: true, - }, - ModelPreset { - id: "gpt-5.4".to_string(), - model: "gpt-5.4".to_string(), - display_name: "gpt-5.4".to_string(), - description: "Frontier flagship model.".to_string(), - default_reasoning_effort: ReasoningEffort::XHigh, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: None, - pro_only: false, - show_in_picker: true, - }, - ModelPreset { - id: "gpt-5.4-mini".to_string(), - model: "gpt-5.4-mini".to_string(), - display_name: "gpt-5.4-mini".to_string(), - description: "Smaller GPT-5.4 variant tuned for faster coding loops.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: None, - pro_only: false, - show_in_picker: true, - }, - ModelPreset { - id: "gpt-5.2-codex".to_string(), - model: "gpt-5.2-codex".to_string(), - display_name: "gpt-5.2-codex".to_string(), - description: "Frontier agentic coding model.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supported_text_verbosity: &[TextVerbosityConfig::Medium], - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: false, - }, - ModelPreset { - id: "gpt-5.2".to_string(), - model: "gpt-5.2".to_string(), - display_name: "gpt-5.2".to_string(), - description: - "Latest frontier model with improvements across knowledge, reasoning and coding" - .to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: - "Balances speed with some reasoning; useful for straightforward queries and short explanations" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: - "Provides a solid balance of reasoning depth and latency for general-purpose tasks" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: false, - }, - ModelPreset { - id: "bengalfox".to_string(), - model: "bengalfox".to_string(), - display_name: "bengalfox".to_string(), - description: "bengalfox".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Greater reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supported_text_verbosity: &[TextVerbosityConfig::Medium], - is_default: false, - upgrade: None, - pro_only: false, - show_in_picker: false, - }, - ModelPreset { - id: "boomslang".to_string(), - model: "boomslang".to_string(), - display_name: "boomslang".to_string(), - description: "boomslang".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: - "Balances speed with some reasoning; useful for straightforward queries and short explanations" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: - "Provides a solid balance of reasoning depth and latency for general-purpose tasks" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning for complex problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: None, - pro_only: false, - show_in_picker: false, - }, - ModelPreset { - id: "gpt-5.1-codex-max".to_string(), - model: "gpt-5.1-codex-max".to_string(), - display_name: "gpt-5.1-codex-max".to_string(), - description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex problems".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems".to_string(), - }, - ], - supported_text_verbosity: &[TextVerbosityConfig::Medium], - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: true, - }, - ModelPreset { - id: "gpt-5.1-codex".to_string(), - model: "gpt-5.1-codex".to_string(), - display_name: "gpt-5.1-codex".to_string(), - description: "Optimized for Code.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: false, - }, - ModelPreset { - id: "gpt-5.1-codex-mini".to_string(), - model: "gpt-5.1-codex-mini".to_string(), - display_name: "gpt-5.1-codex-mini".to_string(), - description: "Optimized for Code. Cheaper, faster, but less capable.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems" - .to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: true, - }, - ModelPreset { - id: "gpt-5.1".to_string(), - model: "gpt-5.1".to_string(), - display_name: "gpt-5.1".to_string(), - description: "Broad world knowledge with strong general reasoning.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: - "Balances speed with some reasoning; useful for straightforward queries and short explanations" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: - "Provides a solid balance of reasoning depth and latency for general-purpose tasks" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: false, - }, - // Deprecated GPT-5 variants kept for migrations / config compatibility. - ModelPreset { - id: "gpt-5-codex".to_string(), - model: "gpt-5-codex".to_string(), - display_name: "gpt-5-codex".to_string(), - description: "Optimized for Code.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: false, - }, - ModelPreset { - id: "gpt-5-codex-mini".to_string(), - model: "gpt-5-codex-mini".to_string(), - display_name: "gpt-5-codex-mini".to_string(), - description: "Optimized for Code. Cheaper, faster, but less capable.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: false, - }, - ModelPreset { - id: "gpt-5".to_string(), - model: "gpt-5".to_string(), - display_name: "gpt-5".to_string(), - description: "Broad world knowledge with strong general reasoning.".to_string(), - default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: vec![ - ReasoningEffortPreset { - effort: ReasoningEffort::Minimal, - description: "Fastest responses with little reasoning".to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Low, - description: - "Balances speed with some reasoning; useful for straightforward queries and short explanations" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: - "Provides a solid balance of reasoning depth and latency for general-purpose tasks" - .to_string(), - }, - ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), - }, - ], - supported_text_verbosity: ALL_TEXT_VERBOSITY, - is_default: false, - upgrade: Some(ModelUpgrade { - id: "gpt-5.5".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_2_CODEX_MIGRATION_PROMPT_CONFIG.to_string(), - }), - pro_only: false, - show_in_picker: false, - }, - ] -}); - -pub fn model_preset_available_for_auth( - preset: &ModelPreset, - auth_mode: Option, - supports_pro_only_models: bool, -) -> bool { - let is_chatgpt_auth = auth_mode.is_some_and(AuthMode::is_chatgpt); - if preset.pro_only && !(is_chatgpt_auth && supports_pro_only_models) { - return false; - } - - match auth_mode { - Some(AuthMode::ApiKey) => preset.id != "gpt-5.2-codex", - _ => true, - } -} - -pub fn builtin_model_presets( - auth_mode: Option, - supports_pro_only_models: bool, -) -> Vec { - PRESETS - .iter() - .filter(|preset| { - model_preset_available_for_auth(preset, auth_mode, supports_pro_only_models) - }) - .filter(|preset| preset.show_in_picker) - .cloned() - .collect() -} - -// todo(aibrahim): remove this once we migrate tests -pub fn all_model_presets() -> &'static Vec { - &PRESETS -} - -fn find_preset_for_model(model: &str) -> Option<&'static ModelPreset> { - let model_lower = model.to_ascii_lowercase(); - - PRESETS.iter().find(|preset| { - preset.model.eq_ignore_ascii_case(&model_lower) - || preset.id.eq_ignore_ascii_case(&model_lower) - || preset.display_name.eq_ignore_ascii_case(&model_lower) - }) -} - -fn reasoning_effort_rank(effort: ReasoningEffort) -> u8 { - match effort { - ReasoningEffort::None => 0, - ReasoningEffort::Minimal => 0, - ReasoningEffort::Low => 1, - ReasoningEffort::Medium => 2, - ReasoningEffort::High => 3, - ReasoningEffort::XHigh => 4, - } -} - -pub fn clamp_reasoning_effort_for_model( - model: &str, - requested: ReasoningEffort, -) -> ReasoningEffort { - let Some(preset) = find_preset_for_model(model) else { - return requested; - }; - - if preset - .supported_reasoning_efforts - .iter() - .any(|opt| opt.effort == requested) - { - return requested; - } - - let requested_rank = reasoning_effort_rank(requested); - - preset - .supported_reasoning_efforts - .iter() - .min_by_key(|opt| { - let rank = reasoning_effort_rank(opt.effort); - (requested_rank.abs_diff(rank), u8::MAX - rank) - }) - .map(|opt| opt.effort) - .unwrap_or(requested) -} - -pub fn allowed_text_verbosity_for_model(model: &str) -> &'static [TextVerbosityConfig] { - find_preset_for_model(model) - .map(|preset| preset.supported_text_verbosity) - .unwrap_or(ALL_TEXT_VERBOSITY) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_one_default_model_is_configured() { - assert_eq!(PRESETS.iter().filter(|preset| preset.is_default).count(), 1); - } - - #[test] - fn retired_models_hidden_for_api_key_auth() { - let presets = builtin_model_presets(Some(AuthMode::ApiKey), false); - assert!(presets.iter().all(|preset| { - preset.id != "gpt-5.2-codex" - && preset.id != "gpt-5.3-codex" - && preset.id != "gpt-5.3-codex-spark" - && preset.id != "gpt-5.2" - })); - } - - #[test] - fn retired_models_hidden_for_chatgpt_auth() { - let presets = builtin_model_presets(Some(AuthMode::Chatgpt), false); - assert!(presets.iter().all(|preset| { - preset.id != "gpt-5.2-codex" - && preset.id != "gpt-5.3-codex" - && preset.id != "gpt-5.3-codex-spark" - && preset.id != "gpt-5.2" - })); - } - - #[test] - fn flagship_gpt_models_available_for_api_key_auth() { - let presets = builtin_model_presets(Some(AuthMode::ApiKey), false); - assert!(presets.iter().any(|preset| preset.id == "gpt-5.5")); - assert!(presets.iter().any(|preset| preset.id == "gpt-5.4")); - assert!(presets.iter().any(|preset| preset.id == "gpt-5.4-mini")); - } - - #[test] - fn gpt_5_5_available_for_chatgpt_auth() { - let presets = builtin_model_presets(Some(AuthMode::Chatgpt), true); - assert!(presets.iter().any(|preset| preset.id == "gpt-5.5")); - } - - #[test] - fn clamp_reasoning_effort_downgrades_to_supported_level() { - let clamped = clamp_reasoning_effort_for_model( - "gpt-5.1-codex", - ReasoningEffort::XHigh, - ); - assert_eq!(clamped, ReasoningEffort::High); - - let clamped_minimal = - clamp_reasoning_effort_for_model("gpt-5.1-codex-mini", ReasoningEffort::Minimal); - assert_eq!(clamped_minimal, ReasoningEffort::Medium); - } -} diff --git a/code-rs/common/src/sandbox_mode_cli_arg.rs b/code-rs/common/src/sandbox_mode_cli_arg.rs deleted file mode 100644 index b37d86d03ad..00000000000 --- a/code-rs/common/src/sandbox_mode_cli_arg.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Standard type to use with the `--sandbox` (`-s`) CLI option. -//! -//! This mirrors the variants of [`code_core::protocol::SandboxPolicy`], but -//! without any of the associated data so it can be expressed as a simple flag -//! on the command-line. Users that need to tweak the advanced options for -//! `workspace-write` can continue to do so via `-c` overrides or their -//! `config.toml`. - -use clap::ValueEnum; -use code_protocol::config_types::SandboxMode; - -#[derive(Clone, Copy, Debug, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub enum SandboxModeCliArg { - ReadOnly, - WorkspaceWrite, - DangerFullAccess, -} - -impl From for SandboxMode { - fn from(value: SandboxModeCliArg) -> Self { - match value { - SandboxModeCliArg::ReadOnly => SandboxMode::ReadOnly, - SandboxModeCliArg::WorkspaceWrite => SandboxMode::WorkspaceWrite, - SandboxModeCliArg::DangerFullAccess => SandboxMode::DangerFullAccess, - } - } -} diff --git a/code-rs/common/src/sandbox_summary.rs b/code-rs/common/src/sandbox_summary.rs deleted file mode 100644 index f7203ee8354..00000000000 --- a/code-rs/common/src/sandbox_summary.rs +++ /dev/null @@ -1,37 +0,0 @@ -use code_core::protocol::SandboxPolicy; - -pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { - match sandbox_policy { - SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly => "read-only".to_string(), - SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - .. - } => { - let mut summary = "workspace-write".to_string(); - - let mut writable_entries = Vec::::new(); - writable_entries.push("workdir".to_string()); - if !*exclude_slash_tmp { - writable_entries.push("/tmp".to_string()); - } - if !*exclude_tmpdir_env_var { - writable_entries.push("$TMPDIR".to_string()); - } - writable_entries.extend( - writable_roots - .iter() - .map(|p| p.to_string_lossy().to_string()), - ); - - summary.push_str(&format!(" [{}]", writable_entries.join(", "))); - if *network_access { - summary.push_str(" (network access enabled)"); - } - summary - } - } -} diff --git a/code-rs/config.md b/code-rs/config.md index 4538ef6de21..5de2a2d9c41 100644 --- a/code-rs/config.md +++ b/code-rs/config.md @@ -2,731 +2,5 @@ This file has moved. Please see the latest configuration documentation here: -- Config-specific command-line flags, such as `--model o3` (highest precedence). -- A generic `-c`/`--config` flag that takes a `key=value` pair, such as `--config model="o3"`. - - The key can contain dots to set a value deeper than the root, e.g. `--config model_providers.openai.wire_api="chat"`. - - Values can contain objects, such as `--config shell_environment_policy.include_only=["PATH", "HOME", "USER"]`. - - For consistency with `config.toml`, values are in TOML format rather than JSON format, so use `{a = 1, b = 2}` rather than `{"a": 1, "b": 2}`. - - If `value` cannot be parsed as a valid TOML value, it is treated as a string value. This means that both `-c model="o3"` and `-c model=o3` are equivalent. -- The `$CODE_HOME/config.toml` configuration file. `CODE_HOME` defaults to `~/.code`; Code also reads from `$CODEX_HOME`/`~/.codex` for backwards compatibility but only writes to `~/.code`. (Logs and other state use the same directory.) - -Both the `--config` flag and the `config.toml` file support the following options: - -## model - -The model that Codex should use. - -```toml -model = "o3" # overrides the default of "gpt-5.5" -``` - -## model_providers - -This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the corresponding provider. Providers must expose an OpenAI-compatible HTTP API (Chat Completions or Responses); native Anthropic/Gemini APIs are not supported directly without a proxy. - -For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you could add the following configuration: - -```toml -# Recall that in TOML, root keys must be listed before tables. -model = "gpt-4o" -model_provider = "openai-chat-completions" - -[model_providers.openai-chat-completions] -# Name of the provider that will be displayed in the Codex UI. -name = "OpenAI using Chat Completions" -# The path `/chat/completions` will be amended to this URL to make the POST -# request for the chat completions. -base_url = "https://api.openai.com/v1" -# If `env_key` is set, identifies an environment variable that must be set when -# using Codex with this provider. The value of the environment variable must be -# non-empty and will be used in the `Bearer TOKEN` HTTP header for the POST request. -env_key = "OPENAI_API_KEY" -# Valid values for wire_api are "chat" and "responses". Defaults to "chat" if omitted. -wire_api = "chat" -# If necessary, extra query params that need to be added to the URL. -# See the Azure example below. -query_params = {} -``` - -Note this makes it possible to use Codex CLI with non-OpenAI models, so long as they use a wire API that is compatible with the OpenAI chat completions API. For example, you could define the following provider to use Codex CLI with Ollama running locally: - -```toml -[model_providers.ollama] -name = "Ollama" -base_url = "http://localhost:11434/v1" -``` - -Or a third-party provider (using a distinct environment variable for the API key): - -```toml -[model_providers.mistral] -name = "Mistral" -base_url = "https://api.mistral.ai/v1" -env_key = "MISTRAL_API_KEY" -``` - -Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider: - -```toml -[model_providers.azure] -name = "Azure" -# Make sure you set the appropriate subdomain for this URL. -base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" -env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use. -query_params = { api-version = "2025-04-01-preview" } -``` - -It is also possible to configure a provider to include extra HTTP headers with a request. These can be hardcoded values (`http_headers`) or values read from environment variables (`env_http_headers`): - -```toml -[model_providers.example] -# name, base_url, ... - -# This will add the HTTP header `X-Example-Header` with value `example-value` -# to each request to the model provider. -http_headers = { "X-Example-Header" = "example-value" } - -# This will add the HTTP header `X-Example-Features` with the value of the -# `EXAMPLE_FEATURES` environment variable to each request to the model provider -# _if_ the environment variable is set and its value is non-empty. -env_http_headers = { "X-Example-Features": "EXAMPLE_FEATURES" } -``` - -### Per-provider network tuning - -The following optional settings control retry behaviour and streaming idle timeouts **per model provider**. They must be specified inside the corresponding `[model_providers.]` block in `config.toml`. (Older releases accepted top‑level keys; those are now ignored.) - -Example: - -```toml -[model_providers.openai] -name = "OpenAI" -base_url = "https://api.openai.com/v1" -env_key = "OPENAI_API_KEY" -# network tuning overrides (all optional; falls back to built‑in defaults) -request_max_retries = 4 # retry failed HTTP requests -stream_max_retries = 10 # retry dropped SSE streams -stream_idle_timeout_ms = 300000 # 5m idle timeout -``` - -#### request_max_retries - -How many times Codex will retry a failed HTTP request to the model provider. Defaults to `4`. - -#### stream_max_retries - -Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `10`. - -#### stream_idle_timeout_ms - -How long Codex will wait for activity on a streaming response before treating the connection as lost. Defaults to `300_000` (5 minutes). - -## model_provider - -Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable and force the wire protocol (`"responses"` or `"chat"`) with `OPENAI_WIRE_API`. - -Note that if you override `model_provider`, then you likely want to override -`model`, as well. For example, if you are running ollama with Mistral locally, -then you would need to add the following to your config in addition to the new entry in the `model_providers` map: - -```toml -model_provider = "ollama" -model = "mistral" -``` - -## approval_policy - -Determines when the user should be prompted to approve whether Codex can execute a command: - -```toml -# Codex has hardcoded logic that defines a set of "trusted" commands. -# Setting the approval_policy to `untrusted` means that Codex will prompt the -# user before running a command not in the "trusted" set. -# -# See https://github.com/openai/codex/issues/1260 for the plan to enable -# end-users to define their own trusted commands. -approval_policy = "untrusted" -``` - -If you want to be notified whenever a command fails, use "on-failure": - -```toml -# If the command fails when run in the sandbox, Codex asks for permission to -# retry the command outside the sandbox. -approval_policy = "on-failure" -``` - -If you want the model to run until it decides that it needs to ask you for escalated permissions, use "on-request": - -```toml -# The model decides when to escalate -approval_policy = "on-request" -``` - -Alternatively, you can have the model run until it is done, and never ask to run a command with escalated permissions: - -```toml -# User is never prompted: if the command fails, Codex will automatically try -# something out. Note the `exec` subcommand always uses this mode. -approval_policy = "never" -``` - -## confirm_guard - -Adds custom regular-expression based guards for commands that should require an explicit `confirm:` prefix before running. Each pattern is checked against the raw command string (`argv` joined with spaces or the `bash -lc` script body). When a pattern matches, Codex blocks the command and instructs the model to resend it with `confirm:`. - -```toml -[[confirm_guard.patterns]] -regex = "^git\s+clean(\s+-[fxd])+" -message = "Blocked git clean; it deletes untracked files. Resend with 'confirm:' if you're sure." - -[[confirm_guard.patterns]] -regex = "rm\s+-rf\s+node_modules" -# message is optional; a default explanation referencing the regex is shown when omitted. -``` - -Codex ships with built-in guards for destructive Git operations that can wipe working tree changes (`git reset`, `git checkout -- `, `git clean`, `git push --force`) and for common shell helpers that can recursively delete large portions of the workspace (`rm -rf` against `.`, `..`, `/`, or `*`, `find . … -delete`, `find . … -exec rm`, `trash -rf`, and `fd … --exec rm`). The snippet above shows how to add extra patterns or override the default messaging when needed. - -Patterns use the same syntax as the `regex` crate (via `regex-lite`). Invalid regexes cause config loading to fail with an explicit error so they can be corrected quickly. - -## profiles - -A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you -want to use at runtime via the `--profile` flag. - -Here is an example of a `config.toml` that defines multiple profiles: - -```toml -model = "o3" -approval_policy = "unless-allow-listed" -disable_response_storage = false - -# Setting `profile` is equivalent to specifying `--profile o3` on the command -# line, though the `--profile` flag can still be used to override this value. -profile = "o3" - -[model_providers.openai-chat-completions] -name = "OpenAI using Chat Completions" -base_url = "https://api.openai.com/v1" -env_key = "OPENAI_API_KEY" -wire_api = "chat" - -[profiles.o3] -model = "o3" -model_provider = "openai" -approval_policy = "never" -model_reasoning_effort = "high" -model_reasoning_summary = "detailed" - -[profiles.gpt3] -model = "gpt-3.5-turbo" -model_provider = "openai-chat-completions" - -[profiles.zdr] -model = "o3" -model_provider = "openai" -approval_policy = "on-failure" -disable_response_storage = true -``` - -Users can specify config values at multiple levels. Order of precedence is as follows: - -1. custom command-line argument, e.g., `--model o3` -2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) -3. as an entry in `config.toml`, e.g., `model = "o3"` -4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5.5`) - -## model_reasoning_effort - -If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5.1`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: - -- `"minimal"` (fastest) -- `"low"` -- `"medium"` (default) -- `"high"` - -Note: Older configs that use `"none"` are still accepted and now map to `"minimal"` for backwards compatibility. - -## model_reasoning_summary - -If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries), this can be set to: - -- `"auto"` (default) -- `"concise"` -- `"detailed"` - -To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in your config: - -```toml -model_reasoning_summary = "none" # disable reasoning summaries -``` - -## model_verbosity - -Controls output length/detail on GPT‑5 family models when using the Responses API. Supported values: - -- `"low"` -- `"medium"` (default when omitted) -- `"high"` - -When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`. - -Example: - -```toml -model = "gpt-5.1" -model_verbosity = "low" -``` - -Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected. - -## model_supports_reasoning_summaries - -By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`: - -```toml -model_supports_reasoning_summaries = true -``` - -## sandbox_mode - -Codex executes model-generated shell commands inside an OS-level sandbox. - -In most cases you can pick the desired behaviour with a single option: - -```toml -# same as `--sandbox read-only` -sandbox_mode = "read-only" -``` - -The default policy is `read-only`, which means commands can read any file on -disk, but attempts to write a file or access the network will be blocked. - -A more relaxed policy is `workspace-write`. When specified, the current working directory for the Codex task will be writable (as well as `$TMPDIR` on macOS). Note that the CLI defaults to using the directory where it was spawned as `cwd`, though this can be overridden using `--cwd/-C`. - -On macOS (and soon Linux), all writable roots (including `cwd`) that contain a `.git/` folder _as an immediate child_ will configure the `.git/` folder to be read-only while the rest of the Git repository will be writable. This means that commands like `git commit` will fail, by default (as it entails writing to `.git/`), and will require Codex to ask for permission. - -```toml -# same as `--sandbox workspace-write` -sandbox_mode = "workspace-write" - -# Extra settings that only apply when `sandbox = "workspace-write"`. -[sandbox_workspace_write] -# By default, the cwd for the Codex session will be writable as well as $TMPDIR -# (if set) and /tmp (if it exists). Setting the respective options to `true` -# will override those defaults. -exclude_tmpdir_env_var = false -exclude_slash_tmp = false - -# Optional list of _additional_ writable roots beyond $TMPDIR and /tmp. -writable_roots = ["/Users/YOU/.pyenv/shims"] - -# Allow the command being run inside the sandbox to make outbound network -# requests. Disabled by default. -network_access = false -``` - -To disable sandboxing altogether, specify `danger-full-access` like so: - -```toml -# same as `--sandbox danger-full-access` -sandbox_mode = "danger-full-access" -``` - -This is reasonable to use if Codex is running in an environment that provides its own sandboxing (such as a Docker container) such that further sandboxing is unnecessary. - -Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. - -## Approval presets - -Codex provides three main Approval Presets: - -- Read Only: Codex can read files and answer questions; edits, running commands, and network access require approval. -- Auto: Codex can read files, make edits, and run commands in the workspace without approval; asks for approval outside the workspace or for network access. -- Full Access: Full disk and network access without prompts; extremely risky. - -You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options. - -## mcp_servers - -Defines the list of MCP servers that Codex can consult for tool use. Currently, only servers that are launched by executing a program that communicate over stdio are supported. For servers that use the SSE transport, consider an adapter like [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy). - -**Note:** Codex may cache the list of tools and resources from an MCP server so that Codex can include this information in context at startup without spawning all the servers. This is designed to save resources by loading MCP servers lazily. - -This config option is comparable to how Claude and Cursor define `mcpServers` in their respective JSON config files, though because Codex uses TOML for its config language, the format is slightly different. For example, the following config in JSON: - -```json -{ - "mcpServers": { - "server-name": { - "command": "npx", - "args": ["-y", "mcp-server"], - "env": { - "API_KEY": "value" - } - } - } -} -``` - -Should be represented as follows in `~/.code/config.toml` (Code will also read the legacy `~/.codex/config.toml` if it exists): - -```toml -# IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`. -[mcp_servers.server-name] -command = "npx" -args = ["-y", "mcp-server"] -env = { "API_KEY" = "value" } -``` - -## agents - -Agents are CLI programs that Code can invoke to handle subtasks. Code includes built-in support for several agent/model selectors (Claude, Antigravity, Qwen, etc.) and allows you to configure custom agents as well. - -Each agent is configured using an `[[agents]]` section in your `config.toml`. Here's the basic structure: - -```toml -[[agents]] -name = "claude" # Agent identifier (required) -command = "claude" # Command to execute (required) -enabled = true # Enable/disable this agent (default: true) -read-only = false # Restrict to read-only operations (default: false) -description = "Claude AI assistant" # Description shown in UI -args = ["--dangerously-skip-permissions"] # Default arguments -env = { API_KEY = "value" } # Environment variables -``` - -### Configuring agent commands - -The `command` field specifies how to invoke the agent. Code supports three approaches: - -**1. Command name (PATH lookup):** -```toml -[[agents]] -name = "claude" -command = "claude" # Code will search for "claude" in your PATH -``` - -**2. Absolute path (recommended for Windows):** -```toml -[[agents]] -name = "antigravity" -command = "C:\\Users\\YourUser\\AppData\\Local\\Programs\\Antigravity\\agy.exe" -``` - -**3. Relative path:** -```toml -[[agents]] -name = "custom" -command = "./bin/custom-agent" # Relative to working directory -``` - -### Windows considerations - -On Windows, agent command discovery follows these rules: - -1. Code checks the `PATHEXT` environment variable for valid executable extensions (`.exe`, `.cmd`, `.bat`, `.com`) -2. If using PATH lookup, ensure the agent's directory is in your `PATH` environment variable -3. For npm-installed agents, the typical location is `C:\Users\YourUser\AppData\Roaming\npm\` -4. **Recommended:** Use absolute paths to avoid PATH issues: - -```toml -[[agents]] -name = "coder" -command = "C:\\Users\\YourUser\\AppData\\Roaming\\npm\\coder.cmd" -enabled = true - -[[agents]] -name = "claude" -command = "C:\\Users\\YourUser\\AppData\\Roaming\\npm\\claude.cmd" -enabled = true -``` - -### Per-agent arguments - -You can specify different arguments for read-only vs. write modes: - -```toml -[[agents]] -name = "custom-agent" -command = "custom-agent" -args = ["--base-arg"] # Always included -args_read_only = ["--read-only"] # Added in read-only mode -args_write = ["--allow-writes"] # Added in write mode -``` - -### Environment variables - -Agents inherit your current environment and can have custom variables: - -```toml -[[agents]] -name = "antigravity" -command = "agy" -``` - -Code automatically mirrors common API key environment variables for convenience: -- `GOOGLE_API_KEY` ↔ `GEMINI_API_KEY` -- `CLAUDE_API_KEY` ↔ `ANTHROPIC_API_KEY` -- `QWEN_API_KEY` ↔ `DASHSCOPE_API_KEY` - -### Built-in agents - -Code includes built-in support for these agents: - -- **code/codex** - Built-in Code CLI agents (use current executable) -- **claude** - Claude AI assistant (requires `claude` CLI) -- **antigravity** - Google Antigravity (requires `agy` CLI; uses Antigravity's configured model) -- **qwen** - Qwen AI assistant (requires `qwen` CLI) -- **cloud** - Cloud-based agents (optional, gated by `CODE_ENABLE_CLOUD_AGENT_MODEL`) - -### Custom agent example - -```toml -[[agents]] -name = "custom-llm" -command = "/usr/local/bin/custom-llm" -enabled = true -read-only = false -description = "Custom LLM implementation" -args = ["--config", "/path/to/config.json"] -env = { MODEL_PATH = "/models/custom.bin", TEMP = "0.7" } -``` - -### Troubleshooting - -**Agent not found errors:** - -If you see errors like `Agent 'xyz' could not be found`, try these steps: - -1. **Verify installation:** Check if the command exists - - Unix/Linux/macOS: `which claude` - - Windows: `where claude` - -2. **Check PATH:** Ensure the agent's directory is in your PATH - - Unix/Linux/macOS: `echo $PATH` - - Windows: `echo %PATH%` (cmd) or `$env:PATH` (PowerShell) - -3. **Use absolute paths:** Especially on Windows, specify the full path: - ```toml - [[agents]] - name = "claude" - command = "C:\\Users\\YourUser\\AppData\\Roaming\\npm\\claude.cmd" - ``` - -4. **Windows file extensions:** Ensure your command includes the proper extension (`.cmd`, `.exe`, `.bat`) - -**Git not found in agents:** - -If subagents can't detect Git, ensure: -1. Git is in your PATH -2. You're running Code from a Git repository -3. The `.git` directory is accessible - -For more details, see the [FAQ](https://github.com/just-every/code/blob/main/docs/faq.md). - -## disable_response_storage - -Currently, customers whose accounts are set to use Zero Data Retention (ZDR) must set `disable_response_storage` to `true` so that Codex uses an alternative to the Responses API that works with ZDR: - -```toml -disable_response_storage = true -``` - -## shell_environment_policy - -Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`: - -```toml -[shell_environment_policy] -# inherit can be "all" (default), "core", or "none" -inherit = "core" -# set to false to *re-enable* the filter for `"*KEY*"`, `"*SECRET*"`, and `"*TOKEN*"` (defaults to true) -ignore_default_excludes = true -# exclude patterns (case-insensitive globs) -exclude = ["AWS_*", "AZURE_*"] -# force-set / override values -set = { CI = "1" } -# if provided, *only* vars matching these patterns are kept -include_only = ["PATH", "HOME"] -``` - -| Field | Type | Default | Description | -| ------------------------- | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `inherit` | string | `all` | Starting template for the environment:
`all` (clone full parent env), `core` (`HOME`, `PATH`, `USER`, …), or `none` (start empty). | -| `ignore_default_excludes` | boolean | `true` | When `false`, Codex removes any var whose **name** contains `KEY`, `SECRET`, or `TOKEN` (case-insensitive) before other rules run; defaults to `true` so this filter is disabled by default. | -| `exclude` | array<string> | `[]` | Case-insensitive glob patterns to drop after the default filter.
Examples: `"AWS_*"`, `"AZURE_*"`. | -| `set` | table<string,string> | `{}` | Explicit key/value overrides or additions – always win over inherited values. | -| `include_only` | array<string> | `[]` | If non-empty, a whitelist of patterns; only variables that match _one_ pattern survive the final step. (Generally used with `inherit = "all"`.) | - -The patterns are **glob style**, not full regular expressions: `*` matches any -number of characters, `?` matches exactly one, and character classes like -`[A-Z]`/`[^0-9]` are supported. Matching is always **case-insensitive**. This -syntax is documented in code as `EnvironmentVariablePattern` (see -`core/src/config_types.rs`). - -If you just need a clean slate with a few custom entries you can write: - -```toml -[shell_environment_policy] -inherit = "none" -set = { PATH = "/usr/bin", MY_FLAG = "1" } -``` - -Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable. - -## notify - -Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.: - -```json -{ - "type": "agent-turn-complete", - "turn-id": "12345", - "input-messages": ["Rename `foo` to `bar` and update the callsites."], - "last-assistant-message": "Rename complete and verified `cargo build` succeeds." -} -``` - -The `"type"` property will always be set. Currently, `"agent-turn-complete"` is the only notification type that is supported. - -As an example, here is a Python script that parses the JSON and decides whether to show a desktop push notification using [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS: - -```python -#!/usr/bin/env python3 - -import json -import subprocess -import sys - - -def main() -> int: - if len(sys.argv) != 2: - print("Usage: notify.py ") - return 1 - - try: - notification = json.loads(sys.argv[1]) - except json.JSONDecodeError: - return 1 - - match notification_type := notification.get("type"): - case "agent-turn-complete": - assistant_message = notification.get("last-assistant-message") - if assistant_message: - title = f"Codex: {assistant_message}" - else: - title = "Codex: Turn Complete!" - input_messages = notification.get("input_messages", []) - message = " ".join(input_messages) - title += message - case _: - print(f"not sending a push notification for: {notification_type}") - return 0 - - subprocess.check_output( - [ - "terminal-notifier", - "-title", - title, - "-message", - message, - "-group", - "codex", - "-ignoreDnD", - "-activate", - "com.googlecode.iterm2", - ] - ) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -To have Codex use this script for notifications, you would configure it via `notify` in `~/.code/config.toml` (legacy `~/.codex/config.toml` is still read) using the appropriate path to `notify.py` on your computer: - -```toml -notify = ["python3", "/Users/mbolin/.codex/notify.py"] -``` - -## history - -By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. - -To disable this behavior, configure `[history]` as follows: - -```toml -[history] -persistence = "none" # "save-all" is the default value -``` - -## file_opener - -Identifies the editor/URI scheme to use for hyperlinking citations in model output. If set, citations to files in the model output will be hyperlinked using the specified URI scheme so they can be ctrl/cmd-clicked from the terminal to open them. - -For example, if the model output includes a reference such as `【F:/home/user/project/main.py†L42-L50】`, then this would be rewritten to link to the URI `vscode://file/home/user/project/main.py:42`. - -Note this is **not** a general editor setting (like `$EDITOR`), as it only accepts a fixed set of values: - -- `"vscode"` (default) -- `"vscode-insiders"` -- `"windsurf"` -- `"cursor"` -- `"none"` to explicitly disable this feature - -Currently, `"vscode"` is the default, though Codex does not verify VS Code is installed. As such, `file_opener` may default to `"none"` or something else in the future. - -## hide_agent_reasoning - -Codex intermittently emits "reasoning" events that show the model's internal "thinking" before it produces a final answer. Some users may find these events distracting, especially in CI logs or minimal terminal output. - -Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the TUI as well as the headless `exec` sub-command: - -```toml -hide_agent_reasoning = true # defaults to false -``` - -## show_raw_agent_reasoning - -Surfaces the model’s raw chain-of-thought ("raw reasoning content") when available. - -Notes: - -- Only takes effect if the selected model/provider actually emits raw reasoning content. Many models do not. When unsupported, this option has no visible effect. -- Raw reasoning may include intermediate thoughts or sensitive context. Enable only if acceptable for your workflow. - -Example: - -```toml -show_raw_agent_reasoning = true # defaults to false -``` - -## model_context_window - -The size of the context window for the model, in tokens. - -In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation. - -## model_max_output_tokens - -This is analogous to `model_context_window`, but for the maximum number of output tokens for the model. - -## tool_output_max_bytes - -Maximum number of bytes of tool output (including shell command output and file reads) to include in a model request. Defaults to 32 KiB. Increase this if you need to send larger outputs to the model (note the exec capture cap remains 32 MiB per stream). - -## project_doc_max_bytes - -Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB. - -## tui - -Options that are specific to the TUI. - -```toml -[tui] -# More to come here -``` +- Full config docs: [docs/config.md](../docs/config.md) +- MCP servers section: [docs/config.md#connecting-to-mcp-servers](../docs/config.md#connecting-to-mcp-servers) diff --git a/code-rs/config/BUILD.bazel b/code-rs/config/BUILD.bazel new file mode 100644 index 00000000000..2b832782b03 --- /dev/null +++ b/code-rs/config/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "config", + crate_name = "codex_config", +) diff --git a/code-rs/config/Cargo.toml b/code-rs/config/Cargo.toml new file mode 100644 index 00000000000..9583a57c62a --- /dev/null +++ b/code-rs/config/Cargo.toml @@ -0,0 +1,71 @@ +[package] +name = "codex-config" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[example]] +name = "generate-proto" +path = "examples/generate-proto.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +base64 = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-execpolicy = { workspace = true } +codex-features = { workspace = true } +codex-file-system = { workspace = true } +codex-git-utils = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-network-proxy = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-path = { workspace = true } +dunce = { workspace = true } +futures = { workspace = true, features = ["alloc", "std"] } +gethostname = { workspace = true } +multimap = { workspace = true } +prost = "0.14.3" +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_path_to_error = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +toml = { workspace = true } +toml_edit = { workspace = true } +tonic = { workspace = true } +tonic-prost = { workspace = true } +tracing = { workspace = true } +wildmatch = { workspace = true } + +[target.'cfg(unix)'.dependencies] +dns-lookup = { workspace = true } +libc = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" + +[target.'cfg(target_os = "windows")'.dependencies] +winapi-util = { workspace = true } +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_UI_Shell", +] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tokio-stream = { workspace = true, features = ["net"] } +tonic = { workspace = true, features = ["router", "transport"] } +tonic-prost-build = { version = "=0.14.3", default-features = false, features = ["transport"] } + +[lib] +doctest = false diff --git a/code-rs/config/examples/generate-proto.rs b/code-rs/config/examples/generate-proto.rs new file mode 100644 index 00000000000..03f0f796da4 --- /dev/null +++ b/code-rs/config/examples/generate-proto.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let Some(proto_dir_arg) = std::env::args().nth(1) else { + eprintln!("Usage: generate-proto "); + std::process::exit(1); + }; + + let proto_dir = PathBuf::from(proto_dir_arg); + let proto_file = proto_dir.join("codex.thread_config.v1.proto"); + + tonic_prost_build::configure() + .build_client(true) + .build_server(true) + .out_dir(&proto_dir) + .compile_protos(&[proto_file], &[proto_dir])?; + + Ok(()) +} diff --git a/code-rs/config/scripts/generate-proto.sh b/code-rs/config/scripts/generate-proto.sh new file mode 100755 index 00000000000..86af22b8957 --- /dev/null +++ b/code-rs/config/scripts/generate-proto.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../../.." && pwd)" +proto_dir="$repo_root/codex-rs/config/src/thread_config/proto" +generated="$proto_dir/codex.thread_config.v1.rs" +tmpdir="$(mktemp -d)" + +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +( + cd "$repo_root/codex-rs" + CARGO_TARGET_DIR="$tmpdir/target" cargo run \ + -p codex-config \ + --example generate-proto \ + -- "$proto_dir" +) + +if ! sed -n '2p' "$generated" | grep -q 'clippy::trivially_copy_pass_by_ref'; then + { + sed -n '1p' "$generated" + printf '#![allow(clippy::trivially_copy_pass_by_ref)]\n' + sed '1d' "$generated" + } > "$tmpdir/generated.rs" + mv "$tmpdir/generated.rs" "$generated" +fi + +rustfmt --edition 2024 "$generated" + +awk ' + NR == 3 && previous ~ /clippy::trivially_copy_pass_by_ref/ && $0 != "" { print "" } + { print; previous = $0 } +' "$generated" > "$tmpdir/formatted.rs" +mv "$tmpdir/formatted.rs" "$generated" diff --git a/code-rs/config/src/cloud_requirements.rs b/code-rs/config/src/cloud_requirements.rs new file mode 100644 index 00000000000..85b904824de --- /dev/null +++ b/code-rs/config/src/cloud_requirements.rs @@ -0,0 +1,105 @@ +use crate::config_requirements::ConfigRequirementsToml; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use std::fmt; +use std::future::Future; +use thiserror::Error; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloudRequirementsLoadErrorCode { + Auth, + Timeout, + Parse, + RequestFailed, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct CloudRequirementsLoadError { + code: CloudRequirementsLoadErrorCode, + message: String, + status_code: Option, +} + +impl CloudRequirementsLoadError { + pub fn new( + code: CloudRequirementsLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> CloudRequirementsLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +#[derive(Clone)] +pub struct CloudRequirementsLoader { + fut: Shared< + BoxFuture<'static, Result, CloudRequirementsLoadError>>, + >, +} + +impl CloudRequirementsLoader { + pub fn new(fut: F) -> Self + where + F: Future, CloudRequirementsLoadError>> + + Send + + 'static, + { + Self { + fut: fut.boxed().shared(), + } + } + + pub async fn get(&self) -> Result, CloudRequirementsLoadError> { + self.fut.clone().await + } +} + +impl fmt::Debug for CloudRequirementsLoader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CloudRequirementsLoader").finish() + } +} + +impl Default for CloudRequirementsLoader { + fn default() -> Self { + Self::new(async { Ok(None) }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[tokio::test] + async fn shared_future_runs_once() { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let loader = CloudRequirementsLoader::new(async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Ok(Some(ConfigRequirementsToml::default())) + }); + + let (first, second) = tokio::join!(loader.get(), loader.get()); + assert_eq!(first, second); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } +} diff --git a/code-rs/config/src/config_requirements.rs b/code-rs/config/src/config_requirements.rs new file mode 100644 index 00000000000..59d1cde9eaf --- /dev/null +++ b/code-rs/config/src/config_requirements.rs @@ -0,0 +1,2842 @@ +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use serde::de::Error as _; +use serde::de::value::Error as ValueDeserializerError; +use serde::de::value::StrDeserializer; +use std::collections::BTreeMap; +use std::fmt; +use wildmatch::WildMatchPattern; + +use super::requirements_exec_policy::RequirementsExecPolicy; +use super::requirements_exec_policy::RequirementsExecPolicyToml; +use crate::Constrained; +use crate::ConstraintError; +use crate::ManagedHooksRequirementsToml; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RequirementSource { + Unknown, + MdmManagedPreferences { domain: String, key: String }, + CloudRequirements, + SystemRequirementsToml { file: AbsolutePathBuf }, + LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf }, + LegacyManagedConfigTomlFromMdm, +} + +impl fmt::Display for RequirementSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RequirementSource::Unknown => write!(f, ""), + RequirementSource::MdmManagedPreferences { domain, key } => { + write!(f, "MDM {domain}:{key}") + } + RequirementSource::CloudRequirements => { + write!(f, "cloud requirements") + } + RequirementSource::SystemRequirementsToml { file } => { + write!(f, "{}", file.as_path().display()) + } + RequirementSource::LegacyManagedConfigTomlFromFile { file } => { + write!(f, "{}", file.as_path().display()) + } + RequirementSource::LegacyManagedConfigTomlFromMdm => { + write!(f, "MDM managed_config.toml (legacy)") + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConstrainedWithSource { + pub value: Constrained, + pub source: Option, +} + +impl ConstrainedWithSource { + pub fn new(value: Constrained, source: Option) -> Self { + Self { value, source } + } +} + +impl std::ops::Deref for ConstrainedWithSource { + type Target = Constrained; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl std::ops::DerefMut for ConstrainedWithSource { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.value + } +} + +/// Normalized version of [`ConfigRequirementsToml`] after deserialization and +/// normalization. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigRequirements { + pub approval_policy: ConstrainedWithSource, + pub approvals_reviewer: ConstrainedWithSource, + pub permission_profile: ConstrainedWithSource, + pub web_search_mode: ConstrainedWithSource, + pub feature_requirements: Option>, + pub managed_hooks: Option>, + pub mcp_servers: Option>>, + pub plugins: Option>>, + pub exec_policy: Option>, + pub enforce_residency: ConstrainedWithSource>, + /// Managed network constraints derived from requirements. + pub network: Option>, + /// Managed filesystem constraints derived from requirements. + pub filesystem: Option>, + /// Source for the managed guardian policy config, when one is configured. + pub guardian_policy_config_source: Option, +} + +impl Default for ConfigRequirements { + fn default() -> Self { + Self { + approval_policy: ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + approvals_reviewer: ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + permission_profile: ConstrainedWithSource::new( + Constrained::allow_any(PermissionProfile::read_only()), + /*source*/ None, + ), + web_search_mode: ConstrainedWithSource::new( + Constrained::allow_any(WebSearchMode::Cached), + /*source*/ None, + ), + feature_requirements: None, + managed_hooks: None, + mcp_servers: None, + plugins: None, + exec_policy: None, + enforce_residency: ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + network: None, + filesystem: None, + guardian_policy_config_source: None, + } + } +} + +impl ConfigRequirements { + pub fn exec_policy_source(&self) -> Option<&RequirementSource> { + self.exec_policy.as_ref().map(|policy| &policy.source) + } +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(untagged)] +pub enum McpServerIdentity { + Command { command: String }, + Url { url: String }, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct McpServerRequirement { + pub identity: McpServerIdentity, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginRequirementsToml { + pub mcp_servers: Option>, +} + +impl PluginRequirementsToml { + pub fn is_empty(&self) -> bool { + self.mcp_servers.as_ref().is_none_or(BTreeMap::is_empty) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkRequirementsToml { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + pub domains: Option, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + pub managed_allowed_domains_only: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, +} + +#[derive(Deserialize)] +struct RawNetworkRequirementsToml { + enabled: Option, + http_port: Option, + socks_port: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_all_unix_sockets: Option, + domains: Option, + #[serde(default)] + allowed_domains: Option>, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + managed_allowed_domains_only: Option, + #[serde(default)] + denied_domains: Option>, + unix_sockets: Option, + #[serde(default)] + allow_unix_sockets: Option>, + allow_local_binding: Option, +} + +impl<'de> Deserialize<'de> for NetworkRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawNetworkRequirementsToml::deserialize(deserializer)?; + let RawNetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + allowed_domains, + managed_allowed_domains_only, + denied_domains, + unix_sockets, + allow_unix_sockets, + allow_local_binding, + } = raw; + + if domains.is_some() && (allowed_domains.is_some() || denied_domains.is_some()) { + return Err(D::Error::custom( + "`experimental_network.domains` cannot be combined with legacy `allowed_domains` or `denied_domains`", + )); + } + + if unix_sockets.is_some() && allow_unix_sockets.is_some() { + return Err(D::Error::custom( + "`experimental_network.unix_sockets` cannot be combined with legacy `allow_unix_sockets`", + )); + } + + Ok(Self { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains: domains + .or_else(|| legacy_domain_permissions_from_lists(allowed_domains, denied_domains)), + managed_allowed_domains_only, + unix_sockets: unix_sockets + .or_else(|| legacy_unix_socket_permissions_from_list(allow_unix_sockets)), + allow_local_binding, + }) + } +} + +/// Legacy list normalization is intentionally lossy: explicit empty legacy +/// lists are treated as unset when converted to the canonical network +/// permission shape. +fn legacy_domain_permissions_from_lists( + allowed_domains: Option>, + denied_domains: Option>, +) -> Option { + let mut entries = BTreeMap::new(); + + for pattern in allowed_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Allow); + } + + for pattern in denied_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Deny); + } + + (!entries.is_empty()).then_some(NetworkDomainPermissionsToml { entries }) +} + +fn legacy_unix_socket_permissions_from_list( + allow_unix_sockets: Option>, +) -> Option { + let entries = allow_unix_sockets + .unwrap_or_default() + .into_iter() + .map(|path| (path, NetworkUnixSocketPermissionToml::Allow)) + .collect::>(); + + (!entries.is_empty()).then_some(NetworkUnixSocketPermissionsToml { entries }) +} + +/// Normalized network constraints derived from requirements TOML. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct NetworkConstraints { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + pub domains: Option, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + pub managed_allowed_domains_only: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, +} + +impl<'de> Deserialize<'de> for NetworkConstraints { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let requirements = NetworkRequirementsToml::deserialize(deserializer)?; + Ok(requirements.into()) + } +} + +impl From for NetworkConstraints { + fn from(value: NetworkRequirementsToml) -> Self { + let NetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + managed_allowed_domains_only, + unix_sockets, + allow_local_binding, + } = value; + Self { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + managed_allowed_domains_only, + unix_sockets, + allow_local_binding, + } + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct FilesystemRequirementsToml { + pub deny_read: Option>, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct PermissionsRequirementsToml { + pub filesystem: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FilesystemConstraints { + pub deny_read: Vec, +} + +impl From for FilesystemConstraints { + fn from(value: PermissionsRequirementsToml) -> Self { + let deny_read = value + .filesystem + .and_then(|filesystem| filesystem.deny_read) + .unwrap_or_default(); + Self { deny_read } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct FilesystemDenyReadPattern(String); + +impl FilesystemDenyReadPattern { + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn contains_glob(&self) -> bool { + self.0.chars().any(is_glob_metacharacter) + } + + pub fn from_input(input: &str) -> Result { + if !input.chars().any(is_glob_metacharacter) { + let path = deserialize_absolute_path(input)?; + return Ok(Self(path.to_string_lossy().into_owned())); + } + + let (directory_prefix, suffix) = split_glob_pattern(input); + let normalized_prefix = if directory_prefix.is_empty() { + deserialize_absolute_path(".")? + } else { + deserialize_absolute_path(directory_prefix)? + }; + let normalized_prefix = normalized_prefix.to_string_lossy(); + let normalized = if suffix.is_empty() { + normalized_prefix.into_owned() + } else if normalized_prefix == "/" { + format!("/{suffix}") + } else { + format!("{normalized_prefix}/{suffix}") + }; + Ok(Self(normalized)) + } +} + +impl From for FilesystemDenyReadPattern { + fn from(value: AbsolutePathBuf) -> Self { + Self(value.to_string_lossy().into_owned()) + } +} + +impl<'de> Deserialize<'de> for FilesystemDenyReadPattern { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = String::deserialize(deserializer)?; + Self::from_input(&input).map_err(D::Error::custom) + } +} + +fn deserialize_absolute_path(input: &str) -> Result { + AbsolutePathBuf::deserialize(StrDeserializer::::new(input)) + .map_err(|err| err.to_string()) +} + +fn split_glob_pattern(input: &str) -> (&str, &str) { + let Some(first_glob) = input.find(is_glob_metacharacter) else { + return ("", input); + }; + let separator_index = input[..first_glob] + .char_indices() + .rev() + .find(|(_, ch)| is_path_separator(*ch)) + .map(|(index, _)| index); + + match separator_index { + Some(0) => ("/", &input[1..]), + Some(index) + if cfg!(windows) + && index == 2 + && input.as_bytes().get(1) == Some(&b':') + && input.as_bytes().get(2).is_some() => + { + (&input[..=index], &input[index + 1..]) + } + Some(index) => (&input[..index], &input[index + 1..]), + None => ("", input), + } +} + +fn is_path_separator(ch: char) -> bool { + if cfg!(windows) { + ch == '/' || ch == '\\' + } else { + ch == '/' + } +} + +fn is_glob_metacharacter(ch: char) -> bool { + matches!(ch, '*' | '?' | '[') +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WebSearchModeRequirement { + Disabled, + Cached, + Live, +} + +impl From for WebSearchModeRequirement { + fn from(mode: WebSearchMode) -> Self { + match mode { + WebSearchMode::Disabled => WebSearchModeRequirement::Disabled, + WebSearchMode::Cached => WebSearchModeRequirement::Cached, + WebSearchMode::Live => WebSearchModeRequirement::Live, + } + } +} + +impl From for WebSearchMode { + fn from(mode: WebSearchModeRequirement) -> Self { + match mode { + WebSearchModeRequirement::Disabled => WebSearchMode::Disabled, + WebSearchModeRequirement::Cached => WebSearchMode::Cached, + WebSearchModeRequirement::Live => WebSearchMode::Live, + } + } +} + +impl fmt::Display for WebSearchModeRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WebSearchModeRequirement::Disabled => write!(f, "disabled"), + WebSearchModeRequirement::Cached => write!(f, "cached"), + WebSearchModeRequirement::Live => write!(f, "live"), + } + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct FeatureRequirementsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FeatureRequirementsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppRequirementToml { + pub enabled: Option, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppsRequirementsToml { + #[serde(default, flatten)] + pub apps: BTreeMap, +} + +impl AppsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.apps.values().all(|app| app.enabled.is_none()) + } +} + +/// Merge `enabled` configs from a lower-precedence source into an existing higher-precedence set. +/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers. +/// Implemented with AppsRequirementsToml for now, could be abstracted if we have more enablement-style configs in the future. +pub(crate) fn merge_enablement_settings_descending( + base: &mut AppsRequirementsToml, + incoming: AppsRequirementsToml, +) { + for (app_id, incoming_requirement) in incoming.apps { + let base_requirement = base.apps.entry(app_id).or_default(); + let higher_precedence = base_requirement.enabled; + let lower_precedence = incoming_requirement.enabled; + base_requirement.enabled = + if higher_precedence == Some(false) || lower_precedence == Some(false) { + Some(false) + } else { + higher_precedence.or(lower_precedence) + }; + } +} + +/// Base config deserialized from system `requirements.toml` or MDM. +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +pub struct ConfigRequirementsToml { + pub allowed_approval_policies: Option>, + pub allowed_approvals_reviewers: Option>, + pub allowed_sandbox_modes: Option>, + pub remote_sandbox_config: Option>, + pub allowed_web_search_modes: Option>, + #[serde(rename = "features", alias = "feature_requirements")] + pub feature_requirements: Option, + pub hooks: Option, + pub mcp_servers: Option>, + pub plugins: Option>, + pub apps: Option, + pub rules: Option, + pub enforce_residency: Option, + #[serde(rename = "experimental_network")] + pub network: Option, + pub permissions: Option, + pub guardian_policy_config: Option, +} + +#[derive(Deserialize, Debug, Clone, PartialEq)] +pub struct RemoteSandboxConfigToml { + pub hostname_patterns: Vec, + pub allowed_sandbox_modes: Vec, +} + +/// Value paired with the requirement source it came from, for better error +/// messages. +#[derive(Debug, Clone, PartialEq)] +pub struct Sourced { + pub value: T, + pub source: RequirementSource, +} + +impl Sourced { + pub fn new(value: T, source: RequirementSource) -> Self { + Self { value, source } + } +} + +impl std::ops::Deref for Sourced { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ConfigRequirementsWithSources { + pub allowed_approval_policies: Option>>, + pub allowed_approvals_reviewers: Option>>, + pub allowed_sandbox_modes: Option>>, + pub allowed_web_search_modes: Option>>, + pub feature_requirements: Option>, + pub hooks: Option>, + pub mcp_servers: Option>>, + pub plugins: Option>>, + pub apps: Option>, + pub rules: Option>, + pub enforce_residency: Option>, + pub network: Option>, + pub permissions: Option>, + pub guardian_policy_config: Option>, +} + +impl ConfigRequirementsWithSources { + pub fn merge_unset_fields(&mut self, source: RequirementSource, other: ConfigRequirementsToml) { + // For every field in `other` that is `Some`, if the corresponding field + // in `self` is `None`, copy the value from `other` into `self`. + macro_rules! fill_missing_take { + ($base:expr, $other:expr, $source:expr, { $($field:ident),+ $(,)? }) => { + $( + if $base.$field.is_none() + && let Some(value) = $other.$field.take() + { + $base.$field = Some(Sourced::new(value, $source.clone())); + } + )+ + }; + } + + // Destructure without `..` so adding fields to `ConfigRequirementsToml` + // forces this merge logic to be updated. + let ConfigRequirementsToml { + allowed_approval_policies: _, + allowed_approvals_reviewers: _, + allowed_sandbox_modes: _, + remote_sandbox_config: _, + allowed_web_search_modes: _, + feature_requirements: _, + hooks: _, + mcp_servers: _, + plugins: _, + apps: _, + rules: _, + enforce_residency: _, + network: _, + permissions: _, + guardian_policy_config: _, + } = &other; + + let mut other = other; + if other + .guardian_policy_config + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + other.guardian_policy_config = None; + } + fill_missing_take!( + self, + other, + source, + { + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_web_search_modes, + feature_requirements, + hooks, + mcp_servers, + plugins, + rules, + enforce_residency, + network, + permissions, + guardian_policy_config, + } + ); + + if let Some(incoming_apps) = other.apps.take() { + if let Some(existing_apps) = self.apps.as_mut() { + merge_enablement_settings_descending(&mut existing_apps.value, incoming_apps); + } else { + self.apps = Some(Sourced::new(incoming_apps, source)); + } + } + } + + pub fn into_toml(self) -> ConfigRequirementsToml { + let ConfigRequirementsWithSources { + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_web_search_modes, + feature_requirements, + hooks, + mcp_servers, + plugins, + apps, + rules, + enforce_residency, + network, + permissions, + guardian_policy_config, + } = self; + ConfigRequirementsToml { + allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), + allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), + allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), + remote_sandbox_config: None, + allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), + feature_requirements: feature_requirements.map(|sourced| sourced.value), + hooks: hooks.map(|sourced| sourced.value), + mcp_servers: mcp_servers.map(|sourced| sourced.value), + plugins: plugins.map(|sourced| sourced.value), + apps: apps.map(|sourced| sourced.value), + rules: rules.map(|sourced| sourced.value), + enforce_residency: enforce_residency.map(|sourced| sourced.value), + network: network.map(|sourced| sourced.value), + permissions: permissions.map(|sourced| sourced.value), + guardian_policy_config: guardian_policy_config.map(|sourced| sourced.value), + } + } +} + +fn normalize_hostname(hostname: &str) -> Option { + let hostname = hostname.trim().trim_end_matches('.'); + (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase()) +} + +fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool { + patterns.iter().any(|pattern| { + normalize_hostname(pattern) + .map(|pattern| WildMatchPattern::<'*', '?'>::new_case_insensitive(&pattern)) + .is_some_and(|pattern| pattern.matches(hostname)) + }) +} + +/// Currently, `external-sandbox` is not supported in config.toml, but it is +/// supported through programmatic use. +#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +pub enum SandboxModeRequirement { + #[serde(rename = "read-only")] + ReadOnly, + + #[serde(rename = "workspace-write")] + WorkspaceWrite, + + #[serde(rename = "danger-full-access")] + DangerFullAccess, + + #[serde(rename = "external-sandbox")] + ExternalSandbox, +} + +impl From for SandboxModeRequirement { + fn from(mode: SandboxMode) -> Self { + match mode { + SandboxMode::ReadOnly => SandboxModeRequirement::ReadOnly, + SandboxMode::WorkspaceWrite => SandboxModeRequirement::WorkspaceWrite, + SandboxMode::DangerFullAccess => SandboxModeRequirement::DangerFullAccess, + } + } +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ResidencyRequirement { + Us, +} + +impl ConfigRequirementsToml { + pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) { + let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else { + return; + }; + let Some(hostname) = hostname.and_then(normalize_hostname) else { + return; + }; + let Some(matched_config) = remote_sandbox_config + .iter() + .find(|config| hostname_matches_any_pattern(&hostname, &config.hostname_patterns)) + else { + return; + }; + self.allowed_sandbox_modes = Some(matched_config.allowed_sandbox_modes.clone()); + } + + pub fn is_empty(&self) -> bool { + self.allowed_approval_policies.is_none() + && self.allowed_approvals_reviewers.is_none() + && self.allowed_sandbox_modes.is_none() + && self.remote_sandbox_config.is_none() + && self.allowed_web_search_modes.is_none() + && self + .feature_requirements + .as_ref() + .is_none_or(FeatureRequirementsToml::is_empty) + && self + .hooks + .as_ref() + .is_none_or(ManagedHooksRequirementsToml::is_empty) + && self.mcp_servers.is_none() + && self + .plugins + .as_ref() + .is_none_or(|plugins| plugins.values().all(PluginRequirementsToml::is_empty)) + && self + .apps + .as_ref() + .is_none_or(AppsRequirementsToml::is_empty) + && self.rules.is_none() + && self.enforce_residency.is_none() + && self.network.is_none() + && self.permissions.is_none() + && self + .guardian_policy_config + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + } +} + +impl TryFrom for ConfigRequirements { + type Error = ConstraintError; + + fn try_from(toml: ConfigRequirementsWithSources) -> Result { + let ConfigRequirementsWithSources { + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_web_search_modes, + feature_requirements, + hooks, + mcp_servers, + plugins, + apps: _apps, + rules, + enforce_residency, + network, + permissions, + guardian_policy_config, + } = toml; + + let approval_policy = match allowed_approval_policies { + Some(Sourced { + value: policies, + source: requirement_source, + }) => { + let Some(initial_value) = policies.first().copied() else { + return Err(ConstraintError::empty_field("allowed_approval_policies")); + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if policies.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: format!("{candidate:?}"), + allowed: format!("{policies:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + }; + + let approvals_reviewer = match allowed_approvals_reviewers { + Some(Sourced { + value: reviewers, + source: requirement_source, + }) => { + let Some(initial_value) = reviewers.first().copied() else { + return Err(ConstraintError::empty_field("allowed_approvals_reviewers")); + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if reviewers.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "approvals_reviewer", + candidate: format!("{candidate:?}"), + allowed: format!("{reviewers:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + }; + + let default_permission_profile = PermissionProfile::read_only(); + let permission_profile = match allowed_sandbox_modes { + Some(Sourced { + value: modes, + source: requirement_source, + }) => { + if !modes.contains(&SandboxModeRequirement::ReadOnly) { + return Err(ConstraintError::InvalidValue { + field_name: "allowed_sandbox_modes", + candidate: format!("{modes:?}"), + allowed: "must include 'read-only' to allow any PermissionProfile" + .to_string(), + requirement_source, + }); + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(default_permission_profile, move |candidate| { + let mode = sandbox_mode_requirement_for_permission_profile(candidate); + if modes.contains(&mode) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: format!("{modes:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(default_permission_profile), + /*source*/ None, + ), + }; + let exec_policy = match rules { + Some(Sourced { value, source }) => { + let policy = value.to_requirements_policy().map_err(|err| { + ConstraintError::ExecPolicyParse { + requirement_source: source.clone(), + reason: err.to_string(), + } + })?; + Some(Sourced::new(policy, source)) + } + None => None, + }; + let web_search_mode = match allowed_web_search_modes { + Some(Sourced { + value: modes, + source: requirement_source, + }) => { + let mut accepted = modes.into_iter().collect::>(); + accepted.insert(WebSearchModeRequirement::Disabled); + let allowed_for_error = format!( + "{:?}", + accepted + .iter() + .copied() + .map(WebSearchMode::from) + .collect::>() + ); + + let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) { + WebSearchMode::Cached + } else if accepted.contains(&WebSearchModeRequirement::Live) { + WebSearchMode::Live + } else { + WebSearchMode::Disabled + }; + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if accepted.contains(&(*candidate).into()) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: allowed_for_error.clone(), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(WebSearchMode::Cached), + /*source*/ None, + ), + }; + let feature_requirements = + feature_requirements.filter(|requirements| !requirements.value.is_empty()); + let managed_hooks = hooks + .filter(|managed_hooks| managed_hooks.value.handler_count() > 0) + .map(|sourced_hooks| { + let Sourced { + value, + source: requirement_source, + } = sourced_hooks; + let allowed = value; + let allowed_for_error = format!("{allowed:?}"); + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(allowed.clone(), move |candidate| { + if candidate == &allowed { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "hooks", + candidate: format!("{candidate:?}"), + allowed: allowed_for_error.clone(), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + Ok(ConstrainedWithSource::new( + constrained, + Some(requirement_source), + )) + }) + .transpose()?; + + let enforce_residency = match enforce_residency { + Some(Sourced { + value: residency, + source: requirement_source, + }) => { + let required = Some(residency); + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(required, move |candidate| { + if candidate == &required { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "enforce_residency", + candidate: format!("{candidate:?}"), + allowed: format!("{required:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + }; + let network = network.map(|sourced_network| { + let Sourced { value, source } = sourced_network; + Sourced::new(NetworkConstraints::from(value), source) + }); + let filesystem = permissions.map(|sourced_permissions| { + let Sourced { value, source } = sourced_permissions; + Sourced::new(FilesystemConstraints::from(value), source) + }); + let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); + Ok(ConfigRequirements { + approval_policy, + approvals_reviewer, + permission_profile, + web_search_mode, + feature_requirements, + managed_hooks, + mcp_servers, + plugins, + exec_policy, + enforce_residency, + network, + filesystem, + guardian_policy_config_source, + }) + } +} + +pub fn sandbox_mode_requirement_for_permission_profile( + permission_profile: &PermissionProfile, +) -> SandboxModeRequirement { + match permission_profile { + PermissionProfile::Disabled => SandboxModeRequirement::DangerFullAccess, + PermissionProfile::External { .. } => SandboxModeRequirement::ExternalSandbox, + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + SandboxModeRequirement::DangerFullAccess + } else if file_system_policy + .entries + .iter() + .any(|entry| entry.access.can_write()) + { + SandboxModeRequirement::WorkspaceWrite + } else { + SandboxModeRequirement::ReadOnly + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HookEventsToml; + use anyhow::Result; + use codex_execpolicy::Decision; + use codex_execpolicy::Evaluation; + use codex_execpolicy::RuleMatch; + use codex_protocol::protocol::NetworkAccess; + use codex_protocol::protocol::SandboxPolicy; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_absolute_path::AbsolutePathBufGuard; + use pretty_assertions::assert_eq; + use toml::from_str; + + fn tokens(cmd: &[&str]) -> Vec { + cmd.iter().map(std::string::ToString::to_string).collect() + } + + fn system_requirements_toml_file_for_test() -> Result { + Ok(AbsolutePathBuf::try_from( + std::env::temp_dir().join("requirements.toml"), + )?) + } + + fn profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { + PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) + } + + fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { + let ConfigRequirementsToml { + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + remote_sandbox_config: _, + allowed_web_search_modes, + feature_requirements, + hooks, + mcp_servers, + plugins, + apps, + rules, + enforce_residency, + network, + permissions, + guardian_policy_config, + } = toml; + ConfigRequirementsWithSources { + allowed_approval_policies: allowed_approval_policies + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_approvals_reviewers: allowed_approvals_reviewers + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_sandbox_modes: allowed_sandbox_modes + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_web_search_modes: allowed_web_search_modes + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + feature_requirements: feature_requirements + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), + mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), + plugins: plugins.map(|value| Sourced::new(value, RequirementSource::Unknown)), + apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)), + rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), + enforce_residency: enforce_residency + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + network: network.map(|value| Sourced::new(value, RequirementSource::Unknown)), + permissions: permissions.map(|value| Sourced::new(value, RequirementSource::Unknown)), + guardian_policy_config: guardian_policy_config + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + } + } + + #[test] + fn merge_unset_fields_copies_every_field_and_sets_sources() { + let mut target = ConfigRequirementsWithSources::default(); + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; + + let allowed_approval_policies = vec![AskForApproval::UnlessTrusted, AskForApproval::Never]; + let allowed_approvals_reviewers = + vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User]; + let allowed_sandbox_modes = vec![ + SandboxModeRequirement::WorkspaceWrite, + SandboxModeRequirement::DangerFullAccess, + ]; + let allowed_web_search_modes = vec![ + WebSearchModeRequirement::Cached, + WebSearchModeRequirement::Live, + ]; + let feature_requirements = FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }; + let enforce_residency = ResidencyRequirement::Us; + let enforce_source = source.clone(); + let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); + + // Intentionally constructed without `..Default::default()` so adding a new field to + // `ConfigRequirementsToml` forces this test to be updated. + let other = ConfigRequirementsToml { + allowed_approval_policies: Some(allowed_approval_policies.clone()), + allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), + allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), + remote_sandbox_config: None, + allowed_web_search_modes: Some(allowed_web_search_modes.clone()), + feature_requirements: Some(feature_requirements.clone()), + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: Some(enforce_residency), + network: None, + permissions: None, + guardian_policy_config: Some(guardian_policy_config.clone()), + }; + + target.merge_unset_fields(source.clone(), other); + + assert_eq!( + target, + ConfigRequirementsWithSources { + allowed_approval_policies: Some(Sourced::new( + allowed_approval_policies, + source.clone() + )), + allowed_approvals_reviewers: Some(Sourced::new( + allowed_approvals_reviewers, + source.clone(), + )), + allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), + allowed_web_search_modes: Some(Sourced::new( + allowed_web_search_modes, + enforce_source.clone(), + )), + feature_requirements: Some(Sourced::new( + feature_requirements, + enforce_source.clone(), + )), + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), + network: None, + permissions: None, + guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)), + } + ); + } + + #[test] + fn merge_unset_fields_fills_missing_values() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + + let source_location = RequirementSource::MdmManagedPreferences { + domain: "com.codex".to_string(), + key: "allowed_approval_policies".to_string(), + }; + + let mut empty_target = ConfigRequirementsWithSources::default(); + empty_target.merge_unset_fields(source_location.clone(), source); + assert_eq!( + empty_target, + ConfigRequirementsWithSources { + allowed_approval_policies: Some(Sourced::new( + vec![AskForApproval::OnRequest], + source_location, + )), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + guardian_policy_config: None, + } + ); + Ok(()) + } + + #[test] + fn merge_unset_fields_does_not_overwrite_existing_values() -> Result<()> { + let existing_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut populated_target = ConfigRequirementsWithSources::default(); + let populated_requirements: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["never"] + "#, + )?; + populated_target.merge_unset_fields(existing_source.clone(), populated_requirements); + + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + let source_location = RequirementSource::MdmManagedPreferences { + domain: "com.codex".to_string(), + key: "allowed_approval_policies".to_string(), + }; + populated_target.merge_unset_fields(source_location, source); + + assert_eq!( + populated_target, + ConfigRequirementsWithSources { + allowed_approval_policies: Some(Sourced::new( + vec![AskForApproval::Never], + existing_source, + )), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + permissions: None, + guardian_policy_config: None, + } + ); + Ok(()) + } + + #[test] + fn merge_unset_fields_ignores_blank_guardian_override() { + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields( + RequirementSource::CloudRequirements, + ConfigRequirementsToml { + guardian_policy_config: Some(" \n\t".to_string()), + ..Default::default() + }, + ); + target.merge_unset_fields( + RequirementSource::SystemRequirementsToml { + file: system_requirements_toml_file_for_test() + .expect("system requirements.toml path"), + }, + ConfigRequirementsToml { + guardian_policy_config: Some("Use the system guardian policy.".to_string()), + ..Default::default() + }, + ); + + assert_eq!( + target.guardian_policy_config, + Some(Sourced::new( + "Use the system guardian policy.".to_string(), + RequirementSource::SystemRequirementsToml { + file: system_requirements_toml_file_for_test() + .expect("system requirements.toml path"), + }, + )), + ); + } + + #[test] + fn deserialize_guardian_policy_config() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" +guardian_policy_config = """ +Use the cloud-managed guardian policy. +""" +"#, + )?; + + assert_eq!( + requirements.guardian_policy_config.as_deref(), + Some("Use the cloud-managed guardian policy.\n") + ); + Ok(()) + } + + #[test] + fn blank_guardian_policy_config_is_empty() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" +guardian_policy_config = """ + +""" +"#, + )?; + + assert!(requirements.is_empty()); + Ok(()) + } + + #[test] + fn allowed_approvals_reviewers_is_not_empty() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" +allowed_approvals_reviewers = ["user"] +"#, + )?; + + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn deserialize_filesystem_deny_read_requirements() -> Result<()> { + let deny_read_0 = if cfg!(windows) { + r"C:\Users\alice\.gitconfig" + } else { + "/home/alice/.gitconfig" + }; + let deny_read_1 = if cfg!(windows) { + r"C:\Users\alice\.ssh" + } else { + "/home/alice/.ssh" + }; + let toml_str = format!( + r#" + [permissions.filesystem] + deny_read = [{deny_read_0:?}, {deny_read_1:?}] + "# + ); + + let config: ConfigRequirementsToml = from_str(&toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.filesystem, + Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![ + AbsolutePathBuf::from_absolute_path(deny_read_0)?.into(), + AbsolutePathBuf::from_absolute_path(deny_read_1)?.into(), + ], + }, + RequirementSource::Unknown, + )) + ); + + Ok(()) + } + + #[test] + fn deserialize_filesystem_deny_read_glob_requirements() -> Result<()> { + let temp_dir = std::env::temp_dir(); + let _guard = AbsolutePathBufGuard::new(&temp_dir); + let config: ConfigRequirementsToml = from_str( + r#" + [permissions.filesystem] + deny_read = ["./private/**/*.txt"] + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.filesystem, + Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![ + FilesystemDenyReadPattern::from_input("./private/**/*.txt") + .expect("normalize glob pattern"), + ], + }, + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn deserialize_apps_requirements() -> Result<()> { + let toml_str = r#" + [apps.connector_123123] + enabled = false + "#; + let requirements: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + requirements.apps, + Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }) + ); + Ok(()) + } + + fn apps_requirements(entries: &[(&str, Option)]) -> AppsRequirementsToml { + AppsRequirementsToml { + apps: entries + .iter() + .map(|(app_id, enabled)| { + ( + (*app_id).to_string(), + AppRequirementToml { enabled: *enabled }, + ) + }) + .collect(), + } + } + + #[test] + fn merge_enablement_settings_descending_unions_distinct_apps() { + let mut merged = apps_requirements(&[("connector_high", Some(false))]); + let lower = apps_requirements(&[("connector_low", Some(true))]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[ + ("connector_high", Some(false)), + ("connector_low", Some(true)) + ]), + ); + } + + #[test] + fn merge_enablement_settings_descending_prefers_false_from_lower_precedence() { + let mut merged = apps_requirements(&[("connector_123123", Some(true))]); + let lower = apps_requirements(&[("connector_123123", Some(false))]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(false))]), + ); + } + + #[test] + fn merge_enablement_settings_descending_keeps_higher_true_when_lower_is_unset() { + let mut merged = apps_requirements(&[("connector_123123", Some(true))]); + let lower = apps_requirements(&[("connector_123123", None)]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(true))]), + ); + } + + #[test] + fn merge_enablement_settings_descending_uses_lower_value_when_higher_missing() { + let mut merged = apps_requirements(&[]); + let lower = apps_requirements(&[("connector_123123", Some(true))]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(true))]), + ); + } + + #[test] + fn merge_enablement_settings_descending_preserves_higher_false_when_lower_missing_app() { + let mut merged = apps_requirements(&[("connector_123123", Some(false))]); + let lower = apps_requirements(&[]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(false))]), + ); + } + + #[test] + fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { + let higher_source = RequirementSource::CloudRequirements; + let lower_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut target = ConfigRequirementsWithSources::default(); + + target.merge_unset_fields( + higher_source.clone(), + ConfigRequirementsToml { + apps: Some(apps_requirements(&[ + ("connector_high", Some(true)), + ("connector_shared", Some(true)), + ])), + ..Default::default() + }, + ); + target.merge_unset_fields( + lower_source, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[ + ("connector_low", Some(false)), + ("connector_shared", Some(false)), + ])), + ..Default::default() + }, + ); + + let apps = target.apps.expect("apps should be present"); + assert_eq!( + apps.value, + apps_requirements(&[ + ("connector_high", Some(true)), + ("connector_low", Some(false)), + ("connector_shared", Some(false)), + ]) + ); + assert_eq!(apps.source, higher_source); + } + + #[test] + fn merge_unset_fields_apps_empty_higher_source_does_not_block_lower_disables() { + let mut target = ConfigRequirementsWithSources::default(); + + target.merge_unset_fields( + RequirementSource::CloudRequirements, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[])), + ..Default::default() + }, + ); + target.merge_unset_fields( + RequirementSource::LegacyManagedConfigTomlFromMdm, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[("connector_123123", Some(false))])), + ..Default::default() + }, + ); + + assert_eq!( + target.apps.map(|apps| apps.value), + Some(apps_requirements(&[("connector_123123", Some(false))])), + ); + } + + #[test] + fn constraint_error_includes_requirement_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + allowed_approvals_reviewers = ["auto_review"] + allowed_sandbox_modes = ["read-only"] + "#, + )?; + + let requirements_toml_file = system_requirements_toml_file_for_test()?; + let source_location = RequirementSource::SystemRequirementsToml { + file: requirements_toml_file, + }; + + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: source_location.clone(), + }) + ); + assert_eq!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly]".into(), + requirement_source: source_location.clone(), + }) + ); + assert_eq!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::User), + Err(ConstraintError::InvalidValue { + field_name: "approvals_reviewer", + candidate: "User".into(), + allowed: "[AutoReview]".into(), + requirement_source: source_location, + }) + ); + + Ok(()) + } + + #[test] + fn constraint_error_includes_cloud_requirements_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + + let source_location = RequirementSource::CloudRequirements; + + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: source_location, + }) + ); + + Ok(()) + } + + #[test] + fn constrained_fields_store_requirement_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + allowed_approvals_reviewers = ["auto_review"] + allowed_sandbox_modes = ["read-only"] + allowed_web_search_modes = ["cached"] + enforce_residency = "us" + [features] + personality = true + "#, + )?; + + let source_location = RequirementSource::CloudRequirements; + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements.approvals_reviewer.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements.permission_profile.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements.web_search_mode.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements + .feature_requirements + .as_ref() + .map(|requirements| requirements.source.clone()), + Some(source_location.clone()) + ); + assert_eq!(requirements.enforce_residency.source, Some(source_location)); + + Ok(()) + } + + #[test] + fn deserialize_allowed_approval_policies() -> Result<()> { + let toml_str = r#" + allowed_approval_policies = ["untrusted", "on-request"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approval_policy.value(), + AskForApproval::UnlessTrusted, + "currently, there is no way to specify the default value for approval policy in the toml, so it picks the first allowed value" + ); + assert!( + requirements + .approval_policy + .can_set(&AskForApproval::UnlessTrusted) + .is_ok() + ); + assert_eq!( + requirements + .approval_policy + .can_set(&AskForApproval::OnFailure), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "OnFailure".into(), + allowed: "[UnlessTrusted, OnRequest]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert!( + requirements + .approval_policy + .can_set(&AskForApproval::OnRequest) + .is_ok() + ); + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "Never".into(), + allowed: "[UnlessTrusted, OnRequest]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy() + )) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_approvals_reviewers() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = ["auto_review", "user"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approvals_reviewer.value(), + ApprovalsReviewer::AutoReview, + "currently, there is no way to specify the default value for approvals reviewer in the toml, so it picks the first allowed value" + ); + assert!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::AutoReview) + .is_ok() + ); + assert!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::User) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn deserialize_legacy_allowed_approvals_reviewer() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = ["guardian_subagent", "user"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approvals_reviewer.value(), + ApprovalsReviewer::AutoReview + ); + + Ok(()) + } + + #[test] + fn empty_allowed_approvals_reviewers_is_rejected() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let err = ConfigRequirements::try_from(with_unknown_source(config)) + .expect_err("empty approvals reviewer allow-list should be rejected"); + + assert_eq!( + err, + ConstraintError::EmptyField { + field_name: "allowed_approvals_reviewers".to_string(), + } + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_sandbox_modes() -> Result<()> { + let toml_str = r#" + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + assert!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy() + )) + .is_ok() + ); + let workspace_write_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + assert!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .is_ok() + ); + assert_eq!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert_eq!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + )), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "ExternalSandbox".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + + Ok(()) + } + + #[test] + fn deserialize_remote_sandbox_config_requires_hostname_patterns_list() -> Result<()> { + let toml_str = r#" + [[remote_sandbox_config]] + hostname_patterns = ["*.org", "runner-??.ci"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + config.remote_sandbox_config, + Some(vec![RemoteSandboxConfigToml { + hostname_patterns: vec!["*.org".to_string(), "runner-??.ci".to_string()], + allowed_sandbox_modes: vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ], + }]) + ); + + let err = from_str::( + r#" + [[remote_sandbox_config]] + hostname_patterns = "*.org" + allowed_sandbox_modes = ["read-only"] + "#, + ) + .expect_err("hostname_patterns should be list-only"); + assert!( + err.to_string().contains("invalid type: string"), + "unexpected error: {err}" + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { + let source = RequirementSource::CloudRequirements; + let mut requirements_toml: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-*.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-01.example.com"] + allowed_sandbox_modes = ["read-only", "danger-full-access"] + "#, + )?; + requirements_toml.apply_remote_sandbox_config(Some("BUILD-01.EXAMPLE.COM.")); + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), requirements_toml); + + assert_eq!( + requirements_with_sources + .allowed_sandbox_modes + .as_ref() + .map(|sourced| sourced.value.clone()), + Some(vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ]) + ); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + let workspace_write_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + assert!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .is_ok() + ); + assert_eq!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + requirement_source: source, + }) + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_non_match_preserves_top_level() -> Result<()> { + let mut requirements_toml: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-*.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#, + )?; + requirements_toml.apply_remote_sandbox_config(Some("laptop.example.com")); + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, requirements_toml); + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + + assert_eq!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> { + let high_source = RequirementSource::CloudRequirements; + let mut high_precedence: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + "#, + )?; + high_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com")); + + let mut low_precedence: ConfigRequirementsToml = from_str( + r#" + [[remote_sandbox_config]] + hostname_patterns = ["runner-*.ci.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#, + )?; + low_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com")); + + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(high_source.clone(), high_precedence); + requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, low_precedence); + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + + assert_eq!( + requirements + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + )), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "WorkspaceWrite".into(), + allowed: "[ReadOnly]".into(), + requirement_source: high_source, + }) + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_web_search_modes() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = ["cached"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Cached); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Live), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Live".into(), + allowed: "[Disabled, Cached]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Cached) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn allowed_web_search_modes_allows_disabled() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = ["disabled"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.web_search_mode.value(), + WebSearchMode::Disabled + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Cached), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Cached".into(), + allowed: "[Disabled]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + + #[test] + fn allowed_web_search_modes_empty_restricts_to_disabled() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.web_search_mode.value(), + WebSearchMode::Disabled + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Cached), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Cached".into(), + allowed: "[Disabled]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + + #[test] + fn deserialize_feature_requirements() -> Result<()> { + let toml_str = r#" + [features] + apps = false + personality = true + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.feature_requirements, + Some(Sourced::new( + FeatureRequirementsToml { + entries: BTreeMap::from([ + ("apps".to_string(), false), + ("personality".to_string(), true), + ]), + }, + RequirementSource::Unknown, + )) + ); + + Ok(()) + } + + #[test] + fn deserialize_managed_hooks_requirements() -> Result<()> { + let toml_str = r#" +managed_dir = "/enterprise/hooks" +windows_managed_dir = 'C:\enterprise\hooks' + +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/hooks/pre.py" +timeout = 10 +statusMessage = "checking" + "#; + let hooks: ManagedHooksRequirementsToml = from_str(toml_str)?; + + assert_eq!( + hooks.managed_dir.as_deref(), + Some(std::path::Path::new("/enterprise/hooks")) + ); + assert_eq!(hooks.handler_count(), 1); + assert_eq!(hooks.hooks.pre_tool_use.len(), 1); + Ok(()) + } + + #[test] + fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> { + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields( + RequirementSource::CloudRequirements, + from_str::( + r#" +[hooks] +managed_dir = "/cloud/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /cloud/hooks/pre.py" + "#, + )?, + ); + target.merge_unset_fields( + RequirementSource::SystemRequirementsToml { + file: system_requirements_toml_file_for_test()?, + }, + from_str::( + r#" +[hooks] +managed_dir = "/system/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /system/hooks/pre.py" + "#, + )?, + ); + + assert_eq!( + target + .hooks + .as_ref() + .and_then(|hooks| hooks.value.managed_dir.as_ref()) + .map(std::path::PathBuf::as_path), + Some(std::path::Path::new("/cloud/hooks")) + ); + assert_eq!( + target.hooks.as_ref().map(|hooks| hooks.source.clone()), + Some(RequirementSource::CloudRequirements) + ); + Ok(()) + } + + #[test] + fn managed_hooks_constraint_rejects_drift() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" +[hooks] +managed_dir = "/enterprise/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/hooks/pre.py" + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + let mut managed_hooks = requirements + .managed_hooks + .expect("expected managed hooks requirements"); + + let err = managed_hooks + .set(ManagedHooksRequirementsToml { + managed_dir: Some(std::path::PathBuf::from("/other/hooks")), + windows_managed_dir: None, + hooks: HookEventsToml::default(), + }) + .expect_err("managed hooks should reject drift"); + + assert!(matches!( + err, + ConstraintError::InvalidValue { + field_name: "hooks", + requirement_source: RequirementSource::Unknown, + .. + } + )); + Ok(()) + } + + #[test] + fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { + let toml_str = r#" + [experimental_network] + enabled = true + allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true + managed_allowed_domains_only = true + allow_local_binding = false + + [experimental_network.domains] + "api.example.com" = "allow" + "*.openai.com" = "allow" + "blocked.example.com" = "deny" + + [experimental_network.unix_sockets] + "/tmp/example.sock" = "allow" + "#; + + let source = RequirementSource::CloudRequirements; + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let sourced_network = requirements + .network + .expect("network requirements should be preserved as constraints"); + + assert_eq!(sourced_network.source, source); + assert_eq!(sourced_network.value.enabled, Some(true)); + assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!( + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); + assert_eq!( + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) + ); + assert_eq!(sourced_network.value.allow_local_binding, Some(false)); + + Ok(()) + } + + #[test] + fn legacy_network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { + let toml_str = r#" + [experimental_network] + enabled = true + allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true + allowed_domains = ["api.example.com", "*.openai.com"] + managed_allowed_domains_only = true + denied_domains = ["blocked.example.com"] + allow_unix_sockets = ["/tmp/example.sock"] + allow_local_binding = false + "#; + + let source = RequirementSource::CloudRequirements; + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let sourced_network = requirements + .network + .expect("network requirements should be preserved as constraints"); + + assert_eq!(sourced_network.source, source); + assert_eq!(sourced_network.value.enabled, Some(true)); + assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!( + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); + assert_eq!( + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) + ); + assert_eq!(sourced_network.value.allow_local_binding, Some(false)); + + Ok(()) + } + + #[test] + fn mixed_legacy_and_canonical_network_requirements_are_rejected() { + let err = from_str::( + r#" + [experimental_network] + allowed_domains = ["api.example.com"] + + [experimental_network.domains] + "*.openai.com" = "allow" + "#, + ) + .expect_err("mixed network domain shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.domains` cannot be combined"), + "unexpected error: {err:#}" + ); + + let err = from_str::( + r#" + [experimental_network] + allow_unix_sockets = ["/tmp/example.sock"] + + [experimental_network.unix_sockets] + "/tmp/another.sock" = "allow" + "#, + ) + .expect_err("mixed network unix socket shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.unix_sockets` cannot be combined"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); + } + + #[test] + fn deserialize_mcp_server_requirements() -> Result<()> { + let toml_str = r#" + [mcp_servers.docs.identity] + command = "codex-mcp" + + [mcp_servers.remote.identity] + url = "https://example.com/mcp" + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.mcp_servers, + Some(Sourced::new( + BTreeMap::from([ + ( + "docs".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "codex-mcp".to_string(), + }, + }, + ), + ( + "remote".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: "https://example.com/mcp".to_string(), + }, + }, + ), + ]), + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn deserialize_plugin_mcp_server_requirements() -> Result<()> { + let toml_str = r#" + [plugins."sample@test".mcp_servers.sample.identity] + command = "sample-mcp" + + [plugins."remote@test".mcp_servers.remote.identity] + url = "https://example.com/mcp" + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.plugins, + Some(Sourced::new( + BTreeMap::from([ + ( + "remote@test".to_string(), + PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "remote".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: "https://example.com/mcp".to_string(), + }, + }, + )])), + }, + ), + ( + "sample@test".to_string(), + PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "sample".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "sample-mcp".to_string(), + }, + }, + )])), + }, + ), + ]), + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn deserialize_exec_policy_requirements() -> Result<()> { + let toml_str = r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "forbidden" }, + ] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + let policy = requirements.exec_policy.expect("exec policy").value; + + assert_eq!( + policy.as_ref().check(&tokens(&["rm", "-rf"]), &|_| { + panic!("rule should match so heuristic should not be called"); + }), + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["rm"]), + decision: Decision::Forbidden, + resolved_program: None, + justification: None, + }], + } + ); + + Ok(()) + } + + #[test] + fn exec_policy_error_includes_requirement_source() -> Result<()> { + let toml_str = r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "rm" }] }, + ] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements_toml_file = system_requirements_toml_file_for_test()?; + let source_location = RequirementSource::SystemRequirementsToml { + file: requirements_toml_file, + }; + + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source_location.clone(), config); + let err = ConfigRequirements::try_from(requirements_with_sources) + .expect_err("invalid exec policy"); + + assert_eq!( + err, + ConstraintError::ExecPolicyParse { + requirement_source: source_location, + reason: "rules prefix_rule at index 0 is missing a decision".to_string(), + } + ); + + Ok(()) + } +} diff --git a/code-rs/config/src/config_toml.rs b/code-rs/config/src/config_toml.rs new file mode 100644 index 00000000000..0a82eaf5363 --- /dev/null +++ b/code-rs/config/src/config_toml.rs @@ -0,0 +1,963 @@ +//! Schema-heavy configuration TOML types used by Codex. + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::Path; + +use crate::HooksToml; +use crate::permissions_toml::PermissionsToml; +use crate::profile_toml::ConfigProfile; +use crate::types::AnalyticsConfigToml; +use crate::types::ApprovalsReviewer; +use crate::types::AppsConfigToml; +use crate::types::AuthCredentialsStoreMode; +use crate::types::FeedbackConfigToml; +use crate::types::History; +use crate::types::MarketplaceConfig; +use crate::types::McpServerConfig; +use crate::types::MemoriesToml; +use crate::types::Notice; +use crate::types::OAuthCredentialsStoreMode; +use crate::types::OtelConfigToml; +use crate::types::PluginConfig; +use crate::types::SandboxWorkspaceWrite; +use crate::types::ShellEnvironmentPolicyToml; +use crate::types::SkillsConfig; +use crate::types::ToolSuggestConfig; +use crate::types::Tui; +use crate::types::UriBasedFileOpener; +use crate::types::WindowsToml; +use codex_app_server_protocol::Tools; +use codex_app_server_protocol::UserSavedConfig; +use codex_features::FeaturesToml; +use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; +use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; +use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; +use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; +use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path::normalize_for_path_comparison; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; + +const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ + AMAZON_BEDROCK_PROVIDER_ID, + OPENAI_PROVIDER_ID, + OLLAMA_OSS_PROVIDER_ID, + LMSTUDIO_OSS_PROVIDER_ID, +]; + +pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; + +const fn default_allow_login_shell() -> Option { + Some(true) +} + +fn default_history() -> Option { + Some(History::default()) +} + +const fn default_project_doc_max_bytes() -> Option { + Some(DEFAULT_PROJECT_DOC_MAX_BYTES) +} + +fn default_project_doc_fallback_filenames() -> Option> { + Some(Vec::new()) +} + +const fn default_hide_agent_reasoning() -> Option { + Some(false) +} + +/// Base config deserialized from ~/.codex/config.toml. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigToml { + /// Optional override of model selection. + pub model: Option, + /// Review model override used by the `/review` feature. + pub review_model: Option, + + /// Provider to use from the model_providers map. + pub model_provider: Option, + + /// Size of the context window for the model, in tokens. + pub model_context_window: Option, + + /// Token usage threshold triggering auto-compaction of conversation history. + pub model_auto_compact_token_limit: Option, + + /// Default approval policy for executing commands. + pub approval_policy: Option, + + /// Configures who approval requests are routed to for review once they have + /// been escalated. This does not disable separate safety checks such as + /// ARC. + pub approvals_reviewer: Option, + + /// Optional policy instructions for the guardian auto-reviewer. + #[serde(default)] + pub auto_review: Option, + + #[serde(default)] + pub shell_environment_policy: ShellEnvironmentPolicyToml, + + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + #[serde(default = "default_allow_login_shell")] + pub allow_login_shell: Option, + + /// Sandbox mode to use. + pub sandbox_mode: Option, + + /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. + pub sandbox_workspace_write: Option, + + /// Default permissions profile to apply. Names starting with `:` refer to + /// built-in profiles; other names are resolved from the `[permissions]` + /// table. + pub default_permissions: Option, + + /// Named permissions profiles. + #[serde(default)] + pub permissions: Option, + + /// Optional external command to spawn for end-user notifications. + #[serde(default)] + pub notify: Option>, + + /// System instructions. + pub instructions: Option, + + /// Developer instructions inserted as a `developer` role message. + #[serde(default)] + pub developer_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_permissions_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_apps_instructions: Option, + + /// Whether to inject the `` user block. + pub include_environment_context: Option, + + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions_file: Option, + + /// Compact prompt used for history compaction. + pub compact_prompt: Option, + + /// Optional commit attribution text for commit message co-author trailers. + /// This top-level setting only takes effect when `[features].codex_git_commit` + /// is enabled. + /// + /// When enabled and unset, Codex uses `Codex `. + /// Set to an empty string to disable automatic commit attribution. + pub commit_attribution: Option, + + /// When set, restricts ChatGPT login to a specific workspace identifier. + #[serde(default)] + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + #[serde(default)] + pub forced_login_method: Option, + + /// Preferred backend for storing CLI auth credentials. + /// file (default): Use a file in the Codex home directory. + /// keyring: Use an OS-specific keyring service. + /// auto: Use the keyring if available, otherwise use a file. + #[serde(default)] + pub cli_auth_credentials_store: Option, + + /// Definition for MCP servers that Codex can reach out to for tool calls. + #[serde(default)] + // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. + #[schemars(schema_with = "crate::schema::mcp_servers_schema")] + pub mcp_servers: HashMap, + + /// Preferred backend for storing MCP OAuth credentials. + /// keyring: Use an OS-specific keyring service. + /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 + /// file: Use a file in the Codex home directory. + /// auto (default): Use the OS-specific keyring service if available, otherwise use a file. + #[serde(default)] + pub mcp_oauth_credentials_store: Option, + + /// Optional fixed port for the local HTTP callback server used during MCP OAuth login. + /// When unset, Codex will bind to an ephemeral port chosen by the OS. + pub mcp_oauth_callback_port: Option, + + /// Optional redirect URI to use during MCP OAuth login. + /// When set, this URI is used in the OAuth authorization request instead + /// of the local listener address. The local callback listener still binds + /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). + pub mcp_oauth_callback_url: Option, + + /// User-defined provider entries that extend the built-in list. Built-in + /// IDs cannot be overridden. + #[serde(default, deserialize_with = "deserialize_model_providers")] + pub model_providers: HashMap, + + /// Maximum number of bytes to include from an AGENTS.md project doc file. + #[serde(default = "default_project_doc_max_bytes")] + pub project_doc_max_bytes: Option, + + /// Ordered list of fallback filenames to look for when AGENTS.md is missing. + #[serde(default = "default_project_doc_fallback_filenames")] + pub project_doc_fallback_filenames: Option>, + + /// Token budget applied when storing tool/function outputs in the context manager. + pub tool_output_token_limit: Option, + + /// Maximum poll window for background terminal output (`write_stdin`), in milliseconds. + /// Default: `300000` (5 minutes). + pub background_terminal_max_timeout: Option, + + /// Deprecated: ignored. + #[schemars(skip)] + pub js_repl_node_path: Option, + + /// Deprecated: ignored. + #[schemars(skip)] + pub js_repl_node_module_dirs: Option>, + + /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. + pub zsh_path: Option, + + /// Profile to use from the `profiles` map. + pub profile: Option, + + /// Named profiles to facilitate switching between different configurations. + #[serde(default)] + pub profiles: HashMap, + + /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. + #[serde(default = "default_history")] + pub history: Option, + + /// Directory where Codex stores the SQLite state DB. + /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. + pub sqlite_home: Option, + + /// Directory where Codex writes log files, for example `codex-tui.log`. + /// Defaults to `$CODEX_HOME/log`. + pub log_dir: Option, + + /// Debugging and reproducibility settings. + pub debug: Option, + + /// Optional URI-based file opener. If set, citations to files in the model + /// output will be hyperlinked using the specified URI scheme. + pub file_opener: Option, + + /// Collection of settings that are specific to the TUI. + pub tui: Option, + + /// When set to `true`, `AgentReasoning` events will be hidden from the + /// UI/output. Defaults to `false`. + #[serde(default = "default_hide_agent_reasoning")] + pub hide_agent_reasoning: Option, + + /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. + /// Defaults to `false`. + pub show_raw_agent_reasoning: Option, + + pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, + pub model_reasoning_summary: Option, + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). + pub model_verbosity: Option, + + /// Override to force-enable reasoning summaries for the configured model. + pub model_supports_reasoning_summaries: Option, + + /// Optional path to a JSON model catalog (applied on startup only). + /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). + pub model_catalog_json: Option, + + /// Optionally specify a personality for the model + pub personality: Option, + + /// Optional explicit service tier preference for new turns (`fast` or `flex`). + pub service_tier: Option, + + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). + pub chatgpt_base_url: Option, + + /// Base URL override for the built-in `openai` model provider. + pub openai_base_url: Option, + + /// Machine-local realtime audio device preferences used by realtime voice. + #[serde(default)] + pub audio: Option, + + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` + /// `/v1/realtime` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + /// Experimental / do not use. Selects the realtime websocket model/snapshot + /// used for the `Op::RealtimeConversation` connection. + pub experimental_realtime_ws_model: Option, + /// Experimental / do not use. Realtime websocket session selection. + /// `version` controls v1/v2 and `type` controls conversational/transcription. + #[serde(default)] + pub realtime: Option, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport instructions (the `Op::RealtimeConversation` + /// `/ws` session.update instructions) without changing normal prompts. + pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, + /// Experimental / do not use. Replaces the built-in realtime start + /// instructions inserted into developer messages when realtime becomes + /// active. + pub experimental_realtime_start_instructions: Option, + + /// Experimental / do not use. When set, app-server fetches thread-scoped + /// config from a remote service at this endpoint. + pub experimental_thread_config_endpoint: Option, + + /// Removed. Former remote thread-store endpoint setting kept only so we can + /// fail fast instead of silently falling back to local persistence. + #[schemars(skip)] + pub experimental_thread_store_endpoint: Option, + + /// Experimental / do not use. Selects the thread store implementation. + pub experimental_thread_store: Option, + pub projects: Option>, + + /// Controls the web search tool mode: disabled, cached, or live. + pub web_search: Option, + + /// Nested tools section for feature toggles + pub tools: Option, + + /// Additional discoverable tools that can be suggested for installation. + pub tool_suggest: Option, + + /// Agent-related settings (thread limits, etc.). + pub agents: Option, + + /// Memories subsystem settings. + pub memories: Option, + + /// User-level skill config entries keyed by SKILL.md path. + pub skills: Option, + + /// Lifecycle hooks configured inline in TOML plus user-level overrides. + pub hooks: Option, + + /// User-level plugin config entries keyed by plugin name. + #[serde(default)] + pub plugins: HashMap, + + /// User-level marketplace entries keyed by marketplace name. + #[serde(default)] + pub marketplaces: HashMap, + + /// Centralized feature flags (new). Prefer this over individual toggles. + #[serde(default)] + // Injects known feature keys into the schema and forbids unknown keys. + #[schemars(schema_with = "crate::schema::features_schema")] + pub features: Option, + + /// Suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: Option, + + /// Compatibility-only settings retained so legacy `ghost_snapshot` + /// config still loads. + #[serde(default)] + pub ghost_snapshot: Option, + + /// Markers used to detect the project root when searching parent + /// directories for `.codex` folders. Defaults to [".git"] when unset. + #[serde(default)] + pub project_root_markers: Option>, + + /// When `true`, checks for Codex updates on startup and surfaces update prompts. + /// Set to `false` only if your Codex updates are centrally managed. + /// Defaults to `true`. + pub check_for_update_on_startup: Option, + + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: Option, + + /// When `false`, disables analytics across Codex product surfaces in this machine. + /// Defaults to `true`. + pub analytics: Option, + + /// When `false`, disables feedback collection across Codex product surfaces. + /// Defaults to `true`. + pub feedback: Option, + + /// Settings for app-specific controls. + #[serde(default)] + pub apps: Option, + + /// OTEL configuration. + pub otel: Option, + + /// Windows-specific configuration. + #[serde(default)] + pub windows: Option, + + /// Tracks whether the Windows onboarding screen has been acknowledged. + pub windows_wsl_setup_acknowledged: Option, + + /// Collection of in-product notices (different from notifications) + /// See [`crate::types::Notice`] for more details + pub notice: Option, + + /// Legacy, now use features + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] + pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". + pub oss_provider: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigLockfileToml { + pub version: u32, + pub codex_version: String, + + /// Replayable effective config captured in the lockfile. + pub config: ConfigToml, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct DebugToml { + pub config_lockfile: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct DebugConfigLockToml { + /// Directory where Codex writes effective session config lock files. + pub export_dir: Option, + + /// Lockfile to replay as the authoritative effective config. + pub load_path: Option, + + /// Allow replaying a lock generated by a different Codex version. + pub allow_codex_version_mismatch: Option, + + /// Save fields resolved from the model catalog/session configuration. + pub save_fields_resolved_from_model_catalog: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThreadStoreToml { + Local {}, + #[schemars(skip)] + InMemory { + id: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct AutoReviewToml { + /// Additional policy instructions inserted into the guardian prompt. + pub policy: Option, +} + +impl From for UserSavedConfig { + fn from(config_toml: ConfigToml) -> Self { + let profiles = config_toml + .profiles + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(); + + Self { + approval_policy: config_toml.approval_policy, + sandbox_mode: config_toml.sandbox_mode, + sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), + forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, + forced_login_method: config_toml.forced_login_method, + model: config_toml.model, + model_reasoning_effort: config_toml.model_reasoning_effort, + model_reasoning_summary: config_toml.model_reasoning_summary, + model_verbosity: config_toml.model_verbosity, + tools: config_toml.tools.map(From::from), + profile: config_toml.profile, + profiles, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ProjectConfig { + pub trust_level: Option, +} + +impl ProjectConfig { + pub fn is_trusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } + + pub fn is_untrusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Untrusted)) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RealtimeAudioConfig { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeWsMode { + #[default] + Conversational, + Transcription, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeTransport { + #[default] + #[serde(rename = "webrtc")] + WebRtc, + Websocket, +} + +pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; +pub use codex_protocol::protocol::RealtimeVoice; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeConfig { + pub version: RealtimeWsVersion, + #[serde(rename = "type")] + pub session_type: RealtimeWsMode, + pub transport: RealtimeTransport, + pub voice: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeToml { + pub version: Option, + #[serde(rename = "type")] + pub session_type: Option, + pub transport: Option, + pub voice: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeAudioToml { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolsToml { + #[serde( + default, + deserialize_with = "deserialize_optional_web_search_tool_config" + )] + pub web_search: Option, + + /// Enable the `view_image` tool that lets the agent attach local images. + #[serde(default)] + pub view_image: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum WebSearchToolConfigInput { + Enabled(bool), + Config(WebSearchToolConfig), +} + +fn deserialize_optional_web_search_tool_config<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + + Ok(match value { + None => None, + Some(WebSearchToolConfigInput::Enabled(enabled)) => { + let _ = enabled; + None + } + Some(WebSearchToolConfigInput::Config(config)) => Some(config), + }) +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentsToml { + /// Maximum number of agent threads that can be open concurrently. + /// When unset, no limit is enforced. + #[schemars(range(min = 1))] + pub max_threads: Option, + /// Maximum nesting depth allowed for spawned agent threads. + /// Root sessions start at depth 0. + #[schemars(range(min = 1))] + pub max_depth: Option, + /// Default maximum runtime in seconds for agent job workers. + #[schemars(range(min = 1))] + pub job_max_runtime_seconds: Option, + /// Whether to record a model-visible message when an agent turn is interrupted. + /// Defaults to true. + pub interrupt_message: Option, + + /// User-defined role declarations keyed by role name. + /// + /// Example: + /// ```toml + /// [agents.researcher] + /// description = "Research-focused role." + /// config_file = "./agents/researcher.toml" + /// nickname_candidates = ["Herodotus", "Ibn Battuta"] + /// ``` + #[serde(default, flatten)] + pub roles: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentRoleToml { + /// Human-facing role documentation used in spawn tool guidance. + /// Required unless supplied by the referenced agent role file. + pub description: Option, + + /// Path to a role-specific config layer. + /// Relative paths are resolved relative to the `config.toml` that defines them. + pub config_file: Option, + + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, +} + +impl From for Tools { + fn from(tools_toml: ToolsToml) -> Self { + Self { + web_search: tools_toml.web_search.is_some().then_some(true), + view_image: tools_toml.view_image, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct GhostSnapshotToml { + /// Legacy no-op setting retained for compatibility. + #[serde(alias = "ignore_untracked_files_over_bytes")] + pub ignore_large_untracked_files: Option, + /// Legacy no-op setting retained for compatibility. + #[serde(alias = "large_untracked_dir_warning_threshold")] + pub ignore_large_untracked_dirs: Option, + /// Legacy no-op setting retained for compatibility. + pub disable_warnings: Option, +} + +impl ConfigToml { + /// Derive the effective permission profile from legacy sandbox config. + /// + /// Call this only after ruling out `default_permissions`: named + /// `[permissions]` profiles must be compiled through the permissions + /// profile pipeline, not reconstructed from `sandbox_mode`. + pub async fn derive_permission_profile( + &self, + sandbox_mode_override: Option, + profile_sandbox_mode: Option, + windows_sandbox_level: WindowsSandboxLevel, + active_project: Option<&ProjectConfig>, + permission_profile_constraint: Option<&crate::Constrained>, + ) -> PermissionProfile { + let sandbox_mode_was_explicit = sandbox_mode_override.is_some() + || profile_sandbox_mode.is_some() + || self.sandbox_mode.is_some(); + let resolved_sandbox_mode = sandbox_mode_override + .or(profile_sandbox_mode) + .or(self.sandbox_mode) + .or(if sandbox_mode_was_explicit { + None + } else { + // If no sandbox_mode is set but this directory has a trust decision, + // default to workspace-write except on unsandboxed Windows where we + // default to read-only. + active_project.and_then(|p| { + if p.is_trusted() || p.is_untrusted() { + if cfg!(target_os = "windows") + && windows_sandbox_level == WindowsSandboxLevel::Disabled + { + Some(SandboxMode::ReadOnly) + } else { + Some(SandboxMode::WorkspaceWrite) + } + } else { + None + } + }) + }) + .unwrap_or_default(); + let effective_sandbox_mode = if cfg!(target_os = "windows") + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) + { + SandboxMode::ReadOnly + } else { + resolved_sandbox_mode + }; + + let permission_profile = match effective_sandbox_mode { + SandboxMode::ReadOnly => PermissionProfile::read_only(), + SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { + Some(SandboxWorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) => { + let network_policy = if *network_access { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }; + PermissionProfile::workspace_write_with( + writable_roots, + network_policy, + *exclude_tmpdir_env_var, + *exclude_slash_tmp, + ) + } + None => PermissionProfile::workspace_write(), + }, + SandboxMode::DangerFullAccess => PermissionProfile::Disabled, + }; + if !sandbox_mode_was_explicit + && let Some(constraint) = permission_profile_constraint + && let Err(err) = constraint.can_set(&permission_profile) + { + tracing::warn!( + error = %err, + "default sandbox policy is disallowed by requirements; falling back to required default" + ); + PermissionProfile::read_only() + } else { + permission_profile + } + } + + /// Resolves the cwd to an existing project, or returns None if ConfigToml + /// does not contain a project corresponding to cwd or the resolved git repo + /// root for cwd. + pub fn get_active_project( + &self, + resolved_cwd: &Path, + repo_root: Option<&Path>, + ) -> Option { + let projects = self.projects.as_ref()?; + + for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) { + if let Some(project_config) = project_config_for_lookup_key(projects, &normalized_cwd) { + return Some(project_config); + } + } + + if let Some(repo_root) = repo_root { + for normalized_repo_root in normalized_project_lookup_keys(repo_root) { + if let Some(project_config_for_root) = + project_config_for_lookup_key(projects, &normalized_repo_root) + { + return Some(project_config_for_root); + } + } + } + + None + } + + pub fn get_config_profile( + &self, + override_profile: Option, + ) -> Result { + let profile = override_profile.or_else(|| self.profile.clone()); + + match profile { + Some(key) => { + if let Some(profile) = self.profiles.get(key.as_str()) { + return Ok(profile.clone()); + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("config profile `{key}` not found"), + )) + } + None => Ok(ConfigProfile::default()), + } + } +} + +/// Canonicalize the path and convert it to a string to be used as a key in the +/// projects trust map. On Windows, strips UNC, when possible, to try to ensure +/// that different paths that point to the same location have the same key. +fn normalized_project_lookup_keys(path: &Path) -> Vec { + let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string()); + let normalized_canonical_path = normalize_project_lookup_key( + normalize_for_path_comparison(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string(), + ); + if normalized_path == normalized_canonical_path { + vec![normalized_canonical_path] + } else { + vec![normalized_canonical_path, normalized_path] + } +} + +fn normalize_project_lookup_key(key: String) -> String { + if cfg!(windows) { + key.to_ascii_lowercase() + } else { + key + } +} + +fn project_config_for_lookup_key( + projects: &HashMap, + lookup_key: &str, +) -> Option { + if let Some(project_config) = projects.get(lookup_key) { + return Some(project_config.clone()); + } + + let mut normalized_matches: Vec<_> = projects + .iter() + .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key) + .collect(); + normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches + .first() + .map(|(_, project_config)| (**project_config).clone()) +} + +pub fn validate_reserved_model_provider_ids( + model_providers: &HashMap, +) -> Result<(), String> { + let mut conflicts = model_providers + .keys() + .filter(|key| { + key.as_str() != AMAZON_BEDROCK_PROVIDER_ID + && RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str()) + }) + .map(|key| format!("`{key}`")) + .collect::>(); + conflicts.sort_unstable(); + if conflicts.is_empty() { + Ok(()) + } else { + Err(format!( + "model_providers contains reserved built-in provider IDs: {}. \ +Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).", + conflicts.join(", ") + )) + } +} + +pub fn validate_model_providers( + model_providers: &HashMap, +) -> Result<(), String> { + validate_reserved_model_provider_ids(model_providers)?; + for (key, provider) in model_providers { + if key == AMAZON_BEDROCK_PROVIDER_ID { + continue; + } + if provider.aws.is_some() { + return Err(format!( + "model_providers.{key}: provider aws is only supported for `{AMAZON_BEDROCK_PROVIDER_ID}`" + )); + } + if provider.name.trim().is_empty() { + return Err(format!( + "model_providers.{key}: provider name must not be empty" + )); + } + provider + .validate() + .map_err(|message| format!("model_providers.{key}: {message}"))?; + } + Ok(()) +} + +fn deserialize_model_providers<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let model_providers = HashMap::::deserialize(deserializer)?; + validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?; + Ok(model_providers) +} + +pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> { + match provider { + LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => Ok(()), + LEGACY_OLLAMA_CHAT_PROVIDER_ID => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + OLLAMA_CHAT_PROVIDER_REMOVED_ERROR, + )), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" + ), + )), + } +} diff --git a/code-rs/config/src/constraint.rs b/code-rs/config/src/constraint.rs new file mode 100644 index 00000000000..64b604cbc2d --- /dev/null +++ b/code-rs/config/src/constraint.rs @@ -0,0 +1,332 @@ +use std::fmt; +use std::sync::Arc; + +use crate::config_requirements::RequirementSource; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ConstraintError { + #[error( + "invalid value for `{field_name}`: `{candidate}` is not in the allowed set {allowed} (set by {requirement_source})" + )] + InvalidValue { + field_name: &'static str, + candidate: String, + allowed: String, + requirement_source: RequirementSource, + }, + + #[error("field `{field_name}` cannot be empty")] + EmptyField { field_name: String }, + + #[error("invalid rules in requirements (set by {requirement_source}): {reason}")] + ExecPolicyParse { + requirement_source: RequirementSource, + reason: String, + }, +} + +impl ConstraintError { + pub fn empty_field(field_name: impl Into) -> Self { + Self::EmptyField { + field_name: field_name.into(), + } + } +} + +pub type ConstraintResult = Result; + +impl From for std::io::Error { + fn from(err: ConstraintError) -> Self { + std::io::Error::new(std::io::ErrorKind::InvalidInput, err) + } +} + +type ConstraintValidator = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync; +/// A ConstraintNormalizer is a function which transforms a value into another of the same type. +/// `Constrained` uses normalizers to transform values to satisfy constraints or enforce values. +type ConstraintNormalizer = dyn Fn(T) -> T + Send + Sync; + +#[derive(Clone)] +pub struct Constrained { + value: T, + validator: Arc>, + normalizer: Option>>, +} + +impl Constrained { + pub fn new( + initial_value: T, + validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static, + ) -> ConstraintResult { + let validator: Arc> = Arc::new(validator); + validator(&initial_value)?; + Ok(Self { + value: initial_value, + validator, + normalizer: None, + }) + } + + /// normalized creates a `Constrained` value with a normalizer function and a validator that allows any value. + pub fn normalized( + initial_value: T, + normalizer: impl Fn(T) -> T + Send + Sync + 'static, + ) -> ConstraintResult { + let validator: Arc> = Arc::new(|_| Ok(())); + let normalizer: Arc> = Arc::new(normalizer); + let normalized = normalizer(initial_value); + validator(&normalized)?; + Ok(Self { + value: normalized, + validator, + normalizer: Some(normalizer), + }) + } + + pub fn allow_any(initial_value: T) -> Self { + Self { + value: initial_value, + validator: Arc::new(|_| Ok(())), + normalizer: None, + } + } + + pub fn allow_only(only_value: T) -> Self + where + T: Clone + fmt::Debug + PartialEq + 'static, + { + let allowed_value = only_value.clone(); + Self { + value: only_value, + validator: Arc::new(move |candidate| { + if candidate == &allowed_value { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "", + candidate: format!("{candidate:?}"), + allowed: format!("[{allowed_value:?}]"), + requirement_source: RequirementSource::Unknown, + }) + } + }), + normalizer: None, + } + } + + /// Allow any value of T, using T's Default as the initial value. + pub fn allow_any_from_default() -> Self + where + T: Default, + { + Self::allow_any(T::default()) + } + + pub fn get(&self) -> &T { + &self.value + } + + pub fn value(&self) -> T + where + T: Copy, + { + self.value + } + + pub fn can_set(&self, candidate: &T) -> ConstraintResult<()> { + (self.validator)(candidate) + } + + /// Composes an additional validator onto the current constraint. + /// + /// The existing value must satisfy the combined validator before it is installed. + pub fn add_validator( + &mut self, + validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static, + ) -> ConstraintResult<()> + where + T: 'static, + { + let existing_validator = self.validator.clone(); + let combined_validator: Arc> = Arc::new(move |candidate| { + existing_validator(candidate)?; + validator(candidate) + }); + + combined_validator(&self.value)?; + self.validator = combined_validator; + Ok(()) + } + + pub fn set(&mut self, value: T) -> ConstraintResult<()> { + let value = if let Some(normalizer) = &self.normalizer { + normalizer(value) + } else { + value + }; + (self.validator)(&value)?; + self.value = value; + Ok(()) + } +} + +impl std::ops::Deref for Constrained { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl fmt::Debug for Constrained { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Constrained") + .field("value", &self.value) + .finish() + } +} + +impl PartialEq for Constrained { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn invalid_value(candidate: impl Into, allowed: impl Into) -> ConstraintError { + ConstraintError::InvalidValue { + field_name: "", + candidate: candidate.into(), + allowed: allowed.into(), + requirement_source: RequirementSource::Unknown, + } + } + + #[test] + fn constrained_allow_any_accepts_any_value() { + let mut constrained = Constrained::allow_any(/*initial_value*/ 5); + constrained + .set(/*value*/ -10) + .expect("allow any accepts all values"); + assert_eq!(constrained.value(), -10); + } + + #[test] + fn constrained_allow_any_default_uses_default_value() { + let constrained = Constrained::::allow_any_from_default(); + assert_eq!(constrained.value(), 0); + } + + #[test] + fn constrained_allow_only_rejects_different_values() { + let mut constrained = Constrained::allow_only(/*only_value*/ 5); + constrained + .set(/*value*/ 5) + .expect("allowed value should be accepted"); + + let err = constrained + .set(/*value*/ 6) + .expect_err("different value should be rejected"); + assert_eq!(err, invalid_value("6", "[5]")); + assert_eq!(constrained.value(), 5); + } + + #[test] + fn constrained_normalizer_applies_on_init_and_set() -> anyhow::Result<()> { + let mut constrained = + Constrained::normalized(/*initial_value*/ -1, |value| value.max(0))?; + assert_eq!(constrained.value(), 0); + constrained.set(/*value*/ -5)?; + assert_eq!(constrained.value(), 0); + constrained.set(/*value*/ 10)?; + assert_eq!(constrained.value(), 10); + Ok(()) + } + + #[test] + fn constrained_add_validator_composes_with_existing_validator() -> anyhow::Result<()> { + let mut constrained = Constrained::new(/*initial_value*/ 5, |value: &i32| { + if *value >= 0 { + Ok(()) + } else { + Err(ConstraintError::empty_field("value")) + } + })?; + constrained.add_validator(|value| { + if *value <= 10 { + Ok(()) + } else { + Err(ConstraintError::empty_field("value")) + } + })?; + + assert_eq!(constrained.can_set(&7), Ok(())); + assert_eq!( + constrained.can_set(&11), + Err(ConstraintError::empty_field("value")) + ); + assert_eq!( + constrained.can_set(&-1), + Err(ConstraintError::empty_field("value")) + ); + + Ok(()) + } + + #[test] + fn constrained_new_rejects_invalid_initial_value() { + let result = Constrained::new(/*initial_value*/ 0, |value| { + if *value > 0 { + Ok(()) + } else { + Err(invalid_value(value.to_string(), "positive values")) + } + }); + + assert_eq!(result, Err(invalid_value("0", "positive values"))); + } + + #[test] + fn constrained_set_rejects_invalid_value_and_leaves_previous() { + let mut constrained = Constrained::new(/*initial_value*/ 1, |value| { + if *value > 0 { + Ok(()) + } else { + Err(invalid_value(value.to_string(), "positive values")) + } + }) + .expect("initial value should be accepted"); + + let err = constrained + .set(/*value*/ -5) + .expect_err("negative values should be rejected"); + assert_eq!(err, invalid_value("-5", "positive values")); + assert_eq!(constrained.value(), 1); + } + + #[test] + fn constrained_can_set_allows_probe_without_setting() { + let constrained = Constrained::new(/*initial_value*/ 1, |value| { + if *value > 0 { + Ok(()) + } else { + Err(invalid_value(value.to_string(), "positive values")) + } + }) + .expect("initial value should be accepted"); + + constrained + .can_set(&2) + .expect("can_set should accept positive value"); + let err = constrained + .can_set(&-1) + .expect_err("can_set should reject negative value"); + assert_eq!(err, invalid_value("-1", "positive values")); + assert_eq!(constrained.value(), 1); + } +} diff --git a/code-rs/config/src/diagnostics.rs b/code-rs/config/src/diagnostics.rs new file mode 100644 index 00000000000..899114d6d74 --- /dev/null +++ b/code-rs/config/src/diagnostics.rs @@ -0,0 +1,397 @@ +//! Helpers for mapping config parse/validation failures to file locations and +//! rendering them in a user-friendly way. + +use crate::ConfigLayerEntry; +use crate::ConfigLayerStack; +use crate::ConfigLayerStackOrdering; +use codex_app_server_protocol::ConfigLayerSource; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::de::DeserializeOwned; +use serde_path_to_error::Path as SerdePath; +use serde_path_to_error::Segment as SerdeSegment; +use std::fmt; +use std::fmt::Write; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use toml_edit::Document; +use toml_edit::Item; +use toml_edit::Table; +use toml_edit::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextPosition { + pub line: usize, + pub column: usize, +} + +/// Text range in 1-based line/column coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigError { + pub path: PathBuf, + pub range: TextRange, + pub message: String, +} + +impl ConfigError { + pub fn new(path: PathBuf, range: TextRange, message: impl Into) -> Self { + Self { + path, + range, + message: message.into(), + } + } +} + +#[derive(Debug)] +pub struct ConfigLoadError { + error: ConfigError, + source: Option, +} + +impl ConfigLoadError { + pub fn new(error: ConfigError, source: Option) -> Self { + Self { error, source } + } + + pub fn config_error(&self) -> &ConfigError { + &self.error + } +} + +impl fmt::Display for ConfigLoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}:{}: {}", + self.error.path.display(), + self.error.range.start.line, + self.error.range.start.column, + self.error.message + ) + } +} + +impl std::error::Error for ConfigLoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|err| err as &dyn std::error::Error) + } +} + +pub fn io_error_from_config_error( + kind: io::ErrorKind, + error: ConfigError, + source: Option, +) -> io::Error { + io::Error::new(kind, ConfigLoadError::new(error, source)) +} + +pub fn config_error_from_toml( + path: impl AsRef, + contents: &str, + err: toml::de::Error, +) -> ConfigError { + let range = err + .span() + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + ConfigError::new(path.as_ref().to_path_buf(), range, err.message()) +} + +pub fn config_error_from_typed_toml( + path: impl AsRef, + contents: &str, +) -> Option { + let deserializer = match toml::de::Deserializer::parse(contents) { + Ok(deserializer) => deserializer, + Err(err) => return Some(config_error_from_toml(path, contents, err)), + }; + + let result: Result = serde_path_to_error::deserialize(deserializer); + match result { + Ok(_) => None, + Err(err) => { + let path_hint = err.path().clone(); + let toml_err: toml::de::Error = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + path.as_ref().to_path_buf(), + range, + toml_err.message(), + )) + } + } +} + +pub async fn first_layer_config_error( + layers: &ConfigLayerStack, + config_toml_file: &str, +) -> Option { + // When the merged config fails schema validation, we surface the first concrete + // per-file error to point users at a specific file and range rather than an + // opaque merged-layer failure. + first_layer_config_error_for_entries::( + layers.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ), + config_toml_file, + ) + .await +} + +pub async fn first_layer_config_error_from_entries( + layers: &[ConfigLayerEntry], + config_toml_file: &str, +) -> Option { + first_layer_config_error_for_entries::(layers.iter(), config_toml_file).await +} + +async fn first_layer_config_error_for_entries<'a, T: DeserializeOwned, I>( + layers: I, + config_toml_file: &str, +) -> Option +where + I: IntoIterator, +{ + for layer in layers { + let Some(path) = config_path_for_layer(layer, config_toml_file) else { + continue; + }; + let contents = match tokio::fs::read_to_string(&path).await { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + tracing::debug!("Failed to read config file {}: {err}", path.display()); + continue; + } + }; + + let Some(parent) = path.parent() else { + tracing::debug!("Config file {} has no parent directory", path.display()); + continue; + }; + let _guard = AbsolutePathBufGuard::new(parent); + if let Some(error) = config_error_from_typed_toml::(&path, &contents) { + return Some(error); + } + } + + None +} + +fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option { + match &layer.name { + ConfigLayerSource::System { file } => Some(file.to_path_buf()), + ConfigLayerSource::User { file } => Some(file.to_path_buf()), + ConfigLayerSource::Project { dot_codex_folder } => { + Some(dot_codex_folder.as_path().join(config_toml_file)) + } + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::SessionFlags + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, + } +} + +fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { + let start = position_for_offset(contents, span.start); + let end_index = if span.end > span.start { + span.end - 1 + } else { + span.end + }; + let end = position_for_offset(contents, end_index); + TextRange { start, end } +} + +pub fn format_config_error(error: &ConfigError, contents: &str) -> String { + let mut output = String::new(); + let start = error.range.start; + let _ = writeln!( + output, + "{}:{}:{}: {}", + error.path.display(), + start.line, + start.column, + error.message + ); + + let line_index = start.line.saturating_sub(1); + let line = match contents.lines().nth(line_index) { + Some(line) => line.trim_end_matches('\r'), + None => return output.trim_end().to_string(), + }; + + let line_number = start.line; + let gutter = line_number.to_string().len(); + let _ = writeln!(output, "{:width$} |", "", width = gutter); + let _ = writeln!(output, "{line_number:>gutter$} | {line}"); + + let highlight_len = if error.range.end.line == error.range.start.line + && error.range.end.column >= error.range.start.column + { + error.range.end.column - error.range.start.column + 1 + } else { + 1 + }; + let spaces = " ".repeat(start.column.saturating_sub(1)); + let carets = "^".repeat(highlight_len.max(1)); + let _ = writeln!(output, "{:width$} | {spaces}{carets}", "", width = gutter); + output.trim_end().to_string() +} + +pub fn format_config_error_with_source(error: &ConfigError) -> String { + match std::fs::read_to_string(&error.path) { + Ok(contents) => format_config_error(error, &contents), + Err(_) => format_config_error(error, ""), + } +} + +fn position_for_offset(contents: &str, index: usize) -> TextPosition { + let bytes = contents.as_bytes(); + if bytes.is_empty() { + return TextPosition { line: 1, column: 1 }; + } + + let safe_index = index.min(bytes.len().saturating_sub(1)); + let column_offset = index.saturating_sub(safe_index); + let index = safe_index; + + let line_start = bytes[..index] + .iter() + .rposition(|byte| *byte == b'\n') + .map(|pos| pos + 1) + .unwrap_or(0); + let line = bytes[..line_start] + .iter() + .filter(|byte| **byte == b'\n') + .count(); + + let column = std::str::from_utf8(&bytes[line_start..=index]) + .map(|slice| slice.chars().count().saturating_sub(1)) + .unwrap_or_else(|_| index - line_start); + let column = column + column_offset; + + TextPosition { + line: line + 1, + column: column + 1, + } +} + +fn default_range() -> TextRange { + let position = TextPosition { line: 1, column: 1 }; + TextRange { + start: position, + end: position, + } +} + +enum TomlNode<'a> { + Item(&'a Item), + Table(&'a Table), + Value(&'a Value), +} + +fn span_for_path(contents: &str, path: &SerdePath) -> Option> { + let doc = contents.parse::>().ok()?; + let node = node_for_path(doc.as_item(), path)?; + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + +fn span_for_config_path(contents: &str, path: &SerdePath) -> Option> { + if is_features_table_path(path) + && let Some(span) = span_for_features_value(contents) + { + return Some(span); + } + span_for_path(contents, path) +} + +fn is_features_table_path(path: &SerdePath) -> bool { + let mut segments = path.iter(); + matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features") + && segments.next().is_none() +} + +fn span_for_features_value(contents: &str) -> Option> { + let doc = contents.parse::>().ok()?; + let root = doc.as_item().as_table_like()?; + let features_item = root.get("features")?; + let features_table = features_item.as_table_like()?; + for (_, item) in features_table.iter() { + match item { + Item::Value(Value::Boolean(_)) => continue, + Item::Value(value) => return value.span(), + Item::Table(table) => return table.span(), + Item::ArrayOfTables(array) => return array.span(), + Item::None => continue, + } + } + None +} + +fn node_for_path<'a>(item: &'a Item, path: &SerdePath) -> Option> { + let segments: Vec<_> = path.iter().cloned().collect(); + let mut node = TomlNode::Item(item); + let mut index = 0; + while index < segments.len() { + match &segments[index] { + SerdeSegment::Map { key } | SerdeSegment::Enum { variant: key } => { + if let Some(next) = map_child(&node, key) { + node = next; + index += 1; + continue; + } + + if index + 1 < segments.len() { + index += 1; + continue; + } + return None; + } + SerdeSegment::Seq { index: seq_index } => { + node = seq_child(&node, *seq_index)?; + index += 1; + } + SerdeSegment::Unknown => return None, + } + } + Some(node) +} + +fn map_child<'a>(node: &TomlNode<'a>, key: &str) -> Option> { + match node { + TomlNode::Item(item) => { + let table = item.as_table_like()?; + table.get(key).map(TomlNode::Item) + } + TomlNode::Table(table) => table.get(key).map(TomlNode::Item), + TomlNode::Value(Value::InlineTable(table)) => table.get(key).map(TomlNode::Value), + _ => None, + } +} + +fn seq_child<'a>(node: &TomlNode<'a>, index: usize) -> Option> { + match node { + TomlNode::Item(Item::Value(Value::Array(array))) => array.get(index).map(TomlNode::Value), + TomlNode::Item(Item::ArrayOfTables(array)) => array.get(index).map(TomlNode::Table), + TomlNode::Value(Value::Array(array)) => array.get(index).map(TomlNode::Value), + _ => None, + } +} diff --git a/code-rs/config/src/fingerprint.rs b/code-rs/config/src/fingerprint.rs new file mode 100644 index 00000000000..d8e02633898 --- /dev/null +++ b/code-rs/config/src/fingerprint.rs @@ -0,0 +1,67 @@ +use codex_app_server_protocol::ConfigLayerMetadata; +use serde_json::Value as JsonValue; +use sha2::Digest; +use sha2::Sha256; +use std::collections::HashMap; +use toml::Value as TomlValue; + +pub(super) fn record_origins( + value: &TomlValue, + meta: &ConfigLayerMetadata, + path: &mut Vec, + origins: &mut HashMap, +) { + match value { + TomlValue::Table(table) => { + for (key, val) in table { + path.push(key.clone()); + record_origins(val, meta, path, origins); + path.pop(); + } + } + TomlValue::Array(items) => { + for (idx, item) in (0_i32..).zip(items.iter()) { + path.push(idx.to_string()); + record_origins(item, meta, path, origins); + path.pop(); + } + } + _ => { + if !path.is_empty() { + origins.insert(path.join("."), meta.clone()); + } + } + } +} + +pub fn version_for_toml(value: &TomlValue) -> String { + let json = serde_json::to_value(value).unwrap_or(JsonValue::Null); + let canonical = canonical_json(&json); + let serialized = serde_json::to_vec(&canonical).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(serialized); + let hash = hasher.finalize(); + let hex = hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("sha256:{hex}") +} + +fn canonical_json(value: &JsonValue) -> JsonValue { + match value { + JsonValue::Object(map) => { + let mut sorted = serde_json::Map::new(); + let mut keys = map.keys().cloned().collect::>(); + keys.sort(); + for key in keys { + if let Some(val) = map.get(&key) { + sorted.insert(key, canonical_json(val)); + } + } + JsonValue::Object(sorted) + } + JsonValue::Array(items) => JsonValue::Array(items.iter().map(canonical_json).collect()), + other => other.clone(), + } +} diff --git a/code-rs/config/src/hook_config.rs b/code-rs/config/src/hook_config.rs new file mode 100644 index 00000000000..630d18c569f --- /dev/null +++ b/code-rs/config/src/hook_config.rs @@ -0,0 +1,179 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::protocol::HookEventName; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HooksFile { + #[serde(default)] + pub hooks: HookEventsToml, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HooksToml { + #[serde(flatten)] + pub events: HookEventsToml, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub state: BTreeMap, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookStateToml { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_hash: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookEventsToml { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest", default)] + pub permission_request: Vec, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec, + #[serde(rename = "PreCompact", default)] + pub pre_compact: Vec, + #[serde(rename = "PostCompact", default)] + pub post_compact: Vec, + #[serde(rename = "SessionStart", default)] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit", default)] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop", default)] + pub stop: Vec, +} + +impl HookEventsToml { + pub fn is_empty(&self) -> bool { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + } = self; + pre_tool_use.is_empty() + && permission_request.is_empty() + && post_tool_use.is_empty() + && pre_compact.is_empty() + && post_compact.is_empty() + && session_start.is_empty() + && user_prompt_submit.is_empty() + && stop.is_empty() + } + + pub fn handler_count(&self) -> usize { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + } = self; + [ + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + ] + .into_iter() + .flatten() + .map(|group| group.hooks.len()) + .sum() + } + + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { + [ + (HookEventName::PreToolUse, self.pre_tool_use), + (HookEventName::PermissionRequest, self.permission_request), + (HookEventName::PostToolUse, self.post_tool_use), + (HookEventName::PreCompact, self.pre_compact), + (HookEventName::PostCompact, self.post_compact), + (HookEventName::SessionStart, self.session_start), + (HookEventName::UserPromptSubmit, self.user_prompt_submit), + (HookEventName::Stop, self.stop), + ] + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MatcherGroup { + #[serde(default)] + pub matcher: Option, + #[serde(default)] + pub hooks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type")] +pub enum HookHandlerConfig { + #[serde(rename = "command")] + Command { + command: String, + #[serde(default, rename = "timeout")] + timeout_sec: Option, + #[serde(default)] + r#async: bool, + #[serde(default, rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + Agent {}, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedHooksRequirementsToml { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(flatten)] + pub hooks: HookEventsToml, +} + +impl ManagedHooksRequirementsToml { + pub fn is_empty(&self) -> bool { + let Self { + managed_dir, + windows_managed_dir, + hooks, + } = self; + managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty() + } + + pub fn handler_count(&self) -> usize { + self.hooks.handler_count() + } + + pub fn managed_dir_for_current_platform(&self) -> Option<&Path> { + #[cfg(windows)] + { + self.windows_managed_dir.as_deref() + } + + #[cfg(not(windows))] + { + self.managed_dir.as_deref() + } + } +} + +#[cfg(test)] +#[path = "hooks_tests.rs"] +mod tests; diff --git a/code-rs/config/src/hooks_tests.rs b/code-rs/config/src/hooks_tests.rs new file mode 100644 index 00000000000..69fcd3fe957 --- /dev/null +++ b/code-rs/config/src/hooks_tests.rs @@ -0,0 +1,166 @@ +use pretty_assertions::assert_eq; + +use std::collections::BTreeMap; + +use super::HookEventsToml; +use super::HookHandlerConfig; +use super::HooksFile; +use super::HooksToml; +use super::ManagedHooksRequirementsToml; +use super::MatcherGroup; + +#[test] +fn hooks_file_deserializes_existing_json_shape() { + let parsed: HooksFile = serde_json::from_str( + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "python3 /tmp/pre.py", + "timeout": 10, + "statusMessage": "checking" + } + ] + } + ] + } +}"#, + ) + .expect("hooks.json should deserialize"); + + assert_eq!( + parsed, + HooksFile { + hooks: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/pre.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + }, + } + ); +} + +#[test] +fn hook_events_deserialize_from_toml_arrays_of_tables() { + let parsed: HookEventsToml = toml::from_str( + r#" +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/pre.py" +timeout = 10 +statusMessage = "checking" +"#, + ) + .expect("hook events TOML should deserialize"); + + assert_eq!( + parsed, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/pre.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + } + ); +} + +#[test] +fn hooks_toml_deserializes_inline_events_and_state_map() { + let parsed: HooksToml = toml::from_str( + r#" +[state."/tmp/hooks.json:pre_tool_use:0:0"] +enabled = false +trusted_hash = "sha256:abc123" + +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/pre.py" +"#, + ) + .expect("hooks TOML should deserialize"); + + assert_eq!( + parsed, + HooksToml { + events: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/pre.py".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + }, + state: BTreeMap::from([( + "/tmp/hooks.json:pre_tool_use:0:0".to_string(), + super::HookStateToml { + enabled: Some(false), + trusted_hash: Some("sha256:abc123".to_string()), + }, + )]), + } + ); +} + +#[test] +fn managed_hooks_requirements_flatten_hook_events() { + let parsed: ManagedHooksRequirementsToml = toml::from_str( + r#" +managed_dir = "/enterprise/place" + +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/place/pre.py" +"#, + ) + .expect("requirements hooks TOML should deserialize"); + + assert_eq!( + parsed, + ManagedHooksRequirementsToml { + managed_dir: Some(std::path::PathBuf::from("/enterprise/place")), + windows_managed_dir: None, + hooks: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /enterprise/place/pre.py".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + }, + } + ); +} diff --git a/code-rs/config/src/host_name.rs b/code-rs/config/src/host_name.rs new file mode 100644 index 00000000000..dcd34b0ba38 --- /dev/null +++ b/code-rs/config/src/host_name.rs @@ -0,0 +1,97 @@ +#[cfg(unix)] +use dns_lookup::AddrInfoHints; +#[cfg(unix)] +use dns_lookup::getaddrinfo; +use std::sync::LazyLock; +#[cfg(windows)] +use winapi_util::sysinfo::ComputerNameKind; +#[cfg(windows)] +use winapi_util::sysinfo::get_computer_name; + +static HOST_NAME: LazyLock> = LazyLock::new(compute_host_name); + +pub fn host_name() -> Option { + HOST_NAME.clone() +} + +fn compute_host_name() -> Option { + let kernel_hostname = gethostname::gethostname(); + let kernel_hostname = normalize_host_name(&kernel_hostname.to_string_lossy())?; + + // Remote sandbox requirements are meant to target remote hosts by DNS name, + // so prefer the canonical FQDN when the local resolver can provide one. + // This is best-effort host classification, not authenticated device proof. + if let Some(fqdn) = local_fqdn_for_hostname(&kernel_hostname) { + return Some(fqdn); + } + + // Some machines have only a short local hostname or resolver setup that + // does not return AI_CANONNAME. Keep matching behavior best-effort by + // falling back to the cleaned kernel hostname instead of returning None. + Some(kernel_hostname) +} + +fn normalize_host_name(hostname: &str) -> Option { + let hostname = hostname.trim().trim_end_matches('.'); + (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase()) +} + +#[cfg(unix)] +fn local_fqdn_for_hostname(hostname: &str) -> Option { + let hints = AddrInfoHints { + flags: libc::AI_CANONNAME, + ..AddrInfoHints::default() + }; + + getaddrinfo(Some(hostname), /*service*/ None, Some(hints)) + .ok()? + .filter_map(Result::ok) + .filter_map(|addr| addr.canonname) + // getaddrinfo may return the short hostname as canonname when no FQDN + // is available. Treat only DNS-qualified names as an FQDN result. + .find_map(|hostname| normalize_fqdn_candidate(&hostname)) +} + +#[cfg(windows)] +fn local_fqdn_for_hostname(_hostname: &str) -> Option { + get_computer_name(ComputerNameKind::PhysicalDnsFullyQualified) + .ok() + .and_then(|hostname| hostname.into_string().ok()) + .and_then(|hostname| normalize_fqdn_candidate(&hostname)) +} + +#[cfg(not(any(unix, windows)))] +fn local_fqdn_for_hostname(_hostname: &str) -> Option { + None +} + +fn normalize_fqdn_candidate(hostname: &str) -> Option { + normalize_host_name(hostname).filter(|hostname| hostname.contains('.')) +} + +#[cfg(test)] +mod tests { + use super::normalize_fqdn_candidate; + use pretty_assertions::assert_eq; + + #[test] + fn normalize_fqdn_candidate_accepts_dns_qualified_name() { + assert_eq!( + normalize_fqdn_candidate("runner-01.ci.example.com"), + Some("runner-01.ci.example.com".to_string()) + ); + } + + #[test] + fn normalize_fqdn_candidate_rejects_short_name() { + assert_eq!(normalize_fqdn_candidate("runner-01"), None); + } + + #[test] + fn normalize_fqdn_candidate_trims_trailing_dot_and_normalizes_case() { + assert_eq!( + normalize_fqdn_candidate("RUNNER-01.CI.EXAMPLE.COM."), + Some("runner-01.ci.example.com".to_string()) + ); + } +} diff --git a/code-rs/config/src/key_aliases.rs b/code-rs/config/src/key_aliases.rs new file mode 100644 index 00000000000..07cb44fa6d4 --- /dev/null +++ b/code-rs/config/src/key_aliases.rs @@ -0,0 +1,52 @@ +use toml::Value as TomlValue; +use toml::map::Map as TomlMap; + +#[derive(Debug, Clone, Copy)] +struct ConfigKeyAlias { + table_path: &'static [&'static str], + legacy_key: &'static str, + canonical_key: &'static str, +} + +const CONFIG_KEY_ALIASES: &[ConfigKeyAlias] = &[ConfigKeyAlias { + table_path: &["memories"], + legacy_key: "no_memories_if_mcp_or_web_search", + canonical_key: "disable_on_external_context", +}]; + +pub(crate) fn normalize_key_aliases(path: &[String], table: &mut TomlMap) { + for alias in CONFIG_KEY_ALIASES { + if path + .iter() + .map(String::as_str) + .eq(alias.table_path.iter().copied()) + && let Some(value) = table.remove(alias.legacy_key) + { + table + .entry(alias.canonical_key.to_string()) + .or_insert(value); + } + } +} + +pub(crate) fn normalized_with_key_aliases(value: &TomlValue, path: &[String]) -> TomlValue { + match value { + TomlValue::Table(table) => { + let mut normalized = TomlMap::new(); + for (key, child) in table { + let mut child_path = path.to_vec(); + child_path.push(key.clone()); + normalized.insert(key.clone(), normalized_with_key_aliases(child, &child_path)); + } + normalize_key_aliases(path, &mut normalized); + TomlValue::Table(normalized) + } + TomlValue::Array(items) => TomlValue::Array( + items + .iter() + .map(|item| normalized_with_key_aliases(item, path)) + .collect(), + ), + _ => value.clone(), + } +} diff --git a/code-rs/config/src/lib.rs b/code-rs/config/src/lib.rs new file mode 100644 index 00000000000..e88c736db0f --- /dev/null +++ b/code-rs/config/src/lib.rs @@ -0,0 +1,128 @@ +mod cloud_requirements; +mod config_requirements; +pub mod config_toml; +mod constraint; +mod diagnostics; +mod fingerprint; +mod hook_config; +mod host_name; +mod key_aliases; +pub mod loader; +mod marketplace_edit; +mod mcp_edit; +mod mcp_types; +mod merge; +mod overrides; +pub mod permissions_toml; +mod plugin_edit; +pub mod profile_toml; +mod project_root_markers; +mod requirements_exec_policy; +pub mod schema; +mod skills_config; +mod state; +mod thread_config; +mod tui_keymap; +pub mod types; + +pub const CONFIG_TOML_FILE: &str = "config.toml"; + +pub use cloud_requirements::CloudRequirementsLoadError; +pub use cloud_requirements::CloudRequirementsLoadErrorCode; +pub use cloud_requirements::CloudRequirementsLoader; +pub use codex_app_server_protocol::ConfigLayerSource; +pub use codex_utils_absolute_path::AbsolutePathBuf; +pub use config_requirements::AppRequirementToml; +pub use config_requirements::AppsRequirementsToml; +pub use config_requirements::ConfigRequirements; +pub use config_requirements::ConfigRequirementsToml; +pub use config_requirements::ConfigRequirementsWithSources; +pub use config_requirements::ConstrainedWithSource; +pub use config_requirements::FeatureRequirementsToml; +pub use config_requirements::FilesystemConstraints; +pub use config_requirements::FilesystemDenyReadPattern; +pub use config_requirements::McpServerIdentity; +pub use config_requirements::McpServerRequirement; +pub use config_requirements::NetworkConstraints; +pub use config_requirements::NetworkDomainPermissionToml; +pub use config_requirements::NetworkDomainPermissionsToml; +pub use config_requirements::NetworkRequirementsToml; +pub use config_requirements::NetworkUnixSocketPermissionToml; +pub use config_requirements::NetworkUnixSocketPermissionsToml; +pub use config_requirements::PluginRequirementsToml; +pub use config_requirements::RemoteSandboxConfigToml; +pub use config_requirements::RequirementSource; +pub use config_requirements::ResidencyRequirement; +pub use config_requirements::SandboxModeRequirement; +pub use config_requirements::Sourced; +pub use config_requirements::WebSearchModeRequirement; +pub use config_requirements::sandbox_mode_requirement_for_permission_profile; +pub use constraint::Constrained; +pub use constraint::ConstraintError; +pub use constraint::ConstraintResult; +pub use diagnostics::ConfigError; +pub use diagnostics::ConfigLoadError; +pub use diagnostics::TextPosition; +pub use diagnostics::TextRange; +pub use diagnostics::config_error_from_toml; +pub use diagnostics::config_error_from_typed_toml; +pub use diagnostics::first_layer_config_error; +pub use diagnostics::first_layer_config_error_from_entries; +pub use diagnostics::format_config_error; +pub use diagnostics::format_config_error_with_source; +pub use diagnostics::io_error_from_config_error; +pub use fingerprint::version_for_toml; +pub use hook_config::HookEventsToml; +pub use hook_config::HookHandlerConfig; +pub use hook_config::HookStateToml; +pub use hook_config::HooksFile; +pub use hook_config::HooksToml; +pub use hook_config::ManagedHooksRequirementsToml; +pub use hook_config::MatcherGroup; +pub use host_name::host_name; +pub use marketplace_edit::MarketplaceConfigUpdate; +pub use marketplace_edit::RemoveMarketplaceConfigOutcome; +pub use marketplace_edit::record_user_marketplace; +pub use marketplace_edit::remove_user_marketplace; +pub use marketplace_edit::remove_user_marketplace_config; +pub use mcp_edit::ConfigEditsBuilder; +pub use mcp_edit::load_global_mcp_servers; +pub use mcp_types::AppToolApproval; +pub use mcp_types::McpServerConfig; +pub use mcp_types::McpServerDisabledReason; +pub use mcp_types::McpServerEnvVar; +pub use mcp_types::McpServerToolConfig; +pub use mcp_types::McpServerTransportConfig; +pub use mcp_types::RawMcpServerConfig; +pub use merge::merge_toml_values; +pub use overrides::build_cli_overrides_layer; +pub use plugin_edit::PluginConfigEdit; +pub use plugin_edit::apply_user_plugin_config_edits; +pub use plugin_edit::clear_user_plugin; +pub use plugin_edit::set_user_plugin_enabled; +pub use project_root_markers::default_project_root_markers; +pub use project_root_markers::project_root_markers_from_config; +pub use requirements_exec_policy::RequirementsExecPolicy; +pub use requirements_exec_policy::RequirementsExecPolicyDecisionToml; +pub use requirements_exec_policy::RequirementsExecPolicyParseError; +pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml; +pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml; +pub use requirements_exec_policy::RequirementsExecPolicyToml; +pub use skills_config::BundledSkillsConfig; +pub use skills_config::SkillConfig; +pub use skills_config::SkillsConfig; +pub use state::ConfigLayerEntry; +pub use state::ConfigLayerStack; +pub use state::ConfigLayerStackOrdering; +pub use state::LoaderOverrides; +pub use thread_config::NoopThreadConfigLoader; +pub use thread_config::RemoteThreadConfigLoader; +pub use thread_config::SessionThreadConfig; +pub use thread_config::StaticThreadConfigLoader; +pub use thread_config::ThreadConfigContext; +pub use thread_config::ThreadConfigLoadError; +pub use thread_config::ThreadConfigLoadErrorCode; +pub use thread_config::ThreadConfigLoader; +pub use thread_config::ThreadConfigSource; +pub use thread_config::UserThreadConfig; +pub use toml::Value as TomlValue; diff --git a/code-rs/config/src/loader/README.md b/code-rs/config/src/loader/README.md new file mode 100644 index 00000000000..28750c49293 --- /dev/null +++ b/code-rs/config/src/loader/README.md @@ -0,0 +1,78 @@ +# `codex-config` loader + +This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: + +- An **effective merged** TOML config. +- **Per-key origins** metadata (which layer “wins” for a given key). +- **Per-layer versions** (stable fingerprints) used for optimistic concurrency / conflict detection. + +## Public surface + +Exported from `codex_config::loader`: + +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `ConfigLayerStack` + - `effective_config() -> toml::Value` + - `origins() -> HashMap` + - `layers_high_to_low() -> Vec` + - `with_user_config(user_config) -> ConfigLayerStack` +- `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata) +- `LoaderOverrides` (test/override hooks for managed config sources) +- `merge_toml_values(base, overlay)` (public helper used elsewhere) + +## Layering model + +Precedence is **top overrides bottom**: + +1. **MDM** managed preferences (macOS only) +2. **System** managed config (e.g. `managed_config.toml`) +3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) +4. **User** config (`config.toml`) + +Thread config entries supplied by `thread_config_loader` are inserted according +to their translated `ConfigLayerSource` precedence. + +Layers with a `disabled_reason` are still surfaced for UI, but are ignored when +computing the effective config and origins metadata. This is what +`ConfigLayerStack::effective_config()` implements. + +## Typical usage + +Most callers want the effective config plus metadata: + +```rust +use codex_config::NoopThreadConfigLoader; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::loader::load_config_layers_state; +use codex_exec_server::LOCAL_FS; +use codex_utils_absolute_path::AbsolutePathBuf; +use toml::Value as TomlValue; + +let cli_overrides: Vec<(String, TomlValue)> = Vec::new(); +let cwd = AbsolutePathBuf::current_dir()?; +let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &cli_overrides, + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &NoopThreadConfigLoader, +).await?; + +let effective = layers.effective_config(); +let origins = layers.origins(); +let layers_for_ui = layers.layers_high_to_low(); +``` + +## Internal layout + +Implementation is split by concern: + +- `state.rs`: public types (`ConfigLayerEntry`, `ConfigLayerStack`) + merge/origins convenience methods. +- `layer_io.rs`: reading `config.toml`, managed config, and managed preferences inputs. +- `overrides.rs`: CLI dotted-path overrides → TOML “session flags” layer. +- `merge.rs`: recursive TOML merge. +- `fingerprint.rs`: stable per-layer hashing and per-key origins traversal. +- `macos.rs`: managed preferences integration (macOS only). diff --git a/code-rs/config/src/loader/layer_io.rs b/code-rs/config/src/loader/layer_io.rs new file mode 100644 index 00000000000..9c15df7271f --- /dev/null +++ b/code-rs/config/src/loader/layer_io.rs @@ -0,0 +1,137 @@ +#[cfg(target_os = "macos")] +use super::macos::ManagedAdminConfigLayer; +#[cfg(target_os = "macos")] +use super::macos::load_managed_admin_config_layer; +use crate::diagnostics::config_error_from_toml; +use crate::diagnostics::io_error_from_config_error; +use crate::state::LoaderOverrides; +use codex_file_system::ExecutorFileSystem; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use toml::Value as TomlValue; + +#[cfg(unix)] +const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml"; + +#[derive(Debug, Clone)] +pub(super) struct MangedConfigFromFile { + pub managed_config: TomlValue, + pub file: AbsolutePathBuf, +} + +#[derive(Debug, Clone)] +pub(super) struct ManagedConfigFromMdm { + pub managed_config: TomlValue, + pub raw_toml: String, +} + +#[derive(Debug, Clone)] +pub(super) struct LoadedConfigLayers { + /// If present, data read from a file such as `/etc/codex/managed_config.toml`. + pub managed_config: Option, + /// If present, data read from managed preferences (macOS only). + pub managed_config_from_mdm: Option, +} + +pub(super) async fn load_config_layers_internal( + fs: &dyn ExecutorFileSystem, + codex_home: &Path, + overrides: LoaderOverrides, +) -> io::Result { + #[cfg(target_os = "macos")] + let LoaderOverrides { + managed_config_path, + managed_preferences_base64, + .. + } = overrides; + + #[cfg(not(target_os = "macos"))] + let LoaderOverrides { + managed_config_path, + .. + } = overrides; + + let managed_config_path = AbsolutePathBuf::from_absolute_path( + managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)), + )?; + + let managed_config = + read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false) + .await? + .map(|managed_config| MangedConfigFromFile { + managed_config, + file: managed_config_path.clone(), + }); + + #[cfg(target_os = "macos")] + let managed_preferences = + load_managed_admin_config_layer(managed_preferences_base64.as_deref()) + .await? + .map(map_managed_admin_layer); + + #[cfg(not(target_os = "macos"))] + let managed_preferences = None; + + Ok(LoadedConfigLayers { + managed_config, + managed_config_from_mdm: managed_preferences, + }) +} + +#[cfg(target_os = "macos")] +fn map_managed_admin_layer(layer: ManagedAdminConfigLayer) -> ManagedConfigFromMdm { + let ManagedAdminConfigLayer { config, raw_toml } = layer; + ManagedConfigFromMdm { + managed_config: config, + raw_toml, + } +} + +pub(super) async fn read_config_from_path( + fs: &dyn ExecutorFileSystem, + path: &AbsolutePathBuf, + log_missing_as_info: bool, +) -> io::Result> { + match fs.read_file_text(path, /*sandbox*/ None).await { + Ok(contents) => match toml::from_str::(&contents) { + Ok(value) => Ok(Some(value)), + Err(err) => { + tracing::error!("Failed to parse {}: {err}", path.as_path().display()); + let config_error = config_error_from_toml(path.as_path(), &contents, err.clone()); + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + Some(err), + )) + } + }, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + if log_missing_as_info { + tracing::info!("{} not found, using defaults", path.as_path().display()); + } else { + tracing::debug!("{} not found", path.as_path().display()); + } + Ok(None) + } + Err(err) => { + tracing::error!("Failed to read {}: {err}", path.as_path().display()); + Err(err) + } + } +} + +/// Return the default managed config path. +pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf { + #[cfg(unix)] + { + let _ = codex_home; + PathBuf::from(CODEX_MANAGED_CONFIG_SYSTEM_PATH) + } + + #[cfg(not(unix))] + { + codex_home.join("managed_config.toml") + } +} diff --git a/code-rs/config/src/loader/macos.rs b/code-rs/config/src/loader/macos.rs new file mode 100644 index 00000000000..3a9fc3a0ea7 --- /dev/null +++ b/code-rs/config/src/loader/macos.rs @@ -0,0 +1,176 @@ +use super::merge_requirements_with_remote_sandbox_config; +use crate::config_requirements::ConfigRequirementsToml; +use crate::config_requirements::ConfigRequirementsWithSources; +use crate::config_requirements::RequirementSource; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use core_foundation::base::TCFType; +use core_foundation::string::CFString; +use core_foundation::string::CFStringRef; +use std::ffi::c_void; +use std::io; +use tokio::task; +use toml::Value as TomlValue; + +const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex"; +const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64"; +const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64"; + +#[derive(Debug, Clone)] +pub(super) struct ManagedAdminConfigLayer { + pub config: TomlValue, + pub raw_toml: String, +} + +pub(super) fn managed_preferences_requirements_source() -> RequirementSource { + RequirementSource::MdmManagedPreferences { + domain: MANAGED_PREFERENCES_APPLICATION_ID.to_string(), + key: MANAGED_PREFERENCES_REQUIREMENTS_KEY.to_string(), + } +} + +pub(crate) async fn load_managed_admin_config_layer( + override_base64: Option<&str>, +) -> io::Result> { + if let Some(encoded) = override_base64 { + let trimmed = encoded.trim(); + return if trimmed.is_empty() { + Ok(None) + } else { + parse_managed_config_base64(trimmed).map(Some) + }; + } + + match task::spawn_blocking(load_managed_admin_config).await { + Ok(result) => result, + Err(join_err) => { + if join_err.is_cancelled() { + tracing::error!("Managed config load task was cancelled"); + } else { + tracing::error!("Managed config load task failed: {join_err}"); + } + Err(io::Error::other("Failed to load managed config")) + } + } +} + +fn load_managed_admin_config() -> io::Result> { + load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)? + .as_deref() + .map(str::trim) + .map(parse_managed_config_base64) + .transpose() +} + +pub(crate) async fn load_managed_admin_requirements_toml( + target: &mut ConfigRequirementsWithSources, + override_base64: Option<&str>, +) -> io::Result<()> { + if let Some(encoded) = override_base64 { + let trimmed = encoded.trim(); + if trimmed.is_empty() { + return Ok(()); + } + + merge_requirements_with_remote_sandbox_config( + target, + managed_preferences_requirements_source(), + parse_managed_requirements_base64(trimmed)?, + ); + return Ok(()); + } + + match task::spawn_blocking(load_managed_admin_requirements).await { + Ok(result) => { + if let Some(requirements) = result? { + merge_requirements_with_remote_sandbox_config( + target, + managed_preferences_requirements_source(), + requirements, + ); + } + Ok(()) + } + Err(join_err) => { + if join_err.is_cancelled() { + tracing::error!("Managed requirements load task was cancelled"); + } else { + tracing::error!("Managed requirements load task failed: {join_err}"); + } + Err(io::Error::other("Failed to load managed requirements")) + } + } +} + +fn load_managed_admin_requirements() -> io::Result> { + load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)? + .as_deref() + .map(str::trim) + .map(parse_managed_requirements_base64) + .transpose() +} + +fn load_managed_preference(key_name: &str) -> io::Result> { + #[link(name = "CoreFoundation", kind = "framework")] + unsafe extern "C" { + fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void; + } + + let value_ref = unsafe { + CFPreferencesCopyAppValue( + CFString::new(key_name).as_concrete_TypeRef(), + CFString::new(MANAGED_PREFERENCES_APPLICATION_ID).as_concrete_TypeRef(), + ) + }; + + if value_ref.is_null() { + tracing::debug!( + "Managed preferences for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name} not found", + ); + return Ok(None); + } + + let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }.to_string(); + Ok(Some(value)) +} + +fn parse_managed_config_base64(encoded: &str) -> io::Result { + let raw_toml = decode_managed_preferences_base64(encoded)?; + match toml::from_str::(&raw_toml) { + Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer { + config: TomlValue::Table(parsed), + raw_toml, + }), + Ok(other) => { + tracing::error!("Managed config TOML must have a table at the root, found {other:?}",); + Err(io::Error::new( + io::ErrorKind::InvalidData, + "managed config root must be a table", + )) + } + Err(err) => { + tracing::error!("Failed to parse managed config TOML: {err}"); + Err(io::Error::new(io::ErrorKind::InvalidData, err)) + } + } +} + +fn parse_managed_requirements_base64(encoded: &str) -> io::Result { + toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err( + |err| { + tracing::error!("Failed to parse managed requirements TOML: {err}"); + io::Error::new(io::ErrorKind::InvalidData, err) + }, + ) +} + +fn decode_managed_preferences_base64(encoded: &str) -> io::Result { + String::from_utf8(BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| { + tracing::error!("Failed to decode managed value as base64: {err}",); + io::Error::new(io::ErrorKind::InvalidData, err) + })?) + .map_err(|err| { + tracing::error!("Managed value base64 contents were not valid UTF-8: {err}",); + io::Error::new(io::ErrorKind::InvalidData, err) + }) +} diff --git a/code-rs/config/src/loader/mod.rs b/code-rs/config/src/loader/mod.rs new file mode 100644 index 00000000000..e9f819bcf9b --- /dev/null +++ b/code-rs/config/src/loader/mod.rs @@ -0,0 +1,1253 @@ +mod layer_io; +#[cfg(target_os = "macos")] +mod macos; + +use self::layer_io::LoadedConfigLayers; +use crate::CONFIG_TOML_FILE; +use crate::cloud_requirements::CloudRequirementsLoader; +use crate::config_requirements::ConfigRequirementsToml; +use crate::config_requirements::ConfigRequirementsWithSources; +use crate::config_requirements::RequirementSource; +use crate::config_requirements::SandboxModeRequirement; +use crate::config_toml::ConfigToml; +use crate::config_toml::ProjectConfig; +use crate::diagnostics::ConfigError; +use crate::diagnostics::config_error_from_toml; +use crate::diagnostics::first_layer_config_error_from_entries as typed_first_layer_config_error_from_entries; +use crate::diagnostics::io_error_from_config_error; +use crate::merge::merge_toml_values; +use crate::overrides::build_cli_overrides_layer; +use crate::project_root_markers::default_project_root_markers; +use crate::project_root_markers::project_root_markers_from_config; +use crate::state::ConfigLayerEntry; +use crate::state::ConfigLayerStack; +use crate::state::LoaderOverrides; +use crate::thread_config::ThreadConfigContext; +use crate::thread_config::ThreadConfigLoader; +use codex_app_server_protocol::ConfigLayerSource; +use codex_file_system::ExecutorFileSystem; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use dunce::canonicalize as normalize_path; +use serde::Deserialize; +use std::io; +use std::path::Path; +#[cfg(windows)] +use std::path::PathBuf; +use toml::Value as TomlValue; + +#[cfg(unix)] +const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; + +#[cfg(windows)] +const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; + +// Project-local config comes from repository contents, so it should not get to +// choose where a user's credentials are sent or which local commands are run. +// These settings are still supported from user, system, managed, and runtime +// config layers. +const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[ + "openai_base_url", + "chatgpt_base_url", + "model_provider", + "model_providers", + "notify", + "profile", + "profiles", + "experimental_realtime_ws_base_url", + "otel", +]; + +async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option { + typed_first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE).await +} + +/// To build up the set of admin-enforced constraints, we build up from multiple +/// configuration layers in the following order, but a constraint defined in an +/// earlier layer cannot be overridden by a later layer: +/// +/// - cloud: managed cloud requirements +/// - admin: managed preferences (*) +/// - system `/etc/codex/requirements.toml` (Unix) or +/// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows) +/// +/// For backwards compatibility, we also load from +/// `managed_config.toml` and map it to `requirements.toml`. +/// +/// Configuration is built up from multiple layers in the following order: +/// +/// - admin: managed preferences (*) +/// - system `/etc/codex/config.toml` (Unix) or +/// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) +/// - user `${CODEX_HOME}/config.toml` +/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) +/// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted) +/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted) +/// - runtime e.g., --config flags, model selector in UI +/// +/// (*) Only available on macOS via managed device profiles. +/// +/// See https://developers.openai.com/codex/security for details. +/// +/// When loading the config stack for a thread, there should be a `cwd` +/// associated with it such that `cwd` should be `Some(...)`. Only for +/// thread-agnostic config loading (e.g., for the app server's `/config` +/// endpoint) should `cwd` be `None`. +#[allow(clippy::too_many_arguments)] +pub async fn load_config_layers_state( + fs: &dyn ExecutorFileSystem, + codex_home: &Path, + cwd: Option, + cli_overrides: &[(String, TomlValue)], + overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + thread_config_loader: &dyn ThreadConfigLoader, +) -> io::Result { + let ignore_managed_requirements = overrides.ignore_managed_requirements; + let ignore_user_config = overrides.ignore_user_config; + let ignore_user_and_project_exec_policy_rules = + overrides.ignore_user_and_project_exec_policy_rules; + let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + + if !ignore_managed_requirements { + if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { + merge_requirements_with_remote_sandbox_config( + &mut config_requirements_toml, + RequirementSource::CloudRequirements, + requirements, + ); + } + + #[cfg(target_os = "macos")] + macos::load_managed_admin_requirements_toml( + &mut config_requirements_toml, + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await?; + + // Honor the system requirements.toml location. + let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?; + load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; + } + + // Make a best-effort to support the legacy `managed_config.toml` as a + // requirements specification. + let loaded_config_layers = + layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?; + if !ignore_managed_requirements { + load_requirements_from_legacy_scheme( + &mut config_requirements_toml, + loaded_config_layers.clone(), + ) + .await?; + } + + let thread_config_context = ThreadConfigContext { + thread_id: None, + cwd: cwd.clone(), + }; + let thread_config_layers = thread_config_loader + .load_config_layers(thread_config_context) + .await + .map_err(io::Error::other)?; + + let mut layers = Vec::::new(); + + let cli_overrides_layer = if cli_overrides.is_empty() { + None + } else { + let cli_overrides_layer = build_cli_overrides_layer(cli_overrides); + let base_dir = cwd + .as_ref() + .map(AbsolutePathBuf::as_path) + .unwrap_or(codex_home); + Some(resolve_relative_paths_in_config_toml( + cli_overrides_layer, + base_dir, + )?) + }; + + // Include an entry for the "system" config folder, loading its config.toml, + // if it exists. + let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?; + let system_layer = + load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| { + ConfigLayerEntry::new( + ConfigLayerSource::System { + file: system_config_toml_file.clone(), + }, + config_toml, + ) + }) + .await?; + layers.push(system_layer); + + // Add a layer for $CODEX_HOME/config.toml so folder-derived resources such + // as rules/ can still be discovered. When user config is ignored, preserve + // the layer metadata without reading config.toml. + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); + let user_layer = if ignore_user_config { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + }, + TomlValue::Table(toml::map::Map::new()), + ) + } else { + load_config_toml_for_required_layer(fs, &user_file, |config_toml| { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + }, + config_toml, + ) + }) + .await? + }; + layers.push(user_layer); + + let mut startup_warnings = None; + if let Some(cwd) = cwd { + let mut merged_so_far = TomlValue::Table(toml::map::Map::new()); + for layer in &layers { + merge_toml_values(&mut merged_so_far, &layer.config); + } + if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() { + merge_toml_values(&mut merged_so_far, cli_overrides_layer); + } + + let project_root_markers = match project_root_markers_from_config(&merged_so_far) { + Ok(markers) => markers.unwrap_or_else(default_project_root_markers), + Err(err) => { + if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )); + } + return Err(err); + } + }; + let project_trust_context = match project_trust_context( + fs, + &merged_so_far, + &cwd, + &project_root_markers, + codex_home, + &user_file, + ) + .await + { + Ok(context) => context, + Err(err) => { + let source = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .cloned(); + if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + source, + )); + } + return Err(err); + } + }; + let project_layers = load_project_layers( + fs, + &cwd, + &project_trust_context.project_root, + &project_trust_context, + codex_home, + ) + .await?; + layers.extend(project_layers.layers); + startup_warnings = Some(project_layers.startup_warnings); + } + + // Add a layer for runtime overrides from the CLI or UI, if any exist. + if let Some(cli_overrides_layer) = cli_overrides_layer { + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + cli_overrides_layer, + )); + } + + for thread_config_layer in thread_config_layers { + insert_layer_by_precedence(&mut layers, thread_config_layer); + } + + // Make a best-effort to support the legacy `managed_config.toml` as a + // config layer on top of everything else. For fields in + // `managed_config.toml` that do not have an equivalent in + // `ConfigRequirements`, note users can still override these values on a + // per-turn basis in the TUI and VS Code. + let LoadedConfigLayers { + managed_config, + managed_config_from_mdm, + } = loaded_config_layers; + if let Some(config) = managed_config { + let managed_parent = config.file.as_path().parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Managed config file {} has no parent directory", + config.file.as_path().display() + ), + ) + })?; + let managed_config = + resolve_relative_paths_in_config_toml(config.managed_config, managed_parent)?; + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file }, + managed_config, + )); + } + if let Some(config) = managed_config_from_mdm { + // As a general rule, config from MDM should _not_ include relative + // paths, starting with `./`, but a path starting with `~/` _is_ a + // supported use case. Because resolve_relative_paths_in_config_toml() + // relies on AbsolutePathBufGuard to resolve `~/`, we must supply a + // value for base_dir, so codex_home is as good a value as any. + let managed_config = + resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; + layers.push(ConfigLayerEntry::new_with_raw_toml( + ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + managed_config, + config.raw_toml, + )); + } + + let config_layer_stack = ConfigLayerStack::new( + layers, + config_requirements_toml.clone().try_into()?, + config_requirements_toml.into_toml(), + )? + .with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules); + Ok(match startup_warnings { + Some(startup_warnings) => config_layer_stack.with_startup_warnings(startup_warnings), + None => config_layer_stack, + }) +} + +fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigLayerEntry) { + match layers + .iter() + .position(|existing| existing.name.precedence() > layer.name.precedence()) + { + Some(index) => layers.insert(index, layer), + None => layers.push(layer), + } +} + +/// Attempts to load a config.toml file from `config_toml`. +/// - If the file exists and is valid TOML, passes the parsed `toml::Value` to +/// `create_entry` and returns the resulting layer entry. +/// - If the file does not exist, uses an empty `Table` with `create_entry` and +/// returns the resulting layer entry. +/// - If there is an error reading the file or parsing the TOML, returns an +/// error. +async fn load_config_toml_for_required_layer( + fs: &dyn ExecutorFileSystem, + toml_file: &AbsolutePathBuf, + create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, +) -> io::Result { + let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await { + Ok(contents) => { + let config: TomlValue = toml::from_str(&contents).map_err(|err| { + let config_error = + config_error_from_toml(toml_file.as_path(), &contents, err.clone()); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) + })?; + let config_parent = toml_file.as_path().parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Config file {} has no parent directory", + toml_file.as_path().display() + ), + ) + })?; + resolve_relative_paths_in_config_toml(config, config_parent) + } + Err(e) => { + if e.kind() == io::ErrorKind::NotFound { + Ok(TomlValue::Table(toml::map::Map::new())) + } else { + Err(io::Error::new( + e.kind(), + format!( + "Failed to read config file {}: {e}", + toml_file.as_path().display() + ), + )) + } + } + }?; + + Ok(create_entry(toml_value)) +} + +/// If available, apply requirements from the platform system +/// `requirements.toml` location to `config_requirements_toml` by filling in +/// any unset fields. +#[doc(hidden)] +pub async fn load_requirements_toml( + fs: &dyn ExecutorFileSystem, + config_requirements_toml: &mut ConfigRequirementsWithSources, + requirements_toml_file: &AbsolutePathBuf, +) -> io::Result<()> { + match fs + .read_file_text(requirements_toml_file, /*sandbox*/ None) + .await + { + Ok(contents) => { + let requirements_parent = requirements_toml_file.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Requirements file {} has no parent directory", + requirements_toml_file.as_ref().display() + ), + ) + })?; + let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path()); + let requirements_config: ConfigRequirementsToml = + toml::from_str(&contents).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing requirements file {}: {e}", + requirements_toml_file.as_path().display(), + ), + ) + })?; + merge_requirements_with_remote_sandbox_config( + config_requirements_toml, + RequirementSource::SystemRequirementsToml { + file: requirements_toml_file.clone(), + }, + requirements_config, + ); + } + Err(e) => { + if e.kind() != io::ErrorKind::NotFound { + return Err(io::Error::new( + e.kind(), + format!( + "Failed to read requirements file {}: {e}", + requirements_toml_file.as_path().display(), + ), + )); + } + } + } + + Ok(()) +} + +#[cfg(unix)] +fn system_requirements_toml_file() -> io::Result { + AbsolutePathBuf::from_absolute_path(Path::new("/etc/codex/requirements.toml")) +} + +#[cfg(windows)] +fn system_requirements_toml_file() -> io::Result { + windows_system_requirements_toml_file() +} + +fn system_requirements_toml_file_with_overrides( + overrides: &LoaderOverrides, +) -> io::Result { + match &overrides.system_requirements_path { + Some(path) => AbsolutePathBuf::from_absolute_path(path), + None => system_requirements_toml_file(), + } +} + +#[cfg(unix)] +pub fn system_config_toml_file() -> io::Result { + AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX)) +} + +#[cfg(windows)] +pub fn system_config_toml_file() -> io::Result { + windows_system_config_toml_file() +} + +fn system_config_toml_file_with_overrides( + overrides: &LoaderOverrides, +) -> io::Result { + match &overrides.system_config_path { + Some(path) => AbsolutePathBuf::from_absolute_path(path), + None => system_config_toml_file(), + } +} + +#[cfg(windows)] +fn windows_codex_system_dir() -> PathBuf { + let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| { + tracing::warn!( + error = %err, + "Failed to resolve ProgramData known folder; using default path" + ); + PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS) + }); + program_data.join("OpenAI").join("Codex") +} + +#[cfg(windows)] +fn windows_system_requirements_toml_file() -> io::Result { + let requirements_toml_file = windows_codex_system_dir().join("requirements.toml"); + AbsolutePathBuf::try_from(requirements_toml_file) +} + +#[cfg(windows)] +fn windows_system_config_toml_file() -> io::Result { + let config_toml_file = windows_codex_system_dir().join("config.toml"); + AbsolutePathBuf::try_from(config_toml_file) +} + +#[cfg(windows)] +fn windows_program_data_dir_from_known_folder() -> io::Result { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::Com::CoTaskMemFree; + use windows_sys::Win32::UI::Shell::FOLDERID_ProgramData; + use windows_sys::Win32::UI::Shell::KF_FLAG_DEFAULT; + use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath; + + let mut path_ptr = std::ptr::null_mut::(); + let known_folder_flags = u32::try_from(KF_FLAG_DEFAULT).map_err(|_| { + io::Error::other(format!( + "KF_FLAG_DEFAULT did not fit in u32: {KF_FLAG_DEFAULT}" + )) + })?; + // Known folder IDs reference: + // https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid + // SAFETY: SHGetKnownFolderPath initializes path_ptr with a CoTaskMem-allocated, + // null-terminated UTF-16 string on success. + let hr = unsafe { + SHGetKnownFolderPath(&FOLDERID_ProgramData, known_folder_flags, 0, &mut path_ptr) + }; + if hr != 0 { + return Err(io::Error::other(format!( + "SHGetKnownFolderPath(FOLDERID_ProgramData) failed with HRESULT {hr:#010x}" + ))); + } + if path_ptr.is_null() { + return Err(io::Error::other( + "SHGetKnownFolderPath(FOLDERID_ProgramData) returned a null pointer", + )); + } + + // SAFETY: path_ptr is a valid null-terminated UTF-16 string allocated by + // SHGetKnownFolderPath and must be freed with CoTaskMemFree. + let path = unsafe { + let mut len = 0usize; + while *path_ptr.add(len) != 0 { + len += 1; + } + let wide = std::slice::from_raw_parts(path_ptr, len); + let path = PathBuf::from(OsString::from_wide(wide)); + CoTaskMemFree(path_ptr.cast()); + path + }; + + Ok(path) +} + +async fn load_requirements_from_legacy_scheme( + config_requirements_toml: &mut ConfigRequirementsWithSources, + loaded_config_layers: LoadedConfigLayers, +) -> io::Result<()> { + // In this implementation, earlier layers cannot be overwritten by later + // layers, so list managed_config_from_mdm first because it has the highest + // precedence. + let LoadedConfigLayers { + managed_config, + managed_config_from_mdm, + } = loaded_config_layers; + + for (source, config) in managed_config_from_mdm + .map(|config| { + ( + RequirementSource::LegacyManagedConfigTomlFromMdm, + config.managed_config, + ) + }) + .into_iter() + .chain(managed_config.map(|c| { + ( + RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file }, + c.managed_config, + ) + })) + { + let legacy_config: LegacyManagedConfigToml = + config.try_into().map_err(|err: toml::de::Error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse config requirements as TOML: {err}"), + ) + })?; + + merge_requirements_with_remote_sandbox_config( + config_requirements_toml, + source, + ConfigRequirementsToml::from(legacy_config), + ); + } + + Ok(()) +} + +pub(super) fn merge_requirements_with_remote_sandbox_config( + target: &mut ConfigRequirementsWithSources, + source: RequirementSource, + mut requirements: ConfigRequirementsToml, +) { + if requirements.remote_sandbox_config.is_some() { + let host_name = crate::host_name(); + requirements.apply_remote_sandbox_config(host_name.as_deref()); + } + target.merge_unset_fields(source, requirements); +} + +struct ProjectTrustContext { + project_root: AbsolutePathBuf, + project_root_key: String, + project_root_lookup_keys: Vec, + repo_root_key: Option, + repo_root_lookup_keys: Option>, + projects_trust: std::collections::HashMap, + user_config_file: AbsolutePathBuf, +} + +#[derive(Deserialize)] +struct ProjectTrustConfigToml { + projects: Option>, +} + +struct ProjectTrustDecision { + trust_level: Option, + trust_key: String, +} + +impl ProjectTrustDecision { + fn is_trusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } +} + +impl ProjectTrustContext { + fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision { + for dir_key in normalized_project_trust_keys(dir.as_path()) { + if let Some((trust_key, trust_level)) = + project_trust_for_lookup_key(&self.projects_trust, &dir_key) + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key, + }; + } + } + + for project_root_key in &self.project_root_lookup_keys { + if let Some((trust_key, trust_level)) = + project_trust_for_lookup_key(&self.projects_trust, project_root_key) + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key, + }; + } + } + + if let Some(repo_root_lookup_keys) = self.repo_root_lookup_keys.as_ref() { + for repo_root_key in repo_root_lookup_keys { + if let Some((trust_key, trust_level)) = + project_trust_for_lookup_key(&self.projects_trust, repo_root_key) + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key, + }; + } + } + } + + ProjectTrustDecision { + trust_level: None, + trust_key: self + .repo_root_key + .clone() + .unwrap_or_else(|| self.project_root_key.clone()), + } + } + + fn disabled_reason_for_decision(&self, decision: &ProjectTrustDecision) -> Option { + if decision.is_trusted() { + return None; + } + + let gated_features = "project-local config, hooks, and exec policies"; + let trust_key = decision.trust_key.as_str(); + let user_config_file = self.user_config_file.as_path().display(); + match decision.trust_level { + Some(TrustLevel::Untrusted) => Some(format!( + "{trust_key} is marked as untrusted in {user_config_file}. To load {gated_features}, mark it trusted." + )), + _ => Some(format!( + "To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}." + )), + } + } +} + +fn project_layer_entry( + dot_codex_folder: &AbsolutePathBuf, + config: TomlValue, + disabled_reason: Option, +) -> ConfigLayerEntry { + let source = ConfigLayerSource::Project { + dot_codex_folder: dot_codex_folder.clone(), + }; + + if let Some(reason) = disabled_reason { + ConfigLayerEntry::new_disabled(source, config, reason) + } else { + ConfigLayerEntry::new(source, config) + } +} + +fn sanitize_project_config(config: &mut TomlValue) -> Vec { + let Some(table) = config.as_table_mut() else { + return Vec::new(); + }; + + let mut ignored_keys = Vec::new(); + for key in PROJECT_LOCAL_CONFIG_DENYLIST { + if table.remove(*key).is_some() { + ignored_keys.push((*key).to_string()); + } + } + + ignored_keys +} + +fn project_ignored_config_keys_warning( + dot_codex_folder: &AbsolutePathBuf, + ignored_keys: &[String], +) -> String { + let config_path = dot_codex_folder.join(CONFIG_TOML_FILE); + let ignored_keys = ignored_keys.join(", "); + format!( + concat!( + "Ignored unsupported project-local config keys in {config_path}: {ignored_keys}. ", + "If you want these settings to apply, manually set them in your ", + "user-level config.toml." + ), + config_path = config_path.display(), + ignored_keys = ignored_keys, + ) +} + +async fn project_trust_context( + fs: &dyn ExecutorFileSystem, + merged_config: &TomlValue, + cwd: &AbsolutePathBuf, + project_root_markers: &[String], + config_base_dir: &Path, + user_config_file: &AbsolutePathBuf, +) -> io::Result { + let project_trust_config: ProjectTrustConfigToml = { + let _guard = AbsolutePathBufGuard::new(config_base_dir); + merged_config + .clone() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))? + }; + + let project_root = find_project_root(fs, cwd, project_root_markers).await?; + let projects = project_trust_config.projects.unwrap_or_default(); + + let project_root_lookup_keys = normalized_project_trust_keys(project_root.as_path()); + let project_root_key = project_root_lookup_keys + .first() + .cloned() + .unwrap_or_else(|| project_trust_key(project_root.as_path())); + let repo_root = resolve_root_git_project_for_trust(fs, cwd).await; + let repo_root_lookup_keys = repo_root + .as_ref() + .map(|root| normalized_project_trust_keys(root.as_path())); + let repo_root_key = repo_root_lookup_keys + .as_ref() + .and_then(|keys| keys.first().cloned()); + + let projects_trust = projects + .into_iter() + .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level))) + .collect(); + + Ok(ProjectTrustContext { + project_root, + project_root_key, + project_root_lookup_keys, + repo_root_key, + repo_root_lookup_keys, + projects_trust, + user_config_file: user_config_file.clone(), + }) +} + +/// Canonicalize the path and convert it to a string to be used as a key in the +/// projects trust map. On Windows, strips UNC, when possible, to try to ensure +/// that different paths that point to the same location have the same key. +pub fn project_trust_key(path: &Path) -> String { + normalized_project_trust_keys(path) + .into_iter() + .next() + .unwrap_or_else(|| normalize_project_trust_lookup_key(path.to_string_lossy().to_string())) +} + +fn normalized_project_trust_keys(path: &Path) -> Vec { + let normalized_path = normalize_project_trust_lookup_key(path.to_string_lossy().to_string()); + let normalized_canonical_path = normalize_project_trust_lookup_key( + normalize_path(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string(), + ); + if normalized_path == normalized_canonical_path { + vec![normalized_canonical_path] + } else { + vec![normalized_canonical_path, normalized_path] + } +} + +fn normalize_project_trust_lookup_key(key: String) -> String { + if cfg!(windows) { + key.to_ascii_lowercase() + } else { + key + } +} +fn project_trust_for_lookup_key( + projects_trust: &std::collections::HashMap, + lookup_key: &str, +) -> Option<(String, TrustLevel)> { + if let Some(trust_level) = projects_trust.get(lookup_key).copied() { + return Some((lookup_key.to_string(), trust_level)); + } + + let mut normalized_matches: Vec<_> = projects_trust + .iter() + .filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key) + .collect(); + normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches + .first() + .map(|(key, trust_level)| ((**key).clone(), **trust_level)) +} +/// Takes a `toml::Value` parsed from a config.toml file and walks through it, +/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new +/// `toml::Value` with the same shape but with paths resolved. +/// +/// This ensures that multiple config layers can be merged together correctly +/// even if they were loaded from different directories. +#[doc(hidden)] +pub fn resolve_relative_paths_in_config_toml( + value_from_config_toml: TomlValue, + base_dir: &Path, +) -> io::Result { + // Use the serialize/deserialize round-trip to convert the + // `toml::Value` into a `ConfigToml` with `AbsolutePath + let _guard = AbsolutePathBufGuard::new(base_dir); + let Ok(resolved) = value_from_config_toml.clone().try_into::() else { + return Ok(value_from_config_toml); + }; + drop(_guard); + + let resolved_value = TomlValue::try_from(resolved).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to serialize resolved config: {e}"), + ) + })?; + + Ok(copy_shape_from_original( + &value_from_config_toml, + &resolved_value, + )) +} + +/// Ensure that every field in `original` is present in the returned +/// `toml::Value`, taking the value from `resolved` where possible. This ensures +/// the fields that we "removed" during the serialize/deserialize round-trip in +/// `resolve_config_paths` are preserved, out of an abundance of caution. +fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue { + match (original, resolved) { + (TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => { + let mut table = toml::map::Map::new(); + for (key, original_value) in original_table { + let resolved_value = resolved_table.get(key).unwrap_or(original_value); + table.insert( + key.clone(), + copy_shape_from_original(original_value, resolved_value), + ); + } + TomlValue::Table(table) + } + (TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => { + let mut items = Vec::new(); + for (index, original_value) in original_array.iter().enumerate() { + let resolved_value = resolved_array.get(index).unwrap_or(original_value); + items.push(copy_shape_from_original(original_value, resolved_value)); + } + TomlValue::Array(items) + } + (_, resolved_value) => resolved_value.clone(), + } +} + +async fn find_project_root( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, + project_root_markers: &[String], +) -> io::Result { + if project_root_markers.is_empty() { + return Ok(cwd.clone()); + } + + for ancestor in cwd.ancestors() { + for marker in project_root_markers { + let marker_path = ancestor.join(marker); + if fs + .get_metadata(&marker_path, /*sandbox*/ None) + .await + .is_ok() + { + return Ok(ancestor); + } + } + } + Ok(cwd.clone()) +} + +struct LoadedProjectLayers { + layers: Vec, + startup_warnings: Vec, +} + +/// Return the appropriate list of layers (each with +/// [ConfigLayerSource::Project] as the source) between `cwd` and +/// `project_root`, inclusive. The list is ordered in _increasing_ precdence, +/// starting from folders closest to `project_root` (which is the lowest +/// precedence) to those closest to `cwd` (which is the highest precedence). +/// Any warnings are stack-level startup messages, not additional config layers. +async fn load_project_layers( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, + project_root: &AbsolutePathBuf, + trust_context: &ProjectTrustContext, + codex_home: &Path, +) -> io::Result { + let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?; + let codex_home_normalized = + normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf()); + let mut dirs = cwd + .ancestors() + .scan(false, |done, a| { + if *done { + None + } else { + if &a == project_root { + *done = true; + } + Some(a) + } + }) + .collect::>(); + dirs.reverse(); + + let mut layers = Vec::new(); + let mut startup_warnings = Vec::new(); + for dir in dirs { + let dot_codex_abs = dir.join(".codex"); + if !fs + .get_metadata(&dot_codex_abs, /*sandbox*/ None) + .await + .map(|metadata| metadata.is_directory) + .unwrap_or(false) + { + continue; + } + + let decision = trust_context.decision_for_dir(&dir); + let disabled_reason = trust_context.disabled_reason_for_decision(&decision); + let dot_codex_normalized = + normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf()); + if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized { + continue; + } + let config_file = dot_codex_abs.join(CONFIG_TOML_FILE); + match fs.read_file_text(&config_file, /*sandbox*/ None).await { + Ok(contents) => { + let config: TomlValue = match toml::from_str(&contents) { + Ok(config) => config, + Err(e) => { + if decision.is_trusted() { + let config_file_display = config_file.as_path().display(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing project config file {config_file_display}: {e}" + ), + )); + } + layers.push(project_layer_entry( + &dot_codex_abs, + TomlValue::Table(toml::map::Map::new()), + disabled_reason.clone(), + )); + continue; + } + }; + let mut config = config; + let ignored_project_config_keys = sanitize_project_config(&mut config); + let config = + resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; + if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() { + startup_warnings.push(project_ignored_config_keys_warning( + &dot_codex_abs, + &ignored_project_config_keys, + )); + } + let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone()); + layers.push(entry); + } + Err(err) => { + if err.kind() == io::ErrorKind::NotFound { + // If there is no config.toml file, record an empty entry + // for this project layer, as this may still have subfolders + // that are significant in the overall ConfigLayerStack. + layers.push(project_layer_entry( + &dot_codex_abs, + TomlValue::Table(toml::map::Map::new()), + disabled_reason, + )); + } else { + let config_file_display = config_file.as_path().display(); + return Err(io::Error::new( + err.kind(), + format!("Failed to read project config file {config_file_display}: {err}"), + )); + } + } + } + } + + Ok(LoadedProjectLayers { + layers, + startup_warnings, + }) +} +/// The legacy mechanism for specifying admin-enforced configuration is to read +/// from a file like `/etc/codex/managed_config.toml` that has the same +/// structure as `config.toml` where fields like `approval_policy` can specify +/// exactly one value rather than a list of allowed values. +/// +/// If present, re-interpret `managed_config.toml` as a `requirements.toml` +/// where each specified field is treated as a constraint. Most fields allow +/// only the specified value. `approvals_reviewer = "auto_review"` also allows +/// `user` so people can opt out of the auto-reviewer. +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +struct LegacyManagedConfigToml { + approval_policy: Option, + approvals_reviewer: Option, + sandbox_mode: Option, +} + +impl From for ConfigRequirementsToml { + fn from(legacy: LegacyManagedConfigToml) -> Self { + let mut config_requirements_toml = ConfigRequirementsToml::default(); + + let LegacyManagedConfigToml { + approval_policy, + approvals_reviewer, + sandbox_mode, + } = legacy; + if let Some(approval_policy) = approval_policy { + config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); + } + if let Some(approvals_reviewer) = approvals_reviewer { + let mut allowed_reviewers = vec![approvals_reviewer]; + if approvals_reviewer == ApprovalsReviewer::AutoReview { + allowed_reviewers.push(ApprovalsReviewer::User); + } + config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers); + } + if let Some(sandbox_mode) = sandbox_mode { + let required_mode: SandboxModeRequirement = sandbox_mode.into(); + // Allowing read-only is a requirement for Codex to function correctly. + // So in this backfill path, we append read-only if it's not already specified. + let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; + if required_mode != SandboxModeRequirement::ReadOnly { + allowed_modes.push(required_mode); + } + config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes); + } + config_requirements_toml + } +} + +// Cannot name this `mod tests` because of tests.rs in this folder. +#[cfg(test)] +mod unit_tests { + use super::*; + #[cfg(windows)] + use std::path::Path; + use tempfile::tempdir; + + #[test] + fn ensure_resolve_relative_paths_in_config_toml_preserves_all_fields() -> anyhow::Result<()> { + let tmp = tempdir()?; + let base_dir = tmp.path(); + let contents = r#" +# This is a field recognized by config.toml that is an AbsolutePathBuf in +# the ConfigToml struct. +model_instructions_file = "./some_file.md" + +# This is a field recognized by config.toml. +model = "gpt-1000" + +# This is a field not recognized by config.toml. +foo = "xyzzy" +"#; + let user_config: TomlValue = toml::from_str(contents)?; + + let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?; + let mut expected_toml_value = toml::map::Map::new(); + expected_toml_value.insert( + "model_instructions_file".to_string(), + TomlValue::String( + AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir) + .as_path() + .to_string_lossy() + .to_string(), + ), + ); + expected_toml_value.insert( + "model".to_string(), + TomlValue::String("gpt-1000".to_string()), + ); + expected_toml_value.insert("foo".to_string(), TomlValue::String("xyzzy".to_string())); + assert_eq!(normalized_toml_value, TomlValue::Table(expected_toml_value)); + Ok(()) + } + + #[test] + fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() { + let legacy = LegacyManagedConfigToml { + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + }; + + let requirements = ConfigRequirementsToml::from(legacy); + + assert_eq!( + requirements.allowed_sandbox_modes, + Some(vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite + ]) + ); + } + + #[test] + fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() { + let legacy = LegacyManagedConfigToml { + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_mode: None, + }; + + let requirements = ConfigRequirementsToml::from(legacy); + + assert_eq!( + requirements.allowed_approvals_reviewers, + Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,]) + ); + } + + #[test] + fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() { + let legacy = LegacyManagedConfigToml { + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_mode: None, + }; + + let requirements = ConfigRequirementsToml::from(legacy); + + assert_eq!( + requirements.allowed_approvals_reviewers, + Some(vec![ApprovalsReviewer::User]) + ); + } + + #[cfg(windows)] + #[test] + fn windows_system_requirements_toml_file_uses_expected_suffix() { + let expected = windows_program_data_dir_from_known_folder() + .unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)) + .join("OpenAI") + .join("Codex") + .join("requirements.toml"); + assert_eq!( + windows_system_requirements_toml_file() + .expect("requirements.toml path") + .as_path(), + expected.as_path() + ); + assert!( + windows_system_requirements_toml_file() + .expect("requirements.toml path") + .as_path() + .ends_with(Path::new("OpenAI").join("Codex").join("requirements.toml")) + ); + } + + #[cfg(windows)] + #[test] + fn windows_system_config_toml_file_uses_expected_suffix() { + let expected = windows_program_data_dir_from_known_folder() + .unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)) + .join("OpenAI") + .join("Codex") + .join("config.toml"); + assert_eq!( + windows_system_config_toml_file() + .expect("config.toml path") + .as_path(), + expected.as_path() + ); + assert!( + windows_system_config_toml_file() + .expect("config.toml path") + .as_path() + .ends_with(Path::new("OpenAI").join("Codex").join("config.toml")) + ); + } +} diff --git a/code-rs/config/src/marketplace_edit.rs b/code-rs/config/src/marketplace_edit.rs new file mode 100644 index 00000000000..2aad2486ebf --- /dev/null +++ b/code-rs/config/src/marketplace_edit.rs @@ -0,0 +1,276 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use toml_edit::DocumentMut; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use toml_edit::Value as TomlValue; +use toml_edit::value; + +use crate::CONFIG_TOML_FILE; + +pub struct MarketplaceConfigUpdate<'a> { + pub last_updated: &'a str, + pub last_revision: Option<&'a str>, + pub source_type: &'a str, + pub source: &'a str, + pub ref_name: Option<&'a str>, + pub sparse_paths: &'a [String], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoveMarketplaceConfigOutcome { + Removed, + NotFound, + NameCaseMismatch { configured_name: String }, +} + +pub fn record_user_marketplace( + codex_home: &Path, + marketplace_name: &str, + update: &MarketplaceConfigUpdate<'_>, +) -> std::io::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = read_or_create_document(&config_path)?; + upsert_marketplace(&mut doc, marketplace_name, update); + fs::create_dir_all(codex_home)?; + fs::write(config_path, doc.to_string()) +} + +pub fn remove_user_marketplace(codex_home: &Path, marketplace_name: &str) -> std::io::Result { + let outcome = remove_user_marketplace_config(codex_home, marketplace_name)?; + Ok(outcome == RemoveMarketplaceConfigOutcome::Removed) +} + +pub fn remove_user_marketplace_config( + codex_home: &Path, + marketplace_name: &str, +) -> std::io::Result { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match fs::read_to_string(&config_path) { + Ok(raw) => raw + .parse::() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(RemoveMarketplaceConfigOutcome::NotFound); + } + Err(err) => return Err(err), + }; + + let outcome = remove_marketplace(&mut doc, marketplace_name); + if outcome != RemoveMarketplaceConfigOutcome::Removed { + return Ok(outcome); + } + + fs::create_dir_all(codex_home)?; + fs::write(config_path, doc.to_string())?; + Ok(RemoveMarketplaceConfigOutcome::Removed) +} + +fn read_or_create_document(config_path: &Path) -> std::io::Result { + match fs::read_to_string(config_path) { + Ok(raw) => raw + .parse::() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()), + Err(err) => Err(err), + } +} + +fn upsert_marketplace( + doc: &mut DocumentMut, + marketplace_name: &str, + update: &MarketplaceConfigUpdate<'_>, +) { + let root = doc.as_table_mut(); + if !root.contains_key("marketplaces") { + root.insert("marketplaces", TomlItem::Table(new_implicit_table())); + } + + let Some(marketplaces_item) = root.get_mut("marketplaces") else { + return; + }; + if !marketplaces_item.is_table() { + *marketplaces_item = TomlItem::Table(new_implicit_table()); + } + + let Some(marketplaces) = marketplaces_item.as_table_mut() else { + return; + }; + let mut entry = TomlTable::new(); + entry.set_implicit(false); + entry["last_updated"] = value(update.last_updated.to_string()); + if let Some(last_revision) = update.last_revision { + entry["last_revision"] = value(last_revision.to_string()); + } + entry["source_type"] = value(update.source_type.to_string()); + entry["source"] = value(update.source.to_string()); + if let Some(ref_name) = update.ref_name { + entry["ref"] = value(ref_name.to_string()); + } + if !update.sparse_paths.is_empty() { + entry["sparse_paths"] = TomlItem::Value(TomlValue::Array( + update.sparse_paths.iter().map(String::as_str).collect(), + )); + } + marketplaces.insert(marketplace_name, TomlItem::Table(entry)); +} + +fn remove_marketplace( + doc: &mut DocumentMut, + marketplace_name: &str, +) -> RemoveMarketplaceConfigOutcome { + let root = doc.as_table_mut(); + let Some(marketplaces_item) = root.get_mut("marketplaces") else { + return RemoveMarketplaceConfigOutcome::NotFound; + }; + + let mut remove_marketplaces = false; + let outcome = match marketplaces_item { + TomlItem::Table(marketplaces) => { + let outcome = if marketplaces.remove(marketplace_name).is_some() { + RemoveMarketplaceConfigOutcome::Removed + } else if let Some(configured_name) = + case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name) + { + RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } + } else { + RemoveMarketplaceConfigOutcome::NotFound + }; + remove_marketplaces = marketplaces.is_empty(); + outcome + } + TomlItem::Value(value) => { + let Some(marketplaces) = value.as_inline_table_mut() else { + return RemoveMarketplaceConfigOutcome::NotFound; + }; + let outcome = if marketplaces.remove(marketplace_name).is_some() { + RemoveMarketplaceConfigOutcome::Removed + } else if let Some(configured_name) = + case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name) + { + RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } + } else { + RemoveMarketplaceConfigOutcome::NotFound + }; + remove_marketplaces = marketplaces.is_empty(); + outcome + } + _ => RemoveMarketplaceConfigOutcome::NotFound, + }; + + if outcome == RemoveMarketplaceConfigOutcome::Removed && remove_marketplaces { + root.remove("marketplaces"); + } + outcome +} + +fn case_mismatched_key<'a>( + mut keys: impl Iterator, + requested_name: &str, +) -> Option { + keys.find(|key| *key != requested_name && key.eq_ignore_ascii_case(requested_name)) + .map(str::to_string) +} + +fn new_implicit_table() -> TomlTable { + let mut table = TomlTable::new(); + table.set_implicit(true); + table +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn remove_user_marketplace_removes_requested_entry() { + let codex_home = TempDir::new().unwrap(); + let update = MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update).unwrap(); + record_user_marketplace(codex_home.path(), "other", &update).unwrap(); + + let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap(); + + assert!(removed); + let config: toml::Value = + toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap()) + .unwrap(); + let marketplaces = config + .get("marketplaces") + .and_then(toml::Value::as_table) + .unwrap(); + assert_eq!(marketplaces.len(), 1); + assert!(marketplaces.contains_key("other")); + } + + #[test] + fn remove_user_marketplace_returns_false_when_missing() { + let codex_home = TempDir::new().unwrap(); + + let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap(); + + assert!(!removed); + } + + #[test] + fn remove_user_marketplace_config_reports_case_mismatch() { + let codex_home = TempDir::new().unwrap(); + let update = MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update).unwrap(); + + let outcome = remove_user_marketplace_config(codex_home.path(), "Debug").unwrap(); + + assert_eq!( + outcome, + RemoveMarketplaceConfigOutcome::NameCaseMismatch { + configured_name: "debug".to_string() + } + ); + } + + #[test] + fn remove_user_marketplace_config_removes_inline_table_entry() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +marketplaces = { + debug = { source_type = "git", source = "https://github.com/owner/repo.git" }, + other = { source_type = "local", source = "/tmp/marketplace" }, +} +"#, + ) + .unwrap(); + + let outcome = remove_user_marketplace_config(codex_home.path(), "debug").unwrap(); + + assert_eq!(outcome, RemoveMarketplaceConfigOutcome::Removed); + let config: toml::Value = + toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap()) + .unwrap(); + let marketplaces = config + .get("marketplaces") + .and_then(toml::Value::as_table) + .unwrap(); + assert_eq!(marketplaces.len(), 1); + assert!(marketplaces.contains_key("other")); + } +} diff --git a/code-rs/config/src/mcp_edit.rs b/code-rs/config/src/mcp_edit.rs new file mode 100644 index 00000000000..f5881a12572 --- /dev/null +++ b/code-rs/config/src/mcp_edit.rs @@ -0,0 +1,285 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; + +use tokio::task; +use toml::Value as TomlValue; +use toml_edit::DocumentMut; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use toml_edit::value; + +use crate::AppToolApproval; +use crate::CONFIG_TOML_FILE; +use crate::McpServerConfig; +use crate::McpServerEnvVar; +use crate::McpServerTransportConfig; + +pub async fn load_global_mcp_servers( + codex_home: &Path, +) -> std::io::Result> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let raw = match tokio::fs::read_to_string(&config_path).await { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(err) => return Err(err), + }; + let parsed = toml::from_str::(&raw) + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?; + let Some(servers_value) = parsed.get("mcp_servers") else { + return Ok(BTreeMap::new()); + }; + + ensure_no_inline_bearer_tokens(servers_value)?; + + servers_value + .clone() + .try_into() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)) +} + +fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> { + let Some(servers_table) = value.as_table() else { + return Ok(()); + }; + + for (server_name, server_value) in servers_table { + if let Some(server_table) = server_value.as_table() + && server_table.contains_key("bearer_token") + { + let message = format!( + "mcp_servers.{server_name} uses unsupported `bearer_token`; set `bearer_token_env_var`." + ); + return Err(std::io::Error::new(ErrorKind::InvalidData, message)); + } + } + + Ok(()) +} + +pub struct ConfigEditsBuilder { + codex_home: PathBuf, + mcp_servers: Option>, +} + +impl ConfigEditsBuilder { + pub fn new(codex_home: &Path) -> Self { + Self { + codex_home: codex_home.to_path_buf(), + mcp_servers: None, + } + } + + pub fn replace_mcp_servers(mut self, servers: &BTreeMap) -> Self { + self.mcp_servers = Some(servers.clone()); + self + } + + pub async fn apply(self) -> std::io::Result<()> { + task::spawn_blocking(move || self.apply_blocking()) + .await + .map_err(|err| { + std::io::Error::other(format!("config persistence task panicked: {err}")) + })? + } + + fn apply_blocking(self) -> std::io::Result<()> { + let config_path = self.codex_home.join(CONFIG_TOML_FILE); + let mut doc = read_or_create_document(&config_path)?; + if let Some(servers) = self.mcp_servers.as_ref() { + replace_mcp_servers(&mut doc, servers); + } + fs::create_dir_all(&self.codex_home)?; + fs::write(config_path, doc.to_string()) + } +} + +fn read_or_create_document(config_path: &Path) -> std::io::Result { + match fs::read_to_string(config_path) { + Ok(raw) => raw + .parse::() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()), + Err(err) => Err(err), + } +} + +fn replace_mcp_servers(doc: &mut DocumentMut, servers: &BTreeMap) { + let root = doc.as_table_mut(); + if servers.is_empty() { + root.remove("mcp_servers"); + return; + } + + let mut table = TomlTable::new(); + table.set_implicit(true); + for (name, config) in servers { + table.insert(name, serialize_mcp_server(config)); + } + root.insert("mcp_servers", TomlItem::Table(table)); +} + +fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + + match &config.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + entry["command"] = value(command.clone()); + if !args.is_empty() { + entry["args"] = array_from_strings(args); + } + if let Some(env) = env + && !env.is_empty() + { + entry["env"] = table_from_pairs(env.iter()); + } + if !env_vars.is_empty() { + entry["env_vars"] = array_from_env_vars(env_vars); + } + if let Some(cwd) = cwd { + entry["cwd"] = value(cwd.to_string_lossy().to_string()); + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + entry["url"] = value(url.clone()); + if let Some(env_var) = bearer_token_env_var { + entry["bearer_token_env_var"] = value(env_var.clone()); + } + if let Some(headers) = http_headers + && !headers.is_empty() + { + entry["http_headers"] = table_from_pairs(headers.iter()); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + entry["env_http_headers"] = table_from_pairs(headers.iter()); + } + } + } + + if !config.enabled { + entry["enabled"] = value(false); + } + if let Some(environment) = &config.experimental_environment { + entry["experimental_environment"] = value(environment.clone()); + } + if config.required { + entry["required"] = value(true); + } + if config.supports_parallel_tool_calls { + entry["supports_parallel_tool_calls"] = value(true); + } + if let Some(timeout) = config.startup_timeout_sec { + entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(timeout) = config.tool_timeout_sec { + entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(approval_mode) = config.default_tools_approval_mode { + entry["default_tools_approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + if let Some(enabled_tools) = &config.enabled_tools + && !enabled_tools.is_empty() + { + entry["enabled_tools"] = array_from_strings(enabled_tools); + } + if let Some(disabled_tools) = &config.disabled_tools + && !disabled_tools.is_empty() + { + entry["disabled_tools"] = array_from_strings(disabled_tools); + } + if let Some(scopes) = &config.scopes + && !scopes.is_empty() + { + entry["scopes"] = array_from_strings(scopes); + } + if let Some(resource) = &config.oauth_resource + && !resource.is_empty() + { + entry["oauth_resource"] = value(resource.clone()); + } + if !config.tools.is_empty() { + let mut tools = TomlTable::new(); + tools.set_implicit(false); + let mut tool_entries: Vec<_> = config.tools.iter().collect(); + tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (name, tool_config) in tool_entries { + let mut tool_entry = TomlTable::new(); + tool_entry.set_implicit(false); + if let Some(approval_mode) = tool_config.approval_mode { + tool_entry["approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + tools.insert(name, TomlItem::Table(tool_entry)); + } + entry.insert("tools", TomlItem::Table(tools)); + } + + TomlItem::Table(entry) +} + +fn array_from_strings(values: &[String]) -> TomlItem { + let mut array = toml_edit::Array::new(); + for value in values { + array.push(value.clone()); + } + TomlItem::Value(array.into()) +} + +fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem { + let mut array = toml_edit::Array::new(); + for env_var in env_vars { + match env_var { + McpServerEnvVar::Name(name) => array.push(name.clone()), + McpServerEnvVar::Config { name, source } => { + let mut table = toml_edit::InlineTable::new(); + table.insert("name", name.clone().into()); + if let Some(source) = source { + table.insert("source", source.clone().into()); + } + array.push(table); + } + } + } + TomlItem::Value(array.into()) +} + +fn table_from_pairs<'a, I>(pairs: I) -> TomlItem +where + I: IntoIterator, +{ + let mut entries: Vec<_> = pairs.into_iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut table = TomlTable::new(); + table.set_implicit(false); + for (key, value_str) in entries { + table.insert(key, value(value_str.clone())); + } + TomlItem::Table(table) +} + +#[cfg(test)] +#[path = "mcp_edit_tests.rs"] +mod tests; diff --git a/code-rs/config/src/mcp_edit_tests.rs b/code-rs/config/src/mcp_edit_tests.rs new file mode 100644 index 00000000000..cfcd73c3e5b --- /dev/null +++ b/code-rs/config/src/mcp_edit_tests.rs @@ -0,0 +1,84 @@ +use super::*; +use crate::McpServerToolConfig; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +#[tokio::test] +async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow::Result<()> { + let unique_suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let codex_home = std::env::temp_dir().join(format!( + "codex-config-mcp-edit-test-{}-{unique_suffix}", + std::process::id() + )); + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: true, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: Some(AppToolApproval::Auto), + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::from([ + ( + "search".to_string(), + McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + }, + ), + ( + "read".to_string(), + McpServerToolConfig { + approval_mode: Some(AppToolApproval::Prompt), + }, + ), + ]), + }, + )]); + + ConfigEditsBuilder::new(&codex_home) + .replace_mcp_servers(&servers) + .apply() + .await?; + + let config_path = codex_home.join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +command = "docs-server" +supports_parallel_tool_calls = true +default_tools_approval_mode = "auto" + +[mcp_servers.docs.tools] + +[mcp_servers.docs.tools.read] +approval_mode = "prompt" + +[mcp_servers.docs.tools.search] +approval_mode = "approve" +"# + ); + + let loaded = load_global_mcp_servers(&codex_home).await?; + assert_eq!(loaded, servers); + + std::fs::remove_dir_all(&codex_home)?; + + Ok(()) +} diff --git a/code-rs/config/src/mcp_types.rs b/code-rs/config/src/mcp_types.rs new file mode 100644 index 00000000000..d642d9fc572 --- /dev/null +++ b/code-rs/config/src/mcp_types.rs @@ -0,0 +1,422 @@ +//! MCP server configuration types. + +use std::collections::HashMap; +use std::fmt; +use std::path::PathBuf; +use std::time::Duration; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; + +use crate::RequirementSource; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AppToolApproval { + #[default] + Auto, + Prompt, + Approve, +} + +/// Human-readable reason a configured MCP server was disabled after requirements +/// were applied. +/// +/// `Display` is intentionally implemented for CLI/TUI status output; avoid +/// relying on `Debug` because enum variant syntax is not part of the user-facing +/// message contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpServerDisabledReason { + /// The server is disabled, but there is no more specific user-facing reason. + Unknown, + /// The server was disabled by config requirements from the given source. + Requirements { source: RequirementSource }, +} + +impl fmt::Display for McpServerDisabledReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + McpServerDisabledReason::Unknown => write!(f, "unknown"), + McpServerDisabledReason::Requirements { source } => { + write!(f, "requirements ({source})") + } + } + } +} + +/// Per-tool approval settings for a single MCP server tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct McpServerToolConfig { + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +pub enum McpServerEnvVar { + Name(String), + Config { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + }, +} + +impl McpServerEnvVar { + pub fn name(&self) -> &str { + match self { + McpServerEnvVar::Name(name) => name, + McpServerEnvVar::Config { name, .. } => name, + } + } + + pub fn source(&self) -> Option<&str> { + match self { + McpServerEnvVar::Name(_) => None, + McpServerEnvVar::Config { source, .. } => source.as_deref(), + } + } + + pub fn is_remote_source(&self) -> bool { + self.source() == Some("remote") + } + + pub fn validate_source(&self) -> Result<(), String> { + match self.source() { + None | Some("local") | Some("remote") => Ok(()), + Some(source) => Err(format!( + "unsupported env_vars source `{source}`; expected `local` or `remote`" + )), + } + } +} + +impl From for McpServerEnvVar { + fn from(value: String) -> Self { + Self::Name(value) + } +} + +impl From<&str> for McpServerEnvVar { + fn from(value: &str) -> Self { + Self::Name(value.to_string()) + } +} + +impl AsRef for McpServerEnvVar { + fn as_ref(&self) -> &str { + self.name() + } +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +pub struct McpServerConfig { + #[serde(flatten)] + pub transport: McpServerTransportConfig, + + /// Experimental environment selector for where Codex should start this MCP server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub experimental_environment: Option, + + /// When `false`, Codex skips initializing this MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub required: bool, + + /// When `true`, every tool from this server is advertised as safe for parallel tool calls. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub supports_parallel_tool_calls: bool, + + /// Reason this server was disabled after applying requirements. + #[serde(skip)] + pub disabled_reason: Option, + + /// Startup timeout in seconds for initializing MCP server & initially listing tools. + #[serde( + default, + with = "option_duration_secs", + skip_serializing_if = "Option::is_none" + )] + pub startup_timeout_sec: Option, + + /// Default timeout for MCP tool calls initiated via this server. + #[serde(default, with = "option_duration_secs")] + pub tool_timeout_sec: Option, + + /// Approval mode for tools in this server unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, + + /// Optional OAuth scopes to request during MCP login. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + + /// Optional OAuth resource parameter to include during MCP login (RFC 8707). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_resource: Option, + + /// Per-tool approval settings keyed by tool name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tools: HashMap, +} + +/// Raw MCP config shape used for deserialization and supported-field JSON +/// Schema generation. +/// +/// Fields that are accepted only to produce targeted validation errors should +/// be skipped in the generated schema. +/// +/// Keep `TryFrom for McpServerConfig` exhaustively +/// destructuring this struct so new TOML fields cannot be added here without +/// updating the validation/mapping logic that produces [`McpServerConfig`]. +#[derive(Deserialize, Clone, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RawMcpServerConfig { + // stdio + pub command: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub env: Option>, + #[serde(default)] + pub env_vars: Option>, + #[serde(default)] + pub cwd: Option, + pub http_headers: Option>, + #[serde(default)] + pub env_http_headers: Option>, + + // streamable_http + pub url: Option, + #[schemars(skip)] + pub bearer_token: Option, + pub bearer_token_env_var: Option, + + // shared + #[serde(default)] + pub experimental_environment: Option, + #[serde(default)] + pub startup_timeout_sec: Option, + #[serde(default)] + pub startup_timeout_ms: Option, + #[serde(default, with = "option_duration_secs")] + #[schemars(with = "Option")] + pub tool_timeout_sec: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub required: Option, + #[serde(default)] + pub supports_parallel_tool_calls: Option, + #[serde(default)] + pub default_tools_approval_mode: Option, + #[serde(default)] + pub enabled_tools: Option>, + #[serde(default)] + pub disabled_tools: Option>, + #[serde(default)] + pub scopes: Option>, + #[serde(default)] + pub oauth_resource: Option, + /// Legacy display-name field accepted for backward compatibility. + #[serde(default, rename = "name")] + pub _name: Option, + #[serde(default)] + pub tools: Option>, +} + +impl TryFrom for McpServerConfig { + type Error = String; + + fn try_from(raw: RawMcpServerConfig) -> Result { + let RawMcpServerConfig { + command, + args, + env, + env_vars, + cwd, + http_headers, + env_http_headers, + url, + bearer_token, + bearer_token_env_var, + experimental_environment, + startup_timeout_sec, + startup_timeout_ms, + tool_timeout_sec, + enabled, + required, + supports_parallel_tool_calls, + default_tools_approval_mode, + enabled_tools, + disabled_tools, + scopes, + oauth_resource, + _name: _, + tools, + } = raw; + + let startup_timeout_sec = match (startup_timeout_sec, startup_timeout_ms) { + (Some(sec), _) => { + Some(Duration::try_from_secs_f64(sec).map_err(|err| err.to_string())?) + } + (None, Some(ms)) => Some(Duration::from_millis(ms)), + (None, None) => None, + }; + + fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> { + if value.is_none() { + return Ok(()); + } + Err(format!("{field} is not supported for {transport}")) + } + + let transport = if let Some(command) = command { + throw_if_set("stdio", "url", url.as_ref())?; + throw_if_set( + "stdio", + "bearer_token_env_var", + bearer_token_env_var.as_ref(), + )?; + throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?; + throw_if_set("stdio", "http_headers", http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; + throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?; + let env_vars = env_vars.unwrap_or_default(); + for env_var in &env_vars { + env_var.validate_source()?; + } + McpServerTransportConfig::Stdio { + command, + args: args.unwrap_or_default(), + env, + env_vars, + cwd, + } + } else if let Some(url) = url { + throw_if_set("streamable_http", "args", args.as_ref())?; + throw_if_set("streamable_http", "env", env.as_ref())?; + throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?; + throw_if_set("streamable_http", "cwd", cwd.as_ref())?; + throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?; + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } + } else { + return Err("invalid transport".to_string()); + }; + + Ok(Self { + transport, + experimental_environment, + startup_timeout_sec, + tool_timeout_sec, + enabled: enabled.unwrap_or_else(default_enabled), + required: required.unwrap_or_default(), + supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(), + disabled_reason: None, + default_tools_approval_mode, + enabled_tools, + disabled_tools, + scopes, + oauth_resource, + tools: tools.unwrap_or_default(), + }) + } +} + +impl<'de> Deserialize<'de> for McpServerConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + RawMcpServerConfig::deserialize(deserializer)? + .try_into() + .map_err(SerdeError::custom) + } +} + +const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] +pub enum McpServerTransportConfig { + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + }, + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http + StreamableHttp { + url: String, + /// Name of the environment variable to read for an HTTP bearer token. + /// When set, requests will include the token via `Authorization: Bearer `. + /// The actual secret value must be provided via the environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + bearer_token_env_var: Option, + /// Additional HTTP headers to include in requests to this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers: Option>, + /// HTTP headers where the value is sourced from an environment variable. + #[serde(default, skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, + }, +} + +mod option_duration_secs { + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + use std::time::Duration; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(duration) => serializer.serialize_some(&duration.as_secs_f64()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let secs = Option::::deserialize(deserializer)?; + secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) + .transpose() + } +} + +#[cfg(test)] +#[path = "mcp_types_tests.rs"] +mod tests; diff --git a/code-rs/config/src/mcp_types_tests.rs b/code-rs/config/src/mcp_types_tests.rs new file mode 100644 index 00000000000..50f705d0f82 --- /dev/null +++ b/code-rs/config/src/mcp_types_tests.rs @@ -0,0 +1,471 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::PathBuf; + +#[test] +fn deserialize_stdio_command_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); + assert!(!cfg.required); + assert!(cfg.enabled_tools.is_none()); + assert!(cfg.disabled_tools.is_none()); +} + +#[test] +fn deserialize_stdio_command_server_config_with_args() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + env = { "FOO" = "BAR" } + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_stdio_command_server_config_with_env_vars() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = ["FOO", "BAR"] + "#, + ) + .expect("should deserialize command config with env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec!["FOO".into(), "BAR".into()], + cwd: None, + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_with_env_var_sources() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = [ + "LEGACY_TOKEN", + { name = "LOCAL_TOKEN", source = "local" }, + { name = "REMOTE_TOKEN", source = "remote" }, + ] + "#, + ) + .expect("should deserialize command config with sourced env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec![ + McpServerEnvVar::Name("LEGACY_TOKEN".to_string()), + McpServerEnvVar::Config { + name: "LOCAL_TOKEN".to_string(), + source: Some("local".to_string()), + }, + McpServerEnvVar::Config { + name: "REMOTE_TOKEN".to_string(), + source: Some("remote".to_string()), + }, + ], + cwd: None, + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_rejects_unknown_env_var_source() { + let err = toml::from_str::( + r#" + command = "echo" + env_vars = [{ name = "TOKEN", source = "elsewhere" }] + "#, + ) + .expect_err("unsupported env var source should be rejected"); + + assert!( + err.to_string() + .contains("unsupported env_vars source `elsewhere`"), + "unexpected error: {err}" + ); +} + +#[test] +fn deserialize_stdio_command_server_config_with_cwd() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + cwd = "/tmp" + "#, + ) + .expect("should deserialize command config with cwd"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(PathBuf::from("/tmp")), + } + ); +} + +#[test] +fn deserialize_disabled_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled = false + "#, + ) + .expect("should deserialize disabled server config"); + + assert!(!cfg.enabled); + assert!(!cfg.required); +} + +#[test] +fn deserialize_required_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + required = true + "#, + ) + .expect("should deserialize required server config"); + + assert!(cfg.required); +} + +#[test] +fn deserialize_streamable_http_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_env_var() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + bearer_token_env_var = "GITHUB_TOKEN" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_headers() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + http_headers = { "X-Foo" = "bar" } + env_http_headers = { "X-Token" = "TOKEN_ENV" } + "#, + ) + .expect("should deserialize http config with headers"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Token".to_string(), + "TOKEN_ENV".to_string() + )])), + } + ); +} + +#[test] +fn deserialize_streamable_http_server_config_with_oauth_resource() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + oauth_resource = "https://api.example.com" + "#, + ) + .expect("should deserialize http config with oauth_resource"); + + assert_eq!( + cfg.oauth_resource, + Some("https://api.example.com".to_string()) + ); +} + +#[test] +fn deserialize_server_config_with_tool_filters() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled_tools = ["allowed"] + disabled_tools = ["blocked"] + "#, + ) + .expect("should deserialize tool filters"); + + assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()])); + assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); +} + +#[test] +fn deserialize_server_config_with_parallel_tool_calls() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + supports_parallel_tool_calls = true + "#, + ) + .expect("should deserialize supports_parallel_tool_calls"); + + assert!(cfg.supports_parallel_tool_calls); +} + +#[test] +fn deserialize_server_config_with_default_tool_approval_mode() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + default_tools_approval_mode = "approve" + + [tools.search] + approval_mode = "prompt" + "#, + ) + .expect("should deserialize default tool approval mode"); + + assert_eq!( + cfg.default_tools_approval_mode, + Some(AppToolApproval::Approve) + ); + assert_eq!( + cfg.tools.get("search"), + Some(&McpServerToolConfig { + approval_mode: Some(AppToolApproval::Prompt), + }) + ); + + let serialized = toml::to_string(&cfg).expect("should serialize MCP config"); + assert!(serialized.contains("default_tools_approval_mode = \"approve\"")); + + let round_tripped: McpServerConfig = + toml::from_str(&serialized).expect("should deserialize serialized MCP config"); + assert_eq!(round_tripped, cfg); +} + +#[test] +fn serialize_round_trips_server_config_with_parallel_tool_calls() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + supports_parallel_tool_calls = true + tool_timeout_sec = 2.0 + "#, + ) + .expect("should deserialize supports_parallel_tool_calls"); + + let serialized = toml::to_string(&cfg).expect("should serialize MCP config"); + assert!(serialized.contains("supports_parallel_tool_calls = true")); + + let round_tripped: McpServerConfig = + toml::from_str(&serialized).expect("should deserialize serialized MCP config"); + assert_eq!(round_tripped, cfg); +} + +#[test] +fn deserialize_ignores_unknown_server_fields() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + trust_level = "trusted" + "#, + ) + .expect("should ignore unknown server fields"); + + assert_eq!( + cfg, + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + } + ); +} + +#[test] +fn deserialize_rejects_command_and_url() { + toml::from_str::( + r#" + command = "echo" + url = "https://example.com" + "#, + ) + .expect_err("should reject command+url"); +} + +#[test] +fn deserialize_rejects_env_for_http_transport() { + toml::from_str::( + r#" + url = "https://example.com" + env = { "FOO" = "BAR" } + "#, + ) + .expect_err("should reject env for http transport"); +} + +#[test] +fn deserialize_rejects_headers_for_stdio() { + toml::from_str::( + r#" + command = "echo" + http_headers = { "X-Foo" = "bar" } + "#, + ) + .expect_err("should reject http_headers for stdio transport"); + + toml::from_str::( + r#" + command = "echo" + env_http_headers = { "X-Foo" = "BAR_ENV" } + "#, + ) + .expect_err("should reject env_http_headers for stdio transport"); + + let err = toml::from_str::( + r#" + command = "echo" + oauth_resource = "https://api.example.com" + "#, + ) + .expect_err("should reject oauth_resource for stdio transport"); + + assert!( + err.to_string() + .contains("oauth_resource is not supported for stdio"), + "unexpected error: {err}" + ); +} + +#[test] +fn deserialize_rejects_inline_bearer_token_field() { + let err = toml::from_str::( + r#" + url = "https://example.com" + bearer_token = "secret" + "#, + ) + .expect_err("should reject bearer_token field"); + + assert!( + err.to_string().contains("bearer_token is not supported"), + "unexpected error: {err}" + ); +} diff --git a/code-rs/config/src/merge.rs b/code-rs/config/src/merge.rs new file mode 100644 index 00000000000..020adb0313a --- /dev/null +++ b/code-rs/config/src/merge.rs @@ -0,0 +1,34 @@ +use crate::key_aliases::normalize_key_aliases; +use crate::key_aliases::normalized_with_key_aliases; +use toml::Value as TomlValue; + +/// Merge config `overlay` into `base`, giving `overlay` precedence. +pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { + merge_toml_values_at_path(base, overlay, &mut Vec::new()); +} + +fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &mut Vec) { + if let TomlValue::Table(overlay_table) = overlay + && let TomlValue::Table(base_table) = base + { + normalize_key_aliases(path, base_table); + let mut overlay_table = overlay_table.clone(); + normalize_key_aliases(path, &mut overlay_table); + + for (key, value) in overlay_table { + path.push(key.clone()); + if let Some(existing) = base_table.get_mut(&key) { + merge_toml_values_at_path(existing, &value, path); + } else { + base_table.insert(key, normalized_with_key_aliases(&value, path)); + } + path.pop(); + } + } else { + *base = normalized_with_key_aliases(overlay, path); + } +} + +#[cfg(test)] +#[path = "merge_tests.rs"] +mod tests; diff --git a/code-rs/config/src/merge_tests.rs b/code-rs/config/src/merge_tests.rs new file mode 100644 index 00000000000..0bb92c00155 --- /dev/null +++ b/code-rs/config/src/merge_tests.rs @@ -0,0 +1,100 @@ +use super::*; +use crate::config_toml::ConfigToml; +use crate::types::MemoriesToml; +use pretty_assertions::assert_eq; + +fn parse_toml(value: &str) -> TomlValue { + toml::from_str(value).expect("TOML should parse") +} + +#[test] +fn merge_toml_values_normalizes_legacy_key_from_base_layer() { + let mut base = parse_toml( + r#" +[memories] +no_memories_if_mcp_or_web_search = false +"#, + ); + let overlay = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + assert_eq!(base, expected); + + let config: ConfigToml = base.try_into().expect("merged config should deserialize"); + assert_eq!( + config.memories, + Some(MemoriesToml { + disable_on_external_context: Some(true), + ..Default::default() + }) + ); +} + +#[test] +fn merge_toml_values_normalizes_legacy_key_from_overlay_layer() { + let mut base = parse_toml( + r#" +[memories] +disable_on_external_context = false +"#, + ); + let overlay = parse_toml( + r#" +[memories] +no_memories_if_mcp_or_web_search = true +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + assert_eq!(base, expected); + + let config: ConfigToml = base.try_into().expect("merged config should deserialize"); + assert_eq!( + config.memories, + Some(MemoriesToml { + disable_on_external_context: Some(true), + ..Default::default() + }) + ); +} + +#[test] +fn merge_toml_values_prefers_canonical_key_when_one_layer_has_both_names() { + let mut base = TomlValue::Table(toml::map::Map::new()); + let overlay = parse_toml( + r#" +[memories] +disable_on_external_context = true +no_memories_if_mcp_or_web_search = false +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + assert_eq!(base, expected); +} diff --git a/code-rs/config/src/overrides.rs b/code-rs/config/src/overrides.rs new file mode 100644 index 00000000000..a27caedb9a6 --- /dev/null +++ b/code-rs/config/src/overrides.rs @@ -0,0 +1,55 @@ +use toml::Value as TomlValue; + +pub(crate) fn default_empty_table() -> TomlValue { + TomlValue::Table(Default::default()) +} + +pub fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue { + let mut root = default_empty_table(); + for (path, value) in cli_overrides { + apply_toml_override(&mut root, path, value.clone()); + } + root +} + +/// Apply a single dotted-path override onto a TOML value. +fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { + use toml::value::Table; + + let mut current = root; + let mut segments_iter = path.split('.').peekable(); + + while let Some(segment) = segments_iter.next() { + let is_last = segments_iter.peek().is_none(); + + if is_last { + match current { + TomlValue::Table(table) => { + table.insert(segment.to_string(), value); + } + _ => { + let mut table = Table::new(); + table.insert(segment.to_string(), value); + *current = TomlValue::Table(table); + } + } + return; + } + + match current { + TomlValue::Table(table) => { + current = table + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + _ => { + *current = TomlValue::Table(Table::new()); + if let TomlValue::Table(tbl) = current { + current = tbl + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + } + } + } +} diff --git a/code-rs/config/src/permissions_toml.rs b/code-rs/config/src/permissions_toml.rs new file mode 100644 index 00000000000..cee68d7abba --- /dev/null +++ b/code-rs/config/src/permissions_toml.rs @@ -0,0 +1,244 @@ +use std::collections::BTreeMap; + +use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; +use codex_network_proxy::NetworkMode; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; +use codex_network_proxy::normalize_host; +use codex_protocol::permissions::FileSystemAccessMode; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct PermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl PermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileToml { + pub filesystem: Option, + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct FilesystemPermissionsToml { + /// Optional maximum depth for expanding unreadable glob patterns on + /// platforms that snapshot glob matches before sandbox startup. + #[schemars(range(min = 1))] + pub glob_scan_max_depth: Option, + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FilesystemPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum FilesystemPermissionToml { + Access(FileSystemAccessMode), + Scoped(BTreeMap), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkToml { + pub enabled: Option, + pub proxy_url: Option, + pub enable_socks5: Option, + pub socks_url: Option, + pub enable_socks5_udp: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + #[schemars(with = "Option")] + pub mode: Option, + pub domains: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum NetworkModeSchema { + Limited, + Full, +} + +impl NetworkToml { + pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { + if let Some(enabled) = self.enabled { + config.network.enabled = enabled; + } + if let Some(proxy_url) = self.proxy_url.as_ref() { + config.network.proxy_url = proxy_url.clone(); + } + if let Some(enable_socks5) = self.enable_socks5 { + config.network.enable_socks5 = enable_socks5; + } + if let Some(socks_url) = self.socks_url.as_ref() { + config.network.socks_url = socks_url.clone(); + } + if let Some(enable_socks5_udp) = self.enable_socks5_udp { + config.network.enable_socks5_udp = enable_socks5_udp; + } + if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { + config.network.allow_upstream_proxy = allow_upstream_proxy; + } + if let Some(dangerously_allow_non_loopback_proxy) = + self.dangerously_allow_non_loopback_proxy + { + config.network.dangerously_allow_non_loopback_proxy = + dangerously_allow_non_loopback_proxy; + } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } + if let Some(mode) = self.mode { + config.network.mode = mode; + } + if let Some(domains) = self.domains.as_ref() { + overlay_network_domain_permissions(config, domains); + } + if let Some(unix_sockets) = self.unix_sockets.as_ref() { + let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default(); + for (path, permission) in &unix_sockets.entries { + let permission = match permission { + NetworkUnixSocketPermissionToml::Allow => { + ProxyNetworkUnixSocketPermission::Allow + } + NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, + }; + proxy_unix_sockets.entries.insert(path.clone(), permission); + } + config.network.unix_sockets = + (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); + } + if let Some(allow_local_binding) = self.allow_local_binding { + config.network.allow_local_binding = allow_local_binding; + } + } + + pub fn to_network_proxy_config(&self) -> NetworkProxyConfig { + let mut config = NetworkProxyConfig::default(); + self.apply_to_network_proxy_config(&mut config); + config + } +} + +pub fn overlay_network_domain_permissions( + config: &mut NetworkProxyConfig, + domains: &NetworkDomainPermissionsToml, +) { + for (pattern, permission) in &domains.entries { + let permission = match permission { + NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, + NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, + }; + config + .network + .upsert_domain_permission(pattern.clone(), permission, normalize_host); + } +} diff --git a/code-rs/config/src/plugin_edit.rs b/code-rs/config/src/plugin_edit.rs new file mode 100644 index 00000000000..63795cc6c63 --- /dev/null +++ b/code-rs/config/src/plugin_edit.rs @@ -0,0 +1,307 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use codex_utils_path::resolve_symlink_write_paths; +use codex_utils_path::write_atomically; +use tokio::task; +use toml_edit::DocumentMut; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use toml_edit::value; + +use crate::CONFIG_TOML_FILE; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginConfigEdit { + SetEnabled { plugin_key: String, enabled: bool }, + Clear { plugin_key: String }, +} + +pub async fn set_user_plugin_enabled( + codex_home: &Path, + plugin_key: String, + enabled: bool, +) -> std::io::Result<()> { + apply_user_plugin_config_edits( + codex_home, + vec![PluginConfigEdit::SetEnabled { + plugin_key, + enabled, + }], + ) + .await +} + +pub async fn clear_user_plugin(codex_home: &Path, plugin_key: String) -> std::io::Result<()> { + apply_user_plugin_config_edits(codex_home, vec![PluginConfigEdit::Clear { plugin_key }]).await +} + +pub async fn apply_user_plugin_config_edits( + codex_home: &Path, + edits: Vec, +) -> std::io::Result<()> { + let codex_home = codex_home.to_path_buf(); + task::spawn_blocking(move || apply_user_plugin_config_edits_blocking(&codex_home, edits)) + .await + .map_err(|err| std::io::Error::other(format!("config persistence task panicked: {err}")))? +} + +fn apply_user_plugin_config_edits_blocking( + codex_home: &Path, + edits: Vec, +) -> std::io::Result<()> { + if edits.is_empty() { + return Ok(()); + } + + let config_path = codex_home.join(CONFIG_TOML_FILE); + let write_paths = resolve_symlink_write_paths(&config_path)?; + let mut doc = read_or_create_document(write_paths.read_path.as_deref())?; + let mut mutated = false; + for edit in edits { + mutated |= match edit { + PluginConfigEdit::SetEnabled { + plugin_key, + enabled, + } => set_plugin_enabled(&mut doc, &plugin_key, enabled), + PluginConfigEdit::Clear { plugin_key } => clear_plugin(&mut doc, &plugin_key), + }; + } + if !mutated { + return Ok(()); + } + write_atomically(&write_paths.write_path, &doc.to_string()) +} + +fn read_or_create_document(config_path: Option<&Path>) -> std::io::Result { + let Some(config_path) = config_path else { + return Ok(DocumentMut::new()); + }; + match fs::read_to_string(config_path) { + Ok(raw) => raw + .parse::() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()), + Err(err) => Err(err), + } +} + +fn set_plugin_enabled(doc: &mut DocumentMut, plugin_key: &str, enabled: bool) -> bool { + let Some(plugins) = ensure_plugins_table(doc) else { + return false; + }; + let Some(plugin) = ensure_table_for_write(&mut plugins[plugin_key]) else { + return false; + }; + let mut replacement = value(enabled); + if let Some(existing) = plugin.get("enabled") { + preserve_decor(existing, &mut replacement); + } + plugin["enabled"] = replacement; + true +} + +fn clear_plugin(doc: &mut DocumentMut, plugin_key: &str) -> bool { + let root = doc.as_table_mut(); + let Some(plugins_item) = root.get_mut("plugins") else { + return false; + }; + let Some(plugins) = ensure_table_for_read(plugins_item) else { + return false; + }; + plugins.remove(plugin_key).is_some() +} + +fn ensure_plugins_table(doc: &mut DocumentMut) -> Option<&mut TomlTable> { + let root = doc.as_table_mut(); + if !root.contains_key("plugins") { + root.insert("plugins", TomlItem::Table(new_implicit_table())); + } + ensure_table_for_write(root.get_mut("plugins")?) +} + +fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(table) => Some(table), + TomlItem::Value(value) => { + let table = value + .as_inline_table() + .map_or_else(new_implicit_table, table_from_inline); + *item = TomlItem::Table(table); + item.as_table_mut() + } + TomlItem::None => { + *item = TomlItem::Table(new_implicit_table()); + item.as_table_mut() + } + _ => None, + } +} + +fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(_) => {} + TomlItem::Value(value) => { + let inline = value.as_inline_table()?.clone(); + *item = TomlItem::Table(table_from_inline(&inline)); + } + _ => return None, + } + item.as_table_mut() +} + +fn table_from_inline(inline: &toml_edit::InlineTable) -> TomlTable { + let mut table = new_implicit_table(); + for (key, value) in inline.iter() { + let mut value = value.clone(); + value.decor_mut().set_suffix(""); + table.insert(key, TomlItem::Value(value)); + } + table +} + +fn new_implicit_table() -> TomlTable { + let mut table = TomlTable::new(); + table.set_implicit(true); + table +} + +fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) { + if let (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) = + (existing, replacement) + { + replacement_value + .decor_mut() + .clone_from(existing_value.decor()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[tokio::test] + async fn set_user_plugin_enabled_writes_plugin_entry() { + let codex_home = TempDir::new().unwrap(); + + set_user_plugin_enabled( + codex_home.path(), + "demo@market".to_string(), + /*enabled*/ true, + ) + .await + .unwrap(); + + let config = read_config(codex_home.path()); + let expected: toml::Value = toml::from_str( + r#" +[plugins."demo@market"] +enabled = true + "#, + ) + .unwrap(); + assert_eq!(config, expected); + } + + #[tokio::test] + async fn set_user_plugin_enabled_preserves_existing_plugin_fields() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[plugins."demo@market"] +enabled = false +source = "/tmp/plugin" +"#, + ) + .unwrap(); + + set_user_plugin_enabled( + codex_home.path(), + "demo@market".to_string(), + /*enabled*/ true, + ) + .await + .unwrap(); + + let config = read_config(codex_home.path()); + let expected: toml::Value = toml::from_str( + r#" +[plugins."demo@market"] +enabled = true +source = "/tmp/plugin" + "#, + ) + .unwrap(); + assert_eq!(config, expected); + } + + #[tokio::test] + async fn clear_user_plugin_removes_empty_plugins_table() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[plugins."demo@market"] +enabled = true +"#, + ) + .unwrap(); + + clear_user_plugin(codex_home.path(), "demo@market".to_string()) + .await + .unwrap(); + + assert_eq!( + fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap(), + "" + ); + } + + #[tokio::test] + async fn clear_user_plugin_missing_entry_does_not_create_config() { + let codex_home = TempDir::new().unwrap(); + + clear_user_plugin(codex_home.path(), "demo@market".to_string()) + .await + .unwrap(); + + assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists()); + } + + #[tokio::test] + #[cfg(unix)] + async fn set_user_plugin_enabled_follows_config_symlink() { + use std::os::unix::fs::symlink; + + let codex_home = TempDir::new().unwrap(); + let target_path = codex_home.path().join("target_config.toml"); + symlink(&target_path, codex_home.path().join(CONFIG_TOML_FILE)).unwrap(); + + set_user_plugin_enabled( + codex_home.path(), + "demo@market".to_string(), + /*enabled*/ true, + ) + .await + .unwrap(); + + let config = + toml::from_str::(&fs::read_to_string(target_path).unwrap()).unwrap(); + let expected: toml::Value = toml::from_str( + r#" +[plugins."demo@market"] +enabled = true + "#, + ) + .unwrap(); + assert_eq!(config, expected); + } + + fn read_config(codex_home: &Path) -> toml::Value { + toml::from_str(&fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).unwrap()).unwrap() + } +} diff --git a/code-rs/config/src/profile_toml.rs b/code-rs/config/src/profile_toml.rs new file mode 100644 index 00000000000..fab78a128c3 --- /dev/null +++ b/code-rs/config/src/profile_toml.rs @@ -0,0 +1,102 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +use crate::config_toml::ToolsToml; +use crate::types::AnalyticsConfigToml; +use crate::types::ApprovalsReviewer; +use crate::types::Personality; +use crate::types::SessionPickerViewMode; +use crate::types::WindowsToml; +use codex_features::FeaturesToml; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; + +/// Collection of common configuration options that a user can define as a unit +/// in `config.toml`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigProfile { + pub model: Option, + /// Optional explicit service tier preference for new turns (`fast` or `flex`). + pub service_tier: Option, + /// The key in the `model_providers` map identifying the + /// [`ModelProviderInfo`] to use. + pub model_provider: Option, + pub approval_policy: Option, + pub approvals_reviewer: Option, + pub sandbox_mode: Option, + pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + /// Optional path to a JSON model catalog (applied on startup only). + pub model_catalog_json: Option, + pub personality: Option, + pub chatgpt_base_url: Option, + /// Optional path to a file containing model instructions. + pub model_instructions_file: Option, + /// Deprecated: ignored. + #[schemars(skip)] + pub js_repl_node_path: Option, + /// Deprecated: ignored. + #[schemars(skip)] + pub js_repl_node_module_dirs: Option>, + /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. + pub zsh_path: Option, + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] + pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, + pub include_apply_patch_tool: Option, + pub include_permissions_instructions: Option, + pub include_apps_instructions: Option, + pub include_environment_context: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + pub tools_view_image: Option, + pub tools: Option, + pub web_search: Option, + pub analytics: Option, + /// TUI settings scoped to this profile. + #[serde(default)] + pub tui: Option, + #[serde(default)] + pub windows: Option, + /// Optional feature toggles scoped to this profile. + #[serde(default)] + // Injects known feature keys into the schema and forbids unknown keys. + #[schemars(schema_with = "crate::schema::features_schema")] + pub features: Option, + pub oss_provider: Option, +} + +/// TUI settings supported inside a named profile. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct ProfileTui { + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, +} + +impl From for codex_app_server_protocol::Profile { + fn from(config_profile: ConfigProfile) -> Self { + Self { + model: config_profile.model, + model_provider: config_profile.model_provider, + approval_policy: config_profile.approval_policy, + model_reasoning_effort: config_profile.model_reasoning_effort, + model_reasoning_summary: config_profile.model_reasoning_summary, + model_verbosity: config_profile.model_verbosity, + chatgpt_base_url: config_profile.chatgpt_base_url, + } + } +} diff --git a/code-rs/config/src/project_root_markers.rs b/code-rs/config/src/project_root_markers.rs new file mode 100644 index 00000000000..3061dacc6b7 --- /dev/null +++ b/code-rs/config/src/project_root_markers.rs @@ -0,0 +1,50 @@ +use std::io; + +use toml::Value as TomlValue; + +const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; + +/// Reads `project_root_markers` from a merged `config.toml` [toml::Value]. +/// +/// Invariants: +/// - If `project_root_markers` is not specified, returns `Ok(None)`. +/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where +/// `markers` is a `Vec` (including `Ok(Some(Vec::new()))` for an +/// empty array, which indicates that root detection should be disabled). +/// - Returns an error if `project_root_markers` is specified but is not an +/// array of strings. +pub fn project_root_markers_from_config(config: &TomlValue) -> io::Result>> { + let Some(table) = config.as_table() else { + return Ok(None); + }; + let Some(markers_value) = table.get("project_root_markers") else { + return Ok(None); + }; + let TomlValue::Array(entries) = markers_value else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + if entries.is_empty() { + return Ok(Some(Vec::new())); + } + let mut markers = Vec::new(); + for entry in entries { + let Some(marker) = entry.as_str() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + markers.push(marker.to_string()); + } + Ok(Some(markers)) +} + +pub fn default_project_root_markers() -> Vec { + DEFAULT_PROJECT_ROOT_MARKERS + .iter() + .map(ToString::to_string) + .collect() +} diff --git a/code-rs/config/src/requirements_exec_policy.rs b/code-rs/config/src/requirements_exec_policy.rs new file mode 100644 index 00000000000..95f02b249fa --- /dev/null +++ b/code-rs/config/src/requirements_exec_policy.rs @@ -0,0 +1,236 @@ +use codex_execpolicy::Decision; +use codex_execpolicy::Policy; +use codex_execpolicy::RuleRef; +use codex_execpolicy::rule::PatternToken; +use codex_execpolicy::rule::PrefixPattern; +use codex_execpolicy::rule::PrefixRule; +use multimap::MultiMap; +use serde::Deserialize; +use std::sync::Arc; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct RequirementsExecPolicy { + policy: Policy, +} + +impl RequirementsExecPolicy { + pub fn new(policy: Policy) -> Self { + Self { policy } + } +} + +impl PartialEq for RequirementsExecPolicy { + fn eq(&self, other: &Self) -> bool { + policy_fingerprint(&self.policy) == policy_fingerprint(&other.policy) + } +} + +impl Eq for RequirementsExecPolicy {} + +impl AsRef for RequirementsExecPolicy { + fn as_ref(&self) -> &Policy { + &self.policy + } +} + +fn policy_fingerprint(policy: &Policy) -> Vec { + let mut entries = Vec::new(); + for (program, rules) in policy.rules().iter_all() { + for rule in rules { + entries.push(format!("{program}:{rule:?}")); + } + } + entries.sort(); + entries +} + +/// TOML representation of `[rules]` within `requirements.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyToml { + pub prefix_rules: Vec, +} + +/// A TOML representation of the `prefix_rule(...)` Starlark builtin. +/// +/// This mirrors the builtin defined in `execpolicy/src/parser.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyPrefixRuleToml { + pub pattern: Vec, + pub decision: Option, + pub justification: Option, +} + +/// TOML-friendly representation of a pattern token. +/// +/// Starlark supports either a string token or a list of alternative tokens at +/// each position, but TOML arrays cannot mix strings and arrays. Using an +/// array of tables sidesteps that restriction. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyPatternTokenToml { + pub token: Option, + pub any_of: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementsExecPolicyDecisionToml { + Allow, + Prompt, + Forbidden, +} + +impl RequirementsExecPolicyDecisionToml { + fn as_decision(self) -> Decision { + match self { + Self::Allow => Decision::Allow, + Self::Prompt => Decision::Prompt, + Self::Forbidden => Decision::Forbidden, + } + } +} + +#[derive(Debug, Error)] +pub enum RequirementsExecPolicyParseError { + #[error("rules prefix_rules cannot be empty")] + EmptyPrefixRules, + + #[error("rules prefix_rule at index {rule_index} has an empty pattern")] + EmptyPattern { rule_index: usize }, + + #[error( + "rules prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}" + )] + InvalidPatternToken { + rule_index: usize, + token_index: usize, + reason: String, + }, + + #[error("rules prefix_rule at index {rule_index} has an empty justification")] + EmptyJustification { rule_index: usize }, + + #[error("rules prefix_rule at index {rule_index} is missing a decision")] + MissingDecision { rule_index: usize }, + + #[error( + "rules prefix_rule at index {rule_index} has decision 'allow', which is not permitted in requirements.toml: Codex merges these rules with other config and uses the most restrictive result (use 'prompt' or 'forbidden')" + )] + AllowDecisionNotAllowed { rule_index: usize }, +} + +impl RequirementsExecPolicyToml { + /// Convert requirements TOML rules into the internal `.rules` + /// representation used by `codex-execpolicy`. + pub fn to_policy(&self) -> Result { + if self.prefix_rules.is_empty() { + return Err(RequirementsExecPolicyParseError::EmptyPrefixRules); + } + + let mut rules_by_program: MultiMap = MultiMap::new(); + + for (rule_index, rule) in self.prefix_rules.iter().enumerate() { + if let Some(justification) = &rule.justification + && justification.trim().is_empty() + { + return Err(RequirementsExecPolicyParseError::EmptyJustification { rule_index }); + } + + if rule.pattern.is_empty() { + return Err(RequirementsExecPolicyParseError::EmptyPattern { rule_index }); + } + + let pattern_tokens = rule + .pattern + .iter() + .enumerate() + .map(|(token_index, token)| parse_pattern_token(token, rule_index, token_index)) + .collect::, _>>()?; + + let decision = match rule.decision { + Some(RequirementsExecPolicyDecisionToml::Allow) => { + return Err(RequirementsExecPolicyParseError::AllowDecisionNotAllowed { + rule_index, + }); + } + Some(decision) => decision.as_decision(), + None => { + return Err(RequirementsExecPolicyParseError::MissingDecision { rule_index }); + } + }; + let justification = rule.justification.clone(); + + let (first_token, remaining_tokens) = pattern_tokens + .split_first() + .ok_or(RequirementsExecPolicyParseError::EmptyPattern { rule_index })?; + + let rest: Arc<[PatternToken]> = remaining_tokens.to_vec().into(); + + for head in first_token.alternatives() { + let rule: RuleRef = Arc::new(PrefixRule { + pattern: PrefixPattern { + first: Arc::from(head.as_str()), + rest: rest.clone(), + }, + decision, + justification: justification.clone(), + }); + rules_by_program.insert(head.clone(), rule); + } + } + + Ok(Policy::new(rules_by_program)) + } + + pub(crate) fn to_requirements_policy( + &self, + ) -> Result { + self.to_policy().map(RequirementsExecPolicy::new) + } +} + +fn parse_pattern_token( + token: &RequirementsExecPolicyPatternTokenToml, + rule_index: usize, + token_index: usize, +) -> Result { + match (&token.token, &token.any_of) { + (Some(single), None) => { + if single.trim().is_empty() { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "token cannot be empty".to_string(), + }); + } + Ok(PatternToken::Single(single.clone())) + } + (None, Some(alternatives)) => { + if alternatives.is_empty() { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "any_of cannot be empty".to_string(), + }); + } + if alternatives.iter().any(|alt| alt.trim().is_empty()) { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "any_of cannot include empty tokens".to_string(), + }); + } + Ok(PatternToken::Alts(alternatives.clone())) + } + (Some(_), Some(_)) => Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "set either token or any_of, not both".to_string(), + }), + (None, None) => Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "set either token or any_of".to_string(), + }), + } +} diff --git a/code-rs/config/src/schema.rs b/code-rs/config/src/schema.rs new file mode 100644 index 00000000000..715822fbfe5 --- /dev/null +++ b/code-rs/config/src/schema.rs @@ -0,0 +1,118 @@ +use crate::config_toml::ConfigToml; +use crate::types::RawMcpServerConfig; +use codex_features::FEATURES; +use codex_features::legacy_feature_keys; +use schemars::r#gen::SchemaGenerator; +use schemars::r#gen::SchemaSettings; +use schemars::schema::InstanceType; +use schemars::schema::ObjectValidation; +use schemars::schema::RootSchema; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde_json::Map; +use serde_json::Value; +use std::path::Path; + +/// Schema for the `[features]` map with known + legacy keys only. +pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let mut validation = ObjectValidation::default(); + for feature in FEATURES { + if feature.id == codex_features::Feature::Artifact { + continue; + } + if feature.id == codex_features::Feature::MultiAgentV2 { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::AppsMcpPathOverride { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + validation + .properties + .insert(feature.key.to_string(), schema_gen.subschema_for::()); + } + for legacy_key in legacy_feature_keys() { + validation + .properties + .insert(legacy_key.to_string(), schema_gen.subschema_for::()); + } + validation.additional_properties = Some(Box::new(Schema::Bool(false))); + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Schema for the `[mcp_servers]` map using the raw input shape. +pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let validation = ObjectValidation { + additional_properties: Some(Box::new(schema_gen.subschema_for::())), + ..Default::default() + }; + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Build the config schema for `config.toml`. +pub fn config_schema() -> RootSchema { + SchemaSettings::draft07() + .with(|settings| { + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::() +} + +/// Canonicalize a JSON value by sorting its keys. +pub fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + Value::Object(map) => { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut sorted = Map::with_capacity(map.len()); + for (key, child) in entries { + sorted.insert(key.clone(), canonicalize(child)); + } + Value::Object(sorted) + } + _ => value.clone(), + } +} + +/// Render the config schema as pretty-printed JSON. +pub fn config_schema_json() -> anyhow::Result> { + let schema = config_schema(); + let value = serde_json::to_value(schema)?; + let value = canonicalize(&value); + let json = serde_json::to_vec_pretty(&value)?; + Ok(json) +} + +/// Write the config schema fixture to disk. +pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { + let json = config_schema_json()?; + std::fs::write(out_path, json)?; + Ok(()) +} diff --git a/code-rs/config/src/skills_config.rs b/code-rs/config/src/skills_config.rs new file mode 100644 index 00000000000..948b9060c48 --- /dev/null +++ b/code-rs/config/src/skills_config.rs @@ -0,0 +1,57 @@ +//! Skill-related configuration types shared across crates. + +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillConfig { + /// Path-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Name-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillsConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundled: Option, + + /// Whether turns receive the automatic skills instructions block. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_instructions: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct BundledSkillsConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +impl Default for BundledSkillsConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +impl TryFrom for SkillsConfig { + type Error = toml::de::Error; + + fn try_from(value: toml::Value) -> Result { + SkillsConfig::deserialize(value) + } +} diff --git a/code-rs/config/src/state.rs b/code-rs/config/src/state.rs new file mode 100644 index 00000000000..c409b404d0e --- /dev/null +++ b/code-rs/config/src/state.rs @@ -0,0 +1,416 @@ +use crate::config_requirements::ConfigRequirements; +use crate::config_requirements::ConfigRequirementsToml; + +use super::fingerprint::record_origins; +use super::fingerprint::version_for_toml; +use super::key_aliases::normalized_with_key_aliases; +use super::merge::merge_toml_values; +use codex_app_server_protocol::ConfigLayer; +use codex_app_server_protocol::ConfigLayerMetadata; +use codex_app_server_protocol::ConfigLayerSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use toml::Value as TomlValue; + +/// LoaderOverrides overrides managed configuration inputs (primarily for tests). +#[derive(Debug, Default, Clone)] +pub struct LoaderOverrides { + pub managed_config_path: Option, + pub system_config_path: Option, + pub system_requirements_path: Option, + pub ignore_managed_requirements: bool, + pub ignore_user_config: bool, + pub ignore_user_and_project_exec_policy_rules: bool, + //TODO(gt): Add a macos_ prefix to this field and remove the target_os check. + #[cfg(target_os = "macos")] + pub managed_preferences_base64: Option, + pub macos_managed_config_requirements_base64: Option, +} + +impl LoaderOverrides { + /// Returns overrides that ignore host-managed configuration. + /// + /// This is intended for tests that should load only repo-controlled config fixtures. + pub fn without_managed_config_for_tests() -> Self { + let base = std::env::temp_dir().join("codex-config-tests"); + Self { + managed_config_path: Some(base.join("managed_config.toml")), + system_config_path: Some(base.join("config.toml")), + system_requirements_path: Some(base.join("requirements.toml")), + ignore_managed_requirements: false, + ignore_user_config: false, + ignore_user_and_project_exec_policy_rules: false, + #[cfg(target_os = "macos")] + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some(String::new()), + } + } + + /// Returns overrides with host MDM disabled and managed config loaded from `managed_config_path`. + /// + /// This is intended for tests that supply an explicit managed config fixture. + pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self { + Self { + managed_config_path: Some(managed_config_path), + ..Self::without_managed_config_for_tests() + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigLayerEntry { + pub name: ConfigLayerSource, + pub config: TomlValue, + pub raw_toml: Option, + pub version: String, + pub disabled_reason: Option, +} + +impl ConfigLayerEntry { + pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + raw_toml: None, + version, + disabled_reason: None, + } + } + + pub fn new_with_raw_toml(name: ConfigLayerSource, config: TomlValue, raw_toml: String) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + raw_toml: Some(raw_toml), + version, + disabled_reason: None, + } + } + + pub fn new_disabled( + name: ConfigLayerSource, + config: TomlValue, + disabled_reason: impl Into, + ) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + raw_toml: None, + version, + disabled_reason: Some(disabled_reason.into()), + } + } + + pub fn is_disabled(&self) -> bool { + self.disabled_reason.is_some() + } + + pub fn raw_toml(&self) -> Option<&str> { + self.raw_toml.as_deref() + } + + pub fn metadata(&self) -> ConfigLayerMetadata { + ConfigLayerMetadata { + name: self.name.clone(), + version: self.version.clone(), + } + } + + pub fn as_layer(&self) -> ConfigLayer { + ConfigLayer { + name: self.name.clone(), + version: self.version.clone(), + config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null), + disabled_reason: self.disabled_reason.clone(), + } + } + + // Get the `.codex/` folder associated with this config layer, if any. + pub fn config_folder(&self) -> Option { + match &self.name { + ConfigLayerSource::Mdm { .. } => None, + ConfigLayerSource::System { file } => file.parent(), + ConfigLayerSource::User { file } => file.parent(), + ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), + ConfigLayerSource::SessionFlags => None, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None, + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigLayerStackOrdering { + LowestPrecedenceFirst, + HighestPrecedenceFirst, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ConfigLayerStack { + /// Layers are listed from lowest precedence (base) to highest (top), so + /// later entries in the Vec override earlier ones. + layers: Vec, + + /// Index into [layers] of the user config layer, if any. + user_layer_index: Option, + + /// Constraints that must be enforced when deriving a [Config] from the + /// layers. + requirements: ConfigRequirements, + + /// Raw requirements data as loaded from requirements.toml/MDM/legacy + /// sources. This preserves the original allow-lists so they can be + /// surfaced via APIs. + requirements_toml: ConfigRequirementsToml, + + /// Whether execpolicy should skip `.rules` files from user and project config-layer folders. + ignore_user_and_project_exec_policy_rules: bool, + + /// Startup warnings discovered while building this stack. + /// + /// `None` means the loader did not check for stack-level warnings, while + /// `Some(vec![])` means it checked and found nothing to report. + startup_warnings: Option>, +} + +impl ConfigLayerStack { + pub fn new( + layers: Vec, + requirements: ConfigRequirements, + requirements_toml: ConfigRequirementsToml, + ) -> std::io::Result { + let user_layer_index = verify_layer_ordering(&layers)?; + Ok(Self { + layers, + user_layer_index, + requirements, + requirements_toml, + ignore_user_and_project_exec_policy_rules: false, + startup_warnings: None, + }) + } + + pub fn with_user_and_project_exec_policy_rules_ignored( + mut self, + ignore_user_and_project_exec_policy_rules: bool, + ) -> Self { + self.ignore_user_and_project_exec_policy_rules = ignore_user_and_project_exec_policy_rules; + self + } + + pub fn ignore_user_and_project_exec_policy_rules(&self) -> bool { + self.ignore_user_and_project_exec_policy_rules + } + + pub(crate) fn with_startup_warnings(mut self, startup_warnings: Vec) -> Self { + self.startup_warnings = Some(startup_warnings); + self + } + + pub fn startup_warnings(&self) -> Option<&[String]> { + self.startup_warnings.as_deref() + } + + /// Returns the raw user config layer, if any. + /// + /// This does not merge other config layers or apply any requirements. + pub fn get_user_layer(&self) -> Option<&ConfigLayerEntry> { + self.user_layer_index + .and_then(|index| self.layers.get(index)) + } + + pub fn requirements(&self) -> &ConfigRequirements { + &self.requirements + } + + pub fn requirements_toml(&self) -> &ConfigRequirementsToml { + &self.requirements_toml + } + + /// Creates a new [ConfigLayerStack] using the specified values to inject a + /// "user layer" into the stack. If such a layer already exists, it is + /// replaced; otherwise, it is inserted into the stack at the appropriate + /// position based on precedence rules. + pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self { + self.with_user_layer(Some(ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_toml.clone(), + }, + user_config, + ))) + } + + /// Returns a new stack with the user layer copied from `other`, preserving + /// every non-user layer already present in this stack. + pub fn with_user_layer_from(&self, other: &Self) -> Self { + self.with_user_layer(other.get_user_layer().cloned()) + } + + fn with_user_layer(&self, user_layer: Option) -> Self { + let mut layers = self.layers.clone(); + let user_layer_index = match (self.user_layer_index, user_layer) { + (Some(index), Some(user_layer)) => { + layers[index] = user_layer; + Some(index) + } + (Some(index), None) => { + layers.remove(index); + None + } + (None, Some(user_layer)) => { + let user_layer_index = match layers + .iter() + .position(|layer| layer.name.precedence() > user_layer.name.precedence()) + { + Some(index) => { + layers.insert(index, user_layer); + index + } + None => { + layers.push(user_layer); + layers.len() - 1 + } + }; + Some(user_layer_index) + } + (None, None) => None, + }; + Self { + layers, + user_layer_index, + requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, + startup_warnings: self.startup_warnings.clone(), + } + } + + /// Returns the merged config-layer view. + /// + /// This only merges ordinary config layers and does not apply requirements + /// such as cloud requirements. + pub fn effective_config(&self) -> TomlValue { + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in self.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + merge_toml_values(&mut merged, &layer.config); + } + merged + } + + /// Returns field origins for the merged config-layer view. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn origins(&self) -> HashMap { + let mut origins = HashMap::new(); + let mut path = Vec::new(); + + for layer in self.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + let config = normalized_with_key_aliases(&layer.config, &[]); + record_origins(&config, &layer.metadata(), &mut path, &mut origins); + } + + origins + } + + /// Returns config layers from highest precedence to lowest precedence. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn layers_high_to_low(&self) -> Vec<&ConfigLayerEntry> { + self.get_layers( + ConfigLayerStackOrdering::HighestPrecedenceFirst, + /*include_disabled*/ false, + ) + } + + /// Returns config layers in the requested precedence order. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn get_layers( + &self, + ordering: ConfigLayerStackOrdering, + include_disabled: bool, + ) -> Vec<&ConfigLayerEntry> { + let mut layers: Vec<&ConfigLayerEntry> = self + .layers + .iter() + .filter(|layer| include_disabled || !layer.is_disabled()) + .collect(); + if ordering == ConfigLayerStackOrdering::HighestPrecedenceFirst { + layers.reverse(); + } + layers + } +} + +/// Ensures precedence ordering of config layers is correct. Returns the index +/// of the user config layer, if any (at most one should exist). +fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result> { + if !layers.iter().map(|layer| &layer.name).is_sorted() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "config layers are not in correct precedence order", + )); + } + + // The previous check ensured `layers` is sorted by precedence, so now we + // further verify that: + // 1. There is at most one user config layer. + // 2. Project layers are ordered from root to cwd. + let mut user_layer_index: Option = None; + let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None; + for (index, layer) in layers.iter().enumerate() { + if matches!(layer.name, ConfigLayerSource::User { .. }) { + if user_layer_index.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "multiple user config layers found", + )); + } + user_layer_index = Some(index); + } + + if let ConfigLayerSource::Project { + dot_codex_folder: current_project_dot_codex_folder, + } = &layer.name + { + if let Some(previous) = previous_project_dot_codex_folder { + let Some(parent) = previous.as_path().parent() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "project layer has no parent directory", + )); + }; + if previous == current_project_dot_codex_folder + || !current_project_dot_codex_folder + .as_path() + .ancestors() + .any(|ancestor| ancestor == parent) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "project layers are not ordered from root to cwd", + )); + } + } + previous_project_dot_codex_folder = Some(current_project_dot_codex_folder); + } + } + + Ok(user_layer_index) +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/code-rs/config/src/state_tests.rs b/code-rs/config/src/state_tests.rs new file mode 100644 index 00000000000..81e1fa63fca --- /dev/null +++ b/code-rs/config/src/state_tests.rs @@ -0,0 +1,34 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn origins_use_canonical_key_aliases() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str( + r#" +[memories] +no_memories_if_mcp_or_web_search = true +"#, + ) + .expect("config TOML should parse"), + ); + let metadata = layer.metadata(); + let stack = ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("single layer stack should be valid"); + + let origins = stack.origins(); + + assert_eq!( + origins.get("memories.disable_on_external_context"), + Some(&metadata) + ); + assert!( + !origins.contains_key("memories.no_memories_if_mcp_or_web_search"), + "legacy key should be canonicalized before origin recording" + ); +} diff --git a/code-rs/config/src/thread_config.rs b/code-rs/config/src/thread_config.rs new file mode 100644 index 00000000000..1b3ea8fe871 --- /dev/null +++ b/code-rs/config/src/thread_config.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; + +use async_trait::async_trait; +use codex_app_server_protocol::ConfigLayerSource; +use codex_model_provider_info::ModelProviderInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use thiserror::Error; +use toml::Value as TomlValue; + +use crate::ConfigLayerEntry; + +mod remote; + +pub use remote::RemoteThreadConfigLoader; + +/// Context available to implementations when loading thread-scoped config. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ThreadConfigContext { + pub thread_id: Option, + pub cwd: Option, +} + +/// Config values owned by the service that starts or manages the session. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SessionThreadConfig { + pub model_provider: Option, + pub model_providers: HashMap, + pub features: BTreeMap, +} + +/// Config values owned by the authenticated user. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UserThreadConfig {} + +/// A typed config payload paired with the authority that produced it. +#[derive(Clone, Debug, PartialEq)] +pub enum ThreadConfigSource { + Session(SessionThreadConfig), + User(UserThreadConfig), +} + +/// Stable category for failures returned while loading thread config. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ThreadConfigLoadErrorCode { + Auth, + Timeout, + Parse, + RequestFailed, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct ThreadConfigLoadError { + code: ThreadConfigLoadErrorCode, + message: String, + status_code: Option, +} + +impl ThreadConfigLoadError { + pub fn new( + code: ThreadConfigLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> ThreadConfigLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +/// Loads typed config sources for a new thread. +/// +/// Implementations should fetch only the source-specific config they own and +/// return typed payloads without applying precedence or merge rules. Callers +/// are responsible for resolving the returned sources into the effective +/// runtime config. +#[async_trait] +pub trait ThreadConfigLoader: Send + Sync { + /// Load source-specific typed config. + /// + /// Implementations should keep this method focused on fetching and parsing + /// their owned sources. Most callers should use [`Self::load_config_layers`] + /// so precedence and merging continue through the ordinary config layer + /// stack. + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError>; + + async fn load_config_layers( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + let sources = self.load(context).await?; + sources + .into_iter() + .map(thread_config_source_to_layer) + .collect::, _>>() + .map(|layers| layers.into_iter().flatten().collect()) + } +} + +/// Loader backed by a static set of typed thread config sources. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StaticThreadConfigLoader { + sources: Vec, +} + +impl StaticThreadConfigLoader { + pub fn new(sources: Vec) -> Self { + Self { sources } + } +} + +#[async_trait] +impl ThreadConfigLoader for StaticThreadConfigLoader { + async fn load( + &self, + _context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + Ok(self.sources.clone()) + } +} + +/// Loader used when no external thread config source is configured. +#[derive(Clone, Debug, Default)] +pub struct NoopThreadConfigLoader; + +#[async_trait] +impl ThreadConfigLoader for NoopThreadConfigLoader { + async fn load( + &self, + _context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + Ok(Vec::new()) + } +} + +fn thread_config_source_to_layer( + source: ThreadConfigSource, +) -> Result, ThreadConfigLoadError> { + match source { + ThreadConfigSource::Session(config) => { + let config = session_thread_config_to_toml(config)?; + if is_empty_table(&config) { + Ok(None) + } else { + Ok(Some(ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + config, + ))) + } + } + // UserThreadConfig has no TOML-backed fields yet. When it grows one, + // fold it into the existing user layer instead of adding another + // ConfigLayerSource variant. + ThreadConfigSource::User(_config) => Ok(None), + } +} + +fn is_empty_table(config: &TomlValue) -> bool { + config.as_table().is_some_and(toml::map::Map::is_empty) +} + +fn session_thread_config_to_toml( + config: SessionThreadConfig, +) -> Result { + let mut table = toml::map::Map::new(); + + if let Some(model_provider) = config.model_provider { + table.insert( + "model_provider".to_string(), + TomlValue::String(model_provider), + ); + } + + if !config.model_providers.is_empty() { + let model_providers = TomlValue::try_from(config.model_providers).map_err(|err| { + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Parse, + /*status_code*/ None, + format!("failed to convert session model providers to config TOML: {err}"), + ) + })?; + table.insert("model_providers".to_string(), model_providers); + } + + if !config.features.is_empty() { + let features = config + .features + .into_iter() + .map(|(feature, enabled)| (feature, TomlValue::Boolean(enabled))) + .collect(); + table.insert("features".to_string(), TomlValue::Table(features)); + } + + Ok(TomlValue::Table(table)) +} + +#[cfg(test)] +mod tests { + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use pretty_assertions::assert_eq; + + use super::*; + + #[tokio::test] + async fn loader_returns_session_and_user_sources() { + let loader = StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ]); + + let sources = loader + .load(ThreadConfigContext { + thread_id: Some("thread-1".to_string()), + ..Default::default() + }) + .await + .expect("thread config loads"); + + assert_eq!( + sources, + vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ] + ); + } + + #[tokio::test] + async fn loader_translates_sources_to_config_layers() { + let loader = StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::User(UserThreadConfig::default()), + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ]); + let layers = loader + .load_config_layers(ThreadConfigContext { + cwd: Some( + AbsolutePathBuf::from_absolute_path_checked( + std::env::temp_dir().join("project"), + ) + .expect("absolute cwd"), + ), + ..Default::default() + }) + .await + .expect("thread config layers load"); + + assert_eq!( + layers, + vec![ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + model_provider = "local" + + [model_providers.local] + name = "local" + base_url = "http://127.0.0.1:8061/api/codex" + wire_api = "responses" + requires_openai_auth = false + supports_websockets = true + + [features] + plugins = false + } + .into() + )] + ); + } + + fn test_provider(name: &str) -> ModelProviderInfo { + ModelProviderInfo { + name: name.to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + aws: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: true, + } + } +} diff --git a/code-rs/config/src/thread_config/proto/codex.thread_config.v1.proto b/code-rs/config/src/thread_config/proto/codex.thread_config.v1.proto new file mode 100644 index 00000000000..1efccfd1bfb --- /dev/null +++ b/code-rs/config/src/thread_config/proto/codex.thread_config.v1.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package codex.thread_config.v1; + +service ThreadConfigLoader { + rpc Load(LoadThreadConfigRequest) returns (LoadThreadConfigResponse); +} + +message LoadThreadConfigRequest { + optional string thread_id = 1; + optional string cwd = 2; +} + +message LoadThreadConfigResponse { + repeated ThreadConfigSource sources = 1; +} + +message ThreadConfigSource { + oneof source { + SessionThreadConfig session = 1; + UserThreadConfig user = 2; + } +} + +message SessionThreadConfig { + optional string model_provider = 1; + repeated ModelProvider model_providers = 2; + map features = 3; +} + +message UserThreadConfig {} + +message ModelProvider { + string id = 1; + string name = 2; + optional string base_url = 3; + optional string env_key = 4; + optional string env_key_instructions = 5; + optional string experimental_bearer_token = 6; + optional ModelProviderAuthInfo auth = 7; + WireApi wire_api = 8; + optional StringMap query_params = 9; + optional StringMap http_headers = 10; + optional StringMap env_http_headers = 11; + optional uint64 request_max_retries = 12; + optional uint64 stream_max_retries = 13; + optional uint64 stream_idle_timeout_ms = 14; + optional uint64 websocket_connect_timeout_ms = 15; + bool requires_openai_auth = 16; + bool supports_websockets = 17; +} + +message StringMap { + map values = 1; +} + +message ModelProviderAuthInfo { + string command = 1; + repeated string args = 2; + uint64 timeout_ms = 3; + uint64 refresh_interval_ms = 4; + string cwd = 5; +} + +enum WireApi { + WIRE_API_UNSPECIFIED = 0; + WIRE_API_RESPONSES = 1; +} diff --git a/code-rs/config/src/thread_config/proto/codex.thread_config.v1.rs b/code-rs/config/src/thread_config/proto/codex.thread_config.v1.rs new file mode 100644 index 00000000000..30a76bc6b2f --- /dev/null +++ b/code-rs/config/src/thread_config/proto/codex.thread_config.v1.rs @@ -0,0 +1,400 @@ +// This file is @generated by prost-build. +#![allow(clippy::trivially_copy_pass_by_ref)] + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LoadThreadConfigRequest { + #[prost(string, optional, tag = "1")] + pub thread_id: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "2")] + pub cwd: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LoadThreadConfigResponse { + #[prost(message, repeated, tag = "1")] + pub sources: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ThreadConfigSource { + #[prost(oneof = "thread_config_source::Source", tags = "1, 2")] + pub source: ::core::option::Option, +} +/// Nested message and enum types in `ThreadConfigSource`. +pub mod thread_config_source { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Source { + #[prost(message, tag = "1")] + Session(super::SessionThreadConfig), + #[prost(message, tag = "2")] + User(super::UserThreadConfig), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SessionThreadConfig { + #[prost(string, optional, tag = "1")] + pub model_provider: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag = "2")] + pub model_providers: ::prost::alloc::vec::Vec, + #[prost(map = "string, bool", tag = "3")] + pub features: ::std::collections::HashMap<::prost::alloc::string::String, bool>, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UserThreadConfig {} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ModelProvider { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub name: ::prost::alloc::string::String, + #[prost(string, optional, tag = "3")] + pub base_url: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "4")] + pub env_key: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "5")] + pub env_key_instructions: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "6")] + pub experimental_bearer_token: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag = "7")] + pub auth: ::core::option::Option, + #[prost(enumeration = "WireApi", tag = "8")] + pub wire_api: i32, + #[prost(message, optional, tag = "9")] + pub query_params: ::core::option::Option, + #[prost(message, optional, tag = "10")] + pub http_headers: ::core::option::Option, + #[prost(message, optional, tag = "11")] + pub env_http_headers: ::core::option::Option, + #[prost(uint64, optional, tag = "12")] + pub request_max_retries: ::core::option::Option, + #[prost(uint64, optional, tag = "13")] + pub stream_max_retries: ::core::option::Option, + #[prost(uint64, optional, tag = "14")] + pub stream_idle_timeout_ms: ::core::option::Option, + #[prost(uint64, optional, tag = "15")] + pub websocket_connect_timeout_ms: ::core::option::Option, + #[prost(bool, tag = "16")] + pub requires_openai_auth: bool, + #[prost(bool, tag = "17")] + pub supports_websockets: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct StringMap { + #[prost(map = "string, string", tag = "1")] + pub values: + ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ModelProviderAuthInfo { + #[prost(string, tag = "1")] + pub command: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "2")] + pub args: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(uint64, tag = "3")] + pub timeout_ms: u64, + #[prost(uint64, tag = "4")] + pub refresh_interval_ms: u64, + #[prost(string, tag = "5")] + pub cwd: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum WireApi { + Unspecified = 0, + Responses = 1, +} +impl WireApi { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "WIRE_API_UNSPECIFIED", + Self::Responses => "WIRE_API_RESPONSES", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "WIRE_API_UNSPECIFIED" => Some(Self::Unspecified), + "WIRE_API_RESPONSES" => Some(Self::Responses), + _ => None, + } + } +} +/// Generated client implementations. +pub mod thread_config_loader_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value + )] + use tonic::codegen::http::Uri; + use tonic::codegen::*; + #[derive(Debug, Clone)] + pub struct ThreadConfigLoaderClient { + inner: tonic::client::Grpc, + } + impl ThreadConfigLoaderClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl ThreadConfigLoaderClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> ThreadConfigLoaderClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + >>::Error: + Into + std::marker::Send + std::marker::Sync, + { + ThreadConfigLoaderClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn load( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> + { + self.inner.ready().await.map_err(|e| { + tonic::Status::unknown(format!("Service was not ready: {}", e.into())) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/codex.thread_config.v1.ThreadConfigLoader/Load", + ); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new( + "codex.thread_config.v1.ThreadConfigLoader", + "Load", + )); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod thread_config_loader_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with ThreadConfigLoaderServer. + #[async_trait] + pub trait ThreadConfigLoader: std::marker::Send + std::marker::Sync + 'static { + async fn load( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + } + #[derive(Debug)] + pub struct ThreadConfigLoaderServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl ThreadConfigLoaderServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor(inner: T, interceptor: F) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for ThreadConfigLoaderServer + where + T: ThreadConfigLoader, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/codex.thread_config.v1.ThreadConfigLoader/Load" => { + #[allow(non_camel_case_types)] + struct LoadSvc(pub Arc); + impl + tonic::server::UnaryService for LoadSvc + { + type Response = super::LoadThreadConfigResponse; + type Future = BoxFuture, tonic::Status>; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::load(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LoadSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => Box::pin(async move { + let mut response = http::Response::new(tonic::body::Body::default()); + let headers = response.headers_mut(); + headers.insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers.insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }), + } + } + } + impl Clone for ThreadConfigLoaderServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "codex.thread_config.v1.ThreadConfigLoader"; + impl tonic::server::NamedService for ThreadConfigLoaderServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/code-rs/config/src/thread_config/remote.rs b/code-rs/config/src/thread_config/remote.rs new file mode 100644 index 00000000000..7b7feacec5e --- /dev/null +++ b/code-rs/config/src/thread_config/remote.rs @@ -0,0 +1,523 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::num::NonZeroU64; +use std::time::Duration; + +use async_trait::async_trait; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::WireApi; +use codex_protocol::config_types::ModelProviderAuthInfo; +use codex_utils_absolute_path::AbsolutePathBuf; + +use super::SessionThreadConfig; +use super::ThreadConfigContext; +use super::ThreadConfigLoadError; +use super::ThreadConfigLoadErrorCode; +use super::ThreadConfigLoader; +use super::ThreadConfigSource; +use super::UserThreadConfig; +use proto::thread_config_loader_client::ThreadConfigLoaderClient; + +#[path = "proto/codex.thread_config.v1.rs"] +mod proto; + +const REMOTE_THREAD_CONFIG_LOAD_TIMEOUT: Duration = Duration::from_secs(5); + +/// gRPC-backed [`ThreadConfigLoader`] implementation. +#[derive(Clone, Debug)] +pub struct RemoteThreadConfigLoader { + endpoint: String, +} + +impl RemoteThreadConfigLoader { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + } + } + + async fn client( + &self, + ) -> Result, ThreadConfigLoadError> { + ThreadConfigLoaderClient::connect(self.endpoint.clone()) + .await + .map_err(|err| { + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::RequestFailed, + /*status_code*/ None, + format!("failed to connect to remote thread config loader: {err}"), + ) + }) + } +} + +#[async_trait] +impl ThreadConfigLoader for RemoteThreadConfigLoader { + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + let response = self + .client() + .await? + .load(load_thread_config_request(context)) + .await + .map_err(remote_status_to_error)? + .into_inner(); + + response + .sources + .into_iter() + .map(thread_config_source_from_proto) + .collect() + } +} + +fn load_thread_config_request( + context: ThreadConfigContext, +) -> tonic::Request { + let mut request = tonic::Request::new(proto::LoadThreadConfigRequest { + thread_id: context.thread_id, + cwd: context.cwd.map(|cwd| cwd.to_string_lossy().into_owned()), + }); + request.set_timeout(REMOTE_THREAD_CONFIG_LOAD_TIMEOUT); + request +} + +fn remote_status_to_error(status: tonic::Status) -> ThreadConfigLoadError { + let code = match status.code() { + tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => { + ThreadConfigLoadErrorCode::Auth + } + tonic::Code::DeadlineExceeded => ThreadConfigLoadErrorCode::Timeout, + tonic::Code::Ok + | tonic::Code::Cancelled + | tonic::Code::Unknown + | tonic::Code::InvalidArgument + | tonic::Code::NotFound + | tonic::Code::AlreadyExists + | tonic::Code::ResourceExhausted + | tonic::Code::FailedPrecondition + | tonic::Code::Aborted + | tonic::Code::OutOfRange + | tonic::Code::Unimplemented + | tonic::Code::Internal + | tonic::Code::Unavailable + | tonic::Code::DataLoss => ThreadConfigLoadErrorCode::RequestFailed, + }; + ThreadConfigLoadError::new( + code, + /*status_code*/ None, + format!("remote thread config request failed: {status}"), + ) +} + +fn thread_config_source_from_proto( + source: proto::ThreadConfigSource, +) -> Result { + match source.source { + Some(proto::thread_config_source::Source::Session(config)) => { + session_thread_config_from_proto(config).map(ThreadConfigSource::Session) + } + Some(proto::thread_config_source::Source::User(_)) => { + Ok(ThreadConfigSource::User(UserThreadConfig::default())) + } + None => Err(parse_error("remote thread config omitted source payload")), + } +} + +fn session_thread_config_from_proto( + config: proto::SessionThreadConfig, +) -> Result { + let model_providers = config + .model_providers + .into_iter() + .map(model_provider_from_proto) + .collect::, _>>()?; + + Ok(SessionThreadConfig { + model_provider: config.model_provider, + model_providers, + features: config.features.into_iter().collect::>(), + }) +} + +fn model_provider_from_proto( + provider: proto::ModelProvider, +) -> Result<(String, ModelProviderInfo), ThreadConfigLoadError> { + if provider.id.is_empty() { + return Err(parse_error( + "remote thread config returned model provider without an id", + )); + } + let id = provider.id; + let wire_api = match proto::WireApi::try_from(provider.wire_api) { + Ok(proto::WireApi::Responses) => WireApi::Responses, + Ok(proto::WireApi::Unspecified) => { + return Err(parse_error("remote thread config omitted wire_api")); + } + Err(_) => { + return Err(parse_error(format!( + "remote thread config returned unknown wire_api: {}", + provider.wire_api + ))); + } + }; + let info = ModelProviderInfo { + name: provider.name, + base_url: provider.base_url, + env_key: provider.env_key, + env_key_instructions: provider.env_key_instructions, + experimental_bearer_token: provider.experimental_bearer_token, + auth: provider + .auth + .map(model_provider_auth_from_proto) + .transpose()?, + aws: None, + wire_api, + query_params: provider.query_params.map(|map| map.values), + http_headers: provider.http_headers.map(|map| map.values), + env_http_headers: provider.env_http_headers.map(|map| map.values), + request_max_retries: provider.request_max_retries, + stream_max_retries: provider.stream_max_retries, + stream_idle_timeout_ms: provider.stream_idle_timeout_ms, + websocket_connect_timeout_ms: provider.websocket_connect_timeout_ms, + requires_openai_auth: provider.requires_openai_auth, + supports_websockets: provider.supports_websockets, + }; + Ok((id, info)) +} + +#[cfg(test)] +fn model_provider_to_proto( + id: impl Into, + provider: ModelProviderInfo, +) -> proto::ModelProvider { + let ModelProviderInfo { + name, + base_url, + env_key, + env_key_instructions, + experimental_bearer_token, + auth, + aws: _, + wire_api, + query_params, + http_headers, + env_http_headers, + request_max_retries, + stream_max_retries, + stream_idle_timeout_ms, + websocket_connect_timeout_ms, + requires_openai_auth, + supports_websockets, + } = provider; + + proto::ModelProvider { + id: id.into(), + name, + base_url, + env_key, + env_key_instructions, + experimental_bearer_token, + auth: auth.map(model_provider_auth_to_proto), + wire_api: proto_wire_api(wire_api).into(), + query_params: query_params.map(proto_string_map), + http_headers: http_headers.map(proto_string_map), + env_http_headers: env_http_headers.map(proto_string_map), + request_max_retries, + stream_max_retries, + stream_idle_timeout_ms, + websocket_connect_timeout_ms, + requires_openai_auth, + supports_websockets, + } +} + +fn model_provider_auth_from_proto( + auth: proto::ModelProviderAuthInfo, +) -> Result { + let timeout_ms = NonZeroU64::new(auth.timeout_ms) + .ok_or_else(|| parse_error("remote thread config returned zero auth timeout_ms"))?; + let cwd = AbsolutePathBuf::from_absolute_path_checked(&auth.cwd).map_err(|err| { + parse_error(format!( + "remote thread config returned invalid auth cwd {:?}: {err}", + auth.cwd + )) + })?; + + Ok(ModelProviderAuthInfo { + command: auth.command, + args: auth.args, + timeout_ms, + refresh_interval_ms: auth.refresh_interval_ms, + cwd, + }) +} + +#[cfg(test)] +fn model_provider_auth_to_proto(auth: ModelProviderAuthInfo) -> proto::ModelProviderAuthInfo { + let ModelProviderAuthInfo { + command, + args, + timeout_ms, + refresh_interval_ms, + cwd, + } = auth; + + proto::ModelProviderAuthInfo { + command, + args, + timeout_ms: timeout_ms.get(), + refresh_interval_ms, + cwd: cwd.to_string_lossy().into_owned(), + } +} + +#[cfg(test)] +fn proto_string_map(values: HashMap) -> proto::StringMap { + proto::StringMap { values } +} + +#[cfg(test)] +fn proto_wire_api(wire_api: WireApi) -> proto::WireApi { + match wire_api { + WireApi::Responses => proto::WireApi::Responses, + } +} + +fn parse_error(message: impl Into) -> ThreadConfigLoadError { + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Parse, + /*status_code*/ None, + message.into(), + ) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::collections::HashMap; + use std::num::NonZeroU64; + + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use codex_protocol::config_types::ModelProviderAuthInfo; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use tonic::Request; + use tonic::Response; + use tonic::Status; + use tonic::transport::Server; + + use super::proto::thread_config_loader_server; + use super::proto::thread_config_loader_server::ThreadConfigLoaderServer; + use super::*; + use crate::SessionThreadConfig; + use crate::UserThreadConfig; + + struct TestServer { + sources: Vec, + expected_cwd: String, + } + + #[tonic::async_trait] + impl thread_config_loader_server::ThreadConfigLoader for TestServer { + async fn load( + &self, + request: Request, + ) -> Result, Status> { + assert_eq!( + request.into_inner(), + proto::LoadThreadConfigRequest { + thread_id: Some("thread-1".to_string()), + cwd: Some(self.expected_cwd.clone()), + } + ); + + Ok(Response::new(proto::LoadThreadConfigResponse { + sources: self.sources.clone(), + })) + } + } + + #[tokio::test] + async fn load_thread_config_calls_remote_service() { + let cwd = workspace_dir().join("project"); + let expected_cwd = cwd.to_string_lossy().into_owned(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let addr = listener.local_addr().expect("test server addr"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + Server::builder() + .add_service(ThreadConfigLoaderServer::new(TestServer { + sources: proto_sources(), + expected_cwd, + })) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::TcpListenerStream::new(listener), + async { + let _ = shutdown_rx.await; + }, + ) + .await + }); + + let loader = RemoteThreadConfigLoader::new(format!("http://{addr}")); + let loaded = loader + .load(ThreadConfigContext { + thread_id: Some("thread-1".to_string()), + cwd: Some(cwd), + }) + .await; + + let _ = shutdown_tx.send(()); + server.await.expect("join server").expect("server"); + + assert_eq!(loaded.expect("load thread config"), expected_sources()); + } + + #[test] + fn load_thread_config_request_sets_timeout() { + let request = load_thread_config_request(ThreadConfigContext::default()); + + assert_eq!( + request + .metadata() + .get("grpc-timeout") + .and_then(|value| value.to_str().ok()), + Some("5000000u") + ); + } + + #[test] + fn model_provider_proto_roundtrips_through_domain_type() { + let expected = expected_provider(); + let proto = model_provider_to_proto("local", expected.clone()); + let (id, actual) = model_provider_from_proto(proto).expect("model provider from proto"); + + assert_eq!(id, "local"); + assert_eq!(actual, expected); + } + + fn proto_sources() -> Vec { + let workspace_cwd = workspace_dir().to_string_lossy().into_owned(); + vec![ + proto::ThreadConfigSource { + source: Some(proto::thread_config_source::Source::Session( + proto::SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: vec![proto::ModelProvider { + id: "local".to_string(), + name: "Local".to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: Some(proto::ModelProviderAuthInfo { + command: "token-helper".to_string(), + args: vec!["--json".to_string()], + timeout_ms: 5_000, + refresh_interval_ms: 300_000, + cwd: workspace_cwd, + }), + wire_api: proto::WireApi::Responses.into(), + query_params: Some(proto::StringMap { + values: HashMap::from([( + "api-version".to_string(), + "2026-04-16".to_string(), + )]), + }), + http_headers: Some(proto::StringMap { + values: HashMap::from([( + "X-Test".to_string(), + "enabled".to_string(), + )]), + }), + env_http_headers: Some(proto::StringMap { + values: HashMap::from([( + "X-Env".to_string(), + "LOCAL_HEADER".to_string(), + )]), + }), + request_max_retries: Some(7), + stream_max_retries: Some(8), + stream_idle_timeout_ms: Some(9_000), + websocket_connect_timeout_ms: Some(10_000), + requires_openai_auth: false, + supports_websockets: true, + }], + features: HashMap::from([ + ("plugins".to_string(), false), + ("tools".to_string(), true), + ]), + }, + )), + }, + proto::ThreadConfigSource { + source: Some(proto::thread_config_source::Source::User( + proto::UserThreadConfig {}, + )), + }, + ] + } + + fn expected_sources() -> Vec { + vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), expected_provider())]), + features: BTreeMap::from([ + ("plugins".to_string(), false), + ("tools".to_string(), true), + ]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ] + } + + fn expected_provider() -> ModelProviderInfo { + ModelProviderInfo { + name: "Local".to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: Some(ModelProviderAuthInfo { + command: "token-helper".to_string(), + args: vec!["--json".to_string()], + timeout_ms: NonZeroU64::new(5_000).expect("non-zero timeout"), + refresh_interval_ms: 300_000, + cwd: workspace_dir(), + }), + wire_api: WireApi::Responses, + query_params: Some(HashMap::from([( + "api-version".to_string(), + "2026-04-16".to_string(), + )])), + http_headers: Some(HashMap::from([( + "X-Test".to_string(), + "enabled".to_string(), + )])), + env_http_headers: Some(HashMap::from([( + "X-Env".to_string(), + "LOCAL_HEADER".to_string(), + )])), + request_max_retries: Some(7), + stream_max_retries: Some(8), + stream_idle_timeout_ms: Some(9_000), + websocket_connect_timeout_ms: Some(10_000), + requires_openai_auth: false, + supports_websockets: true, + aws: None, + } + } + + fn workspace_dir() -> AbsolutePathBuf { + AbsolutePathBuf::current_dir() + .expect("current dir") + .join("workspace") + } +} diff --git a/code-rs/config/src/tui_keymap.rs b/code-rs/config/src/tui_keymap.rs new file mode 100644 index 00000000000..3e8be83d6a4 --- /dev/null +++ b/code-rs/config/src/tui_keymap.rs @@ -0,0 +1,584 @@ +//! TUI keymap config schema and canonical key-spec normalization. +//! +//! This module defines the on-disk `[tui.keymap]` contract used by +//! `~/.codex/config.toml` and normalizes user-entered key specs into canonical +//! forms consumed by runtime keymap resolution in `codex-rs/tui/src/keymap.rs`. +//! +//! Responsibilities: +//! +//! 1. Define strongly typed config contexts/actions with unknown-field +//! rejection. +//! 2. Normalize accepted key aliases into canonical names. +//! 3. Reject malformed bindings early with user-facing diagnostics. +//! +//! Non-responsibilities: +//! +//! 1. Dispatch precedence and conflict validation. +//! 2. Input event matching at runtime. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; +use std::collections::BTreeMap; + +/// Normalized string representation of a single key event (for example `ctrl-a`). +/// +/// The parser accepts a small alias set (for example `escape` -> `esc`, +/// `pageup` -> `page-up`) and stores the canonical form. +/// +/// This deliberately represents one terminal key event, not a sequence of +/// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding +/// multi-step chords would require a separate runtime state machine. +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct KeybindingSpec(#[schemars(with = "String")] pub String); + +impl KeybindingSpec { + /// Returns the canonical key-spec string (for example `ctrl-a`). + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for KeybindingSpec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + let normalized = normalize_keybinding_spec(&raw).map_err(SerdeError::custom)?; + Ok(Self(normalized)) + } +} + +/// One action binding value in config. +/// +/// This accepts either: +/// +/// 1. A single key spec string (`"ctrl-a"`). +/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`). +/// +/// An empty list explicitly unbinds the action in that scope. Because an +/// explicit empty list is still a configured value, runtime resolution must not +/// fall through to global or built-in defaults for that action. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum KeybindingsSpec { + One(KeybindingSpec), + Many(Vec), +} + +impl KeybindingsSpec { + /// Returns all configured key specs for one action in declaration order. + /// + /// Callers should preserve this ordering when deriving UI hints so the + /// first binding remains the primary affordance shown to users. + pub fn specs(&self) -> Vec<&KeybindingSpec> { + match self { + Self::One(spec) => vec![spec], + Self::Many(specs) => specs.iter().collect(), + } + } +} + +/// Global keybindings. These are used when a context does not define an override. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiGlobalKeymap { + /// Open the transcript overlay. + pub open_transcript: Option, + /// Open the external editor for the current draft. + pub open_external_editor: Option, + /// Copy the last agent response to the clipboard. + pub copy: Option, + /// Clear the terminal UI. + pub clear_terminal: Option, + /// Submit the current composer draft. + pub submit: Option, + /// Queue the current composer draft while a task is running. + pub queue: Option, + /// Toggle the composer shortcut overlay. + pub toggle_shortcuts: Option, + /// Toggle Vim mode for the composer input. + pub toggle_vim_mode: Option, + /// Toggle Fast mode. + pub toggle_fast_mode: Option, + /// Toggle raw scrollback mode for copy-friendly transcript selection. + pub toggle_raw_output: Option, +} + +/// Chat context keybindings. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiChatKeymap { + /// Decrease the active reasoning effort. + pub decrease_reasoning_effort: Option, + /// Increase the active reasoning effort. + pub increase_reasoning_effort: Option, + /// Edit the most recently queued message. + pub edit_queued_message: Option, +} + +/// Composer context keybindings. These override corresponding `global` actions. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiComposerKeymap { + /// Submit the current composer draft. + pub submit: Option, + /// Queue the current composer draft while a task is running. + pub queue: Option, + /// Toggle the composer shortcut overlay. + pub toggle_shortcuts: Option, + /// Open reverse history search or move to the previous match. + pub history_search_previous: Option, + /// Move to the next match in reverse history search. + pub history_search_next: Option, +} + +/// Editor context keybindings for text editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiEditorKeymap { + /// Insert a newline in the editor. + pub insert_newline: Option, + /// Move cursor left by one grapheme. + pub move_left: Option, + /// Move cursor right by one grapheme. + pub move_right: Option, + /// Move cursor up one visual line. + pub move_up: Option, + /// Move cursor down one visual line. + pub move_down: Option, + /// Move cursor to beginning of previous word. + pub move_word_left: Option, + /// Move cursor to end of next word. + pub move_word_right: Option, + /// Move cursor to beginning of line. + pub move_line_start: Option, + /// Move cursor to end of line. + pub move_line_end: Option, + /// Delete one grapheme to the left. + pub delete_backward: Option, + /// Delete one grapheme to the right. + pub delete_forward: Option, + /// Delete the previous word. + pub delete_backward_word: Option, + /// Delete the next word. + pub delete_forward_word: Option, + /// Kill text from cursor to line start. + pub kill_line_start: Option, + /// Kill the current line. + pub kill_whole_line: Option, + /// Kill text from cursor to line end. + pub kill_line_end: Option, + /// Yank the kill buffer. + pub yank: Option, +} + +/// Vim normal-mode keybindings for modal editing inside text areas. +/// +/// Actions that use uppercase letters (like `A` for append-line-end) should +/// be specified as `shift-a` in config; the runtime matcher handles +/// cross-terminal shift-reporting differences automatically. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimNormalKeymap { + /// Enter insert mode at cursor (`i`). + pub enter_insert: Option, + /// Enter insert mode after cursor (`a`). + pub append_after_cursor: Option, + /// Enter insert mode at end of line (`A`). + pub append_line_end: Option, + /// Enter insert mode at first non-blank of line (`I`). + pub insert_line_start: Option, + /// Open a new line below and enter insert mode (`o`). + pub open_line_below: Option, + /// Open a new line above and enter insert mode (`O`). + pub open_line_above: Option, + /// Move cursor left (`h`). + pub move_left: Option, + /// Move cursor right (`l`). + pub move_right: Option, + /// Move cursor up (`k`), or recall older composer history at history boundaries. + pub move_up: Option, + /// Move cursor down (`j`), or recall newer composer history at history boundaries. + pub move_down: Option, + /// Move cursor to start of next word (`w`). + pub move_word_forward: Option, + /// Move cursor to start of previous word (`b`). + pub move_word_backward: Option, + /// Move cursor to end of current/next word (`e`). + pub move_word_end: Option, + /// Move cursor to start of line (`0`). + pub move_line_start: Option, + /// Move cursor to end of line (`$`). + pub move_line_end: Option, + /// Delete character under cursor (`x`). + pub delete_char: Option, + /// Delete from cursor to end of line (`D`). + pub delete_to_line_end: Option, + /// Yank the entire line (`Y`). + pub yank_line: Option, + /// Paste after cursor (`p`). + pub paste_after: Option, + /// Begin delete operator; next key selects motion (`d`). + pub start_delete_operator: Option, + /// Begin yank operator; next key selects motion (`y`). + pub start_yank_operator: Option, + /// Cancel a pending operator and return to normal mode. + pub cancel_operator: Option, +} + +/// Vim operator-pending keybindings for modal editing inside text areas. +/// +/// This context is active only while waiting for a motion after `d` or `y`. +/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing +/// `Esc` cancels the pending operator and returns to normal mode without +/// modifying text. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimOperatorKeymap { + /// Repeat delete operator to delete the whole line (`dd`). + pub delete_line: Option, + /// Repeat yank operator to yank the whole line (`yy`). + pub yank_line: Option, + /// Motion: left (`h`). + pub motion_left: Option, + /// Motion: right (`l`). + pub motion_right: Option, + /// Motion: up one line (`k`). + pub motion_up: Option, + /// Motion: down one line (`j`). + pub motion_down: Option, + /// Motion: to start of next word (`w`). + pub motion_word_forward: Option, + /// Motion: to start of previous word (`b`). + pub motion_word_backward: Option, + /// Motion: to end of current/next word (`e`). + pub motion_word_end: Option, + /// Motion: to start of line (`0`). + pub motion_line_start: Option, + /// Motion: to end of line (`$`). + pub motion_line_end: Option, + /// Cancel the pending operator and return to normal mode. + pub cancel: Option, +} + +/// Pager context keybindings for transcript and static overlays. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiPagerKeymap { + /// Scroll up by one row. + pub scroll_up: Option, + /// Scroll down by one row. + pub scroll_down: Option, + /// Scroll up by one page. + pub page_up: Option, + /// Scroll down by one page. + pub page_down: Option, + /// Scroll up by half a page. + pub half_page_up: Option, + /// Scroll down by half a page. + pub half_page_down: Option, + /// Jump to the beginning. + pub jump_top: Option, + /// Jump to the end. + pub jump_bottom: Option, + /// Close the pager overlay. + pub close: Option, + /// Close the transcript overlay via its dedicated toggle key. + pub close_transcript: Option, +} + +/// List selection context keybindings for popup-style selectable lists. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiListKeymap { + /// Move list selection up. + pub move_up: Option, + /// Move list selection down. + pub move_down: Option, + /// Accept current selection. + pub accept: Option, + /// Cancel and close selection view. + pub cancel: Option, +} + +/// Approval overlay keybindings. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiApprovalKeymap { + /// Open the full-screen approval details view. + pub open_fullscreen: Option, + /// Open the thread that requested approval when shown from another thread. + pub open_thread: Option, + /// Approve the primary option. + pub approve: Option, + /// Approve for session when that option exists. + pub approve_for_session: Option, + /// Approve with exec-policy prefix when that option exists. + pub approve_for_prefix: Option, + /// Deny without providing follow-up guidance. + pub deny: Option, + /// Decline and provide corrective guidance. + pub decline: Option, + /// Cancel an elicitation request. + pub cancel: Option, +} + +/// Raw keymap configuration from `[tui.keymap]`. +/// +/// Each context contains action-level overrides. Missing actions inherit from +/// built-in defaults, and selected chat/composer actions can fall back +/// through `global` during runtime resolution. +/// +/// This type is intentionally a persistence shape, not the structure used by +/// input handlers. Runtime consumers should resolve it into +/// `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key +/// validation are applied consistently. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiKeymap { + #[serde(default)] + pub global: TuiGlobalKeymap, + #[serde(default)] + pub chat: TuiChatKeymap, + #[serde(default)] + pub composer: TuiComposerKeymap, + #[serde(default)] + pub editor: TuiEditorKeymap, + #[serde(default)] + pub vim_normal: TuiVimNormalKeymap, + #[serde(default)] + pub vim_operator: TuiVimOperatorKeymap, + #[serde(default)] + pub pager: TuiPagerKeymap, + #[serde(default)] + pub list: TuiListKeymap, + #[serde(default)] + pub approval: TuiApprovalKeymap, +} + +/// Normalize one user-entered key spec into canonical storage format. +/// +/// The output always orders modifiers as `ctrl-alt-shift-` when present +/// and applies accepted aliases (`escape` -> `esc`, `pageup` -> `page-up`). +/// Inputs that cannot be represented unambiguously are rejected. +/// +/// Normalization happens at config-deserialization time so downstream runtime +/// code only has to parse one spelling for each key. Callers should not bypass +/// this function when accepting user-authored key specs, or otherwise equivalent +/// keys can fail to compare equal in tests, UI hints, and duplicate detection. +fn normalize_keybinding_spec(raw: &str) -> Result { + let lower = raw.trim().to_ascii_lowercase(); + if lower.is_empty() { + return Err( + "keybinding cannot be empty. Use values like `ctrl-a` or `shift-enter`.\n\ +See the Codex keymap documentation for supported actions and examples." + .to_string(), + ); + } + + let segments: Vec<&str> = lower + .split('-') + .filter(|segment| !segment.is_empty()) + .collect(); + if segments.is_empty() { + return Err(format!( + "invalid keybinding `{raw}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`." + )); + } + + let mut modifiers = + BTreeMap::<&str, bool>::from([("ctrl", false), ("alt", false), ("shift", false)]); + let mut key_segments = Vec::new(); + let mut saw_key = false; + + for segment in segments { + let canonical_mod = match segment { + "ctrl" | "control" => Some("ctrl"), + "alt" | "option" => Some("alt"), + "shift" => Some("shift"), + _ => None, + }; + + if !saw_key && let Some(modifier) = canonical_mod { + if modifiers.get(modifier).copied().unwrap_or(false) { + return Err(format!( + "duplicate modifier in keybinding `{raw}`. Use each modifier at most once." + )); + } + modifiers.insert(modifier, true); + continue; + } + + saw_key = true; + key_segments.push(segment); + } + + if key_segments.is_empty() { + return Err(format!( + "missing key in keybinding `{raw}`. Add a key name like `a`, `enter`, or `page-down`." + )); + } + + if key_segments + .iter() + .any(|segment| matches!(*segment, "ctrl" | "control" | "alt" | "option" | "shift")) + { + return Err(format!( + "invalid keybinding `{raw}`: modifiers must come before the key (for example `ctrl-a`)." + )); + } + + let key = normalize_key_name(&key_segments.join("-"), raw)?; + let mut normalized = Vec::new(); + if modifiers.get("ctrl").copied().unwrap_or(false) { + normalized.push("ctrl".to_string()); + } + if modifiers.get("alt").copied().unwrap_or(false) { + normalized.push("alt".to_string()); + } + if modifiers.get("shift").copied().unwrap_or(false) { + normalized.push("shift".to_string()); + } + normalized.push(key); + Ok(normalized.join("-")) +} + +/// Normalize and validate one key name segment. +/// +/// This accepts a constrained key vocabulary to keep runtime parser behavior +/// deterministic across platforms. +fn normalize_key_name(key: &str, original: &str) -> Result { + let alias = match key { + "escape" => "esc", + "return" => "enter", + "spacebar" => "space", + "pgup" | "pageup" => "page-up", + "pgdn" | "pagedown" => "page-down", + "del" => "delete", + other => other, + }; + + if alias.len() == 1 { + let ch = alias.chars().next().unwrap_or_default(); + if ch.is_ascii() && !ch.is_ascii_control() && ch != '-' { + return Ok(alias.to_string()); + } + } + + if matches!( + alias, + "enter" + | "tab" + | "backspace" + | "esc" + | "delete" + | "up" + | "down" + | "left" + | "right" + | "home" + | "end" + | "page-up" + | "page-down" + | "space" + ) { + return Ok(alias.to_string()); + } + + if let Some(number) = alias.strip_prefix('f') + && let Ok(number) = number.parse::() + && (1..=12).contains(&number) + { + return Ok(alias.to_string()); + } + + Err(format!( + "unknown key `{key}` in keybinding `{original}`. \ +Use a printable character (for example `a`), function keys (`f1`-`f12`), \ +or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space.\n\ +See the Codex keymap documentation for supported actions and examples." + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn misplaced_action_at_keymap_root_is_rejected() { + // Actions placed directly under [tui.keymap] instead of a context + // sub-table (e.g. [tui.keymap.global]) must produce a parse error, + // not be silently ignored. + let toml_input = r#" + open_transcript = "ctrl-s" + "#; + let result = toml::from_str::(toml_input); + assert!( + result.is_err(), + "expected error for action at keymap root, got: {result:?}" + ); + } + + #[test] + fn misspelled_action_under_context_is_rejected() { + let toml_input = r#" + [global] + open_transcrip = "ctrl-x" + "#; + let err = toml::from_str::(toml_input) + .expect_err("expected unknown action under context"); + assert!( + err.to_string().contains("open_transcrip"), + "expected error to mention misspelled field, got: {err}" + ); + } + + #[test] + fn removed_backtrack_actions_are_rejected() { + for (context, action) in [ + ("global", "edit_previous_message"), + ("global", "confirm_edit_previous_message"), + ("chat", "edit_previous_message"), + ("chat", "confirm_edit_previous_message"), + ("pager", "edit_previous_message"), + ("pager", "edit_next_message"), + ("pager", "confirm_edit_message"), + ] { + let toml_input = format!( + r#" + [{context}] + {action} = "ctrl-x" + "# + ); + let err = toml::from_str::(&toml_input) + .expect_err("expected removed backtrack action to be rejected"); + assert!( + err.to_string().contains(action), + "expected error to mention removed field {action}, got: {err}" + ); + } + } + + #[test] + fn action_under_global_context_is_accepted() { + let toml_input = r#" + [global] + open_transcript = "ctrl-s" + "#; + let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config"); + assert!(keymap.global.open_transcript.is_some()); + } +} diff --git a/code-rs/config/src/types.rs b/code-rs/config/src/types.rs new file mode 100644 index 00000000000..39fd0a442f5 --- /dev/null +++ b/code-rs/config/src/types.rs @@ -0,0 +1,929 @@ +//! Types used to define loaded and effective Codex configuration values. + +// Note this file should generally be restricted to simple struct/enum +// definitions that do not contain business logic. + +pub use crate::mcp_types::AppToolApproval; +pub use crate::mcp_types::McpServerConfig; +pub use crate::mcp_types::McpServerDisabledReason; +pub use crate::mcp_types::McpServerEnvVar; +pub use crate::mcp_types::McpServerToolConfig; +pub use crate::mcp_types::McpServerTransportConfig; +pub use crate::mcp_types::RawMcpServerConfig; +pub use codex_protocol::config_types::AltScreenMode; +pub use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::EnvironmentVariablePattern; +pub use codex_protocol::config_types::ModeKind; +pub use codex_protocol::config_types::Personality; +pub use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +pub use codex_protocol::config_types::WebSearchMode; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fmt; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +pub use crate::tui_keymap::KeybindingSpec; +pub use crate::tui_keymap::KeybindingsSpec; +pub use crate::tui_keymap::TuiApprovalKeymap; +pub use crate::tui_keymap::TuiChatKeymap; +pub use crate::tui_keymap::TuiComposerKeymap; +pub use crate::tui_keymap::TuiEditorKeymap; +pub use crate::tui_keymap::TuiGlobalKeymap; +pub use crate::tui_keymap::TuiKeymap; +pub use crate::tui_keymap::TuiListKeymap; +pub use crate::tui_keymap::TuiPagerKeymap; +pub use crate::tui_keymap::TuiVimNormalKeymap; +pub use crate::tui_keymap::TuiVimOperatorKeymap; + +pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; +pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2; +pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 10; +pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6; +pub const DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT: i64 = 25; +pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256; +pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30; +const MIN_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 1; +const MAX_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 4096; +const MIN_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 1; +const MAX_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 128; + +const fn default_enabled() -> bool { + true +} + +/// Preferred layout for the resume/fork session picker. +#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum SessionPickerViewMode { + Comfortable, + #[default] + Dense, +} + +impl SessionPickerViewMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Comfortable => "comfortable", + Self::Dense => "dense", + } + } +} + +impl fmt::Display for SessionPickerViewMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Determine where Codex should store CLI auth credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuthCredentialsStoreMode { + #[default] + /// Persist credentials in CODEX_HOME/auth.json. + File, + /// Persist credentials in the keyring. Fail if unavailable. + Keyring, + /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. + Auto, + /// Store credentials in memory only for the current process. + Ephemeral, +} + +/// Determine where Codex should store and read MCP credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum OAuthCredentialsStoreMode { + /// `Keyring` when available; otherwise, `File`. + /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. + #[default] + Auto, + /// CODEX_HOME/.credentials.json + /// This file will be readable to Codex and other applications running as the same user. + File, + /// Keyring when available, otherwise fail. + Keyring, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum WindowsSandboxModeToml { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct WindowsToml { + pub sandbox: Option, + /// Defaults to `true`. Set to `false` to launch the final sandboxed child + /// process on `Winsta0\\Default` instead of a private desktop. + pub sandbox_private_desktop: Option, +} + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)] +pub enum UriBasedFileOpener { + #[serde(rename = "vscode")] + VsCode, + + #[serde(rename = "vscode-insiders")] + VsCodeInsiders, + + #[serde(rename = "windsurf")] + Windsurf, + + #[serde(rename = "cursor")] + Cursor, + + /// Option to disable the URI-based file opener. + #[serde(rename = "none")] + None, +} + +impl UriBasedFileOpener { + pub fn get_scheme(&self) -> Option<&str> { + match self { + UriBasedFileOpener::VsCode => Some("vscode"), + UriBasedFileOpener::VsCodeInsiders => Some("vscode-insiders"), + UriBasedFileOpener::Windsurf => Some("windsurf"), + UriBasedFileOpener::Cursor => Some("cursor"), + UriBasedFileOpener::None => None, + } + } +} + +/// Settings that govern if and what will be written to `~/.codex/history.jsonl`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[serde(default)] +#[schemars(deny_unknown_fields)] +pub struct History { + /// If true, history entries will not be written to disk. + pub persistence: HistoryPersistence, + + /// If set, the maximum size of the history file in bytes. The oldest entries + /// are dropped once the file exceeds this limit. + pub max_bytes: Option, +} + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HistoryPersistence { + /// Save all history entries to disk. + #[default] + SaveAll, + /// Do not write history to disk. + None, +} + +// ===== Analytics configuration ===== + +/// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AnalyticsConfigToml { + /// When `false`, disables analytics across Codex product surfaces in this profile. + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct FeedbackConfigToml { + /// When `false`, disables the feedback flow across Codex product surfaces. + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToolSuggestDiscoverableType { + Connector, + Plugin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestDiscoverable { + #[serde(rename = "type")] + pub kind: ToolSuggestDiscoverableType, + pub id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestDisabledTool { + #[serde(rename = "type")] + pub kind: ToolSuggestDiscoverableType, + pub id: String, +} + +impl ToolSuggestDisabledTool { + pub fn plugin(id: impl Into) -> Self { + Self { + kind: ToolSuggestDiscoverableType::Plugin, + id: id.into(), + } + } + + pub fn connector(id: impl Into) -> Self { + Self { + kind: ToolSuggestDiscoverableType::Connector, + id: id.into(), + } + } + + pub fn normalized(&self) -> Option { + let id = self.id.trim(); + (!id.is_empty()).then(|| Self { + kind: self.kind, + id: id.to_string(), + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestConfig { + #[serde(default)] + pub discoverables: Vec, + #[serde(default)] + pub disabled_tools: Vec, +} + +/// Memories settings loaded from config.toml. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MemoriesToml { + /// When `true`, external context sources mark the thread `memory_mode` as `"polluted"`. + #[serde(alias = "no_memories_if_mcp_or_web_search")] + pub disable_on_external_context: Option, + /// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB. + pub generate_memories: Option, + /// When `false`, skip injecting memory usage instructions into developer prompts. + pub use_memories: Option, + /// Maximum number of recent raw memories retained for global consolidation. + #[schemars(range(min = 1, max = 4096))] + pub max_raw_memories_for_consolidation: Option, + /// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection. + pub max_unused_days: Option, + /// Maximum age of the threads used for memories. + pub max_rollout_age_days: Option, + /// Maximum number of rollout candidates processed per pass. + #[schemars(range(min = 1, max = 128))] + pub max_rollouts_per_startup: Option, + /// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended. + pub min_rollout_idle_hours: Option, + /// Minimum remaining percentage required in Codex rate-limit windows before memory startup runs. + #[schemars(range(min = 0, max = 100))] + pub min_rate_limit_remaining_percent: Option, + /// Model used for thread summarisation. + pub extract_model: Option, + /// Model used for memory consolidation. + pub consolidation_model: Option, +} + +/// Effective memories settings after defaults are applied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MemoriesConfig { + pub disable_on_external_context: bool, + pub generate_memories: bool, + pub use_memories: bool, + pub max_raw_memories_for_consolidation: usize, + pub max_unused_days: i64, + pub max_rollout_age_days: i64, + pub max_rollouts_per_startup: usize, + pub min_rollout_idle_hours: i64, + pub min_rate_limit_remaining_percent: i64, + pub extract_model: Option, + pub consolidation_model: Option, +} + +impl Default for MemoriesConfig { + fn default() -> Self { + Self { + disable_on_external_context: false, + generate_memories: true, + use_memories: true, + max_raw_memories_for_consolidation: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS, + max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS, + max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP, + min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS, + min_rate_limit_remaining_percent: DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT, + extract_model: None, + consolidation_model: None, + } + } +} + +impl From for MemoriesConfig { + fn from(toml: MemoriesToml) -> Self { + let defaults = Self::default(); + Self { + disable_on_external_context: toml + .disable_on_external_context + .unwrap_or(defaults.disable_on_external_context), + generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories), + use_memories: toml.use_memories.unwrap_or(defaults.use_memories), + max_raw_memories_for_consolidation: toml + .max_raw_memories_for_consolidation + .unwrap_or(defaults.max_raw_memories_for_consolidation) + .clamp( + MIN_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + MAX_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + ), + max_unused_days: toml + .max_unused_days + .unwrap_or(defaults.max_unused_days) + .clamp(0, 365), + max_rollout_age_days: toml + .max_rollout_age_days + .unwrap_or(defaults.max_rollout_age_days) + .clamp(0, 90), + max_rollouts_per_startup: toml + .max_rollouts_per_startup + .unwrap_or(defaults.max_rollouts_per_startup) + .clamp( + MIN_MEMORIES_MAX_ROLLOUTS_PER_STARTUP, + MAX_MEMORIES_MAX_ROLLOUTS_PER_STARTUP, + ), + min_rollout_idle_hours: toml + .min_rollout_idle_hours + .unwrap_or(defaults.min_rollout_idle_hours) + .clamp(1, 48), + min_rate_limit_remaining_percent: toml + .min_rate_limit_remaining_percent + .unwrap_or(defaults.min_rate_limit_remaining_percent) + .clamp(0, 100), + extract_model: toml.extract_model, + consolidation_model: toml.consolidation_model, + } + } +} + +/// Default settings that apply to all apps. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsDefaultConfig { + /// When `false`, apps are disabled unless overridden by per-app settings. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Whether tools with `destructive_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub destructive_enabled: bool, + + /// Whether tools with `open_world_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub open_world_enabled: bool, +} + +/// Per-tool settings for a single app tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolConfig { + /// Whether this tool is enabled. `Some(true)` explicitly allows this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +/// Tool settings for a single app. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolsConfig { + /// Per-tool overrides keyed by tool name (for example `repos/list`). + #[serde(default, flatten)] + pub tools: HashMap, +} + +/// Config values for a single app/connector. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppConfig { + /// When `false`, Codex does not surface this app. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Whether tools with `destructive_hint = true` are allowed for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destructive_enabled: Option, + + /// Whether tools with `open_world_hint = true` are allowed for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_enabled: Option, + + /// Approval mode for tools in this app unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Whether tools are enabled by default for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_enabled: Option, + + /// Per-tool settings for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, +} + +/// App/connector settings loaded from `config.toml`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsConfigToml { + /// Default settings for all apps. + #[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Per-app settings keyed by app ID (for example `[apps.google_drive]`). + #[serde(default, flatten)] + pub apps: HashMap, +} + +// ===== OTEL configuration ===== + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum OtelHttpProtocol { + /// Binary payload + Binary, + /// JSON payload + Json, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +pub struct OtelTlsConfig { + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, +} + +/// Which OTEL exporter to use. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +pub enum OtelExporterKind { + None, + Statsig, + OtlpHttp { + endpoint: String, + #[serde(default)] + headers: HashMap, + protocol: OtelHttpProtocol, + #[serde(default)] + tls: Option, + }, + OtlpGrpc { + endpoint: String, + #[serde(default)] + headers: HashMap, + #[serde(default)] + tls: Option, + }, +} + +/// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OtelConfigToml { + /// Log user prompt in traces + pub log_user_prompt: Option, + + /// Mark traces with environment (dev, staging, prod, test). Defaults to dev. + pub environment: Option, + + /// Optional log exporter + pub exporter: Option, + + /// Optional trace exporter + pub trace_exporter: Option, + + /// Optional metrics exporter + pub metrics_exporter: Option, + + /// Attributes to add to every exported trace span. + pub span_attributes: Option>, + + /// Semicolon-separated `key:value` fields to upsert into W3C tracestate members. + pub tracestate: Option>>, +} + +/// Effective OTEL settings after defaults are applied. +#[derive(Debug, Clone, PartialEq)] +pub struct OtelConfig { + pub log_user_prompt: bool, + pub environment: String, + pub exporter: OtelExporterKind, + pub trace_exporter: OtelExporterKind, + pub metrics_exporter: OtelExporterKind, + pub span_attributes: BTreeMap, + pub tracestate: BTreeMap>, +} + +impl Default for OtelConfig { + fn default() -> Self { + OtelConfig { + log_user_prompt: false, + environment: DEFAULT_OTEL_ENVIRONMENT.to_owned(), + exporter: OtelExporterKind::None, + trace_exporter: OtelExporterKind::None, + metrics_exporter: OtelExporterKind::Statsig, + span_attributes: BTreeMap::new(), + tracestate: BTreeMap::new(), + } + } +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Notifications { + Enabled(bool), + Custom(Vec), +} + +impl Default for Notifications { + fn default() -> Self { + Self::Enabled(true) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationMethod { + #[default] + Auto, + Osc9, + Bel, +} + +impl fmt::Display for NotificationMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationMethod::Auto => write!(f, "auto"), + NotificationMethod::Osc9 => write!(f, "osc9"), + NotificationMethod::Bel => write!(f, "bel"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationCondition { + /// Emit TUI notifications only while the terminal is unfocused. + #[default] + Unfocused, + /// Emit TUI notifications regardless of terminal focus. + Always, +} + +impl fmt::Display for NotificationCondition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationCondition::Unfocused => write!(f, "unfocused"), + NotificationCondition::Always => write!(f, "always"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiNotificationSettings { + /// Enable desktop notifications from the TUI. + /// Defaults to `true`. + #[serde(default, rename = "notifications")] + pub notifications: Notifications, + + /// Notification method to use for terminal notifications. + /// Defaults to `auto`. + #[serde(default, rename = "notification_method")] + pub method: NotificationMethod, + + /// Controls whether TUI notifications are delivered only when the terminal is unfocused or + /// regardless of focus. Defaults to `unfocused`. + #[serde(default, rename = "notification_condition")] + pub condition: NotificationCondition, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ModelAvailabilityNuxConfig { + /// Number of times a startup availability NUX has been shown per model slug. + #[serde(default, flatten)] + pub shown_count: HashMap, +} + +/// Fallback resize-reflow row cap when Codex cannot identify a terminal-specific scrollback size. +pub const DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS: usize = 1_000; + +/// Collection of settings that are specific to the TUI. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct Tui { + #[serde(default, flatten)] + pub notification_settings: TuiNotificationSettings, + + /// Enable animations (welcome screen, shimmer effects, spinners). + /// Defaults to `true`. + #[serde(default = "default_true")] + pub animations: bool, + + /// Show startup tooltips in the TUI welcome screen. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_tooltips: bool, + + /// Start the composer in Vim mode (`Normal`) by default. + /// Defaults to `false`. + #[serde(default)] + pub vim_mode_default: bool, + + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + /// Defaults to `false`. + #[serde(default)] + pub raw_output_mode: bool, + + /// Controls whether the TUI uses the terminal's alternate screen buffer. + /// + /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. + /// - `always`: Always use alternate screen (original behavior). + /// - `never`: Never use alternate screen (inline mode only, preserves scrollback). + /// + /// Using alternate screen provides a cleaner fullscreen experience but prevents + /// scrollback in terminal multiplexers like Zellij that follow the xterm spec. + #[serde(default)] + pub alternate_screen: AltScreenMode, + + /// Ordered list of status line item identifiers. + /// + /// When set, the TUI renders the selected items as the status line. + /// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`. + #[serde(default)] + pub status_line: Option>, + + /// Color status line items with colors derived from the active syntax theme. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub status_line_use_colors: bool, + + /// Ordered list of terminal title item identifiers. + /// + /// When set, the TUI renders the selected items into the terminal window/tab title. + /// When unset, the TUI defaults to: `activity` and `project`. + /// The `activity` item spins while working and shows an action-required + /// message when blocked on the user. + #[serde(default)] + pub terminal_title: Option>, + + /// Syntax highlighting theme name (kebab-case). + /// + /// When set, overrides automatic light/dark theme detection. + /// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes. + #[serde(default)] + pub theme: Option, + + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, + + /// Keybinding overrides for the TUI. + /// + /// This supports rebinding selected actions globally and by context. + /// Context bindings take precedence over `global` bindings. + #[serde(default)] + pub keymap: TuiKeymap, + + /// Startup tooltip availability NUX state persisted by the TUI. + #[serde(default)] + pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Trim terminal resize-reflow replay to the most recent rendered terminal rows when the + /// transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to + /// keep all rendered rows. + #[serde(default)] + #[schemars(range(min = 0))] + pub terminal_resize_reflow_max_rows: Option, +} + +const fn default_true() -> bool { + true +} + +/// Settings for notices we display to users via the tui and app-server clients +/// (primarily the Codex IDE extension). NOTE: these are different from +/// notifications - notices are warnings, NUX screens, acknowledgements, etc. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ExternalConfigMigrationPrompts { + /// Tracks whether home-level external config migration prompts are hidden. + pub home: Option, + /// Tracks the last time the home-level external config migration prompt was shown. + pub home_last_prompted_at: Option, + /// Tracks which project paths have opted out of external config migration prompts. + #[serde(default)] + pub projects: BTreeMap, + /// Tracks the last time a project-level external config migration prompt was shown. + #[serde(default)] + pub project_last_prompted_at: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct Notice { + /// Tracks whether the user has acknowledged the full access warning prompt. + pub hide_full_access_warning: Option, + /// Tracks whether the user has acknowledged the Windows world-writable directories warning. + pub hide_world_writable_warning: Option, + /// Tracks whether the user opted out of Codex-managed fast defaults. + pub fast_default_opt_out: Option, + /// Tracks whether the user opted out of the rate limit model switch reminder. + pub hide_rate_limit_model_nudge: Option, + /// Tracks whether the user has seen the model migration prompt + pub hide_gpt5_1_migration_prompt: Option, + /// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt + #[serde(rename = "hide_gpt-5.1-codex-max_migration_prompt")] + pub hide_gpt_5_1_codex_max_migration_prompt: Option, + /// Tracks acknowledged model migrations as old->new model slug mappings. + #[serde(default)] + pub model_migrations: BTreeMap, + /// Tracks scopes where external config migration prompts should be suppressed. + #[serde(default)] + pub external_config_migration_prompts: ExternalConfigMigrationPrompts, +} + +pub use crate::skills_config::BundledSkillsConfig; +pub use crate::skills_config::SkillConfig; +pub use crate::skills_config::SkillsConfig; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PluginConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Per-MCP-server policy overlays for MCP servers contributed by this plugin. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcp_servers: HashMap, +} + +/// Policy settings for a plugin-provided MCP server. +/// +/// This intentionally excludes transport settings: plugin manifests own how the +/// MCP server is launched, while user config owns enablement and tool policy. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PluginMcpServerConfig { + /// When `false`, Codex skips initializing this plugin MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Approval mode for tools in this server unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Explicit allow-list of tools exposed from this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools are removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, + + /// Per-tool approval settings keyed by tool name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tools: HashMap, +} + +impl Default for PluginMcpServerConfig { + fn default() -> Self { + Self { + enabled: true, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + tools: HashMap::new(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MarketplaceConfig { + /// Last time Codex successfully added or refreshed this marketplace. + #[serde(default)] + pub last_updated: Option, + /// Git revision Codex last successfully activated for this marketplace. + #[serde(default)] + pub last_revision: Option, + /// Source kind used to install this marketplace. + #[serde(default)] + pub source_type: Option, + /// Source location used when the marketplace was added. + #[serde(default)] + pub source: Option, + /// Git ref to check out when `source_type` is `git`. + #[serde(default, rename = "ref")] + pub ref_name: Option, + /// Sparse checkout paths used when `source_type` is `git`. + #[serde(default)] + pub sparse_paths: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MarketplaceSourceType { + Git, + Local, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +impl From for codex_app_server_protocol::SandboxSettings { + fn from(sandbox_workspace_write: SandboxWorkspaceWrite) -> Self { + Self { + writable_roots: sandbox_workspace_write.writable_roots, + network_access: Some(sandbox_workspace_write.network_access), + exclude_tmpdir_env_var: Some(sandbox_workspace_write.exclude_tmpdir_env_var), + exclude_slash_tmp: Some(sandbox_workspace_write.exclude_slash_tmp), + } + } +} + +/// Policy for building the `env` when spawning a process via either the +/// `shell` or `local_shell` tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ShellEnvironmentPolicyToml { + pub inherit: Option, + + pub ignore_default_excludes: Option, + + /// List of regular expressions. + pub exclude: Option>, + + pub r#set: Option>, + + /// List of regular expressions. + pub include_only: Option>, + + pub experimental_use_profile: Option, +} + +impl From for ShellEnvironmentPolicy { + fn from(toml: ShellEnvironmentPolicyToml) -> Self { + // Default to inheriting the full environment when not specified. + let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All); + let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(true); + let exclude = toml + .exclude + .unwrap_or_default() + .into_iter() + .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s)) + .collect(); + let r#set = toml.r#set.unwrap_or_default(); + let include_only = toml + .include_only + .unwrap_or_default() + .into_iter() + .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s)) + .collect(); + let use_profile = toml.experimental_use_profile.unwrap_or(false); + + Self { + inherit, + ignore_default_excludes, + exclude, + r#set, + include_only, + use_profile, + } + } +} + +#[cfg(test)] +#[path = "types_tests.rs"] +mod tests; diff --git a/code-rs/config/src/types_tests.rs b/code-rs/config/src/types_tests.rs new file mode 100644 index 00000000000..2c3f69d9867 --- /dev/null +++ b/code-rs/config/src/types_tests.rs @@ -0,0 +1,88 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn deserialize_skill_config_with_name_selector() { + let cfg: SkillConfig = toml::from_str( + r#" + name = "github:yeet" + enabled = false + "#, + ) + .expect("should deserialize skill config with name selector"); + + assert_eq!(cfg.name.as_deref(), Some("github:yeet")); + assert_eq!(cfg.path, None); + assert!(!cfg.enabled); +} + +#[test] +fn deserialize_skill_config_with_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let cfg: SkillConfig = toml::from_str(&format!( + r#" + path = {path:?} + enabled = false + "#, + path = skill_path.display().to_string(), + )) + .expect("should deserialize skill config with path selector"); + + assert_eq!( + cfg, + SkillConfig { + path: Some( + AbsolutePathBuf::from_absolute_path(&skill_path) + .expect("skill path should be absolute"), + ), + name: None, + enabled: false, + } + ); +} + +#[test] +fn memories_config_clamps_count_limits_to_nonzero_values() { + let config = MemoriesConfig::from(MemoriesToml { + max_raw_memories_for_consolidation: Some(0), + max_rollouts_per_startup: Some(0), + ..Default::default() + }); + + assert_eq!( + config, + MemoriesConfig { + max_raw_memories_for_consolidation: 1, + max_rollouts_per_startup: 1, + ..MemoriesConfig::default() + } + ); +} + +#[test] +fn memories_config_clamps_rate_limit_remaining_threshold() { + let config = MemoriesConfig::from(MemoriesToml { + min_rate_limit_remaining_percent: Some(101), + ..Default::default() + }); + assert_eq!( + config, + MemoriesConfig { + min_rate_limit_remaining_percent: 100, + ..MemoriesConfig::default() + } + ); + + let config = MemoriesConfig::from(MemoriesToml { + min_rate_limit_remaining_percent: Some(-1), + ..Default::default() + }); + assert_eq!( + config, + MemoriesConfig { + min_rate_limit_remaining_percent: 0, + ..MemoriesConfig::default() + } + ); +} diff --git a/code-rs/connectors/BUILD.bazel b/code-rs/connectors/BUILD.bazel new file mode 100644 index 00000000000..c4cb9ebde8c --- /dev/null +++ b/code-rs/connectors/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "connectors", + crate_name = "codex_connectors", +) diff --git a/code-rs/connectors/Cargo.toml b/code-rs/connectors/Cargo.toml new file mode 100644 index 00000000000..c0094102c31 --- /dev/null +++ b/code-rs/connectors/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "codex-connectors" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-app-server-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } +urlencoding = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lib] +doctest = false diff --git a/code-rs/connectors/src/accessible.rs b/code-rs/connectors/src/accessible.rs new file mode 100644 index 00000000000..c44f8d8a38a --- /dev/null +++ b/code-rs/connectors/src/accessible.rs @@ -0,0 +1,76 @@ +use std::collections::BTreeSet; +use std::collections::HashMap; + +use crate::metadata::connector_install_url; +use crate::normalize_connector_value; +use codex_app_server_protocol::AppInfo; + +pub struct AccessibleConnectorTool { + pub connector_id: String, + pub connector_name: Option, + pub connector_description: Option, + pub plugin_display_names: Vec, +} + +pub fn collect_accessible_connectors(tools: I) -> Vec +where + I: IntoIterator, +{ + let mut connectors: HashMap)> = HashMap::new(); + for tool in tools { + let connector_id = tool.connector_id; + let connector_name = normalize_connector_value(tool.connector_name.as_deref()) + .unwrap_or_else(|| connector_id.clone()); + let connector_description = + normalize_connector_value(tool.connector_description.as_deref()); + if let Some((existing, existing_plugin_display_names)) = connectors.get_mut(&connector_id) { + if existing.name == connector_id && connector_name != connector_id { + existing.name = connector_name; + } + if existing.description.is_none() && connector_description.is_some() { + existing.description = connector_description; + } + existing_plugin_display_names.extend(tool.plugin_display_names); + } else { + connectors.insert( + connector_id.clone(), + ( + AppInfo { + id: connector_id.clone(), + name: connector_name, + description: connector_description, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + tool.plugin_display_names + .into_iter() + .collect::>(), + ), + ); + } + } + let mut accessible: Vec = connectors + .into_values() + .map(|(mut connector, plugin_display_names)| { + connector.plugin_display_names = plugin_display_names.into_iter().collect(); + connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); + connector + }) + .collect(); + accessible.sort_by(|left, right| { + right + .is_accessible + .cmp(&left.is_accessible) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }); + accessible +} diff --git a/code-rs/connectors/src/filter.rs b/code-rs/connectors/src/filter.rs new file mode 100644 index 00000000000..82c334f82d5 --- /dev/null +++ b/code-rs/connectors/src/filter.rs @@ -0,0 +1,68 @@ +use std::collections::HashSet; + +use codex_app_server_protocol::AppInfo; + +pub fn filter_tool_suggest_discoverable_connectors( + directory_connectors: Vec, + accessible_connectors: &[AppInfo], + discoverable_connector_ids: &HashSet, + originator_value: &str, +) -> Vec { + let accessible_connector_ids: HashSet<&str> = accessible_connectors + .iter() + .filter(|connector| connector.is_accessible) + .map(|connector| connector.id.as_str()) + .collect(); + + let mut connectors = filter_disallowed_connectors(directory_connectors, originator_value) + .into_iter() + .filter(|connector| !accessible_connector_ids.contains(connector.id.as_str())) + .filter(|connector| discoverable_connector_ids.contains(connector.id.as_str())) + .collect::>(); + connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + connectors +} + +const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ + "asdk_app_6938a94a61d881918ef32cb999ff937c", + "connector_2b0a9009c9c64bf9933a3dae3f2b1254", + "connector_3f8d1a79f27c4c7ba1a897ab13bf37dc", + "connector_68de829bf7648191acd70a907364c67c", + "connector_68e004f14af881919eb50893d3d9f523", + "connector_69272cb413a081919685ec3c88d1744e", +]; +const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = + &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; +const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; + +pub fn filter_disallowed_connectors( + connectors: Vec, + originator_value: &str, +) -> Vec { + let first_party_chat_originator = is_first_party_chat_originator(originator_value); + connectors + .into_iter() + .filter(|connector| { + is_connector_id_allowed(connector.id.as_str(), first_party_chat_originator) + }) + .collect() +} + +fn is_first_party_chat_originator(originator_value: &str) -> bool { + originator_value == "codex_atlas" || originator_value == "codex_chatgpt_desktop" +} + +fn is_connector_id_allowed(connector_id: &str, first_party_chat_originator: bool) -> bool { + let disallowed_connector_ids = if first_party_chat_originator { + FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS + } else { + DISALLOWED_CONNECTOR_IDS + }; + + !connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) + && !disallowed_connector_ids.contains(&connector_id) +} diff --git a/code-rs/connectors/src/lib.rs b/code-rs/connectors/src/lib.rs new file mode 100644 index 00000000000..e6260d0e1d9 --- /dev/null +++ b/code-rs/connectors/src/lib.rs @@ -0,0 +1,616 @@ +use std::collections::HashMap; +use std::future::Future; +use std::sync::LazyLock; +use std::sync::Mutex as StdMutex; +use std::time::Duration; +use std::time::Instant; + +use codex_app_server_protocol::AppBranding; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppMetadata; +use serde::Deserialize; + +pub mod accessible; +pub mod filter; +pub mod merge; +pub mod metadata; + +pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AllConnectorsCacheKey { + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +impl AllConnectorsCacheKey { + pub fn new( + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, + ) -> Self { + Self { + chatgpt_base_url, + account_id, + chatgpt_user_id, + is_workspace_account, + } + } +} + +#[derive(Clone)] +struct CachedAllConnectors { + key: AllConnectorsCacheKey, + expires_at: Instant, + connectors: Vec, +} + +static ALL_CONNECTORS_CACHE: LazyLock>> = + LazyLock::new(|| StdMutex::new(None)); + +#[derive(Debug, Deserialize)] +pub struct DirectoryListResponse { + apps: Vec, + #[serde(alias = "nextToken")] + next_token: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DirectoryApp { + id: String, + name: String, + description: Option, + #[serde(alias = "appMetadata")] + app_metadata: Option, + branding: Option, + labels: Option>, + #[serde(alias = "logoUrl")] + logo_url: Option, + #[serde(alias = "logoUrlDark")] + logo_url_dark: Option, + #[serde(alias = "distributionChannel")] + distribution_channel: Option, + visibility: Option, +} + +pub fn cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option> { + let mut cache_guard = ALL_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = Instant::now(); + + if let Some(cached) = cache_guard.as_ref() { + if now < cached.expires_at && cached.key == *cache_key { + return Some(cached.connectors.clone()); + } + if now >= cached.expires_at { + *cache_guard = None; + } + } + + None +} + +pub async fn list_all_connectors_with_options( + cache_key: AllConnectorsCacheKey, + is_workspace_account: bool, + force_refetch: bool, + mut fetch_page: F, +) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + if !force_refetch && let Some(cached_connectors) = cached_all_connectors(&cache_key) { + return Ok(cached_connectors); + } + + let mut apps = list_directory_connectors(&mut fetch_page).await?; + if is_workspace_account { + apps.extend(list_workspace_connectors(&mut fetch_page).await?); + } + + let mut connectors = merge_directory_apps(apps) + .into_iter() + .map(directory_app_to_app_info) + .collect::>(); + for connector in &mut connectors { + let install_url = match connector.install_url.take() { + Some(install_url) => install_url, + None => connector_install_url(&connector.name, &connector.id), + }; + connector.name = normalize_connector_name(&connector.name, &connector.id); + connector.description = normalize_connector_value(connector.description.as_deref()); + connector.install_url = Some(install_url); + connector.is_accessible = false; + } + connectors.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + write_cached_all_connectors(cache_key, &connectors); + Ok(connectors) +} + +fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) { + let mut cache_guard = ALL_CONNECTORS_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *cache_guard = Some(CachedAllConnectors { + key: cache_key, + expires_at: Instant::now() + CONNECTORS_CACHE_TTL, + connectors: connectors.to_vec(), + }); +} + +async fn list_directory_connectors(fetch_page: &mut F) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + let mut apps = Vec::new(); + let mut next_token: Option = None; + loop { + let path = match next_token.as_deref() { + Some(token) => { + let encoded_token = urlencoding::encode(token); + format!("/connectors/directory/list?token={encoded_token}&external_logos=true") + } + None => "/connectors/directory/list?external_logos=true".to_string(), + }; + let response = fetch_page(path).await?; + apps.extend( + response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)), + ); + next_token = response + .next_token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()); + if next_token.is_none() { + break; + } + } + Ok(apps) +} + +async fn list_workspace_connectors(fetch_page: &mut F) -> anyhow::Result> +where + F: FnMut(String) -> Fut, + Fut: Future>, +{ + let response = + fetch_page("/connectors/directory/list_workspace?external_logos=true".to_string()).await; + match response { + Ok(response) => Ok(response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)) + .collect()), + Err(_) => Ok(Vec::new()), + } +} + +fn merge_directory_apps(apps: Vec) -> Vec { + let mut merged: HashMap = HashMap::new(); + for app in apps { + if let Some(existing) = merged.get_mut(&app.id) { + merge_directory_app(existing, app); + } else { + merged.insert(app.id.clone(), app); + } + } + merged.into_values().collect() +} + +fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { + let DirectoryApp { + id: _, + name, + description, + app_metadata, + branding, + labels, + logo_url, + logo_url_dark, + distribution_channel, + visibility: _, + } = incoming; + + let incoming_name_is_empty = name.trim().is_empty(); + if existing.name.trim().is_empty() && !incoming_name_is_empty { + existing.name = name; + } + + let incoming_description_present = description + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + if incoming_description_present { + existing.description = description; + } + + if existing.logo_url.is_none() && logo_url.is_some() { + existing.logo_url = logo_url; + } + if existing.logo_url_dark.is_none() && logo_url_dark.is_some() { + existing.logo_url_dark = logo_url_dark; + } + if existing.distribution_channel.is_none() && distribution_channel.is_some() { + existing.distribution_channel = distribution_channel; + } + + if let Some(incoming_branding) = branding { + if let Some(existing_branding) = existing.branding.as_mut() { + if existing_branding.category.is_none() && incoming_branding.category.is_some() { + existing_branding.category = incoming_branding.category; + } + if existing_branding.developer.is_none() && incoming_branding.developer.is_some() { + existing_branding.developer = incoming_branding.developer; + } + if existing_branding.website.is_none() && incoming_branding.website.is_some() { + existing_branding.website = incoming_branding.website; + } + if existing_branding.privacy_policy.is_none() + && incoming_branding.privacy_policy.is_some() + { + existing_branding.privacy_policy = incoming_branding.privacy_policy; + } + if existing_branding.terms_of_service.is_none() + && incoming_branding.terms_of_service.is_some() + { + existing_branding.terms_of_service = incoming_branding.terms_of_service; + } + if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app { + existing_branding.is_discoverable_app = true; + } + } else { + existing.branding = Some(incoming_branding); + } + } + + if let Some(incoming_app_metadata) = app_metadata { + if let Some(existing_app_metadata) = existing.app_metadata.as_mut() { + if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() { + existing_app_metadata.review = incoming_app_metadata.review; + } + if existing_app_metadata.categories.is_none() + && incoming_app_metadata.categories.is_some() + { + existing_app_metadata.categories = incoming_app_metadata.categories; + } + if existing_app_metadata.sub_categories.is_none() + && incoming_app_metadata.sub_categories.is_some() + { + existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories; + } + if existing_app_metadata.seo_description.is_none() + && incoming_app_metadata.seo_description.is_some() + { + existing_app_metadata.seo_description = incoming_app_metadata.seo_description; + } + if existing_app_metadata.screenshots.is_none() + && incoming_app_metadata.screenshots.is_some() + { + existing_app_metadata.screenshots = incoming_app_metadata.screenshots; + } + if existing_app_metadata.developer.is_none() + && incoming_app_metadata.developer.is_some() + { + existing_app_metadata.developer = incoming_app_metadata.developer; + } + if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() { + existing_app_metadata.version = incoming_app_metadata.version; + } + if existing_app_metadata.version_id.is_none() + && incoming_app_metadata.version_id.is_some() + { + existing_app_metadata.version_id = incoming_app_metadata.version_id; + } + if existing_app_metadata.version_notes.is_none() + && incoming_app_metadata.version_notes.is_some() + { + existing_app_metadata.version_notes = incoming_app_metadata.version_notes; + } + if existing_app_metadata.first_party_type.is_none() + && incoming_app_metadata.first_party_type.is_some() + { + existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type; + } + if existing_app_metadata.first_party_requires_install.is_none() + && incoming_app_metadata.first_party_requires_install.is_some() + { + existing_app_metadata.first_party_requires_install = + incoming_app_metadata.first_party_requires_install; + } + if existing_app_metadata + .show_in_composer_when_unlinked + .is_none() + && incoming_app_metadata + .show_in_composer_when_unlinked + .is_some() + { + existing_app_metadata.show_in_composer_when_unlinked = + incoming_app_metadata.show_in_composer_when_unlinked; + } + } else { + existing.app_metadata = Some(incoming_app_metadata); + } + } + + if existing.labels.is_none() && labels.is_some() { + existing.labels = labels; + } +} + +fn is_hidden_directory_app(app: &DirectoryApp) -> bool { + matches!(app.visibility.as_deref(), Some("HIDDEN")) +} + +fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { + AppInfo { + id: app.id, + name: app.name, + description: app.description, + logo_url: app.logo_url, + logo_url_dark: app.logo_url_dark, + distribution_channel: app.distribution_channel, + branding: app.branding, + app_metadata: app.app_metadata, + labels: app.labels, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } +} + +fn connector_install_url(name: &str, connector_id: &str) -> String { + let slug = connector_name_slug(name); + format!("https://chatgpt.com/apps/{slug}/{connector_id}") +} + +fn connector_name_slug(name: &str) -> String { + let mut normalized = String::with_capacity(name.len()); + for character in name.chars() { + if character.is_ascii_alphanumeric() { + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push('-'); + } + } + let normalized = normalized.trim_matches('-'); + if normalized.is_empty() { + "app".to_string() + } else { + normalized.to_string() + } +} + +fn normalize_connector_name(name: &str, connector_id: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + connector_id.to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_connector_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + static ALL_CONNECTORS_CACHE_TEST_LOCK: LazyLock> = + LazyLock::new(|| tokio::sync::Mutex::new(())); + + fn cache_key(id: &str) -> AllConnectorsCacheKey { + AllConnectorsCacheKey::new( + "https://chatgpt.example".to_string(), + Some(format!("account-{id}")), + Some(format!("user-{id}")), + /*is_workspace_account*/ true, + ) + } + + fn app(id: &str, name: &str) -> DirectoryApp { + DirectoryApp { + id: id.to_string(), + name: name.to_string(), + description: None, + app_metadata: None, + branding: None, + labels: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + visibility: None, + } + } + + #[tokio::test] + #[expect( + clippy::await_holding_invalid_type, + reason = "test serializes access to the shared connector cache for its full duration" + )] + async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> { + let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await; + + let calls = Arc::new(AtomicUsize::new(0)); + let call_counter = Arc::clone(&calls); + let key = cache_key("shared"); + + let first = list_all_connectors_with_options( + key.clone(), + /*is_workspace_account*/ false, + /*force_refetch*/ false, + move |_path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok(DirectoryListResponse { + apps: vec![app("alpha", "Alpha")], + next_token: None, + }) + } + }, + ) + .await?; + + let second = list_all_connectors_with_options( + key, + /*is_workspace_account*/ false, + /*force_refetch*/ false, + move |_path| async move { + anyhow::bail!("cache should have been used"); + }, + ) + .await?; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(first, second); + Ok(()) + } + + #[tokio::test] + #[expect( + clippy::await_holding_invalid_type, + reason = "test serializes access to the shared connector cache for its full duration" + )] + async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> { + let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await; + + let key = cache_key("merged"); + let calls = Arc::new(AtomicUsize::new(0)); + let call_counter = Arc::clone(&calls); + + let connectors = list_all_connectors_with_options( + key, + /*is_workspace_account*/ true, + /*force_refetch*/ true, + move |path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + if path.starts_with("/connectors/directory/list_workspace") { + Ok(DirectoryListResponse { + apps: vec![ + DirectoryApp { + description: Some("Merged description".to_string()), + branding: Some(AppBranding { + category: Some("calendar".to_string()), + developer: None, + website: None, + privacy_policy: None, + terms_of_service: None, + is_discoverable_app: true, + }), + ..app("alpha", "") + }, + DirectoryApp { + visibility: Some("HIDDEN".to_string()), + ..app("hidden", "Hidden") + }, + ], + next_token: None, + }) + } else { + Ok(DirectoryListResponse { + apps: vec![app("alpha", " Alpha "), app("beta", "Beta")], + next_token: None, + }) + } + } + }, + ) + .await?; + + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(connectors.len(), 2); + assert_eq!(connectors[0].id, "alpha"); + assert_eq!(connectors[0].name, "Alpha"); + assert_eq!( + connectors[0].description.as_deref(), + Some("Merged description") + ); + assert_eq!( + connectors[0].install_url.as_deref(), + Some("https://chatgpt.com/apps/alpha/alpha") + ); + assert_eq!( + connectors[0] + .branding + .as_ref() + .and_then(|branding| branding.category.as_deref()), + Some("calendar") + ); + assert_eq!(connectors[1].id, "beta"); + assert_eq!(connectors[1].name, "Beta"); + Ok(()) + } + + #[tokio::test] + async fn list_directory_connectors_omits_tier_for_all_pages() -> anyhow::Result<()> { + let requested_paths: Arc>> = Arc::new(Mutex::new(Vec::new())); + let paths = Arc::clone(&requested_paths); + + let apps = list_directory_connectors(&mut move |path| { + let paths = Arc::clone(&paths); + async move { + paths + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(path.clone()); + if path == "/connectors/directory/list?external_logos=true" { + Ok(DirectoryListResponse { + apps: vec![app("alpha", "Alpha")], + next_token: Some("page 2".to_string()), + }) + } else { + assert_eq!( + path, + "/connectors/directory/list?token=page%202&external_logos=true" + ); + Ok(DirectoryListResponse { + apps: vec![app("beta", "Beta")], + next_token: None, + }) + } + } + }) + .await?; + + assert_eq!( + apps.iter().map(|app| app.id.as_str()).collect::>(), + vec!["alpha", "beta"] + ); + assert_eq!( + requested_paths + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_slice(), + &[ + "/connectors/directory/list?external_logos=true".to_string(), + "/connectors/directory/list?token=page%202&external_logos=true".to_string(), + ] + ); + Ok(()) + } +} diff --git a/code-rs/connectors/src/merge.rs b/code-rs/connectors/src/merge.rs new file mode 100644 index 00000000000..b41ee63add5 --- /dev/null +++ b/code-rs/connectors/src/merge.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use crate::metadata::connector_install_url; +use crate::metadata::sort_connectors_by_accessibility_and_name; +use codex_app_server_protocol::AppInfo; + +pub fn merge_connectors( + connectors: Vec, + accessible_connectors: Vec, +) -> Vec { + let mut merged: HashMap = connectors + .into_iter() + .map(|mut connector| { + connector.is_accessible = false; + (connector.id.clone(), connector) + }) + .collect(); + + for mut connector in accessible_connectors { + connector.is_accessible = true; + let connector_id = connector.id.clone(); + if let Some(existing) = merged.get_mut(&connector_id) { + existing.is_accessible = true; + if existing.name == existing.id && connector.name != connector.id { + existing.name = connector.name; + } + if existing.description.is_none() && connector.description.is_some() { + existing.description = connector.description; + } + if existing.logo_url.is_none() && connector.logo_url.is_some() { + existing.logo_url = connector.logo_url; + } + if existing.logo_url_dark.is_none() && connector.logo_url_dark.is_some() { + existing.logo_url_dark = connector.logo_url_dark; + } + if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() { + existing.distribution_channel = connector.distribution_channel; + } + existing + .plugin_display_names + .extend(connector.plugin_display_names); + } else { + merged.insert(connector_id, connector); + } + } + + let mut merged = merged.into_values().collect::>(); + for connector in &mut merged { + if connector.install_url.is_none() { + connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); + } + connector.plugin_display_names.sort_unstable(); + connector.plugin_display_names.dedup(); + } + sort_connectors_by_accessibility_and_name(&mut merged); + merged +} + +pub fn merge_plugin_connectors(connectors: Vec, plugin_app_ids: I) -> Vec +where + I: IntoIterator, +{ + let mut merged = connectors; + let mut connector_ids = merged + .iter() + .map(|connector| connector.id.clone()) + .collect::>(); + + for connector_id in plugin_app_ids { + if connector_ids.insert(connector_id.clone()) { + merged.push(plugin_connector_to_app_info(connector_id)); + } + } + + sort_connectors_by_accessibility_and_name(&mut merged); + merged +} + +pub fn merge_plugin_connectors_with_accessible( + plugin_app_ids: I, + accessible_connectors: Vec, +) -> Vec +where + I: IntoIterator, +{ + let accessible_connector_ids: HashSet<&str> = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect(); + let plugin_connectors = plugin_app_ids + .into_iter() + .filter(|connector_id| accessible_connector_ids.contains(connector_id.as_str())) + .map(plugin_connector_to_app_info) + .collect::>(); + merge_connectors(plugin_connectors, accessible_connectors) +} + +pub fn plugin_connector_to_app_info(connector_id: String) -> AppInfo { + // Leave the placeholder name as the connector id so merge_connectors() can + // replace it with canonical app metadata from directory fetches or + // connector_name values from codex_apps tool discovery. + let name = connector_id.clone(); + AppInfo { + id: connector_id.clone(), + name: name.clone(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url(&name, &connector_id)), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } +} diff --git a/code-rs/connectors/src/metadata.rs b/code-rs/connectors/src/metadata.rs new file mode 100644 index 00000000000..64becb766d0 --- /dev/null +++ b/code-rs/connectors/src/metadata.rs @@ -0,0 +1,27 @@ +use codex_app_server_protocol::AppInfo; + +pub fn connector_display_label(connector: &AppInfo) -> String { + connector.name.clone() +} + +pub fn connector_mention_slug(connector: &AppInfo) -> String { + crate::connector_name_slug(&connector_display_label(connector)) +} + +pub fn connector_install_url(name: &str, connector_id: &str) -> String { + crate::connector_install_url(name, connector_id) +} + +pub fn sanitize_name(name: &str) -> String { + crate::connector_name_slug(name).replace("-", "_") +} + +pub(crate) fn sort_connectors_by_accessibility_and_name(connectors: &mut [AppInfo]) { + connectors.sort_by(|left, right| { + right + .is_accessible + .cmp(&left.is_accessible) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }); +} diff --git a/code-rs/core-api/BUILD.bazel b/code-rs/core-api/BUILD.bazel new file mode 100644 index 00000000000..646452cdc64 --- /dev/null +++ b/code-rs/core-api/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "core-api", + crate_name = "codex_core_api", +) diff --git a/code-rs/core-api/Cargo.toml b/code-rs/core-api/Cargo.toml new file mode 100644 index 00000000000..2b4a0216d61 --- /dev/null +++ b/code-rs/core-api/Cargo.toml @@ -0,0 +1,28 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-core-api" +version.workspace = true + +[lib] +doctest = false +name = "codex_core_api" +path = "src/lib.rs" +test = false + +[lints] +workspace = true + +[dependencies] +codex-app-server-protocol = { workspace = true } +codex-arg0 = { workspace = true } +codex-analytics = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-exec-server = { workspace = true } +codex-features = { workspace = true } +codex-login = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-models-manager = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } diff --git a/code-rs/core-api/src/lib.rs b/code-rs/core-api/src/lib.rs new file mode 100644 index 00000000000..f9bdc9b56b4 --- /dev/null +++ b/code-rs/core-api/src/lib.rs @@ -0,0 +1,77 @@ +//! Public facade for thread management APIs built on `codex-core`. + +#![deny(private_bounds, private_interfaces, unreachable_pub)] + +pub use codex_analytics::AnalyticsEventsClient; +pub use codex_app_server_protocol::ServerNotification; +pub use codex_app_server_protocol::item_event_to_server_notification; +pub use codex_arg0::Arg0DispatchPaths; +pub use codex_arg0::arg0_dispatch_or_else; +pub use codex_config::ConfigLayerStack; +pub use codex_config::config_toml::ProjectConfig; +pub use codex_config::config_toml::RealtimeAudioConfig; +pub use codex_config::config_toml::RealtimeConfig; +pub use codex_config::types::AuthCredentialsStoreMode; +pub use codex_config::types::History; +pub use codex_config::types::MemoriesConfig; +pub use codex_config::types::ModelAvailabilityNuxConfig; +pub use codex_config::types::Notice; +pub use codex_config::types::OAuthCredentialsStoreMode; +pub use codex_config::types::OtelConfig; +pub use codex_config::types::SessionPickerViewMode; +pub use codex_config::types::ToolSuggestConfig; +pub use codex_config::types::TuiKeymap; +pub use codex_config::types::TuiNotificationSettings; +pub use codex_config::types::UriBasedFileOpener; +pub use codex_core::CodexThread; +pub use codex_core::ForkSnapshot; +pub use codex_core::McpManager; +pub use codex_core::NewThread; +pub use codex_core::StartThreadOptions; +pub use codex_core::StateDbHandle; +pub use codex_core::ThreadManager; +pub use codex_core::ThreadShutdownReport; +pub use codex_core::config::Config; +pub use codex_core::config::Constrained; +pub use codex_core::config::GhostSnapshotConfig; +pub use codex_core::config::MultiAgentV2Config; +pub use codex_core::config::Permissions; +pub use codex_core::config::TerminalResizeReflowConfig; +pub use codex_core::config::ThreadStoreConfig; +pub use codex_core::config::find_codex_home; +pub use codex_core::init_state_db; +pub use codex_core::resolve_installation_id; +pub use codex_core::skills::SkillsManager; +pub use codex_core::thread_store_from_config; +pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentManagerArgs; +pub use codex_exec_server::ExecServerRuntimePaths; +pub use codex_features::Feature; +pub use codex_features::Features; +pub use codex_login::AuthManager; +pub use codex_login::default_client::set_default_originator; +pub use codex_model_provider_info::OPENAI_PROVIDER_ID; +pub use codex_model_provider_info::built_in_model_providers; +pub use codex_models_manager::manager::RefreshStrategy; +pub use codex_models_manager::manager::SharedModelsManager; +pub use codex_protocol::ThreadId; +pub use codex_protocol::config_types::AltScreenMode; +pub use codex_protocol::config_types::ApprovalsReviewer; +pub use codex_protocol::config_types::CollaborationModeMask; +pub use codex_protocol::config_types::ShellEnvironmentPolicy; +pub use codex_protocol::config_types::WebSearchMode; +pub use codex_protocol::dynamic_tools::DynamicToolSpec; +pub use codex_protocol::error::Result as CodexResult; +pub use codex_protocol::models::PermissionProfile; +pub use codex_protocol::openai_models::ModelPreset; +pub use codex_protocol::protocol::AskForApproval; +pub use codex_protocol::protocol::EventMsg; +pub use codex_protocol::protocol::InitialHistory; +pub use codex_protocol::protocol::McpServerRefreshConfig; +pub use codex_protocol::protocol::Op; +pub use codex_protocol::protocol::SessionConfiguredEvent; +pub use codex_protocol::protocol::SessionSource; +pub use codex_protocol::protocol::TurnEnvironmentSelection; +pub use codex_protocol::protocol::W3cTraceContext; +pub use codex_protocol::user_input::UserInput; +pub use codex_utils_absolute_path::AbsolutePathBuf; diff --git a/code-rs/core-plugins/BUILD.bazel b/code-rs/core-plugins/BUILD.bazel new file mode 100644 index 00000000000..aa19b9f3688 --- /dev/null +++ b/code-rs/core-plugins/BUILD.bazel @@ -0,0 +1,15 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "core-plugins", + crate_name = "codex_core_plugins", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), +) diff --git a/code-rs/core-plugins/Cargo.toml b/code-rs/core-plugins/Cargo.toml new file mode 100644 index 00000000000..352d6e57149 --- /dev/null +++ b/code-rs/core-plugins/Cargo.toml @@ -0,0 +1,50 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-core-plugins" +version.workspace = true + +[lib] +doctest = false +name = "codex_core_plugins" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-analytics = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-config = { workspace = true } +codex-core-skills = { workspace = true } +codex-exec-server = { workspace = true } +codex-git-utils = { workspace = true } +codex-hooks = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-otel = { workspace = true } +codex-plugin = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-plugins = { workspace = true } +chrono = { workspace = true } +dirs = { workspace = true } +flate2 = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tar = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros", "rt", "time"] } +toml = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +zip = { workspace = true } + +[dev-dependencies] +libc = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +wiremock = { workspace = true } diff --git a/code-rs/core-plugins/src/installed_marketplaces.rs b/code-rs/core-plugins/src/installed_marketplaces.rs new file mode 100644 index 00000000000..f313a06276f --- /dev/null +++ b/code-rs/core-plugins/src/installed_marketplaces.rs @@ -0,0 +1,76 @@ +use std::path::Path; +use std::path::PathBuf; + +use codex_config::ConfigLayerStack; +use codex_plugin::validate_plugin_segment; +use codex_utils_absolute_path::AbsolutePathBuf; +use tracing::warn; + +use crate::marketplace::find_marketplace_manifest_path; + +pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces"; + +pub fn marketplace_install_root(codex_home: &Path) -> PathBuf { + codex_home.join(INSTALLED_MARKETPLACES_DIR) +} + +pub fn installed_marketplace_roots_from_layer_stack( + config_layer_stack: &ConfigLayerStack, + codex_home: &Path, +) -> Vec { + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return Vec::new(); + }; + let Some(marketplaces_value) = user_layer.config.get("marketplaces") else { + return Vec::new(); + }; + let Some(marketplaces) = marketplaces_value.as_table() else { + warn!("invalid marketplaces config: expected table"); + return Vec::new(); + }; + let default_install_root = marketplace_install_root(codex_home); + let mut roots = marketplaces + .iter() + .filter_map(|(marketplace_name, marketplace)| { + if !marketplace.is_table() { + warn!( + marketplace_name, + "ignoring invalid configured marketplace entry" + ); + return None; + } + if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") { + warn!( + marketplace_name, + error = %err, + "ignoring invalid configured marketplace name" + ); + return None; + } + let path = resolve_configured_marketplace_root( + marketplace_name, + marketplace, + &default_install_root, + )?; + find_marketplace_manifest_path(&path).map(|_| path) + }) + .filter_map(|path| AbsolutePathBuf::try_from(path).ok()) + .collect::>(); + roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path())); + roots +} + +pub fn resolve_configured_marketplace_root( + marketplace_name: &str, + marketplace: &toml::Value, + default_install_root: &Path, +) -> Option { + match marketplace.get("source_type").and_then(toml::Value::as_str) { + Some("local") => marketplace + .get("source") + .and_then(toml::Value::as_str) + .filter(|source| !source.is_empty()) + .map(PathBuf::from), + _ => Some(default_install_root.join(marketplace_name)), + } +} diff --git a/code-rs/core-plugins/src/lib.rs b/code-rs/core-plugins/src/lib.rs new file mode 100644 index 00000000000..b7246990397 --- /dev/null +++ b/code-rs/core-plugins/src/lib.rs @@ -0,0 +1,60 @@ +pub mod installed_marketplaces; +pub mod loader; +mod manager; +pub mod manifest; +pub mod marketplace; +pub mod marketplace_add; +pub mod marketplace_remove; +pub mod marketplace_upgrade; +pub mod remote; +pub mod remote_bundle; +pub mod remote_legacy; +pub(crate) mod startup_remote_sync; +pub mod startup_sync; +pub mod store; +#[cfg(test)] +mod test_support; +pub mod toggles; + +pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; +pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; + +pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ + "github@openai-curated", + "notion@openai-curated", + "slack@openai-curated", + "gmail@openai-curated", + "google-calendar@openai-curated", + "google-drive@openai-curated", + "openai-developers@openai-curated", + "canva@openai-curated", + "teams@openai-curated", + "sharepoint@openai-curated", + "outlook-email@openai-curated", + "outlook-calendar@openai-curated", + "linear@openai-curated", + "figma@openai-curated", + "chrome@openai-bundled", + "computer-use@openai-bundled", +]; + +pub type LoadedPlugin = codex_plugin::LoadedPlugin; +pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; + +pub use manager::ConfiguredMarketplace; +pub use manager::ConfiguredMarketplaceListOutcome; +pub use manager::ConfiguredMarketplacePlugin; +pub use manager::PluginDetail; +pub use manager::PluginDetailsUnavailableReason; +pub use manager::PluginInstallError; +pub use manager::PluginInstallOutcome; +pub use manager::PluginInstallRequest; +pub use manager::PluginReadOutcome; +pub use manager::PluginReadRequest; +pub use manager::PluginRemoteSyncError; +pub use manager::PluginUninstallError; +pub use manager::PluginsConfigInput; +pub use manager::PluginsManager; +pub use manager::RemotePluginSyncResult; +pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; +pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; diff --git a/code-rs/core-plugins/src/loader.rs b/code-rs/core-plugins/src/loader.rs new file mode 100644 index 00000000000..f348a3414df --- /dev/null +++ b/code-rs/core-plugins/src/loader.rs @@ -0,0 +1,1212 @@ +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::manifest::PluginManifestHooks; +use crate::manifest::PluginManifestPaths; +use crate::manifest::load_plugin_manifest; +use crate::marketplace::MarketplacePluginSource; +use crate::marketplace::list_marketplaces; +use crate::marketplace::load_marketplace; +use crate::remote::RemoteInstalledPlugin; +use crate::store::PluginStore; +use crate::store::plugin_version_for_source; +use codex_config::ConfigLayerStack; +use codex_config::HooksFile; +use codex_config::types::McpServerConfig; +use codex_config::types::PluginConfig; +use codex_config::types::PluginMcpServerConfig; +use codex_core_skills::SkillMetadata; +use codex_core_skills::config_rules::SkillConfigRules; +use codex_core_skills::config_rules::resolve_disabled_skill_paths; +use codex_core_skills::config_rules::skill_config_rules_from_stack; +use codex_core_skills::loader::SkillRoot; +use codex_core_skills::loader::load_skills_from_roots; +use codex_exec_server::LOCAL_FS; +use codex_plugin::AppConnectorId; +use codex_plugin::LoadedPlugin; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginHookSource; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_plugin::PluginLoadOutcome; +use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::protocol::Product; +use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::find_plugin_manifest_path; +use serde::Deserialize; +use serde_json::Map as JsonMap; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; +use tempfile::TempDir; +use tracing::warn; + +const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; +const DEFAULT_HOOKS_CONFIG_FILE: &str = "hooks/hooks.json"; +const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; +const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; +const CONFIG_TOML_FILE: &str = "config.toml"; +const CURATED_PLUGIN_CACHE_VERSION_SHA_PREFIX_LEN: usize = 8; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum NonCuratedCacheRefreshMode { + IfVersionChanged, + ForceReinstall, +} + +pub fn log_plugin_load_errors(outcome: &PluginLoadOutcome) { + for plugin in outcome + .plugins() + .iter() + .filter(|plugin| plugin.error.is_some()) + { + if let Some(error) = plugin.error.as_deref() { + warn!( + plugin = plugin.config_name, + path = %plugin.root.display(), + "failed to load plugin: {error}" + ); + } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginMcpServersFile { + mcp_servers: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PluginMcpFile { + McpServersObject(PluginMcpServersFile), + ServerMap(HashMap), +} + +impl PluginMcpFile { + fn into_mcp_servers(self) -> HashMap { + match self { + Self::McpServersObject(file) => file.mcp_servers, + Self::ServerMap(mcp_servers) => mcp_servers, + } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginAppFile { + #[serde(default)] + apps: HashMap, +} + +#[derive(Debug, Default, Deserialize)] +struct PluginAppConfig { + id: String, +} + +pub async fn load_plugins_from_layer_stack( + config_layer_stack: &ConfigLayerStack, + extra_plugins: HashMap, + store: &PluginStore, + restriction_product: Option, + plugin_hooks_enabled: bool, +) -> PluginLoadOutcome { + let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); + let mut configured_plugins = configured_plugins_from_stack(config_layer_stack); + configured_plugins.extend(extra_plugins); + let mut configured_plugins: Vec<_> = configured_plugins.into_iter().collect(); + configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + + let mut plugins = Vec::with_capacity(configured_plugins.len()); + let mut seen_mcp_server_names = HashMap::::new(); + for (configured_name, plugin) in configured_plugins { + let loaded_plugin = load_plugin( + configured_name.clone(), + &plugin, + store, + restriction_product, + &skill_config_rules, + plugin_hooks_enabled, + ) + .await; + for name in loaded_plugin.mcp_servers.keys() { + if let Some(previous_plugin) = + seen_mcp_server_names.insert(name.clone(), configured_name.clone()) + { + warn!( + plugin = configured_name, + previous_plugin, + server = name, + "skipping duplicate plugin MCP server name" + ); + } + } + plugins.push(loaded_plugin); + } + + PluginLoadOutcome::from_plugins(plugins) +} + +pub fn remote_installed_plugins_to_config( + plugins: &[RemoteInstalledPlugin], + store: &PluginStore, +) -> HashMap { + plugins + .iter() + .filter_map(|plugin| { + let plugin_id = + match PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) { + Ok(plugin_id) => plugin_id, + Err(err) => { + warn!( + plugin = %plugin.name, + remote_id = %plugin.id, + error = %err, + "ignoring invalid remote installed plugin name" + ); + return None; + } + }; + // TODO(remote plugins): download or update missing local bundles during remote + // installed reconciliation. Until then, only publish remote installed state for + // bundles already present in the local plugin cache. + store.active_plugin_root(&plugin_id)?; + Some(( + plugin_id.as_key(), + PluginConfig { + enabled: plugin.enabled, + mcp_servers: HashMap::new(), + }, + )) + }) + .collect() +} + +pub fn refresh_curated_plugin_cache( + codex_home: &Path, + plugin_version: &str, + configured_curated_plugin_ids: &[PluginId], +) -> Result { + let cache_plugin_version = curated_plugin_cache_version(plugin_version); + let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; + let curated_marketplace_path = AbsolutePathBuf::try_from( + codex_home + .join(".tmp/plugins") + .join(".agents/plugins/marketplace.json"), + ) + .map_err(|_| "local curated marketplace is not available".to_string())?; + let curated_marketplace = load_marketplace(&curated_marketplace_path) + .map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?; + + let mut plugin_sources = HashMap::::new(); + for plugin in curated_marketplace.plugins { + let plugin_name = plugin.name; + if plugin_sources.contains_key(&plugin_name) { + warn!( + plugin = plugin_name, + marketplace = OPENAI_CURATED_MARKETPLACE_NAME, + "ignoring duplicate curated plugin entry during cache refresh" + ); + continue; + } + let source_path = match plugin.source { + MarketplacePluginSource::Local { path } => path, + MarketplacePluginSource::Git { .. } => { + warn!( + plugin = plugin_name, + marketplace = OPENAI_CURATED_MARKETPLACE_NAME, + "skipping remote curated plugin source during cache refresh" + ); + continue; + } + }; + plugin_sources.insert(plugin_name, source_path); + } + + let mut cache_refreshed = false; + for plugin_id in configured_curated_plugin_ids { + if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str()) + { + continue; + } + + let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { + warn!( + plugin = plugin_id.plugin_name, + marketplace = OPENAI_CURATED_MARKETPLACE_NAME, + "configured curated plugin no longer exists in curated marketplace during cache refresh" + ); + continue; + }; + + store + .install_with_version(source_path, plugin_id.clone(), cache_plugin_version.clone()) + .map_err(|err| { + format!( + "failed to refresh curated plugin cache for {}: {err}", + plugin_id.as_key() + ) + })?; + cache_refreshed = true; + } + + Ok(cache_refreshed) +} + +pub fn curated_plugin_cache_version(plugin_version: &str) -> String { + if is_full_git_sha(plugin_version) { + plugin_version[..CURATED_PLUGIN_CACHE_VERSION_SHA_PREFIX_LEN].to_string() + } else { + plugin_version.to_string() + } +} + +pub fn refresh_non_curated_plugin_cache( + codex_home: &Path, + additional_roots: &[AbsolutePathBuf], +) -> Result { + refresh_non_curated_plugin_cache_with_mode( + codex_home, + additional_roots, + NonCuratedCacheRefreshMode::IfVersionChanged, + ) +} + +pub fn refresh_non_curated_plugin_cache_force_reinstall( + codex_home: &Path, + additional_roots: &[AbsolutePathBuf], +) -> Result { + refresh_non_curated_plugin_cache_with_mode( + codex_home, + additional_roots, + NonCuratedCacheRefreshMode::ForceReinstall, + ) +} + +fn refresh_non_curated_plugin_cache_with_mode( + codex_home: &Path, + additional_roots: &[AbsolutePathBuf], + mode: NonCuratedCacheRefreshMode, +) -> Result { + let configured_non_curated_plugin_ids = + non_curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home( + codex_home, + "failed to read user config while refreshing non-curated plugin cache", + "failed to parse user config while refreshing non-curated plugin cache", + )); + if configured_non_curated_plugin_ids.is_empty() { + return Ok(false); + } + let configured_non_curated_plugin_keys = configured_non_curated_plugin_ids + .iter() + .map(PluginId::as_key) + .collect::>(); + + let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; + let marketplace_outcome = list_marketplaces(additional_roots) + .map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?; + let mut plugin_sources = HashMap::::new(); + + for marketplace in marketplace_outcome.marketplaces { + if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME { + continue; + } + + for plugin in marketplace.plugins { + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(|err| { + match err { + PluginIdError::Invalid(message) => { + format!("failed to prepare non-curated plugin cache refresh: {message}") + } + } + })?; + let plugin_key = plugin_id.as_key(); + if !configured_non_curated_plugin_keys.contains(&plugin_key) { + continue; + } + if plugin_sources.contains_key(&plugin_key) { + warn!( + plugin = plugin.name, + marketplace = marketplace.name, + "ignoring duplicate non-curated plugin entry during cache refresh" + ); + continue; + } + + plugin_sources.insert(plugin_key, plugin.source); + } + } + + let mut cache_refreshed = false; + for plugin_id in configured_non_curated_plugin_ids { + let plugin_key = plugin_id.as_key(); + let Some(source) = plugin_sources.get(&plugin_key).cloned() else { + warn!( + plugin = plugin_id.plugin_name, + marketplace = plugin_id.marketplace_name, + "configured non-curated plugin no longer exists in discovered marketplaces during cache refresh" + ); + continue; + }; + let materialized = + materialize_marketplace_plugin_source(codex_home, &source).map_err(|err| { + format!("failed to materialize plugin source for {plugin_key}: {err}") + })?; + let source_path = materialized.path.clone(); + let plugin_version = plugin_version_for_source(source_path.as_path()) + .map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?; + + if mode == NonCuratedCacheRefreshMode::IfVersionChanged + && store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str()) + { + continue; + } + + store + .install_with_version(source_path, plugin_id.clone(), plugin_version) + .map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?; + cache_refreshed = true; + } + + Ok(cache_refreshed) +} + +fn configured_plugins_from_stack( + config_layer_stack: &ConfigLayerStack, +) -> HashMap { + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return HashMap::new(); + }; + configured_plugins_from_user_config_value(&user_layer.config) +} + +fn is_full_git_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn configured_plugins_from_user_config_value( + user_config: &toml::Value, +) -> HashMap { + let Some(plugins_value) = user_config.get("plugins") else { + return HashMap::new(); + }; + match plugins_value.clone().try_into() { + Ok(plugins) => plugins, + Err(err) => { + warn!("invalid plugins config: {err}"); + HashMap::new() + } + } +} + +fn configured_plugins_from_codex_home( + codex_home: &Path, + read_error_message: &str, + parse_error_message: &str, +) -> HashMap { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let user_config = match fs::read_to_string(&config_path) { + Ok(user_config) => user_config, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), + Err(err) => { + warn!( + path = %config_path.display(), + error = %err, + "{read_error_message}" + ); + return HashMap::new(); + } + }; + + let user_config = match toml::from_str::(&user_config) { + Ok(user_config) => user_config, + Err(err) => { + warn!( + path = %config_path.display(), + error = %err, + "{parse_error_message}" + ); + return HashMap::new(); + } + }; + + configured_plugins_from_user_config_value(&user_config) +} + +fn configured_plugin_ids( + configured_plugins: HashMap, + invalid_plugin_key_message: &str, +) -> Vec { + configured_plugins + .into_keys() + .filter_map(|plugin_key| match PluginId::parse(&plugin_key) { + Ok(plugin_id) => Some(plugin_id), + Err(err) => { + warn!( + plugin_key, + error = %err, + "{invalid_plugin_key_message}" + ); + None + } + }) + .collect() +} + +fn curated_plugin_ids_from_config_keys( + configured_plugins: HashMap, +) -> Vec { + let mut configured_curated_plugin_ids = configured_plugin_ids( + configured_plugins, + "ignoring invalid configured plugin key during curated sync setup", + ) + .into_iter() + .filter(|plugin_id| plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME) + .collect::>(); + configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); + configured_curated_plugin_ids +} + +fn non_curated_plugin_ids_from_config_keys( + configured_plugins: HashMap, +) -> Vec { + let mut configured_non_curated_plugin_ids = configured_plugin_ids( + configured_plugins, + "ignoring invalid plugin key during non-curated cache refresh setup", + ) + .into_iter() + .filter(|plugin_id| plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME) + .collect::>(); + configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); + configured_non_curated_plugin_ids +} + +pub fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec { + curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home( + codex_home, + "failed to read user config while refreshing curated plugin cache", + "failed to parse user config while refreshing curated plugin cache", + )) +} + +async fn load_plugin( + config_name: String, + plugin: &PluginConfig, + store: &PluginStore, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, + plugin_hooks_enabled: bool, +) -> LoadedPlugin { + let plugin_id = PluginId::parse(&config_name); + let active_plugin_root = plugin_id + .as_ref() + .ok() + .and_then(|plugin_id| store.active_plugin_root(plugin_id)); + let root = active_plugin_root + .clone() + .unwrap_or_else(|| match &plugin_id { + Ok(plugin_id) => store.plugin_base_root(plugin_id), + Err(_) => store.root().clone(), + }); + let mut loaded_plugin = LoadedPlugin { + config_name, + manifest_name: None, + manifest_description: None, + root, + enabled: plugin.enabled, + skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, + mcp_servers: HashMap::new(), + apps: Vec::new(), + hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), + error: None, + }; + + if !plugin.enabled { + return loaded_plugin; + } + + let (loaded_plugin_id, plugin_root) = match plugin_id { + Ok(plugin_id) => { + let Some(plugin_root) = active_plugin_root else { + loaded_plugin.error = Some("plugin is not installed".to_string()); + return loaded_plugin; + }; + (plugin_id, plugin_root) + } + Err(err) => { + loaded_plugin.error = Some(err.to_string()); + return loaded_plugin; + } + }; + + if !plugin_root.as_path().is_dir() { + loaded_plugin.error = Some("path does not exist or is not a directory".to_string()); + return loaded_plugin; + } + + let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else { + loaded_plugin.error = Some("missing or invalid plugin.json".to_string()); + return loaded_plugin; + }; + + let manifest_paths = &manifest.paths; + loaded_plugin.manifest_name = manifest + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .or_else(|| Some(manifest.name.clone())); + loaded_plugin.manifest_description = manifest.description.clone(); + loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); + let resolved_skills = load_plugin_skills( + &plugin_root, + &loaded_plugin_id, + manifest_paths, + restriction_product, + skill_config_rules, + ) + .await; + let has_enabled_skills = resolved_skills.has_enabled_skills(); + loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; + loaded_plugin.has_enabled_skills = has_enabled_skills; + let mut mcp_servers = HashMap::new(); + for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { + let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await; + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin.mcp_servers.get(&name) { + apply_plugin_mcp_server_policy(&mut config, policy); + } + if mcp_servers.insert(name.clone(), config).is_some() { + warn!( + plugin = %plugin_root.display(), + path = %mcp_config_path.display(), + server = name, + "plugin MCP file overwrote an earlier server definition" + ); + } + } + } + loaded_plugin.mcp_servers = mcp_servers; + loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; + if plugin_hooks_enabled { + let (hook_sources, hook_load_warnings) = load_plugin_hooks( + &plugin_root, + &loaded_plugin_id, + &store.plugin_data_root(&loaded_plugin_id), + manifest_paths, + ); + loaded_plugin.hook_sources = hook_sources; + loaded_plugin.hook_load_warnings = hook_load_warnings; + } + loaded_plugin +} + +fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginMcpServerConfig) { + config.enabled = policy.enabled; + if let Some(approval_mode) = policy.default_tools_approval_mode { + config.default_tools_approval_mode = Some(approval_mode); + } + if let Some(enabled_tools) = &policy.enabled_tools { + config.enabled_tools = Some(enabled_tools.clone()); + } + if let Some(disabled_tools) = &policy.disabled_tools { + config.disabled_tools = Some(disabled_tools.clone()); + } + for (tool_name, tool_policy) in &policy.tools { + let tool_config = config.tools.entry(tool_name.clone()).or_default(); + if let Some(approval_mode) = tool_policy.approval_mode { + tool_config.approval_mode = Some(approval_mode); + } + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedPluginSkills { + pub skills: Vec, + pub disabled_skill_paths: HashSet, + pub had_errors: bool, +} + +impl ResolvedPluginSkills { + pub fn has_enabled_skills(&self) -> bool { + self.had_errors + || self + .skills + .iter() + .any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md)) + } +} + +pub async fn load_plugin_skills( + plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, + manifest_paths: &PluginManifestPaths, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, +) -> ResolvedPluginSkills { + let roots = plugin_skill_roots(plugin_root, manifest_paths) + .into_iter() + .map(|path| SkillRoot { + path, + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some(plugin_id.as_key()), + }) + .collect::>(); + let outcome = load_skills_from_roots(roots).await; + let had_errors = !outcome.errors.is_empty(); + let skills = outcome + .skills + .into_iter() + .filter(|skill| skill.matches_product_restriction_for_product(restriction_product)) + .collect::>(); + let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules); + + ResolvedPluginSkills { + skills, + disabled_skill_paths, + had_errors, + } +} + +fn plugin_skill_roots( + plugin_root: &AbsolutePathBuf, + manifest_paths: &PluginManifestPaths, +) -> Vec { + let mut paths = default_skill_roots(plugin_root); + if let Some(path) = &manifest_paths.skills { + paths.push(path.clone()); + } + paths.sort_unstable(); + paths.dedup(); + paths +} + +fn default_skill_roots(plugin_root: &AbsolutePathBuf) -> Vec { + let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME); + if skills_dir.is_dir() { + vec![skills_dir] + } else { + Vec::new() + } +} + +fn plugin_mcp_config_paths( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, +) -> Vec { + if let Some(path) = &manifest_paths.mcp_servers { + return vec![path.clone()]; + } + default_mcp_config_paths(plugin_root) +} + +fn default_mcp_config_paths(plugin_root: &Path) -> Vec { + let mut paths = Vec::new(); + let default_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE); + if default_path.is_file() + && let Ok(default_path) = AbsolutePathBuf::try_from(default_path) + { + paths.push(default_path); + } + paths.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path())); + paths.dedup_by(|left, right| left.as_path() == right.as_path()); + paths +} + +pub async fn load_plugin_apps(plugin_root: &Path) -> Vec { + if let Some(manifest) = load_plugin_manifest(plugin_root) { + return load_apps_from_paths( + plugin_root, + plugin_app_config_paths(plugin_root, &manifest.paths), + ) + .await; + } + load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await +} + +fn plugin_app_config_paths( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, +) -> Vec { + if let Some(path) = &manifest_paths.apps { + return vec![path.clone()]; + } + default_app_config_paths(plugin_root) +} + +fn default_app_config_paths(plugin_root: &Path) -> Vec { + let mut paths = Vec::new(); + let default_path = plugin_root.join(DEFAULT_APP_CONFIG_FILE); + if default_path.is_file() + && let Ok(default_path) = AbsolutePathBuf::try_from(default_path) + { + paths.push(default_path); + } + paths.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path())); + paths.dedup_by(|left, right| left.as_path() == right.as_path()); + paths +} + +// Discover plugin-bundled hooks from manifest `hooks` entries when present +// (path, paths, inline object, or inline objects), otherwise from the default +// `hooks/hooks.json` file. +pub fn load_plugin_hooks( + plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, + plugin_data_root: &AbsolutePathBuf, + manifest_paths: &PluginManifestPaths, +) -> (Vec, Vec) { + let mut sources = Vec::new(); + let mut warnings = Vec::new(); + match &manifest_paths.hooks { + Some(PluginManifestHooks::Paths(paths)) => { + for path in paths { + append_plugin_hook_file( + plugin_root, + plugin_id, + plugin_data_root, + path, + &mut sources, + &mut warnings, + ); + } + } + Some(PluginManifestHooks::Inline(hooks_files)) => { + let manifest_path = find_plugin_manifest_path(plugin_root.as_path()) + .and_then(|path| AbsolutePathBuf::try_from(path).ok()) + .unwrap_or_else(|| plugin_root.join(".codex-plugin/plugin.json")); + for (index, hooks_file) in hooks_files.iter().enumerate() { + if hooks_file.hooks.is_empty() { + continue; + } + sources.push(PluginHookSource { + plugin_id: plugin_id.clone(), + plugin_root: plugin_root.clone(), + plugin_data_root: plugin_data_root.clone(), + source_path: manifest_path.clone(), + source_relative_path: format!("plugin.json#hooks[{index}]"), + hooks: hooks_file.hooks.clone(), + }); + } + } + None => { + let default_path = plugin_root.join(DEFAULT_HOOKS_CONFIG_FILE); + if default_path.as_path().is_file() { + append_plugin_hook_file( + plugin_root, + plugin_id, + plugin_data_root, + &default_path, + &mut sources, + &mut warnings, + ); + } + } + } + (sources, warnings) +} + +// Append one resolved plugin hook file, keeping source metadata for runtime +// reporting and collecting load warnings for startup surfacing. +fn append_plugin_hook_file( + plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, + plugin_data_root: &AbsolutePathBuf, + path: &AbsolutePathBuf, + sources: &mut Vec, + warnings: &mut Vec, +) { + let contents = match fs::read_to_string(path.as_path()) { + Ok(contents) => contents, + Err(err) => { + warnings.push(format!( + "failed to read plugin hooks config {}: {err}", + path.display() + )); + return; + } + }; + let parsed = match serde_json::from_str::(&contents) { + Ok(parsed) => parsed, + Err(err) => { + warnings.push(format!( + "failed to parse plugin hooks config {}: {err}", + path.display() + )); + return; + } + }; + if parsed.hooks.is_empty() { + return; + } + + let source_relative_path = path + .as_path() + .strip_prefix(plugin_root.as_path()) + .unwrap_or(path.as_path()) + .to_string_lossy() + .replace('\\', "/"); + + sources.push(PluginHookSource { + plugin_id: plugin_id.clone(), + plugin_root: plugin_root.clone(), + plugin_data_root: plugin_data_root.clone(), + source_path: path.clone(), + source_relative_path, + hooks: parsed.hooks, + }); +} + +async fn load_apps_from_paths( + plugin_root: &Path, + app_config_paths: Vec, +) -> Vec { + let mut connector_ids = Vec::new(); + for app_config_path in app_config_paths { + let Ok(contents) = tokio::fs::read_to_string(app_config_path.as_path()).await else { + continue; + }; + let parsed = match serde_json::from_str::(&contents) { + Ok(parsed) => parsed, + Err(err) => { + warn!( + path = %app_config_path.display(), + "failed to parse plugin app config: {err}" + ); + continue; + } + }; + + let mut apps: Vec = parsed.apps.into_values().collect(); + apps.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + + connector_ids.extend(apps.into_iter().filter_map(|app| { + if app.id.trim().is_empty() { + warn!( + plugin = %plugin_root.display(), + "plugin app config is missing an app id" + ); + None + } else { + Some(AppConnectorId(app.id)) + } + })); + } + connector_ids.dedup(); + connector_ids +} + +pub async fn plugin_telemetry_metadata_from_root( + plugin_id: &PluginId, + plugin_root: &AbsolutePathBuf, +) -> PluginTelemetryMetadata { + let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else { + return PluginTelemetryMetadata::from_plugin_id(plugin_id); + }; + + let manifest_paths = &manifest.paths; + let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty(); + let mut mcp_server_names = Vec::new(); + for path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { + mcp_server_names.extend( + load_mcp_servers_from_file(plugin_root.as_path(), &path) + .await + .mcp_servers + .into_keys(), + ); + } + mcp_server_names.sort_unstable(); + mcp_server_names.dedup(); + + PluginTelemetryMetadata { + plugin_id: plugin_id.clone(), + remote_plugin_id: None, + capability_summary: Some(PluginCapabilitySummary { + config_name: plugin_id.as_key(), + display_name: plugin_id.plugin_name.clone(), + description: None, + has_skills, + mcp_server_names, + app_connector_ids: load_apps_from_paths( + plugin_root.as_path(), + plugin_app_config_paths(plugin_root.as_path(), manifest_paths), + ) + .await, + }), + } +} + +pub async fn load_plugin_mcp_servers(plugin_root: &Path) -> HashMap { + let Some(manifest) = load_plugin_manifest(plugin_root) else { + return HashMap::new(); + }; + + let mut mcp_servers = HashMap::new(); + for mcp_config_path in plugin_mcp_config_paths(plugin_root, &manifest.paths) { + let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await; + for (name, config) in plugin_mcp.mcp_servers { + mcp_servers.entry(name).or_insert(config); + } + } + + mcp_servers +} + +pub async fn installed_plugin_telemetry_metadata( + codex_home: &Path, + plugin_id: &PluginId, +) -> PluginTelemetryMetadata { + let store = match PluginStore::try_new(codex_home.to_path_buf()) { + Ok(store) => store, + Err(err) => { + warn!("failed to resolve plugin cache root: {err}"); + return PluginTelemetryMetadata::from_plugin_id(plugin_id); + } + }; + let Some(plugin_root) = store.active_plugin_root(plugin_id) else { + return PluginTelemetryMetadata::from_plugin_id(plugin_id); + }; + + plugin_telemetry_metadata_from_root(plugin_id, &plugin_root).await +} + +async fn load_mcp_servers_from_file( + plugin_root: &Path, + mcp_config_path: &AbsolutePathBuf, +) -> PluginMcpDiscovery { + let Ok(contents) = tokio::fs::read_to_string(mcp_config_path.as_path()).await else { + return PluginMcpDiscovery::default(); + }; + let parsed = match serde_json::from_str::(&contents) { + Ok(parsed) => parsed, + Err(err) => { + warn!( + path = %mcp_config_path.display(), + "failed to parse plugin MCP config: {err}" + ); + return PluginMcpDiscovery::default(); + } + }; + normalize_plugin_mcp_servers( + plugin_root, + parsed.into_mcp_servers(), + mcp_config_path.to_string_lossy().as_ref(), + ) +} + +fn normalize_plugin_mcp_servers( + plugin_root: &Path, + plugin_mcp_servers: HashMap, + source: &str, +) -> PluginMcpDiscovery { + let mut mcp_servers = HashMap::new(); + + for (name, config_value) in plugin_mcp_servers { + let normalized = normalize_plugin_mcp_server_value(plugin_root, config_value); + match serde_json::from_value::(JsonValue::Object(normalized)) { + Ok(config) => { + mcp_servers.insert(name, config); + } + Err(err) => { + warn!( + plugin = %plugin_root.display(), + server = name, + "failed to parse plugin MCP server from {source}: {err}" + ); + } + } + } + + PluginMcpDiscovery { mcp_servers } +} + +fn normalize_plugin_mcp_server_value( + plugin_root: &Path, + value: JsonValue, +) -> JsonMap { + let mut object = match value { + JsonValue::Object(object) => object, + _ => return JsonMap::new(), + }; + + if let Some(JsonValue::String(transport_type)) = object.remove("type") { + match transport_type.as_str() { + "http" | "streamable_http" | "streamable-http" => {} + "stdio" => {} + other => { + warn!( + plugin = %plugin_root.display(), + transport = other, + "plugin MCP server uses an unknown transport type" + ); + } + } + } + + if let Some(JsonValue::Object(oauth)) = object.remove("oauth") + && oauth.contains_key("callbackPort") + { + warn!( + plugin = %plugin_root.display(), + "plugin MCP server OAuth callbackPort is ignored; Codex uses global MCP OAuth callback settings" + ); + } + + if let Some(JsonValue::String(cwd)) = object.get("cwd") + && !Path::new(cwd).is_absolute() + { + object.insert( + "cwd".to_string(), + JsonValue::String(plugin_root.join(cwd).display().to_string()), + ); + } + + object +} + +#[derive(Debug, Default)] +struct PluginMcpDiscovery { + mcp_servers: HashMap, +} + +#[derive(Debug)] +pub struct MaterializedMarketplacePluginSource { + pub path: AbsolutePathBuf, + _tempdir: Option, +} + +pub fn materialize_marketplace_plugin_source( + codex_home: &Path, + source: &MarketplacePluginSource, +) -> Result { + match source { + MarketplacePluginSource::Local { path } => Ok(MaterializedMarketplacePluginSource { + path: path.clone(), + _tempdir: None, + }), + MarketplacePluginSource::Git { + url, + path, + ref_name, + sha, + } => { + let staging_root = codex_home.join("plugins/.marketplace-plugin-source-staging"); + fs::create_dir_all(&staging_root).map_err(|err| { + format!( + "failed to create marketplace plugin source staging directory {}: {err}", + staging_root.display() + ) + })?; + let tempdir = tempfile::Builder::new() + .prefix("marketplace-plugin-source-") + .tempdir_in(&staging_root) + .map_err(|err| { + format!( + "failed to create marketplace plugin source staging directory in {}: {err}", + staging_root.display() + ) + })?; + clone_git_plugin_source( + url, + ref_name.as_deref(), + sha.as_deref(), + path.as_deref(), + tempdir.path(), + )?; + let path = if let Some(path) = path { + AbsolutePathBuf::try_from(tempdir.path().join(path)).map_err(|err| { + format!("failed to resolve materialized plugin source path: {err}") + })? + } else { + AbsolutePathBuf::try_from(tempdir.path().to_path_buf()).map_err(|err| { + format!("failed to resolve materialized plugin source path: {err}") + })? + }; + Ok(MaterializedMarketplacePluginSource { + path, + _tempdir: Some(tempdir), + }) + } + } +} + +fn clone_git_plugin_source( + url: &str, + ref_name: Option<&str>, + sha: Option<&str>, + sparse_checkout_path: Option<&str>, + destination: &Path, +) -> Result<(), String> { + if let Some(sparse_checkout_path) = sparse_checkout_path { + run_git( + &[ + "clone", + "--filter=blob:none", + "--sparse", + "--no-checkout", + url, + destination.to_string_lossy().as_ref(), + ], + /*cwd*/ None, + )?; + run_git( + &[ + "sparse-checkout", + "set", + "--no-cone", + "--", + sparse_checkout_path, + ], + Some(destination), + )?; + } else { + run_git( + &["clone", url, destination.to_string_lossy().as_ref()], + /*cwd*/ None, + )?; + } + if let Some(target) = sha.or(ref_name) { + run_git(&["checkout", target], Some(destination))?; + } else if sparse_checkout_path.is_some() { + run_git(&["checkout"], Some(destination))?; + } + Ok(()) +} + +fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), String> { + let mut command = Command::new("git"); + command.args(args); + command.env("GIT_TERMINAL_PROMPT", "0"); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let output = command + .output() + .map_err(|err| format!("failed to run git {}: {err}", args.join(" ")))?; + if output.status.success() { + return Ok(()); + } + + Err(format!( + "git {} failed with status {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + )) +} + +#[cfg(test)] +#[path = "loader_tests.rs"] +mod tests; diff --git a/code-rs/core-plugins/src/loader_tests.rs b/code-rs/core-plugins/src/loader_tests.rs new file mode 100644 index 00000000000..d9029c584eb --- /dev/null +++ b/code-rs/core-plugins/src/loader_tests.rs @@ -0,0 +1,369 @@ +use super::*; +use crate::manifest::load_plugin_manifest; +use codex_plugin::PluginId; +use pretty_assertions::assert_eq; + +#[test] +fn plugin_mcp_file_supports_mcp_servers_object_format() { + let parsed = serde_json::from_str::( + r#"{ + "mcpServers": { + "sample": { + "command": "sample-mcp" + } + } +}"#, + ) + .expect("parse wrapped plugin mcp config") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "sample".to_string(), + serde_json::json!({ + "command": "sample-mcp" + }), + )]) + ); +} + +#[test] +fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() { + let parsed = serde_json::from_str::( + r#"{ + "$schema": "https://example.com/plugin-mcp.schema.json", + "mcpServers": { + "sample": { + "command": "sample-mcp" + } + } +}"#, + ) + .expect("parse plugin mcp config with metadata") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "sample".to_string(), + serde_json::json!({ + "command": "sample-mcp" + }), + )]) + ); +} + +#[test] +fn plugin_mcp_file_supports_top_level_server_map_format() { + let parsed = serde_json::from_str::( + r#"{ + "linear": { + "type": "http", + "url": "https://mcp.linear.app/mcp" + } +}"#, + ) + .expect("parse flat plugin mcp config") + .into_mcp_servers(); + + assert_eq!( + parsed, + HashMap::from([( + "linear".to_string(), + serde_json::json!({ + "type": "http", + "url": "https://mcp.linear.app/mcp" + }), + )]) + ); +} + +#[test] +fn curated_plugin_cache_version_shortens_full_git_sha() { + assert_eq!( + curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"), + "01234567" + ); +} + +#[test] +fn curated_plugin_cache_version_preserves_non_git_sha_versions() { + assert_eq!( + curated_plugin_cache_version("export-backup"), + "export-backup" + ); + assert_eq!(curated_plugin_cache_version("0123456"), "0123456"); +} + +fn plugin_id() -> PluginId { + PluginId::parse("demo-plugin@test-marketplace").expect("plugin id") +} + +fn plugin_root() -> (tempfile::TempDir, AbsolutePathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + let plugin_root = + AbsolutePathBuf::try_from(tmp.path().join("demo-plugin")).expect("plugin root"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir"); + (tmp, plugin_root) +} + +fn write_manifest(plugin_root: &AbsolutePathBuf, manifest: &str) { + fs::write(plugin_root.join(".codex-plugin/plugin.json"), manifest).expect("write manifest"); +} + +fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &str, command: &str) { + fs::write( + plugin_root.join(relative_path), + format!( + r#"{{ + "hooks": {{ + "{event}": [ + {{ + "hooks": [{{ "type": "command", "command": "{command}" }}] + }} + ] + }} +}}"# + ), + ) + .expect("write hooks"); +} + +fn load_sources(plugin_root: &AbsolutePathBuf) -> (Vec, Vec) { + let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); + let plugin_data_root = AbsolutePathBuf::try_from( + plugin_root + .as_path() + .parent() + .expect("plugin root parent") + .join("plugin-data"), + ) + .expect("plugin data root"); + load_plugin_hooks( + plugin_root, + &plugin_id(), + &plugin_data_root, + &manifest.paths, + ) +} + +fn assert_sources(sources: &[PluginHookSource], expected_relative_paths: &[&str]) { + assert_eq!( + sources + .iter() + .map(|source| source.plugin_id.clone()) + .collect::>(), + vec![plugin_id(); expected_relative_paths.len()] + ); + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + expected_relative_paths + ); + assert_eq!( + sources + .iter() + .map(|source| source.hooks.handler_count()) + .collect::>(), + vec![1; expected_relative_paths.len()] + ); +} + +#[test] +fn load_plugin_hooks_discovers_default_hooks_file() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest(&plugin_root, r#"{ "name": "demo-plugin" }"#); + fs::write( + plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "echo default" }] + } + ] + } +}"#, + ) + .expect("write hooks"); + + let (sources, warnings) = load_sources(&plugin_root); + + assert_eq!(warnings, Vec::::new()); + assert_sources(&sources, &["hooks/hooks.json"]); +} + +#[test] +fn load_plugin_hooks_supports_manifest_hook_path() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": "./hooks/one.json" +}"#, + ); + write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one"); + + let (sources, warnings) = load_sources(&plugin_root); + + assert_eq!(warnings, Vec::::new()); + assert_sources(&sources, &["hooks/one.json"]); +} + +#[test] +fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": ["./hooks/one.json", "./hooks/two.json"] +}"#, + ); + write_hook_file( + &plugin_root, + "hooks/hooks.json", + "PreToolUse", + "echo ignored", + ); + write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one"); + write_hook_file(&plugin_root, "hooks/two.json", "PostToolUse", "echo two"); + + let (sources, warnings) = load_sources(&plugin_root); + + assert_eq!(warnings, Vec::::new()); + assert_sources(&sources, &["hooks/one.json", "hooks/two.json"]); +} + +#[test] +fn load_plugin_hooks_supports_inline_manifest_hooks() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": { + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [{ "type": "command", "command": "echo inline" }] + } + ] + } + } +}"#, + ); + + let (sources, warnings) = load_sources(&plugin_root); + + assert_eq!(warnings, Vec::::new()); + assert_sources(&sources, &["plugin.json#hooks[0]"]); +} + +#[test] +fn load_plugin_hooks_reports_invalid_hook_file() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest(&plugin_root, r#"{ "name": "demo-plugin" }"#); + fs::write(plugin_root.join("hooks/hooks.json"), "{ not-json").expect("write invalid hooks"); + + let (sources, warnings) = load_sources(&plugin_root); + + assert_eq!(sources, Vec::::new()); + assert_eq!( + warnings, + vec![format!( + "failed to parse plugin hooks config {}: key must be a string at line 1 column 3", + plugin_root.join("hooks/hooks.json").display() + )] + ); +} + +#[test] +fn load_plugin_hooks_supports_inline_manifest_hook_list() { + let (_tmp, plugin_root) = plugin_root(); + write_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "hooks": [ + { + "hooks": { + "SessionStart": [ + { + "hooks": [{ "type": "command", "command": "echo inline one" }] + } + ] + } + }, + { + "hooks": { + "Stop": [ + { + "hooks": [{ "type": "command", "command": "echo inline two" }] + } + ] + } + } + ] +}"#, + ); + + let (sources, warnings) = load_sources(&plugin_root); + + assert_eq!(warnings, Vec::::new()); + assert_sources(&sources, &["plugin.json#hooks[0]", "plugin.json#hooks[1]"]); +} + +#[test] +fn materialize_git_subdir_uses_sparse_checkout() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let repo = tempfile::tempdir().expect("create git repo"); + let plugin_dir = repo.path().join("plugins/toolkit"); + fs::create_dir_all(&plugin_dir).expect("create plugin directory"); + fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin"); + fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker"); + fs::write(repo.path().join("plugins/other/marker.txt"), "other").expect("write other marker"); + fs::write(repo.path().join("root.txt"), "root").expect("write root marker"); + + run_git(&["init"], Some(repo.path())).expect("init git repo"); + run_git( + &["config", "user.email", "test@example.com"], + Some(repo.path()), + ) + .expect("configure git email"); + run_git(&["config", "user.name", "Test User"], Some(repo.path())).expect("configure git name"); + run_git(&["add", "."], Some(repo.path())).expect("stage git repo"); + run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo"); + + let materialized = materialize_marketplace_plugin_source( + codex_home.path(), + &MarketplacePluginSource::Git { + url: repo.path().display().to_string(), + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + }, + ) + .expect("materialize git source"); + + assert_eq!( + plugin_dir.file_name(), + materialized.path.as_path().file_name() + ); + assert!(materialized.path.as_path().join("marker.txt").is_file()); + let checkout_root = materialized + .path + .as_path() + .parent() + .and_then(Path::parent) + .expect("materialized path should be nested under checkout root"); + assert!(!checkout_root.join("root.txt").exists()); + assert!(!checkout_root.join("plugins/other/marker.txt").exists()); +} diff --git a/code-rs/core-plugins/src/manager.rs b/code-rs/core-plugins/src/manager.rs new file mode 100644 index 00000000000..adb8084cdc0 --- /dev/null +++ b/code-rs/core-plugins/src/manager.rs @@ -0,0 +1,2003 @@ +use super::PluginLoadOutcome; +use super::startup_remote_sync::start_startup_remote_plugin_sync_once; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; +use crate::loader::configured_curated_plugin_ids_from_codex_home; +use crate::loader::curated_plugin_cache_version; +use crate::loader::installed_plugin_telemetry_metadata; +use crate::loader::load_plugin_apps; +use crate::loader::load_plugin_hooks; +use crate::loader::load_plugin_mcp_servers; +use crate::loader::load_plugin_skills; +use crate::loader::load_plugins_from_layer_stack; +use crate::loader::log_plugin_load_errors; +use crate::loader::materialize_marketplace_plugin_source; +use crate::loader::plugin_telemetry_metadata_from_root; +use crate::loader::refresh_curated_plugin_cache; +use crate::loader::refresh_non_curated_plugin_cache; +use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; +use crate::loader::remote_installed_plugins_to_config; +use crate::manifest::PluginManifestInterface; +use crate::manifest::load_plugin_manifest; +use crate::marketplace::MarketplaceError; +use crate::marketplace::MarketplaceInterface; +use crate::marketplace::MarketplaceListError; +use crate::marketplace::MarketplacePluginAuthPolicy; +use crate::marketplace::MarketplacePluginPolicy; +use crate::marketplace::MarketplacePluginSource; +use crate::marketplace::ResolvedMarketplacePlugin; +use crate::marketplace::find_installable_marketplace_plugin; +use crate::marketplace::find_marketplace_plugin; +use crate::marketplace::list_marketplaces; +use crate::marketplace::load_marketplace; +use crate::marketplace::plugin_interface_with_marketplace_category; +use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; +use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; +use crate::marketplace_upgrade::configured_git_marketplace_names; +use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; +use crate::remote::RemoteInstalledPlugin; +use crate::remote::RemotePluginCatalogError; +use crate::remote::RemotePluginServiceConfig; +use crate::remote_legacy::RemotePluginFetchError; +use crate::remote_legacy::RemotePluginMutationError; +use crate::startup_sync::curated_plugins_repo_path; +use crate::startup_sync::read_curated_plugins_sha; +use crate::startup_sync::sync_openai_plugins_repo; +use crate::store::PluginInstallResult as StorePluginInstallResult; +use crate::store::PluginStore; +use crate::store::PluginStoreError; +use codex_analytics::AnalyticsEventsClient; +use codex_config::ConfigLayerStack; +use codex_config::PluginConfigEdit; +use codex_config::apply_user_plugin_config_edits; +use codex_config::clear_user_plugin; +use codex_config::set_user_plugin_enabled; +use codex_config::types::PluginConfig; +use codex_config::version_for_toml; +use codex_core_skills::SkillMetadata; +use codex_hooks::plugin_hook_declarations; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_plugin::prompt_safe_plugin_description; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::Product; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Instant; +use tokio::sync::Semaphore; +use tracing::info; +use tracing::warn; + +static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); +const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration = + std::time::Duration::from_secs(60 * 60 * 3); + +#[derive(Debug, Clone)] +pub struct PluginsConfigInput { + pub config_layer_stack: ConfigLayerStack, + pub plugins_enabled: bool, + pub remote_plugin_enabled: bool, + pub plugin_hooks_enabled: bool, + pub chatgpt_base_url: String, +} + +impl PluginsConfigInput { + pub fn new( + config_layer_stack: ConfigLayerStack, + plugins_enabled: bool, + remote_plugin_enabled: bool, + plugin_hooks_enabled: bool, + chatgpt_base_url: String, + ) -> Self { + Self { + config_layer_stack, + plugins_enabled, + remote_plugin_enabled, + plugin_hooks_enabled, + chatgpt_base_url, + } + } +} + +#[derive(Clone, PartialEq, Eq)] +struct FeaturedPluginIdsCacheKey { + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +#[derive(Clone)] +struct CachedFeaturedPluginIds { + key: FeaturedPluginIdsCacheKey, + expires_at: Instant, + featured_plugin_ids: Vec, +} + +struct RemoteInstalledPluginsCacheRefreshRequest { + service_config: RemotePluginServiceConfig, + auth: Option, + notify: RemoteInstalledPluginsCacheRefreshNotify, + // App-server attaches side effects such as skills metadata invalidation and MCP refreshes when + // remote installed state changes. + on_effective_plugins_changed: Option>, +} + +#[derive(Clone, Copy)] +enum RemoteInstalledPluginsCacheRefreshNotify { + IfCacheChanged, + // Remote mutations may change local bundles or active MCP state even when the installed set is + // unchanged. Notify after `/installed` succeeds so MCP refreshes are ordered after the remote + // installed cache. + AfterSuccessfulRefresh, +} + +#[derive(Default)] +struct RemoteInstalledPluginsCacheRefreshState { + requested: Option, + in_flight: bool, +} + +#[derive(Clone, PartialEq, Eq)] +struct NonCuratedCacheRefreshRequest { + roots: Vec, + mode: NonCuratedCacheRefreshMode, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum NonCuratedCacheRefreshMode { + IfVersionChanged, + ForceReinstall, +} + +#[derive(Default)] +struct NonCuratedCacheRefreshState { + requested: Option, + last_refreshed: Option, + in_flight: bool, +} + +#[derive(Default)] +struct ConfiguredMarketplaceUpgradeState { + in_flight: bool, +} + +fn remote_plugin_service_config(config: &PluginsConfigInput) -> RemotePluginServiceConfig { + RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + } +} + +fn featured_plugin_ids_cache_key( + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, +) -> FeaturedPluginIdsCacheKey { + FeaturedPluginIdsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id: auth.and_then(CodexAuth::get_account_id), + chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), + is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstallRequest { + pub plugin_name: String, + pub marketplace_path: AbsolutePathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginReadRequest { + pub plugin_name: String, + pub marketplace_path: AbsolutePathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstallOutcome { + pub plugin_id: PluginId, + pub plugin_version: String, + pub installed_path: AbsolutePathBuf, + pub auth_policy: MarketplacePluginAuthPolicy, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginReadOutcome { + pub marketplace_name: String, + pub marketplace_path: Option, + pub plugin: PluginDetail, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginDetail { + pub id: String, + pub name: String, + pub description: Option, + pub source: MarketplacePluginSource, + pub policy: MarketplacePluginPolicy, + pub interface: Option, + pub keywords: Vec, + pub installed: bool, + pub enabled: bool, + pub skills: Vec, + pub disabled_skill_paths: HashSet, + pub hooks: Vec, + pub apps: Vec, + pub mcp_server_names: Vec, + pub details_unavailable_reason: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginDetailsUnavailableReason { + InstallRequiredForRemoteSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfiguredMarketplace { + pub name: String, + pub path: AbsolutePathBuf, + pub interface: Option, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfiguredMarketplacePlugin { + pub id: String, + pub name: String, + pub source: MarketplacePluginSource, + pub policy: MarketplacePluginPolicy, + pub interface: Option, + pub keywords: Vec, + pub installed: bool, + pub enabled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConfiguredMarketplaceListOutcome { + pub marketplaces: Vec, + pub errors: Vec, +} + +impl From for PluginCapabilitySummary { + fn from(value: PluginDetail) -> Self { + let has_skills = value.skills.iter().any(|skill| { + !value + .disabled_skill_paths + .contains(&skill.path_to_skills_md) + }); + Self { + config_name: value.id, + display_name: value.name, + description: prompt_safe_plugin_description(value.description.as_deref()), + has_skills, + mcp_server_names: value.mcp_server_names, + app_connector_ids: value.apps, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RemotePluginSyncResult { + /// Plugin ids newly installed into the local plugin cache. + pub installed_plugin_ids: Vec, + /// Plugin ids whose local config was changed to enabled. + pub enabled_plugin_ids: Vec, + /// Plugin ids whose local config was changed to disabled. + /// This is not populated by `sync_plugins_from_remote`. + pub disabled_plugin_ids: Vec, + /// Plugin ids removed from local cache or plugin config. + pub uninstalled_plugin_ids: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PluginRemoteSyncError { + #[error("chatgpt authentication required to sync remote plugins")] + AuthRequired, + + #[error( + "chatgpt authentication required to sync remote plugins; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin sync: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("failed to send remote plugin sync request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin sync response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + + #[error("local curated marketplace is not available")] + LocalMarketplaceNotFound, + + #[error("remote marketplace `{marketplace_name}` is not available locally")] + UnknownRemoteMarketplace { marketplace_name: String }, + + #[error("duplicate remote plugin `{plugin_name}` in sync response")] + DuplicateRemotePlugin { plugin_name: String }, + + #[error( + "remote plugin `{plugin_name}` was not found in local marketplace `{marketplace_name}`" + )] + UnknownRemotePlugin { + plugin_name: String, + marketplace_name: String, + }, + + #[error("{0}")] + InvalidPluginId(#[from] PluginIdError), + + #[error("{0}")] + Marketplace(#[from] MarketplaceError), + + #[error("{0}")] + Store(#[from] PluginStoreError), + + #[error("{0}")] + Config(#[from] anyhow::Error), + + #[error("failed to join remote plugin sync task: {0}")] + Join(#[from] tokio::task::JoinError), +} + +impl PluginRemoteSyncError { + fn join(source: tokio::task::JoinError) -> Self { + Self::Join(source) + } +} + +impl From for PluginRemoteSyncError { + fn from(value: RemotePluginFetchError) -> Self { + match value { + RemotePluginFetchError::AuthRequired => Self::AuthRequired, + RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode, + RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source), + RemotePluginFetchError::Request { url, source } => Self::Request { url, source }, + RemotePluginFetchError::UnexpectedStatus { url, status, body } => { + Self::UnexpectedStatus { url, status, body } + } + RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source }, + } + } +} + +pub struct PluginsManager { + codex_home: PathBuf, + store: PluginStore, + featured_plugin_ids_cache: RwLock>, + configured_marketplace_upgrade_state: RwLock, + non_curated_cache_refresh_state: RwLock, + cached_enabled_outcome: RwLock>, + remote_installed_plugins_cache: RwLock>>, + remote_installed_plugins_cache_refresh_state: RwLock, + remote_sync_lock: Semaphore, + restriction_product: Option, + analytics_events_client: RwLock>, +} + +#[derive(Clone)] +struct CachedPluginLoadOutcome { + config_version: String, + plugin_hooks_enabled: bool, + outcome: PluginLoadOutcome, +} + +impl PluginsManager { + pub fn new(codex_home: PathBuf) -> Self { + Self::new_with_restriction_product(codex_home, Some(Product::Codex)) + } + + pub fn new_with_restriction_product( + codex_home: PathBuf, + restriction_product: Option, + ) -> Self { + // Product restrictions are enforced at marketplace admission time for a given CODEX_HOME: + // listing, install, and curated refresh all consult this restriction context before new + // plugins enter local config or cache. After admission, runtime plugin loading trusts the + // contents of that CODEX_HOME and does not re-filter configured plugins by product, so + // already-admitted plugins may continue exposing MCP servers/tools from shared local state. + // + // This assumes a single CODEX_HOME is only used by one product. + Self { + codex_home: codex_home.clone(), + store: PluginStore::new(codex_home), + featured_plugin_ids_cache: RwLock::new(None), + configured_marketplace_upgrade_state: RwLock::new( + ConfiguredMarketplaceUpgradeState::default(), + ), + non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), + cached_enabled_outcome: RwLock::new(None), + remote_installed_plugins_cache: RwLock::new(None), + remote_installed_plugins_cache_refresh_state: RwLock::new( + RemoteInstalledPluginsCacheRefreshState::default(), + ), + remote_sync_lock: Semaphore::new(/*permits*/ 1), + restriction_product, + analytics_events_client: RwLock::new(None), + } + } + + pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) { + let mut stored_client = match self.analytics_events_client.write() { + Ok(client_guard) => client_guard, + Err(err) => err.into_inner(), + }; + *stored_client = Some(analytics_events_client); + } + + fn restriction_product_matches(&self, products: Option<&[Product]>) -> bool { + match products { + None => true, + Some([]) => false, + Some(products) => self + .restriction_product + .is_some_and(|product| product.matches_product_restriction(products)), + } + } + + pub async fn plugins_for_config(&self, config: &PluginsConfigInput) -> PluginLoadOutcome { + self.plugins_for_config_with_force_reload(config, /*force_reload*/ false) + .await + } + + pub(crate) async fn plugins_for_config_with_force_reload( + &self, + config: &PluginsConfigInput, + force_reload: bool, + ) -> PluginLoadOutcome { + if !config.plugins_enabled { + return PluginLoadOutcome::default(); + } + + let plugin_hooks_enabled = config.plugin_hooks_enabled; + let config_version = version_for_toml(&config.config_layer_stack.effective_config()); + if !force_reload + && let Some(outcome) = + self.cached_enabled_outcome(&config_version, plugin_hooks_enabled) + { + return outcome; + } + + let outcome = load_plugins_from_layer_stack( + &config.config_layer_stack, + self.remote_installed_plugin_configs(config), + &self.store, + self.restriction_product, + plugin_hooks_enabled, + ) + .await; + log_plugin_load_errors(&outcome); + let mut cache = match self.cached_enabled_outcome.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + *cache = Some(CachedPluginLoadOutcome { + config_version, + plugin_hooks_enabled, + outcome: outcome.clone(), + }); + outcome + } + + pub fn clear_cache(&self) { + self.clear_enabled_outcome_cache(); + let mut featured_plugin_ids_cache = match self.featured_plugin_ids_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + *featured_plugin_ids_cache = None; + } + + fn clear_enabled_outcome_cache(&self) { + let mut cached_enabled_outcome = match self.cached_enabled_outcome.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + *cached_enabled_outcome = None; + } + + /// Load plugins for a config layer stack without touching the plugins cache. + pub async fn plugins_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + config: &PluginsConfigInput, + plugin_hooks_feature_enabled: bool, + ) -> PluginLoadOutcome { + if !config.plugins_enabled { + return PluginLoadOutcome::default(); + } + load_plugins_from_layer_stack( + config_layer_stack, + self.remote_installed_plugin_configs(config), + &self.store, + self.restriction_product, + plugin_hooks_feature_enabled, + ) + .await + } + + /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. + pub async fn effective_skill_roots_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + config: &PluginsConfigInput, + ) -> Vec { + self.plugins_for_layer_stack(config_layer_stack, config, config.plugin_hooks_enabled) + .await + .effective_plugin_skill_roots() + } + + fn cached_enabled_outcome( + &self, + config_version: &str, + plugin_hooks_enabled: bool, + ) -> Option { + match self.cached_enabled_outcome.read() { + Ok(cache) => cache + .as_ref() + .filter(|cached| { + cached.config_version == config_version + && cached.plugin_hooks_enabled == plugin_hooks_enabled + }) + .map(|cached| cached.outcome.clone()), + Err(err) => err + .into_inner() + .as_ref() + .filter(|cached| { + cached.config_version == config_version + && cached.plugin_hooks_enabled == plugin_hooks_enabled + }) + .map(|cached| cached.outcome.clone()), + } + } + + fn remote_installed_plugin_configs( + &self, + config: &PluginsConfigInput, + ) -> HashMap { + if !config.remote_plugin_enabled { + return HashMap::new(); + } + + let cache = match self.remote_installed_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + let Some(plugins) = cache.as_ref() else { + return HashMap::new(); + }; + + remote_installed_plugins_to_config(plugins, &self.store) + } + + fn write_remote_installed_plugins_cache(&self, plugins: Vec) -> bool { + let mut cache = match self.remote_installed_plugins_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + if cache.as_ref().is_some_and(|cache| cache.eq(&plugins)) { + return false; + } + *cache = Some(plugins); + drop(cache); + self.clear_enabled_outcome_cache(); + true + } + + pub fn clear_remote_installed_plugins_cache(&self) -> bool { + let mut cache = match self.remote_installed_plugins_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + if cache.is_none() { + return false; + } + *cache = None; + drop(cache); + self.clear_enabled_outcome_cache(); + true + } + + pub fn maybe_start_remote_installed_plugins_cache_refresh( + self: &Arc, + config: &PluginsConfigInput, + auth: Option, + on_effective_plugins_changed: Option>, + ) { + self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( + config, + auth, + RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged, + on_effective_plugins_changed, + ); + } + + pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation( + self: &Arc, + config: &PluginsConfigInput, + auth: Option, + on_effective_plugins_changed: Option>, + ) { + self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( + config, + auth, + RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh, + on_effective_plugins_changed, + ); + } + + fn maybe_start_remote_installed_plugins_cache_refresh_with_notify( + self: &Arc, + config: &PluginsConfigInput, + auth: Option, + notify: RemoteInstalledPluginsCacheRefreshNotify, + on_effective_plugins_changed: Option>, + ) { + if !config.plugins_enabled || !config.remote_plugin_enabled { + return; + } + + self.schedule_remote_installed_plugins_cache_refresh( + RemoteInstalledPluginsCacheRefreshRequest { + service_config: remote_plugin_service_config(config), + auth, + notify, + on_effective_plugins_changed, + }, + ); + } + + fn maybe_start_remote_installed_plugin_bundle_sync( + self: &Arc, + config: &PluginsConfigInput, + auth: Option, + on_effective_plugins_changed: Option>, + ) { + if !config.plugins_enabled || !config.remote_plugin_enabled { + return; + } + + let manager = Arc::clone(self); + let config_for_refresh = config.clone(); + let auth_for_refresh = auth.clone(); + let on_local_cache_changed = Arc::new(move || { + manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( + &config_for_refresh, + auth_for_refresh.clone(), + on_effective_plugins_changed.clone(), + ); + }); + + crate::remote::maybe_start_remote_installed_plugin_bundle_sync( + self.codex_home.clone(), + remote_plugin_service_config(config), + auth, + Some(on_local_cache_changed), + ); + } + + pub fn maybe_start_plugin_list_background_tasks_for_config( + self: &Arc, + config: &PluginsConfigInput, + auth: Option, + roots: &[AbsolutePathBuf], + on_effective_plugins_changed: Option>, + ) { + self.maybe_start_non_curated_plugin_cache_refresh(roots); + self.maybe_start_remote_installed_plugins_cache_refresh( + config, + auth.clone(), + on_effective_plugins_changed.clone(), + ); + self.maybe_start_remote_installed_plugin_bundle_sync( + config, + auth, + on_effective_plugins_changed, + ); + } + + fn cached_featured_plugin_ids( + &self, + cache_key: &FeaturedPluginIdsCacheKey, + ) -> Option> { + { + let cache = match self.featured_plugin_ids_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if let Some(cached) = cache.as_ref() + && now < cached.expires_at + && cached.key == *cache_key + { + return Some(cached.featured_plugin_ids.clone()); + } + } + + let mut cache = match self.featured_plugin_ids_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if cache + .as_ref() + .is_some_and(|cached| now >= cached.expires_at || cached.key != *cache_key) + { + *cache = None; + } + None + } + + fn write_featured_plugin_ids_cache( + &self, + cache_key: FeaturedPluginIdsCacheKey, + featured_plugin_ids: &[String], + ) { + let mut cache = match self.featured_plugin_ids_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + *cache = Some(CachedFeaturedPluginIds { + key: cache_key, + expires_at: Instant::now() + FEATURED_PLUGIN_IDS_CACHE_TTL, + featured_plugin_ids: featured_plugin_ids.to_vec(), + }); + } + + pub async fn featured_plugin_ids_for_config( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + ) -> Result, RemotePluginFetchError> { + if !config.plugins_enabled { + return Ok(Vec::new()); + } + + let cache_key = featured_plugin_ids_cache_key(config, auth); + if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) { + return Ok(featured_plugin_ids); + } + let featured_plugin_ids = crate::remote_legacy::fetch_remote_featured_plugin_ids( + &remote_plugin_service_config(config), + auth, + self.restriction_product, + ) + .await?; + self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids); + Ok(featured_plugin_ids) + } + + pub async fn install_plugin( + &self, + request: PluginInstallRequest, + ) -> Result { + let resolved = find_installable_marketplace_plugin( + &request.marketplace_path, + &request.plugin_name, + self.restriction_product, + )?; + self.install_resolved_plugin(resolved).await + } + + pub async fn install_plugin_with_remote_sync( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + request: PluginInstallRequest, + ) -> Result { + let resolved = find_installable_marketplace_plugin( + &request.marketplace_path, + &request.plugin_name, + self.restriction_product, + )?; + let plugin_id = resolved.plugin_id.as_key(); + // This only forwards the backend mutation before the local install flow. + crate::remote_legacy::enable_remote_plugin( + &remote_plugin_service_config(config), + auth, + &plugin_id, + ) + .await + .map_err(PluginInstallError::from)?; + self.install_resolved_plugin(resolved).await + } + + async fn install_resolved_plugin( + &self, + resolved: ResolvedMarketplacePlugin, + ) -> Result { + let auth_policy = resolved.policy.authentication; + let plugin_version = + if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) + .ok_or_else(|| { + PluginStoreError::Invalid( + "local curated marketplace sha is not available".to_string(), + ) + })?; + Some(curated_plugin_cache_version(&curated_plugin_version)) + } else { + None + }; + let store = self.store.clone(); + let codex_home = self.codex_home.clone(); + let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || { + let materialized = + materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source) + .map_err(PluginStoreError::Invalid)?; + let source_path = materialized.path; + if let Some(plugin_version) = plugin_version { + store.install_with_version(source_path, resolved.plugin_id, plugin_version) + } else { + store.install(source_path, resolved.plugin_id) + } + }) + .await + .map_err(PluginInstallError::join)??; + + set_user_plugin_enabled( + &self.codex_home, + result.plugin_id.as_key(), + /*enabled*/ true, + ) + .await + .map_err(anyhow::Error::from)?; + + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_plugin_installed( + plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path) + .await, + ); + } + + Ok(PluginInstallOutcome { + plugin_id: result.plugin_id, + plugin_version: result.plugin_version, + installed_path: result.installed_path, + auth_policy, + }) + } + + pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> { + let plugin_id = PluginId::parse(&plugin_id)?; + self.uninstall_plugin_id(plugin_id).await + } + + pub async fn uninstall_plugin_with_remote_sync( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + plugin_id: String, + ) -> Result<(), PluginUninstallError> { + // TODO: Remove this legacy remote-sync path once remote plugins have + // their own manager and installed-state API. + let plugin_id = PluginId::parse(&plugin_id)?; + let plugin_key = plugin_id.as_key(); + // This only forwards the backend mutation before the local uninstall flow. + crate::remote_legacy::uninstall_remote_plugin( + &remote_plugin_service_config(config), + auth, + &plugin_key, + ) + .await + .map_err(PluginUninstallError::from)?; + self.uninstall_plugin_id(plugin_id).await + } + + async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { + let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() { + Some(installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id).await) + } else { + None + }; + let store = self.store.clone(); + let plugin_id_for_store = plugin_id.clone(); + tokio::task::spawn_blocking(move || store.uninstall(&plugin_id_for_store)) + .await + .map_err(PluginUninstallError::join)??; + + clear_user_plugin(&self.codex_home, plugin_id.as_key()) + .await + .map_err(anyhow::Error::from)?; + + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(plugin_telemetry) = plugin_telemetry + && let Some(analytics_events_client) = analytics_events_client + { + analytics_events_client.track_plugin_uninstalled(plugin_telemetry); + } + + Ok(()) + } + + pub async fn sync_plugins_from_remote( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + additive_only: bool, + ) -> Result { + let _remote_sync_guard = self.remote_sync_lock.acquire().await.map_err(|_| { + PluginRemoteSyncError::Config(anyhow::anyhow!("remote plugin sync semaphore closed")) + })?; + + if !config.plugins_enabled { + return Ok(RemotePluginSyncResult::default()); + } + + info!("starting remote plugin sync"); + let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status( + &remote_plugin_service_config(config), + auth, + ) + .await + .map_err(PluginRemoteSyncError::from)?; + let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); + let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path()); + let curated_marketplace_path = AbsolutePathBuf::try_from( + curated_marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?; + let curated_marketplace = match load_marketplace(&curated_marketplace_path) { + Ok(marketplace) => marketplace, + Err(MarketplaceError::MarketplaceNotFound { .. }) => { + return Err(PluginRemoteSyncError::LocalMarketplaceNotFound); + } + Err(err) => return Err(err.into()), + }; + + let marketplace_name = curated_marketplace.name.clone(); + let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) + .ok_or_else(|| { + PluginStoreError::Invalid( + "local curated marketplace sha is not available".to_string(), + ) + })?; + let cache_plugin_version = curated_plugin_cache_version(&curated_plugin_version); + let mut local_plugins = Vec::<( + String, + PluginId, + AbsolutePathBuf, + Option, + Option, + bool, + )>::new(); + let mut local_plugin_names = HashSet::new(); + for plugin in curated_marketplace.plugins { + let plugin_name = plugin.name; + if !local_plugin_names.insert(plugin_name.clone()) { + warn!( + plugin = plugin_name, + marketplace = %marketplace_name, + "ignoring duplicate local plugin entry during remote sync" + ); + continue; + } + + let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?; + let plugin_key = plugin_id.as_key(); + let source_path = match plugin.source { + MarketplacePluginSource::Local { path } => path, + MarketplacePluginSource::Git { .. } => { + warn!( + plugin = plugin_name, + marketplace = %marketplace_name, + "skipping remote plugin source during remote sync" + ); + continue; + } + }; + let current_enabled = configured_plugins + .get(&plugin_key) + .map(|plugin| plugin.enabled); + let installed_version = self.store.active_plugin_version(&plugin_id); + let product_allowed = + self.restriction_product_matches(plugin.policy.products.as_deref()); + local_plugins.push(( + plugin_name, + plugin_id, + source_path, + current_enabled, + installed_version, + product_allowed, + )); + } + + let mut missing_remote_plugins = Vec::::new(); + let mut remote_installed_plugin_names = HashSet::::new(); + for plugin in remote_plugins { + if plugin.marketplace_name != marketplace_name { + return Err(PluginRemoteSyncError::UnknownRemoteMarketplace { + marketplace_name: plugin.marketplace_name, + }); + } + if !local_plugin_names.contains(&plugin.name) { + missing_remote_plugins.push(plugin.name); + continue; + } + // For now, sync treats remote `enabled = false` as uninstall rather than a distinct + // disabled state. + // TODO: Switch sync to `plugins/installed` so install and enable states stay distinct. + if !plugin.enabled { + continue; + } + if !remote_installed_plugin_names.insert(plugin.name.clone()) { + return Err(PluginRemoteSyncError::DuplicateRemotePlugin { + plugin_name: plugin.name, + }); + } + } + + let mut config_edits = Vec::new(); + let mut installs = Vec::new(); + let mut uninstalls = Vec::new(); + let mut result = RemotePluginSyncResult::default(); + let remote_plugin_count = remote_installed_plugin_names.len(); + let local_plugin_count = local_plugins.len(); + if !missing_remote_plugins.is_empty() { + let sample_missing_plugins = missing_remote_plugins + .iter() + .take(10) + .cloned() + .collect::>(); + warn!( + marketplace = %marketplace_name, + missing_remote_plugin_count = missing_remote_plugins.len(), + missing_remote_plugin_examples = ?sample_missing_plugins, + "ignoring remote plugins missing from local marketplace during sync" + ); + } + + for ( + plugin_name, + plugin_id, + source_path, + current_enabled, + installed_version, + product_allowed, + ) in local_plugins + { + let plugin_key = plugin_id.as_key(); + let is_installed = installed_version.is_some(); + if !product_allowed { + continue; + } + if remote_installed_plugin_names.contains(&plugin_name) { + if !is_installed { + installs.push((source_path, plugin_id.clone(), cache_plugin_version.clone())); + } + if !is_installed { + result.installed_plugin_ids.push(plugin_key.clone()); + } + + if current_enabled != Some(true) { + result.enabled_plugin_ids.push(plugin_key.clone()); + config_edits.push(PluginConfigEdit::SetEnabled { + plugin_key, + enabled: true, + }); + } + } else if !additive_only { + if is_installed { + uninstalls.push(plugin_id); + } + if is_installed || current_enabled.is_some() { + result.uninstalled_plugin_ids.push(plugin_key.clone()); + } + if current_enabled.is_some() { + config_edits.push(PluginConfigEdit::Clear { plugin_key }); + } + } + } + + let store = self.store.clone(); + let store_result = tokio::task::spawn_blocking(move || { + for (source_path, plugin_id, plugin_version) in installs { + store.install_with_version(source_path, plugin_id, plugin_version)?; + } + for plugin_id in uninstalls { + store.uninstall(&plugin_id)?; + } + Ok::<(), PluginStoreError>(()) + }) + .await + .map_err(PluginRemoteSyncError::join)?; + if let Err(err) = store_result { + self.clear_cache(); + return Err(err.into()); + } + + let config_result = if config_edits.is_empty() { + Ok(()) + } else { + apply_user_plugin_config_edits(&self.codex_home, config_edits).await + }; + self.clear_cache(); + config_result.map_err(anyhow::Error::from)?; + + info!( + marketplace = %marketplace_name, + remote_plugin_count, + local_plugin_count, + installed_plugin_ids = ?result.installed_plugin_ids, + enabled_plugin_ids = ?result.enabled_plugin_ids, + disabled_plugin_ids = ?result.disabled_plugin_ids, + uninstalled_plugin_ids = ?result.uninstalled_plugin_ids, + "completed remote plugin sync" + ); + + Ok(result) + } + + pub fn list_marketplaces_for_config( + &self, + config: &PluginsConfigInput, + additional_roots: &[AbsolutePathBuf], + ) -> Result { + if !config.plugins_enabled { + return Ok(ConfiguredMarketplaceListOutcome::default()); + } + + let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); + let marketplace_outcome = + list_marketplaces(&self.marketplace_roots(config, additional_roots))?; + let mut seen_plugin_keys = HashSet::new(); + let marketplaces = marketplace_outcome + .marketplaces + .into_iter() + .filter_map(|marketplace| { + let marketplace_name = marketplace.name.clone(); + let plugins = marketplace + .plugins + .into_iter() + .filter_map(|plugin| { + let plugin_key = format!("{}@{marketplace_name}", plugin.name); + if !seen_plugin_keys.insert(plugin_key.clone()) { + return None; + } + if !self.restriction_product_matches(plugin.policy.products.as_deref()) { + return None; + } + + Some(ConfiguredMarketplacePlugin { + // Enabled state is keyed by `@`, so duplicate + // plugin entries from duplicate marketplace files intentionally + // resolve to the first discovered source. + id: plugin_key.clone(), + installed: installed_plugins.contains(&plugin_key), + enabled: enabled_plugins.contains(&plugin_key), + name: plugin.name, + source: plugin.source, + policy: plugin.policy, + interface: plugin.interface, + keywords: plugin.keywords, + }) + }) + .collect::>(); + + (!plugins.is_empty()).then_some(ConfiguredMarketplace { + name: marketplace.name, + path: marketplace.path, + interface: marketplace.interface, + plugins, + }) + }) + .collect(); + + Ok(ConfiguredMarketplaceListOutcome { + marketplaces, + errors: marketplace_outcome.errors, + }) + } + + pub async fn read_plugin_for_config( + &self, + config: &PluginsConfigInput, + request: &PluginReadRequest, + ) -> Result { + if !config.plugins_enabled { + return Err(MarketplaceError::PluginsDisabled); + } + + let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; + if !self.restriction_product_matches(plugin.policy.products.as_deref()) { + return Err(MarketplaceError::PluginNotFound { + plugin_name: plugin.plugin_id.plugin_name, + marketplace_name: plugin.plugin_id.marketplace_name, + }); + } + + let marketplace_name = plugin.plugin_id.marketplace_name.clone(); + let plugin_key = plugin.plugin_id.as_key(); + let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); + let plugin = self + .read_plugin_detail_for_marketplace_plugin( + config, + &marketplace_name, + ConfiguredMarketplacePlugin { + id: plugin_key.clone(), + name: plugin.plugin_id.plugin_name, + source: plugin.source, + policy: plugin.policy, + interface: plugin.interface, + keywords: plugin + .manifest + .as_ref() + .map(|manifest| manifest.keywords.clone()) + .unwrap_or_default(), + installed: installed_plugins.contains(&plugin_key), + enabled: enabled_plugins.contains(&plugin_key), + }, + ) + .await?; + + Ok(PluginReadOutcome { + marketplace_name, + marketplace_path: Some(request.marketplace_path.clone()), + plugin, + }) + } + + pub async fn read_plugin_detail_for_marketplace_plugin( + &self, + config: &PluginsConfigInput, + marketplace_name: &str, + plugin: ConfiguredMarketplacePlugin, + ) -> Result { + if !self.restriction_product_matches(plugin.policy.products.as_deref()) { + return Err(MarketplaceError::PluginNotFound { + plugin_name: plugin.name, + marketplace_name: marketplace_name.to_string(), + }); + } + + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| { + match err { + PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message), + } + })?; + let plugin_key = plugin_id.as_key(); + if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && !plugin.installed { + let description = remote_plugin_install_required_description(&plugin.source); + return Ok(PluginDetail { + id: plugin_key, + name: plugin.name, + description: Some(description), + source: plugin.source, + policy: plugin.policy, + interface: plugin.interface, + keywords: plugin.keywords, + installed: plugin.installed, + enabled: plugin.enabled, + skills: Vec::new(), + disabled_skill_paths: HashSet::new(), + hooks: Vec::new(), + apps: Vec::new(), + mcp_server_names: Vec::new(), + details_unavailable_reason: Some( + PluginDetailsUnavailableReason::InstallRequiredForRemoteSource, + ), + }); + } + + let source_path = + if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && plugin.installed { + self.store.active_plugin_root(&plugin_id).ok_or_else(|| { + MarketplaceError::InvalidPlugin(format!( + "installed plugin cache entry is missing for {plugin_key}" + )) + })? + } else { + let codex_home = self.codex_home.clone(); + let source = plugin.source.clone(); + let materialized = tokio::task::spawn_blocking(move || { + materialize_marketplace_plugin_source(codex_home.as_path(), &source) + }) + .await + .map_err(|err| { + MarketplaceError::InvalidPlugin(format!( + "failed to materialize plugin source: {err}" + )) + })? + .map_err(MarketplaceError::InvalidPlugin)?; + materialized.path.clone() + }; + if !source_path.as_path().is_dir() { + return Err(MarketplaceError::InvalidPlugin( + "path does not exist or is not a directory".to_string(), + )); + } + let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| { + MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string()) + })?; + let description = manifest.description.clone(); + let marketplace_category = plugin + .interface + .as_ref() + .and_then(|interface| interface.category.clone()); + let interface = plugin_interface_with_marketplace_category( + manifest.interface.clone(), + marketplace_category, + ); + let resolved_skills = load_plugin_skills( + &source_path, + &plugin_id, + &manifest.paths, + self.restriction_product, + &codex_core_skills::config_rules::skill_config_rules_from_stack( + &config.config_layer_stack, + ), + ) + .await; + let hooks = if config.plugin_hooks_enabled { + let plugin_data_root = self.store.plugin_data_root(&plugin_id); + let (hook_sources, _hook_load_warnings) = + load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths); + plugin_hook_declarations(&hook_sources) + .into_iter() + .map(|hook| PluginHookSummary { + key: hook.key, + event_name: hook.event_name, + }) + .collect() + } else { + Vec::new() + }; + let apps = load_plugin_apps(source_path.as_path()).await; + let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path()) + .await + .into_keys() + .collect::>(); + mcp_server_names.sort_unstable(); + mcp_server_names.dedup(); + + Ok(PluginDetail { + id: plugin.id, + name: plugin.name, + description, + source: plugin.source, + policy: plugin.policy, + interface, + keywords: manifest.keywords, + installed: plugin.installed, + enabled: plugin.enabled, + skills: resolved_skills.skills, + disabled_skill_paths: resolved_skills.disabled_skill_paths, + hooks, + apps, + mcp_server_names, + details_unavailable_reason: None, + }) + } + + pub fn maybe_start_plugin_startup_tasks_for_config( + self: &Arc, + config: &PluginsConfigInput, + auth_manager: Arc, + on_effective_plugins_changed: Option>, + ) { + if config.plugins_enabled { + self.start_curated_repo_sync(); + let should_spawn_marketplace_auto_upgrade = { + let mut state = match self.configured_marketplace_upgrade_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + if state.in_flight { + false + } else { + state.in_flight = true; + true + } + }; + if should_spawn_marketplace_auto_upgrade { + let manager = Arc::clone(self); + let config = config.clone(); + if let Err(err) = std::thread::Builder::new() + .name("plugins-marketplace-auto-upgrade".to_string()) + .spawn(move || { + let outcome = manager.upgrade_configured_marketplaces_for_config( + &config, /*marketplace_name*/ None, + ); + match outcome { + Ok(outcome) => { + for error in outcome.errors { + warn!( + marketplace = error.marketplace_name, + error = %error.message, + "failed to auto-upgrade configured marketplace" + ); + } + } + Err(err) => { + warn!("failed to auto-upgrade configured marketplaces: {err}"); + } + } + + let mut state = match manager.configured_marketplace_upgrade_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + }) + { + let mut state = match self.configured_marketplace_upgrade_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + warn!("failed to start configured marketplace auto-upgrade task: {err}"); + } + } + start_startup_remote_plugin_sync_once( + Arc::clone(self), + self.codex_home.clone(), + config.clone(), + auth_manager.clone(), + ); + + if config.remote_plugin_enabled { + let config = config.clone(); + let manager = Arc::clone(self); + let auth_manager = auth_manager.clone(); + let on_effective_plugins_changed = on_effective_plugins_changed.clone(); + tokio::spawn(async move { + let auth = auth_manager.auth().await; + manager.maybe_start_remote_installed_plugins_cache_refresh( + &config, + auth.clone(), + on_effective_plugins_changed.clone(), + ); + manager.maybe_start_remote_installed_plugin_bundle_sync( + &config, + auth, + on_effective_plugins_changed, + ); + }); + } + + let config = config.clone(); + let manager = Arc::clone(self); + tokio::spawn(async move { + let auth = auth_manager.auth().await; + if let Err(err) = manager + .featured_plugin_ids_for_config(&config, auth.as_ref()) + .await + { + warn!( + error = %err, + "failed to warm featured plugin ids cache" + ); + } + }); + } + } + + pub fn upgrade_configured_marketplaces_for_config( + &self, + config: &PluginsConfigInput, + marketplace_name: Option<&str>, + ) -> Result { + if let Some(marketplace_name) = marketplace_name + && !configured_git_marketplace_names(&config.config_layer_stack) + .iter() + .any(|name| name == marketplace_name) + { + return Err(format!( + "marketplace `{marketplace_name}` is not configured as a Git marketplace" + )); + } + + let mut outcome = upgrade_configured_git_marketplaces( + self.codex_home.as_path(), + &config.config_layer_stack, + marketplace_name, + ); + if !outcome.upgraded_roots.is_empty() { + match refresh_non_curated_plugin_cache_force_reinstall( + self.codex_home.as_path(), + &outcome.upgraded_roots, + ) { + Ok(cache_refreshed) => { + if cache_refreshed { + self.clear_cache(); + } + } + Err(err) => { + self.clear_cache(); + outcome.errors.push(ConfiguredMarketplaceUpgradeError { + marketplace_name: marketplace_name + .unwrap_or("all configured marketplaces") + .to_string(), + message: format!( + "failed to refresh installed plugin cache after marketplace upgrade: {err}" + ), + }); + } + } + } + Ok(outcome) + } + + pub fn maybe_start_non_curated_plugin_cache_refresh( + self: &Arc, + roots: &[AbsolutePathBuf], + ) { + self.schedule_non_curated_plugin_cache_refresh( + roots, + NonCuratedCacheRefreshMode::IfVersionChanged, + ); + } + + fn schedule_remote_installed_plugins_cache_refresh( + self: &Arc, + mut request: RemoteInstalledPluginsCacheRefreshRequest, + ) { + let should_spawn = { + let mut state = match self.remote_installed_plugins_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + if let Some(existing_request) = state.requested.as_ref() { + if matches!( + existing_request.notify, + RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh + ) { + request.notify = + RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh; + } + if request.on_effective_plugins_changed.is_none() { + request.on_effective_plugins_changed = + existing_request.on_effective_plugins_changed.clone(); + } + } + state.requested = Some(request); + if state.in_flight { + false + } else { + state.in_flight = true; + true + } + }; + if !should_spawn { + return; + } + + let manager = Arc::clone(self); + tokio::spawn(async move { + manager + .run_remote_installed_plugins_cache_refresh_loop() + .await; + }); + } + + fn schedule_non_curated_plugin_cache_refresh( + self: &Arc, + roots: &[AbsolutePathBuf], + mode: NonCuratedCacheRefreshMode, + ) { + let mut roots = roots.to_vec(); + roots.sort_unstable(); + roots.dedup(); + if roots.is_empty() { + return; + } + let request = NonCuratedCacheRefreshRequest { roots, mode }; + + let should_spawn = { + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + // Collapse repeated plugin/list requests onto one worker and only queue another pass + // when the requested roots set actually changes. Forced reinstall requests are not + // deduped against the last completed pass because the same marketplace root path can + // point at newly activated files after an auto-upgrade. + if state.requested.as_ref() == Some(&request) + || (mode == NonCuratedCacheRefreshMode::IfVersionChanged + && !state.in_flight + && state.last_refreshed.as_ref() == Some(&request)) + { + return; + } + if mode == NonCuratedCacheRefreshMode::IfVersionChanged + && state.requested.as_ref().is_some_and(|requested| { + requested.mode == NonCuratedCacheRefreshMode::ForceReinstall + && requested.roots == request.roots + }) + { + return; + } + state.requested = Some(request); + if state.in_flight { + false + } else { + state.in_flight = true; + true + } + }; + if !should_spawn { + return; + } + + let manager = Arc::clone(self); + if let Err(err) = std::thread::Builder::new() + .name("plugins-non-curated-cache-refresh".to_string()) + .spawn(move || manager.run_non_curated_plugin_cache_refresh_loop()) + { + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + state.requested = None; + warn!("failed to start non-curated plugin cache refresh task: {err}"); + } + } + + fn start_curated_repo_sync(self: &Arc) { + if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { + return; + } + let manager = Arc::clone(self); + let codex_home = self.codex_home.clone(); + if let Err(err) = std::thread::Builder::new() + .name("plugins-curated-repo-sync".to_string()) + .spawn( + move || match sync_openai_plugins_repo(codex_home.as_path()) { + Ok(curated_plugin_version) => { + let configured_curated_plugin_ids = + configured_curated_plugin_ids_from_codex_home(codex_home.as_path()); + match refresh_curated_plugin_cache( + codex_home.as_path(), + &curated_plugin_version, + &configured_curated_plugin_ids, + ) { + Ok(cache_refreshed) => { + if cache_refreshed { + manager.clear_cache(); + } + } + Err(err) => { + manager.clear_cache(); + CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); + warn!("failed to refresh curated plugin cache after sync: {err}"); + } + } + } + Err(err) => { + CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); + warn!("failed to sync curated plugins repo: {err}"); + } + }, + ) + { + CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); + warn!("failed to start curated plugins repo sync task: {err}"); + } + } + + async fn run_remote_installed_plugins_cache_refresh_loop(self: Arc) { + loop { + let request = { + let mut state = match self.remote_installed_plugins_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + match state.requested.take() { + Some(request) => request, + None => { + state.in_flight = false; + return; + } + } + }; + + let installed_plugins = crate::remote::fetch_remote_installed_plugins( + &request.service_config, + request.auth.as_ref(), + ) + .await; + match installed_plugins { + Ok(installed_plugins) => { + // TODO(remote plugins): reconcile missing or stale local bundles before + // publishing remote installed state as effective local plugin config. + let changed = self.write_remote_installed_plugins_cache(installed_plugins); + let should_notify = changed + || matches!( + request.notify, + RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh + ); + if should_notify + && let Some(on_effective_plugins_changed) = + request.on_effective_plugins_changed + { + on_effective_plugins_changed(); + } + } + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => { + let changed = self.clear_remote_installed_plugins_cache(); + if changed + && let Some(on_effective_plugins_changed) = + request.on_effective_plugins_changed + { + on_effective_plugins_changed(); + } + } + Err(err) => { + warn!( + error = %err, + "failed to refresh remote installed plugins cache" + ); + } + } + } + } + + fn run_non_curated_plugin_cache_refresh_loop(self: Arc) { + loop { + let request = { + let state = match self.non_curated_cache_refresh_state.read() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.requested.clone() + }; + + let Some(request) = request else { + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.in_flight = false; + return; + }; + + let refresh_result = match request.mode { + NonCuratedCacheRefreshMode::IfVersionChanged => { + refresh_non_curated_plugin_cache(self.codex_home.as_path(), &request.roots) + } + NonCuratedCacheRefreshMode::ForceReinstall => { + refresh_non_curated_plugin_cache_force_reinstall( + self.codex_home.as_path(), + &request.roots, + ) + } + }; + let refreshed = match refresh_result { + Ok(cache_refreshed) => { + if cache_refreshed { + self.clear_cache(); + } + true + } + Err(err) => { + self.clear_cache(); + warn!("failed to refresh non-curated plugin cache: {err}"); + false + } + }; + + let mut state = match self.non_curated_cache_refresh_state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + if refreshed { + state.last_refreshed = Some(request.clone()); + } + if state.requested.as_ref() == Some(&request) { + state.requested = None; + state.in_flight = false; + return; + } + } + } + + fn configured_plugin_states( + &self, + config: &PluginsConfigInput, + ) -> (HashSet, HashSet) { + let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); + let installed_plugins = configured_plugins + .keys() + .filter(|plugin_key| { + PluginId::parse(plugin_key) + .ok() + .is_some_and(|plugin_id| self.store.is_installed(&plugin_id)) + }) + .cloned() + .collect::>(); + let enabled_plugins = configured_plugins + .into_iter() + .filter_map(|(plugin_key, plugin)| plugin.enabled.then_some(plugin_key)) + .collect::>(); + (installed_plugins, enabled_plugins) + } + + fn marketplace_roots( + &self, + config: &PluginsConfigInput, + additional_roots: &[AbsolutePathBuf], + ) -> Vec { + // Treat the curated catalog as an extra marketplace root so plugin listing can surface it + // without requiring every caller to know where it is stored. + let mut roots = additional_roots.to_vec(); + roots.extend(installed_marketplace_roots_from_layer_stack( + &config.config_layer_stack, + self.codex_home.as_path(), + )); + let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); + if curated_repo_root.is_dir() + && let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root) + { + roots.push(curated_repo_root); + } + roots.sort_unstable(); + roots.dedup(); + roots + } +} + +fn remote_plugin_install_required_description(source: &MarketplacePluginSource) -> String { + let source_description = match source { + MarketplacePluginSource::Git { + url, + path, + ref_name, + sha, + } => { + let mut parts = vec![url.clone()]; + if let Some(path) = path { + parts.push(format!("path `{path}`")); + } + if let Some(ref_name) = ref_name { + parts.push(format!("ref `{ref_name}`")); + } + if let Some(sha) = sha { + parts.push(format!("sha `{sha}`")); + } + parts.join(", ") + } + MarketplacePluginSource::Local { path } => path.as_path().display().to_string(), + }; + + format!( + "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {source_description}." + ) +} + +#[derive(Debug, thiserror::Error)] +pub enum PluginInstallError { + #[error("{0}")] + Marketplace(#[from] MarketplaceError), + + #[error("{0}")] + Remote(#[from] RemotePluginMutationError), + + #[error("{0}")] + Store(#[from] PluginStoreError), + + #[error("{0}")] + Config(#[from] anyhow::Error), + + #[error("failed to join plugin install task: {0}")] + Join(#[from] tokio::task::JoinError), +} + +impl PluginInstallError { + fn join(source: tokio::task::JoinError) -> Self { + Self::Join(source) + } + + pub fn is_invalid_request(&self) -> bool { + matches!( + self, + Self::Marketplace( + MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginNotFound { .. } + | MarketplaceError::PluginNotAvailable { .. } + | MarketplaceError::InvalidPlugin(_) + ) | Self::Store(PluginStoreError::Invalid(_)) + ) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PluginUninstallError { + #[error("{0}")] + InvalidPluginId(#[from] PluginIdError), + + #[error("{0}")] + Remote(#[from] RemotePluginMutationError), + + #[error("{0}")] + Store(#[from] PluginStoreError), + + #[error("{0}")] + Config(#[from] anyhow::Error), + + #[error("failed to join plugin uninstall task: {0}")] + Join(#[from] tokio::task::JoinError), +} + +impl PluginUninstallError { + fn join(source: tokio::task::JoinError) -> Self { + Self::Join(source) + } + + pub fn is_invalid_request(&self) -> bool { + matches!(self, Self::InvalidPluginId(_)) + } +} + +pub(crate) fn configured_plugins_from_stack( + config_layer_stack: &ConfigLayerStack, +) -> HashMap { + // Plugin entries remain persisted user config only. + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return HashMap::new(); + }; + configured_plugins_from_user_config_value(&user_layer.config) +} + +fn configured_plugins_from_user_config_value( + user_config: &toml::Value, +) -> HashMap { + let Some(plugins_value) = user_config.get("plugins") else { + return HashMap::new(); + }; + match plugins_value.clone().try_into() { + Ok(plugins) => plugins, + Err(err) => { + warn!("invalid plugins config: {err}"); + HashMap::new() + } + } +} + +#[cfg(test)] +#[path = "manager_tests.rs"] +mod tests; diff --git a/code-rs/core-plugins/src/manager_tests.rs b/code-rs/core-plugins/src/manager_tests.rs new file mode 100644 index 00000000000..06736a853ce --- /dev/null +++ b/code-rs/core-plugins/src/manager_tests.rs @@ -0,0 +1,3566 @@ +use super::*; +use crate::LoadedPlugin; +use crate::PluginLoadOutcome; +use crate::installed_marketplaces::marketplace_install_root; +use crate::loader::load_plugins_from_layer_stack; +use crate::loader::refresh_non_curated_plugin_cache; +use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; +use crate::marketplace::MarketplacePluginInstallPolicy; +use crate::remote::RemoteInstalledPlugin; +use crate::startup_sync::curated_plugins_repo_path; +use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; +use crate::test_support::TEST_CURATED_PLUGIN_SHA; +use crate::test_support::load_plugins_config as load_plugins_config_input; +use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; +use crate::test_support::write_file; +use crate::test_support::write_openai_curated_marketplace; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::AppToolApproval; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_config::McpServerConfig; +use codex_config::McpServerToolConfig; +use codex_config::types::McpServerTransportConfig; +use codex_login::CodexAuth; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::Product; +use codex_utils_absolute_path::test_support::PathBufExt; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::TempDir; +use toml::Value; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; + +fn write_plugin_with_version( + root: &Path, + dir_name: &str, + manifest_name: &str, + manifest_version: Option<&str>, +) { + let plugin_root = root.join(dir_name); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(plugin_root.join("skills")).unwrap(); + let version = manifest_version + .map(|manifest_version| format!(r#","version":"{manifest_version}""#)) + .unwrap_or_default(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{manifest_name}"{version}}}"#), + ) + .unwrap(); + fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); + fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); +} + +fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { + write_plugin_with_version( + root, + dir_name, + manifest_name, + /*manifest_version*/ None, + ); +} + +fn init_git_repo(repo: &Path) { + run_git(repo, &["init"]); + run_git(repo, &["config", "user.email", "codex-test@example.com"]); + run_git(repo, &["config", "user.name", "Codex Test"]); + run_git(repo, &["add", "."]); + run_git(repo, &["commit", "-m", "initial"]); +} + +fn run_git(repo: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .unwrap_or_else(|err| panic!("git should run: {err}")); + assert!( + output.status.success(), + "git -C {} {} failed\nstdout:\n{}\nstderr:\n{}", + repo.display(), + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { + plugin_config_toml_with_plugin_hooks( + enabled, + plugins_feature_enabled, + /*plugin_hooks_feature_enabled*/ false, + ) +} + +fn plugin_config_toml_with_plugin_hooks( + enabled: bool, + plugins_feature_enabled: bool, + plugin_hooks_feature_enabled: bool, +) -> String { + let mut root = toml::map::Map::new(); + + let mut features = toml::map::Map::new(); + features.insert( + "plugins".to_string(), + Value::Boolean(plugins_feature_enabled), + ); + features.insert( + "plugin_hooks".to_string(), + Value::Boolean(plugin_hooks_feature_enabled), + ); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugin = toml::map::Map::new(); + plugin.insert("enabled".to_string(), Value::Boolean(enabled)); + + let mut plugins = toml::map::Map::new(); + plugins.insert("sample@test".to_string(), Value::Table(plugin)); + root.insert("plugins".to_string(), Value::Table(plugins)); + + toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") +} + +async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome { + write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml); + let config = load_config(codex_home, codex_home).await; + PluginsManager::new(codex_home.to_path_buf()) + .plugins_for_config(&config) + .await +} + +async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { + load_plugins_config_input(codex_home, cwd).await +} + +#[tokio::test] +async fn load_plugins_loads_default_skills_and_mcp_servers() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "description": "Plugin that includes the sample MCP server and Skills" +}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp", + "oauth": { + "clientId": "client-id", + "callbackPort": 3118 + } + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + ) + .await; + + assert_eq!( + outcome.plugins(), + vec![LoadedPlugin { + config_name: "sample@test".to_string(), + manifest_name: Some("sample".to_string()), + manifest_description: Some( + "Plugin that includes the sample MCP server and Skills".to_string(), + ), + root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), + enabled: true, + skill_roots: vec![plugin_root.join("skills").abs()], + disabled_skill_paths: HashSet::new(), + has_enabled_skills: true, + mcp_servers: HashMap::from([( + "sample".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://sample.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )]), + apps: vec![AppConnectorId("connector_example".to_string())], + hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), + error: None, + }] + ); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: Some("Plugin that includes the sample MCP server and Skills".to_string(),), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + }] + ); + assert_eq!( + outcome.effective_skill_roots(), + vec![plugin_root.join("skills").abs()] + ); + assert_eq!(outcome.effective_mcp_servers().len(), 1); + assert_eq!( + outcome.effective_apps(), + vec![AppConnectorId("connector_example".to_string())] + ); +} + +#[tokio::test] +async fn load_plugins_applies_plugin_mcp_server_policy() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample" +}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp", + "default_tools_approval_mode": "prompt", + "enabled_tools": ["read", "search"], + "tools": { + "search": { "approval_mode": "prompt" } + } + } + } +}"#, + ); + let config_toml = r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true + +[plugins."sample@test".mcp_servers.sample] +enabled = false +default_tools_approval_mode = "approve" +enabled_tools = ["search"] +disabled_tools = ["delete"] + +[plugins."sample@test".mcp_servers.sample.tools.search] +approval_mode = "approve" +"#; + + let outcome = load_plugins_from_config(config_toml, codex_home.path()).await; + let server = outcome.plugins()[0] + .mcp_servers + .get("sample") + .expect("sample server"); + + assert!(!server.enabled); + assert_eq!( + server.default_tools_approval_mode, + Some(AppToolApproval::Approve) + ); + assert_eq!(server.enabled_tools, Some(vec!["search".to_string()])); + assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()])); + assert_eq!( + server.tools.get("search"), + Some(&McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + }) + ); +} + +#[tokio::test] +async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_config() { + let codex_home = TempDir::new().unwrap(); + let plugin_base = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear"); + write_plugin(&plugin_base, "local", "linear"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { + marketplace_name: "chatgpt-global".to_string(), + id: "plugins~Plugin_linear".to_string(), + name: "linear".to_string(), + enabled: true, + }]); + + let outcome = manager.plugins_for_config(&config).await; + assert_eq!( + outcome.effective_skill_roots(), + vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()] + ); + assert_eq!(outcome.plugins().len(), 1); + assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global"); +} + +#[tokio::test] +async fn remote_installed_cache_ignores_plugins_missing_local_cache() { + let codex_home = TempDir::new().unwrap(); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { + marketplace_name: "chatgpt-global".to_string(), + id: "plugins~Plugin_linear".to_string(), + name: "linear".to_string(), + enabled: true, + }]); + + let outcome = manager.plugins_for_config(&config).await; + assert_eq!(outcome, PluginLoadOutcome::default()); +} + +#[tokio::test] +async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + let skill_path = plugin_root.join("skills/sample-search/SKILL.md"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &skill_path, + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:sample-search" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()).await; + let skill_path = std::fs::canonicalize(skill_path) + .expect("skill path should canonicalize") + .abs(); + + assert_eq!( + outcome.plugins()[0].disabled_skill_paths, + HashSet::from([skill_path]) + ); + assert!(!outcome.plugins()[0].has_enabled_skills); + assert!(outcome.capability_summaries().is_empty()); +} + +#[tokio::test] +async fn load_plugins_ignores_unknown_disabled_skill_names() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:missing-skill" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()).await; + + assert!(outcome.plugins()[0].disabled_skill_paths.is_empty()); + assert!(outcome.plugins()[0].has_enabled_skills); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }] + ); +} + +#[tokio::test] +async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample" +}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + + let metadata = plugin_telemetry_metadata_from_root( + &PluginId::parse("sample@test").expect("plugin id should parse"), + &plugin_root.abs(), + ) + .await; + + assert_eq!( + metadata.capability_summary, + Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }) + ); +} + +#[tokio::test] +async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "description": "Plugin that\n includes the sample\tserver" +}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + ) + .await; + + assert_eq!( + outcome.plugins()[0].manifest_description.as_deref(), + Some("Plugin that\n includes the sample\tserver") + ); + assert_eq!( + outcome.capability_summaries()[0].description.as_deref(), + Some("Plugin that includes the sample server") + ); +} + +#[tokio::test] +async fn capability_summary_truncates_overlong_plugin_descriptions() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + let too_long = "x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN + 1); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ + "name": "sample", + "description": "{too_long}" +}}"# + ), + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + ) + .await; + + assert_eq!( + outcome.plugins()[0].manifest_description.as_deref(), + Some(too_long.as_str()) + ); + assert_eq!( + outcome.capability_summaries()[0].description, + Some("x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)) + ); +} + +#[tokio::test] +async fn load_plugins_uses_manifest_configured_component_paths() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "skills": "./custom-skills/", + "mcpServers": "./config/custom.mcp.json", + "apps": "./config/custom.app.json" +}"#, + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "default": { + "type": "http", + "url": "https://default.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ + "mcpServers": { + "custom": { + "type": "http", + "url": "https://custom.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "default": { + "id": "connector_default" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ + "apps": { + "custom": { + "id": "connector_custom" + } + } +}"#, + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + ) + .await; + + assert_eq!( + outcome.plugins()[0].skill_roots, + vec![ + plugin_root.join("custom-skills").abs(), + plugin_root.join("skills").abs() + ] + ); + assert_eq!( + outcome.plugins()[0].mcp_servers, + HashMap::from([( + "custom".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://custom.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )]) + ); + assert_eq!( + outcome.plugins()[0].apps, + vec![AppConnectorId("connector_custom".to_string())] + ); +} + +#[tokio::test] +async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "skills": "custom-skills", + "mcpServers": "config/custom.mcp.json", + "apps": "config/custom.app.json" +}"#, + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "default": { + "type": "http", + "url": "https://default.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ + "mcpServers": { + "custom": { + "type": "http", + "url": "https://custom.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "default": { + "id": "connector_default" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ + "apps": { + "custom": { + "id": "connector_custom" + } + } +}"#, + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + ) + .await; + + assert_eq!( + outcome.plugins()[0].skill_roots, + vec![plugin_root.join("skills").abs()] + ); + assert_eq!( + outcome.plugins()[0].mcp_servers, + HashMap::from([( + "default".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://default.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )]) + ); + assert_eq!( + outcome.plugins()[0].apps, + vec![AppConnectorId("connector_default".to_string())] + ); +} + +#[tokio::test] +async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml( + /*enabled*/ false, /*plugins_feature_enabled*/ true, + ), + codex_home.path(), + ) + .await; + + assert_eq!( + outcome.plugins(), + vec![LoadedPlugin { + config_name: "sample@test".to_string(), + manifest_name: None, + manifest_description: None, + root: AbsolutePathBuf::try_from(plugin_root).unwrap(), + enabled: false, + skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, + mcp_servers: HashMap::new(), + apps: Vec::new(), + hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), + error: None, + }] + ); + assert!(outcome.effective_skill_roots().is_empty()); + assert!(outcome.effective_mcp_servers().is_empty()); +} + +#[tokio::test] +async fn effective_apps_dedupes_connector_ids_across_plugins() { + let codex_home = TempDir::new().unwrap(); + let plugin_a_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-a/local"); + let plugin_b_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-b/local"); + + write_file( + &plugin_a_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-a"}"#, + ); + write_file( + &plugin_a_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); + write_file( + &plugin_b_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-b"}"#, + ); + write_file( + &plugin_b_root.join(".app.json"), + r#"{ + "apps": { + "chat": { + "id": "connector_example" + }, + "gmail": { + "id": "connector_gmail" + } + } +}"#, + ); + + let mut root = toml::map::Map::new(); + let mut features = toml::map::Map::new(); + features.insert("plugins".to_string(), Value::Boolean(true)); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugins = toml::map::Map::new(); + + let mut plugin_a = toml::map::Map::new(); + plugin_a.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a)); + + let mut plugin_b = toml::map::Map::new(); + plugin_b.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b)); + + root.insert("plugins".to_string(), Value::Table(plugins)); + let config_toml = + toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); + + let outcome = load_plugins_from_config(&config_toml, codex_home.path()).await; + + assert_eq!( + outcome.effective_apps(), + vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ] + ); +} + +#[test] +fn capability_index_filters_inactive_and_zero_capability_plugins() { + let codex_home = TempDir::new().unwrap(); + let connector = |id: &str| AppConnectorId(id.to_string()); + let http_server = |url: &str| McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: url.to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }; + let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { + config_name: config_name.to_string(), + manifest_name: Some(manifest_name.to_string()), + manifest_description: None, + root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), + enabled: true, + skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, + mcp_servers: HashMap::new(), + apps: Vec::new(), + hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), + error: None, + }; + let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary { + config_name: config_name.to_string(), + display_name: display_name.to_string(), + description: None, + ..PluginCapabilitySummary::default() + }; + let outcome = PluginLoadOutcome::from_plugins(vec![ + LoadedPlugin { + skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()], + has_enabled_skills: true, + ..plugin("skills@test", "skills-plugin", "skills-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), + apps: vec![connector("connector_example")], + ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), + apps: vec![connector("connector_example"), connector("connector_gmail")], + ..plugin("beta@test", "beta-plugin", "beta-plugin") + }, + plugin("empty@test", "empty-plugin", "empty-plugin"), + LoadedPlugin { + enabled: false, + skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()], + apps: vec![connector("connector_hidden")], + ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") + }, + LoadedPlugin { + apps: vec![connector("connector_broken")], + error: Some("failed to load".to_string()), + ..plugin("broken@test", "broken-plugin", "broken-plugin") + }, + ]); + + assert_eq!( + outcome.capability_summaries(), + &[ + PluginCapabilitySummary { + has_skills: true, + ..summary("skills@test", "skills-plugin") + }, + PluginCapabilitySummary { + mcp_server_names: vec!["alpha".to_string()], + app_connector_ids: vec![connector("connector_example")], + ..summary("alpha@test", "alpha-plugin") + }, + PluginCapabilitySummary { + mcp_server_names: vec!["beta".to_string()], + app_connector_ids: vec![ + connector("connector_example"), + connector("connector_gmail"), + ], + ..summary("beta@test", "beta-plugin") + }, + ] + ); +} + +#[tokio::test] +async fn load_plugins_returns_empty_when_feature_disabled() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml( + /*enabled*/ true, /*plugins_feature_enabled*/ false, + ), + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let outcome = PluginsManager::new(codex_home.path().to_path_buf()) + .plugins_for_config(&config) + .await; + + assert_eq!(outcome, PluginLoadOutcome::default()); +} + +#[tokio::test] +async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [{ "type": "command", "command": "echo plugin hook" }] + } + ] + } +}"#, + ); + + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml_with_plugin_hooks( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + /*plugin_hooks_feature_enabled*/ false, + ), + ); + let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; + let without_plugin_hooks = manager + .plugins_for_config(&config_without_plugin_hooks) + .await; + assert!( + without_plugin_hooks + .effective_plugin_hook_sources() + .is_empty() + ); + + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml_with_plugin_hooks( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + /*plugin_hooks_feature_enabled*/ true, + ), + ); + let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; + let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await; + assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1); +} + +#[tokio::test] +async fn load_plugins_rejects_invalid_plugin_keys() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + + let mut root = toml::map::Map::new(); + let mut features = toml::map::Map::new(); + features.insert("plugins".to_string(), Value::Boolean(true)); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugin = toml::map::Map::new(); + plugin.insert("enabled".to_string(), Value::Boolean(true)); + + let mut plugins = toml::map::Map::new(); + plugins.insert("sample".to_string(), Value::Table(plugin)); + root.insert("plugins".to_string(), Value::Table(plugins)); + + let outcome = load_plugins_from_config( + &toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"), + codex_home.path(), + ) + .await; + + assert_eq!(outcome.plugins().len(), 1); + assert_eq!( + outcome.plugins()[0].error.as_deref(), + Some("invalid plugin key `sample`; expected @") + ); + assert!(outcome.effective_skill_roots().is_empty()); + assert!(outcome.effective_mcp_servers().is_empty()); +} + +#[tokio::test] +async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin(&repo_root, "sample-plugin", "sample-plugin"); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + }, + "policy": { + "authentication": "ON_USE" + } + } + ] +}"#, + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnUse, + } + ); + + let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap(); + assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); + assert!(config.contains("enabled = true")); +} + +#[tokio::test] +async fn install_openai_curated_plugin_uses_short_sha_cache_version() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "slack".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp.path().join(format!( + "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" + )); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string() + ) + .unwrap(), + plugin_version: TEST_CURATED_PLUGIN_CACHE_VERSION.to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); +} + +#[tokio::test] +async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version( + &repo_root, + "sample-plugin", + "sample-plugin", + Some("1.2.3-beta+7"), + ); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp + .path() + .join("plugins/cache/debug/sample-plugin/1.2.3-beta+7"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), + plugin_version: "1.2.3-beta+7".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); +} + +#[tokio::test] +async fn install_plugin_supports_git_subdir_marketplace_sources() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("marketplace"); + let remote_repo = tmp.path().join("remote-plugin-repo"); + let remote_repo_url = url::Url::from_directory_path(&remote_repo) + .unwrap() + .to_string(); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin(&remote_repo, "plugins/toolkit", "toolkit"); + init_git_repo(&remote_repo); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{remote_repo_url}", + "path": "plugins/toolkit" + }} + }} + ] +}}"# + ), + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "toolkit".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(), + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + +#[tokio::test] +async fn install_plugin_supports_relative_git_subdir_marketplace_sources() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("marketplace"); + let remote_repo = repo_root.join("remote-plugin-repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin(&remote_repo, "plugins/toolkit", "toolkit"); + init_git_repo(&remote_repo); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "toolkit", + "source": { + "source": "git-subdir", + "url": "./remote-plugin-repo", + "path": "plugins/toolkit" + } + } + ] +}"#, + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "toolkit".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(), + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + +#[tokio::test] +async fn uninstall_plugin_removes_cache_and_config_entry() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); +} + +#[tokio::test] +async fn list_marketplaces_includes_enabled_state() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "enabled-plugin/local", + "enabled-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "disabled-plugin/local", + "disabled-plugin", + ); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + }, + { + "name": "disabled-plugin", + "source": { + "source": "local", + "path": "./disabled-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."enabled-plugin@debug"] +enabled = true + +[plugins."disabled-plugin@debug"] +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap() + .marketplaces; + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("expected repo marketplace entry"); + + assert_eq!( + marketplace, + ConfiguredMarketplace { + name: "debug".to_string(), + path: AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap(), + interface: None, + plugins: vec![ + ConfiguredMarketplacePlugin { + id: "enabled-plugin@debug".to_string(), + name: "enabled-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) + .unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: true, + enabled: true, + }, + ConfiguredMarketplacePlugin { + id: "disabled-plugin@debug".to_string(), + name: "disabled-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),) + .unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: true, + enabled: false, + }, + ], + } + ); +} + +#[tokio::test] +async fn list_marketplaces_returns_empty_when_feature_disabled() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = false + +[plugins."enabled-plugin@debug"] +enabled = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap() + .marketplaces; + + assert_eq!(marketplaces, Vec::new()); +} + +#[tokio::test] +async fn list_marketplaces_excludes_plugins_with_explicit_empty_products() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "disabled-plugin", + "source": { + "source": "local", + "path": "./disabled-plugin" + }, + "policy": { + "products": [] + } + }, + { + "name": "default-plugin", + "source": { + "source": "local", + "path": "./default-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap() + .marketplaces; + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("expected repo marketplace entry"); + assert_eq!( + marketplace.plugins, + vec![ConfiguredMarketplacePlugin { + id: "default-plugin@debug".to_string(), + name: "default-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: false, + enabled: false, + }] + ); +} + +#[tokio::test] +async fn read_plugin_for_config_returns_plugins_disabled_when_feature_disabled() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(); + fs::write( + marketplace_path.as_path(), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = false + +[plugins."enabled-plugin@debug"] +enabled = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let err = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "enabled-plugin".to_string(), + marketplace_path, + }, + ) + .await + .unwrap_err(); + + assert!(matches!(err, MarketplaceError::PluginsDisabled)); +} + +#[tokio::test] +async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("enabled-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + } + ] +}"#, + ); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"enabled-plugin"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."enabled-plugin@debug"] +enabled = true +"#, + ); + write_file( + &repo_root.join(".codex/config.toml"), + r#"[[skills.config]] +name = "enabled-plugin:sample-search" +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let outcome = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "enabled-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .await + .unwrap(); + + assert!(outcome.plugin.disabled_skill_paths.is_empty()); +} + +#[tokio::test] +async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }}, + "policy": {{ + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }} + }} + ] +}}"# + ), + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let outcome = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "toolkit".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .await + .unwrap(); + + assert_eq!( + outcome.plugin.details_unavailable_reason, + Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource) + ); + assert!(!outcome.plugin.installed); + let expected_description = format!( + "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`." + ); + assert_eq!( + outcome.plugin.description.as_deref(), + Some(expected_description.as_str()) + ); + assert!(outcome.plugin.skills.is_empty()); + assert!(outcome.plugin.apps.is_empty()); + assert!(outcome.plugin.mcp_server_names.is_empty()); + assert!( + !tmp.path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); +} + +#[tokio::test] +async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }}, + "category": "Developer Tools" + }} + ] +}}"# + ), + ); + let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local"); + write_file( + &cached_plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "toolkit", + "description": "Cached toolkit plugin", + "interface": { + "displayName": "Toolkit" + } +}"#, + ); + write_file( + &cached_plugin_root.join("skills/search/SKILL.md"), + "---\nname: search\ndescription: search cached data\n---\n", + ); + write_file( + &cached_plugin_root.join(".app.json"), + r#"{"apps":{"calendar":{"id":"connector_calendar"}}}"#, + ); + write_file( + &cached_plugin_root.join(".mcp.json"), + r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#, + ); + write_file( + &cached_plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ] + } + ] + } +}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +plugin_hooks = true + +[plugins."toolkit@debug"] +enabled = true + +[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let outcome = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "toolkit".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .await + .unwrap(); + + assert_eq!(outcome.plugin.details_unavailable_reason, None); + assert_eq!( + outcome.plugin.description.as_deref(), + Some("Cached toolkit plugin") + ); + assert_eq!( + outcome.plugin.interface, + Some(PluginManifestInterface { + display_name: Some("Toolkit".to_string()), + category: Some("Developer Tools".to_string()), + ..Default::default() + }) + ); + assert!(outcome.plugin.installed); + assert_eq!(outcome.plugin.skills.len(), 1); + assert_eq!(outcome.plugin.skills[0].name, "toolkit:search"); + assert_eq!( + outcome.plugin.apps, + vec![AppConnectorId("connector_calendar".to_string())] + ); + assert_eq!( + outcome.plugin.hooks, + vec![ + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + }, + ] + ); + assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]); + assert!( + !tmp.path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); +} + +#[tokio::test] +async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = false +"#, + ); + + let config = load_config(tmp.path(), tmp.path()).await; + let outcome = PluginsManager::new(tmp.path().to_path_buf()) + .sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false) + .await + .unwrap(); + + assert_eq!(outcome, RemotePluginSyncResult::default()); +} + +#[tokio::test] +async fn list_marketplaces_includes_curated_repo_marketplace() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + let plugin_root = curated_root.join("plugins/linear"); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "linear", + "source": { + "source": "local", + "path": "./plugins/linear" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .unwrap(); + + let config = load_config(tmp.path(), tmp.path()).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[]) + .unwrap() + .marketplaces; + + let curated_marketplace = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("curated marketplace should be listed"); + + assert_eq!( + curated_marketplace, + ConfiguredMarketplace { + name: "openai-curated".to_string(), + path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + interface: None, + plugins: vec![ConfiguredMarketplacePlugin { + id: "linear@openai-curated".to_string(), + name: "linear".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: false, + enabled: false, + }], + } + ); +} + +#[tokio::test] +async fn list_marketplaces_includes_installed_marketplace_roots() { + let tmp = tempfile::tempdir().unwrap(); + let marketplace_root = marketplace_install_root(tmp.path()).join("debug"); + let plugin_root = marketplace_root.join("plugins/sample"); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[marketplaces.debug] +last_updated = "2026-04-10T12:34:56Z" +source_type = "git" +source = "/tmp/debug" +"#, + ); + fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + marketplace_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .unwrap(); + let config = load_config(tmp.path(), tmp.path()).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[]) + .unwrap() + .marketplaces; + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("installed marketplace should be listed"); + + assert_eq!( + marketplace.path, + AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json")) + .unwrap() + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins[0].id, "sample@debug"); + assert_eq!( + marketplace.plugins[0].source, + MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(plugin_root).unwrap(), + } + ); +} + +#[tokio::test] +async fn list_marketplaces_uses_config_when_known_registry_is_malformed() { + let tmp = tempfile::tempdir().unwrap(); + let marketplace_root = marketplace_install_root(tmp.path()).join("debug"); + let plugin_root = marketplace_root.join("plugins/sample"); + let registry_path = tmp.path().join(".tmp/known_marketplaces.json"); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[marketplaces.debug] +last_updated = "2026-04-10T12:34:56Z" +source_type = "git" +source = "/tmp/debug" +"#, + ); + fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + marketplace_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .unwrap(); + fs::create_dir_all(registry_path.parent().unwrap()).unwrap(); + fs::write(registry_path, "{not valid json").unwrap(); + + let config = load_config(tmp.path(), tmp.path()).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[]) + .unwrap() + .marketplaces; + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("configured marketplace should be discovered"); + + assert_eq!(marketplace.plugins[0].id, "sample@debug"); +} + +#[tokio::test] +async fn list_marketplaces_ignores_installed_roots_missing_from_config() { + let tmp = tempfile::tempdir().unwrap(); + let marketplace_root = marketplace_install_root(tmp.path()).join("debug"); + let plugin_root = marketplace_root.join("plugins/sample"); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + marketplace_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .unwrap(); + let config = load_config(tmp.path(), tmp.path()).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[]) + .unwrap() + .marketplaces; + + assert!( + marketplaces.iter().all(|marketplace| { + marketplace.path + != AbsolutePathBuf::try_from( + marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap() + }), + "installed marketplace root missing from config should not be listed" + ); +} + +#[tokio::test] +async fn list_marketplaces_uses_first_duplicate_plugin_entry() { + let tmp = tempfile::tempdir().unwrap(); + let repo_a_root = tmp.path().join("repo-a"); + let repo_b_root = tmp.path().join("repo-b"); + fs::create_dir_all(repo_a_root.join(".git")).unwrap(); + fs::create_dir_all(repo_b_root.join(".git")).unwrap(); + fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_a_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "dup-plugin", + "source": { + "source": "local", + "path": "./from-a" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_b_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "dup-plugin", + "source": { + "source": "local", + "path": "./from-b" + } + }, + { + "name": "b-only-plugin", + "source": { + "source": "local", + "path": "./from-b-only" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."dup-plugin@debug"] +enabled = true + +[plugins."b-only-plugin@debug"] +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_a_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config( + &config, + &[ + AbsolutePathBuf::try_from(repo_a_root).unwrap(), + AbsolutePathBuf::try_from(repo_b_root).unwrap(), + ], + ) + .unwrap() + .marketplaces; + + let repo_a_marketplace = marketplaces + .iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo-a/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("repo-a marketplace should be listed"); + assert_eq!( + repo_a_marketplace.plugins, + vec![ConfiguredMarketplacePlugin { + id: "dup-plugin@debug".to_string(), + name: "dup-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: false, + enabled: true, + }] + ); + + let repo_b_marketplace = marketplaces + .iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo-b/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("repo-b marketplace should be listed"); + assert_eq!( + repo_b_marketplace.plugins, + vec![ConfiguredMarketplacePlugin { + id: "b-only-plugin@debug".to_string(), + name: "b-only-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: false, + enabled: false, + }] + ); + + let duplicate_plugin_count = marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .filter(|plugin| plugin.name == "dup-plugin") + .count(); + assert_eq!(duplicate_plugin_count, 1); +} + +#[tokio::test] +async fn list_marketplaces_marks_configured_plugin_uninstalled_when_cache_is_missing() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap() + .marketplaces; + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("expected repo marketplace entry"); + + assert_eq!( + marketplace, + ConfiguredMarketplace { + name: "debug".to_string(), + path: AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap(), + interface: None, + plugins: vec![ConfiguredMarketplacePlugin { + id: "sample-plugin@debug".to_string(), + name: "sample-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: false, + enabled: true, + }], + } + ); +} + +#[tokio::test] +async fn sync_plugins_from_remote_reconciles_cache_and_config() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "linear/local", + "linear", + ); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "gmail/local", + "gmail", + ); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "calendar/local", + "calendar", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false + +[plugins."gmail@openai-curated"] +enabled = false + +[plugins."calendar@openai-curated"] +enabled = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, + {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + /*additive_only*/ false, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: Vec::new(), + enabled_plugin_ids: vec!["linear@openai-curated".to_string()], + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: vec![ + "gmail@openai-curated".to_string(), + "calendar@openai-curated".to_string(), + ], + } + ); + + assert!( + tmp.path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/gmail") + .exists() + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/calendar") + .exists() + ); + + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!(config.contains("enabled = true")); + assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); + assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); + + let synced_config = load_config(tmp.path(), tmp.path()).await; + let curated_marketplace = manager + .list_marketplaces_for_config(&synced_config, &[]) + .unwrap() + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + .unwrap(); + assert_eq!( + curated_marketplace + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ("linear@openai-curated".to_string(), true, true), + ("gmail@openai-curated".to_string(), false, false), + ("calendar@openai-curated".to_string(), false, false), + ] + ); +} + +#[tokio::test] +async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "linear/local", + "linear", + ); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "gmail/local", + "gmail", + ); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "calendar/local", + "calendar", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false + +[plugins."gmail@openai-curated"] +enabled = false + +[plugins."calendar@openai-curated"] +enabled = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, + {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + /*additive_only*/ true, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: Vec::new(), + enabled_plugin_ids: vec!["linear@openai-curated".to_string()], + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: Vec::new(), + } + ); + + assert!( + tmp.path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + tmp.path() + .join("plugins/cache/openai-curated/gmail/local") + .is_dir() + ); + assert!( + tmp.path() + .join("plugins/cache/openai-curated/calendar/local") + .is_dir() + ); + + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); + assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#)); + assert!(config.contains("enabled = true")); +} + +#[tokio::test] +async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + /*additive_only*/ false, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: Vec::new(), + enabled_plugin_ids: Vec::new(), + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], + } + ); + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/linear") + .exists() + ); +} + +#[tokio::test] +async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "linear/local", + "linear", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let err = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + /*additive_only*/ false, + ) + .await + .unwrap_err(); + + assert!(matches!( + err, + PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) + if message.contains("plugin source path is not a directory") + )); + assert!( + tmp.path() + .join("plugins/cache/openai-curated/linear/local") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/gmail") + .exists() + ); + + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); + assert!(config.contains("enabled = false")); +} + +#[tokio::test] +async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); + fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail-first" + } + }, + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail-second" + } + } + ] +}"#, + ) + .unwrap(); + write_plugin(&curated_root, "plugins/gmail-first", "gmail"); + write_plugin(&curated_root, "plugins/gmail-second", "gmail"); + fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); + fs::write( + curated_root.join("plugins/gmail-second/marker.txt"), + "second", + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let result = manager + .sync_plugins_from_remote( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + /*additive_only*/ false, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginSyncResult { + installed_plugin_ids: vec!["gmail@openai-curated".to_string()], + enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], + disabled_plugin_ids: Vec::new(), + uninstalled_plugin_ids: Vec::new(), + } + ); + assert_eq!( + fs::read_to_string(tmp.path().join(format!( + "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_CACHE_VERSION}/marker.txt" + ))) + .unwrap(), + "first" + ); +} + +#[tokio::test] +async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "chat")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#)) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new_with_restriction_product( + tmp.path().to_path_buf(), + Some(Product::Chatgpt), + ); + + let featured_plugin_ids = manager + .featured_plugin_ids_for_config( + &config, + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]); +} + +#[tokio::test] +async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#)) + .mount(&server) + .await; + + let mut config = load_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = PluginsManager::new_with_restriction_product( + tmp.path().to_path_buf(), + /*restriction_product*/ None, + ); + + let featured_plugin_ids = manager + .featured_plugin_ids_for_config(&config, /*auth*/ None) + .await + .unwrap(); + + assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]); +} + +#[test] +fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + let plugin_id = PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + "slack/local", + "slack", + ); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should succeed") + ); + + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/slack/local") + .exists() + ); + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" + )) + .is_dir() + ); +} + +#[test] +fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_short_version() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + let plugin_id = PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should recreate missing configured plugin") + ); + + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" + )) + .is_dir() + ); +} + +#[test] +fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() { + let tmp = tempfile::tempdir().unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."slack@openai-curated"] +enabled = true + +[plugins."sample@debug"] +enabled = true +"#, + ); + + assert_eq!( + configured_curated_plugin_ids_from_codex_home(tmp.path()) + .into_iter() + .map(|plugin_id| plugin_id.as_key()) + .collect::>(), + vec!["slack@openai-curated".to_string()] + ); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + assert_eq!( + configured_curated_plugin_ids_from_codex_home(tmp.path()), + Vec::::new() + ); +} + +#[test] +fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + let plugin_id = PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + &format!("slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"), + "slack", + ); + + assert!( + !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should be a no-op when configured plugins are current") + ); +} + +#[test] +fn refresh_curated_plugin_cache_migrates_full_sha_cache_version_to_short_version() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + let plugin_id = PluginId::new( + "slack".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/openai-curated"), + &format!("slack/{TEST_CURATED_PLUGIN_SHA}"), + "slack", + ); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should migrate the full sha cache version") + ); + assert!( + !tmp.path() + .join(format!( + "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}" + )) + .exists() + ); + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}" + )) + .is_dir() + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should succeed") + ); + + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin/local") + .exists() + ); + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/1.2.3") + .is_dir() + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_reinstalls_missing_configured_plugin_with_manifest_version() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should reinstall missing configured plugin") + ); + + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/1.2.3") + .is_dir() + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_refreshes_configured_git_source() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let remote_repo = tmp.path().join("remote-plugin-repo"); + let remote_repo_url = url::Url::from_directory_path(&remote_repo) + .unwrap() + .to_string(); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + write_plugin_with_version( + &remote_repo, + "plugins/sample-plugin", + "sample-plugin", + Some("1.2.3"), + ); + init_git_repo(&remote_repo); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "sample-plugin", + "source": {{ + "source": "git-subdir", + "url": "{remote_repo_url}", + "path": "plugins/sample-plugin" + }} + }} + ] +}}"# + ), + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should materialize configured Git plugin") + ); + + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/1.2.3") + .is_dir() + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ); + write_plugin_with_version( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/1.2.3", + "sample-plugin", + Some("1.2.3"), + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + !refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should be a no-op when configured plugins are current") + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_force_reinstalls_current_local_version() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin(&repo_root, "sample-plugin", "sample-plugin"); + fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "new skill").unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + fs::write( + tmp.path() + .join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md"), + "old skill", + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache_force_reinstall( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should reinstall unchanged local version") + ); + + assert_eq!( + fs::read_to_string( + tmp.path() + .join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md") + ) + .unwrap(), + "new skill" + ); +} + +#[test] +fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3")); + write_plugin_with_version(&repo_root, "broken-plugin", "broken-plugin", Some(" ")); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + }, + { + "name": "broken-plugin", + "source": { + "source": "local", + "path": "./broken-plugin" + } + } + ] +}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + assert!( + refresh_non_curated_plugin_cache( + tmp.path(), + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + ) + .expect("cache refresh should ignore unrelated invalid plugin manifests") + ); + + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/1.2.3") + .is_dir() + ); +} + +#[tokio::test] +async fn load_plugins_ignores_project_config_files() { + let codex_home = TempDir::new().unwrap(); + let project_root = codex_home.path().join("project"); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &project_root.join(".codex/config.toml"), + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + ); + + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")).unwrap(), + }, + toml::from_str(&plugin_config_toml( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + )) + .expect("project config should parse"), + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build"); + + let outcome = load_plugins_from_layer_stack( + &stack, + std::collections::HashMap::new(), + &PluginStore::new(codex_home.path().to_path_buf()), + Some(Product::Codex), + /*plugin_hooks_enabled*/ false, + ) + .await; + + assert_eq!(outcome, PluginLoadOutcome::default()); +} diff --git a/code-rs/core-plugins/src/manifest.rs b/code-rs/core-plugins/src/manifest.rs new file mode 100644 index 00000000000..6de7f820b89 --- /dev/null +++ b/code-rs/core-plugins/src/manifest.rs @@ -0,0 +1,624 @@ +use codex_config::HooksFile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::find_plugin_manifest_path; +use serde::Deserialize; +use serde_json::Value as JsonValue; +use std::fs; +use std::path::Component; +use std::path::Path; +const MAX_DEFAULT_PROMPT_COUNT: usize = 3; +const MAX_DEFAULT_PROMPT_LEN: usize = 128; + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifest { + #[serde(default)] + name: String, + #[serde(default)] + version: Option, + #[serde(default)] + description: Option, + #[serde(default)] + keywords: Vec, + // Keep manifest paths as raw strings so we can validate the required `./...` syntax before + // resolving them under the plugin root. + #[serde(default)] + skills: Option, + #[serde(default)] + mcp_servers: Option, + #[serde(default)] + apps: Option, + #[serde(default)] + hooks: Option, + #[serde(default)] + interface: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManifest { + pub name: String, + pub version: Option, + pub description: Option, + pub keywords: Vec, + pub paths: PluginManifestPaths, + pub interface: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManifestPaths { + pub skills: Option, + pub mcp_servers: Option, + pub apps: Option, + pub hooks: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginManifestHooks { + Paths(Vec), + Inline(Vec), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginManifestInterface { + pub display_name: Option, + pub short_description: Option, + pub long_description: Option, + pub developer_name: Option, + pub category: Option, + pub capabilities: Vec, + pub website_url: Option, + pub privacy_policy_url: Option, + pub terms_of_service_url: Option, + pub default_prompt: Option>, + pub brand_color: Option, + pub composer_icon: Option, + pub logo: Option, + pub screenshots: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifestInterface { + #[serde(default)] + display_name: Option, + #[serde(default)] + short_description: Option, + #[serde(default)] + long_description: Option, + #[serde(default)] + developer_name: Option, + #[serde(default)] + category: Option, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + #[serde(alias = "websiteURL")] + website_url: Option, + #[serde(default)] + #[serde(alias = "privacyPolicyURL")] + privacy_policy_url: Option, + #[serde(default)] + #[serde(alias = "termsOfServiceURL")] + terms_of_service_url: Option, + #[serde(default)] + default_prompt: Option, + #[serde(default)] + brand_color: Option, + #[serde(default)] + composer_icon: Option, + #[serde(default)] + logo: Option, + #[serde(default)] + screenshots: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawPluginManifestDefaultPrompt { + String(String), + List(Vec), + Invalid(JsonValue), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawPluginManifestDefaultPromptEntry { + String(String), + Invalid(JsonValue), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawPluginManifestHooks { + Path(String), + Paths(Vec), + Inline(HooksFile), + InlineList(Vec), + Invalid(JsonValue), +} + +pub fn load_plugin_manifest(plugin_root: &Path) -> Option { + let manifest_path = find_plugin_manifest_path(plugin_root)?; + let contents = fs::read_to_string(&manifest_path).ok()?; + match serde_json::from_str::(&contents) { + Ok(manifest) => { + let RawPluginManifest { + name: raw_name, + version, + description, + keywords, + skills, + mcp_servers, + apps, + hooks, + interface, + } = manifest; + let name = plugin_root + .file_name() + .and_then(|entry| entry.to_str()) + .filter(|_| raw_name.trim().is_empty()) + .unwrap_or(&raw_name) + .to_string(); + let version = version.and_then(|version| { + let version = version.trim(); + (!version.is_empty()).then(|| version.to_string()) + }); + let interface = interface.and_then(|interface| { + let RawPluginManifestInterface { + display_name, + short_description, + long_description, + developer_name, + category, + capabilities, + website_url, + privacy_policy_url, + terms_of_service_url, + default_prompt, + brand_color, + composer_icon, + logo, + screenshots, + } = interface; + + let interface = PluginManifestInterface { + display_name, + short_description, + long_description, + developer_name, + category, + capabilities, + website_url, + privacy_policy_url, + terms_of_service_url, + default_prompt: resolve_default_prompts(plugin_root, default_prompt.as_ref()), + brand_color, + composer_icon: resolve_interface_asset_path( + plugin_root, + "interface.composerIcon", + composer_icon.as_deref(), + ), + logo: resolve_interface_asset_path( + plugin_root, + "interface.logo", + logo.as_deref(), + ), + screenshots: screenshots + .iter() + .filter_map(|screenshot| { + resolve_interface_asset_path( + plugin_root, + "interface.screenshots", + Some(screenshot), + ) + }) + .collect(), + }; + + let has_fields = interface.display_name.is_some() + || interface.short_description.is_some() + || interface.long_description.is_some() + || interface.developer_name.is_some() + || interface.category.is_some() + || !interface.capabilities.is_empty() + || interface.website_url.is_some() + || interface.privacy_policy_url.is_some() + || interface.terms_of_service_url.is_some() + || interface.default_prompt.is_some() + || interface.brand_color.is_some() + || interface.composer_icon.is_some() + || interface.logo.is_some() + || !interface.screenshots.is_empty(); + + has_fields.then_some(interface) + }); + Some(PluginManifest { + name, + version, + description, + keywords, + paths: PluginManifestPaths { + skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()), + mcp_servers: resolve_manifest_path( + plugin_root, + "mcpServers", + mcp_servers.as_deref(), + ), + apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), + hooks: resolve_manifest_hooks(plugin_root, hooks), + }, + interface, + }) + } + Err(err) => { + tracing::warn!( + path = %manifest_path.display(), + "failed to parse plugin manifest: {err}" + ); + None + } + } +} + +fn resolve_manifest_hooks( + plugin_root: &Path, + hooks: Option, +) -> Option { + match hooks? { + RawPluginManifestHooks::Path(path) => { + resolve_manifest_path(plugin_root, "hooks", Some(&path)) + .map(|path| PluginManifestHooks::Paths(vec![path])) + } + RawPluginManifestHooks::Paths(paths) => { + let hooks = paths + .iter() + .filter_map(|path| resolve_manifest_path(plugin_root, "hooks", Some(path))) + .collect::>(); + (!hooks.is_empty()).then_some(PluginManifestHooks::Paths(hooks)) + } + RawPluginManifestHooks::Inline(hooks) => Some(PluginManifestHooks::Inline(vec![hooks])), + RawPluginManifestHooks::InlineList(hooks) => { + (!hooks.is_empty()).then_some(PluginManifestHooks::Inline(hooks)) + } + RawPluginManifestHooks::Invalid(value) => { + tracing::warn!( + "ignoring hooks: expected a string, string array, object, or object array; found {}", + json_value_type(&value) + ); + None + } + } +} + +fn resolve_interface_asset_path( + plugin_root: &Path, + field: &'static str, + path: Option<&str>, +) -> Option { + resolve_manifest_path(plugin_root, field, path) +} + +fn resolve_default_prompts( + plugin_root: &Path, + value: Option<&RawPluginManifestDefaultPrompt>, +) -> Option> { + match value? { + RawPluginManifestDefaultPrompt::String(prompt) => { + resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt) + .map(|prompt| vec![prompt]) + } + RawPluginManifestDefaultPrompt::List(values) => { + let mut prompts = Vec::new(); + for (index, item) in values.iter().enumerate() { + if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT { + warn_invalid_default_prompt( + plugin_root, + "interface.defaultPrompt", + &format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"), + ); + break; + } + + match item { + RawPluginManifestDefaultPromptEntry::String(prompt) => { + let field = format!("interface.defaultPrompt[{index}]"); + if let Some(prompt) = + resolve_default_prompt_str(plugin_root, &field, prompt) + { + prompts.push(prompt); + } + } + RawPluginManifestDefaultPromptEntry::Invalid(value) => { + let field = format!("interface.defaultPrompt[{index}]"); + warn_invalid_default_prompt( + plugin_root, + &field, + &format!("expected a string, found {}", json_value_type(value)), + ); + } + } + } + + (!prompts.is_empty()).then_some(prompts) + } + RawPluginManifestDefaultPrompt::Invalid(value) => { + warn_invalid_default_prompt( + plugin_root, + "interface.defaultPrompt", + &format!( + "expected a string or array of strings, found {}", + json_value_type(value) + ), + ); + None + } + } +} + +fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option { + let prompt = prompt.split_whitespace().collect::>().join(" "); + if prompt.is_empty() { + warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty"); + return None; + } + if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN { + warn_invalid_default_prompt( + plugin_root, + field, + &format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"), + ); + return None; + } + Some(prompt) +} + +fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) { + if let Some(manifest_path) = find_plugin_manifest_path(plugin_root) { + tracing::warn!( + path = %manifest_path.display(), + "ignoring {field}: {message}" + ); + } else { + tracing::warn!("ignoring {field}: {message}"); + } +} + +fn json_value_type(value: &JsonValue) -> &'static str { + match value { + JsonValue::Null => "null", + JsonValue::Bool(_) => "boolean", + JsonValue::Number(_) => "number", + JsonValue::String(_) => "string", + JsonValue::Array(_) => "array", + JsonValue::Object(_) => "object", + } +} + +fn resolve_manifest_path( + plugin_root: &Path, + field: &'static str, + path: Option<&str>, +) -> Option { + // `plugin.json` paths are required to be relative to the plugin root and we return the + // normalized absolute path to the rest of the system. + let path = path?; + if path.is_empty() { + return None; + } + let Some(relative_path) = path.strip_prefix("./") else { + tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root"); + return None; + }; + if relative_path.is_empty() { + tracing::warn!("ignoring {field}: path must not be `./`"); + return None; + } + + let mut normalized = std::path::PathBuf::new(); + for component in Path::new(relative_path).components() { + match component { + Component::Normal(component) => normalized.push(component), + Component::ParentDir => { + tracing::warn!("ignoring {field}: path must not contain '..'"); + return None; + } + _ => { + tracing::warn!("ignoring {field}: path must stay within the plugin root"); + return None; + } + } + } + + AbsolutePathBuf::try_from(plugin_root.join(normalized)) + .map_err(|err| { + tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}"); + err + }) + .ok() +} + +#[cfg(test)] +mod tests { + use super::MAX_DEFAULT_PROMPT_LEN; + use super::PluginManifest; + use super::load_plugin_manifest; + use pretty_assertions::assert_eq; + use std::fs; + use std::path::Path; + use tempfile::tempdir; + + const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; + + fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) { + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + let version = version + .map(|version| format!(" \"version\": \"{version}\",\n")) + .unwrap_or_default(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!( + r#"{{ + "name": "demo-plugin", +{version} + "interface": {interface} +}}"# + ), + ) + .expect("write manifest"); + } + + fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) { + let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")) + .expect("create manifest dir"); + fs::write(manifest_path, contents).expect("write manifest"); + } + + fn load_manifest(plugin_root: &Path) -> PluginManifest { + load_plugin_manifest(plugin_root).expect("load plugin manifest") + } + + #[test] + fn plugin_interface_accepts_legacy_default_prompt_string() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + /*version*/ None, + r#"{ + "displayName": "Demo Plugin", + "defaultPrompt": " Summarize my inbox " + }"#, + ); + + let manifest = load_manifest(&plugin_root); + let interface = manifest.interface.expect("plugin interface"); + + assert_eq!( + interface.default_prompt, + Some(vec!["Summarize my inbox".to_string()]) + ); + } + + #[test] + fn plugin_interface_normalizes_default_prompt_array() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); + write_manifest( + &plugin_root, + /*version*/ None, + &format!( + r#"{{ + "displayName": "Demo Plugin", + "defaultPrompt": [ + " Summarize my inbox ", + 123, + "{too_long}", + " ", + "Draft the reply ", + "Find my next action", + "Archive old mail" + ] + }}"# + ), + ); + + let manifest = load_manifest(&plugin_root); + let interface = manifest.interface.expect("plugin interface"); + + assert_eq!( + interface.default_prompt, + Some(vec![ + "Summarize my inbox".to_string(), + "Draft the reply".to_string(), + "Find my next action".to_string(), + ]) + ); + } + + #[test] + fn plugin_interface_ignores_invalid_default_prompt_shape() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + /*version*/ None, + r#"{ + "displayName": "Demo Plugin", + "defaultPrompt": { "text": "Summarize my inbox" } + }"#, + ); + + let manifest = load_manifest(&plugin_root); + let interface = manifest.interface.expect("plugin interface"); + + assert_eq!(interface.default_prompt, None); + } + + #[test] + fn plugin_manifest_reads_trimmed_version() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + Some(" 1.2.3-beta+7 "), + r#"{ + "displayName": "Demo Plugin" + }"#, + ); + + let manifest = load_manifest(&plugin_root); + + assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string())); + } + + #[test] + fn plugin_manifest_reads_keywords() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "keywords": ["api-key", "developer tools"] +}"#, + ) + .expect("write manifest"); + + let manifest = load_manifest(&plugin_root); + + assert_eq!( + manifest.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); + } + + #[test] + fn plugin_manifest_uses_alternate_discoverable_path() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_alternate_plugin_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "version": " 2.0.0 ", + "interface": { + "displayName": "Fallback Plugin" + } +}"#, + ); + + let manifest = load_manifest(&plugin_root); + + assert_eq!(manifest.version, Some("2.0.0".to_string())); + assert_eq!( + manifest + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Fallback Plugin") + ); + } +} diff --git a/code-rs/core-plugins/src/marketplace.rs b/code-rs/core-plugins/src/marketplace.rs new file mode 100644 index 00000000000..f66b5d1b227 --- /dev/null +++ b/code-rs/core-plugins/src/marketplace.rs @@ -0,0 +1,783 @@ +use crate::manifest::PluginManifestInterface; +use crate::manifest::load_plugin_manifest; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_git_utils::get_git_repo_root; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_protocol::protocol::Product; +use codex_utils_absolute_path::AbsolutePathBuf; +use dirs::home_dir; +use serde::Deserialize; +use serde_json::Value as JsonValue; +use std::fs; +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use tracing::warn; + +const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[ + ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedMarketplacePlugin { + pub plugin_id: PluginId, + pub source: MarketplacePluginSource, + pub policy: MarketplacePluginPolicy, + pub interface: Option, + pub manifest: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Marketplace { + pub name: String, + pub path: AbsolutePathBuf, + pub interface: Option, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceListError { + pub path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MarketplaceListOutcome { + pub marketplaces: Vec, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceInterface { + pub display_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplacePlugin { + pub name: String, + pub source: MarketplacePluginSource, + pub policy: MarketplacePluginPolicy, + pub interface: Option, + pub keywords: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MarketplacePluginSource { + Local { + path: AbsolutePathBuf, + }, + Git { + url: String, + path: Option, + ref_name: Option, + sha: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplacePluginPolicy { + pub installation: MarketplacePluginInstallPolicy, + pub authentication: MarketplacePluginAuthPolicy, + // TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of + // only carrying it through core marketplace metadata. + pub products: Option>, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +pub enum MarketplacePluginInstallPolicy { + #[serde(rename = "NOT_AVAILABLE")] + NotAvailable, + #[default] + #[serde(rename = "AVAILABLE")] + Available, + #[serde(rename = "INSTALLED_BY_DEFAULT")] + InstalledByDefault, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +pub enum MarketplacePluginAuthPolicy { + #[default] + #[serde(rename = "ON_INSTALL")] + OnInstall, + #[serde(rename = "ON_USE")] + OnUse, +} + +impl From for PluginInstallPolicy { + fn from(value: MarketplacePluginInstallPolicy) -> Self { + match value { + MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable, + MarketplacePluginInstallPolicy::Available => Self::Available, + MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault, + } + } +} + +impl From for PluginAuthPolicy { + fn from(value: MarketplacePluginAuthPolicy) -> Self { + match value { + MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall, + MarketplacePluginAuthPolicy::OnUse => Self::OnUse, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum MarketplaceError { + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + #[error("marketplace file `{path}` does not exist")] + MarketplaceNotFound { path: PathBuf }, + + #[error("invalid marketplace file `{path}`: {message}")] + InvalidMarketplaceFile { path: PathBuf, message: String }, + + #[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")] + PluginNotFound { + plugin_name: String, + marketplace_name: String, + }, + + #[error( + "plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`" + )] + PluginNotAvailable { + plugin_name: String, + marketplace_name: String, + }, + + #[error("plugins feature is disabled")] + PluginsDisabled, + + #[error("{0}")] + InvalidPlugin(String), +} + +impl MarketplaceError { + fn io(context: &'static str, source: io::Error) -> Self { + Self::Io { context, source } + } +} + +pub fn find_marketplace_plugin( + marketplace_path: &AbsolutePathBuf, + plugin_name: &str, +) -> Result { + let marketplace = load_raw_marketplace_manifest(marketplace_path)?; + let marketplace_name = marketplace.name; + let marketplace_name_for_not_found = marketplace_name.clone(); + for plugin in marketplace.plugins { + if plugin.name != plugin_name { + continue; + } + + if let Some(plugin) = + resolve_marketplace_plugin_entry(marketplace_path, &marketplace_name, plugin)? + { + return Ok(plugin); + } + } + + Err(MarketplaceError::PluginNotFound { + plugin_name: plugin_name.to_string(), + marketplace_name: marketplace_name_for_not_found, + }) +} + +pub fn find_installable_marketplace_plugin( + marketplace_path: &AbsolutePathBuf, + plugin_name: &str, + restriction_product: Option, +) -> Result { + let resolved = find_marketplace_plugin(marketplace_path, plugin_name)?; + let product_allowed = match resolved.policy.products.as_deref() { + None => true, + Some([]) => false, + Some(products) => { + restriction_product.is_some_and(|product| product.matches_product_restriction(products)) + } + }; + if resolved.policy.installation == MarketplacePluginInstallPolicy::NotAvailable + || !product_allowed + { + return Err(MarketplaceError::PluginNotAvailable { + plugin_name: resolved.plugin_id.plugin_name, + marketplace_name: resolved.plugin_id.marketplace_name, + }); + } + + Ok(resolved) +} + +pub fn list_marketplaces( + additional_roots: &[AbsolutePathBuf], +) -> Result { + list_marketplaces_with_home(additional_roots, home_dir().as_deref()) +} + +pub fn validate_marketplace_root(root: &Path) -> Result { + let Some(path) = find_marketplace_manifest_path(root) else { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: root.to_path_buf(), + message: "marketplace root does not contain a supported manifest".to_string(), + }); + }; + let marketplace = load_marketplace(&path)?; + Ok(marketplace.name) +} + +pub fn find_marketplace_manifest_path(root: &Path) -> Option { + MARKETPLACE_MANIFEST_RELATIVE_PATHS + .iter() + .find_map(|relative_path| { + let path = root.join(relative_path); + if !path.is_file() { + return None; + } + AbsolutePathBuf::try_from(path).ok() + }) +} + +fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError { + MarketplaceError::InvalidMarketplaceFile { + path: path.to_path_buf(), + message: "marketplace file is not in a supported location".to_string(), + } +} + +fn marketplace_root_from_layout(marketplace_path: &Path, relative_path: &str) -> Option { + let mut current = marketplace_path; + for component in Path::new(relative_path).components().rev() { + let expected = match component { + Component::Normal(expected) => expected, + _ => return None, + }; + if current.file_name() != Some(expected) { + return None; + } + current = current.parent()?; + } + Some(current.to_path_buf()) +} + +pub fn load_marketplace(path: &AbsolutePathBuf) -> Result { + let marketplace = load_raw_marketplace_manifest(path)?; + let mut plugins = Vec::new(); + + for plugin in marketplace.plugins { + let plugin = match resolve_marketplace_plugin_entry(path, &marketplace.name, plugin) { + Ok(Some(plugin)) => plugin, + Ok(None) => continue, + Err(MarketplaceError::InvalidPlugin(message)) => { + warn!( + path = %path.display(), + marketplace = %marketplace.name, + error = %message, + "skipping invalid marketplace plugin" + ); + continue; + } + Err(err) => return Err(err), + }; + + plugins.push(MarketplacePlugin { + name: plugin.plugin_id.plugin_name, + source: plugin.source, + policy: plugin.policy, + interface: plugin.interface, + keywords: plugin + .manifest + .map(|manifest| manifest.keywords) + .unwrap_or_default(), + }); + } + + Ok(Marketplace { + name: marketplace.name, + path: path.clone(), + interface: resolve_marketplace_interface(marketplace.interface), + plugins, + }) +} + +#[doc(hidden)] +pub fn list_marketplaces_with_home( + additional_roots: &[AbsolutePathBuf], + home_dir: Option<&Path>, +) -> Result { + let mut outcome = MarketplaceListOutcome::default(); + + for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { + match load_marketplace(&marketplace_path) { + Ok(marketplace) => outcome.marketplaces.push(marketplace), + Err(err) => { + warn!( + path = %marketplace_path.display(), + error = %err, + "skipping marketplace that failed to load" + ); + outcome.errors.push(MarketplaceListError { + path: marketplace_path, + message: err.to_string(), + }); + } + } + } + + Ok(outcome) +} + +fn discover_marketplace_paths_from_roots( + additional_roots: &[AbsolutePathBuf], + home_dir: Option<&Path>, +) -> Vec { + let mut paths = Vec::new(); + + if let Some(home) = home_dir + && let Some(path) = find_marketplace_manifest_path(home) + { + paths.push(path); + } + + for root in additional_roots { + // Curated marketplaces can now come from an HTTP-downloaded directory that is not a git + // checkout, so check the root directly before falling back to repo-root discovery. + if let Some(path) = find_marketplace_manifest_path(root.as_path()) + && !paths.contains(&path) + { + paths.push(path); + continue; + } + if let Some(repo_root) = get_git_repo_root(root.as_path()) + && let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root) + && let Some(path) = find_marketplace_manifest_path(repo_root.as_path()) + && !paths.contains(&path) + { + paths.push(path); + } + } + + paths +} + +fn load_raw_marketplace_manifest( + path: &AbsolutePathBuf, +) -> Result { + let contents = fs::read_to_string(path.as_path()).map_err(|err| { + if err.kind() == io::ErrorKind::NotFound { + MarketplaceError::MarketplaceNotFound { + path: path.to_path_buf(), + } + } else { + MarketplaceError::io("failed to read marketplace file", err) + } + })?; + serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile { + path: path.to_path_buf(), + message: err.to_string(), + }) +} + +fn resolve_marketplace_plugin_entry( + marketplace_path: &AbsolutePathBuf, + marketplace_name: &str, + plugin: RawMarketplaceManifestPlugin, +) -> Result, MarketplaceError> { + let RawMarketplaceManifestPlugin { + name, + source, + policy, + category, + } = plugin; + let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else { + return Ok(None); + }; + + let manifest = match &source { + MarketplacePluginSource::Local { path } => load_plugin_manifest(path.as_path()), + MarketplacePluginSource::Git { .. } => None, + }; + let interface = plugin_interface_with_marketplace_category( + manifest + .as_ref() + .and_then(|manifest| manifest.interface.clone()), + category, + ); + + Ok(Some(ResolvedMarketplacePlugin { + plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err { + PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message), + })?, + source, + policy: MarketplacePluginPolicy { + installation: policy.installation, + authentication: policy.authentication, + products: policy.products, + }, + interface, + manifest, + })) +} + +fn resolve_supported_plugin_source( + marketplace_path: &AbsolutePathBuf, + plugin_name: &str, + source: RawMarketplaceManifestPluginSource, +) -> Option { + match source { + RawMarketplaceManifestPluginSource::Unsupported(_) => { + warn!( + path = %marketplace_path.display(), + plugin = plugin_name, + "skipping marketplace plugin with unsupported source" + ); + None + } + source => match resolve_plugin_source(marketplace_path, source) { + Ok(source) => Some(source), + Err(err) => { + warn!( + path = %marketplace_path.display(), + plugin = plugin_name, + error = %err, + "skipping marketplace plugin that failed to resolve" + ); + None + } + }, + } +} + +fn resolve_plugin_source( + marketplace_path: &AbsolutePathBuf, + source: RawMarketplaceManifestPluginSource, +) -> Result { + match source { + RawMarketplaceManifestPluginSource::Path(path) + | RawMarketplaceManifestPluginSource::Object( + RawMarketplaceManifestPluginSourceObject::Local { path }, + ) => Ok(MarketplacePluginSource::Local { + path: resolve_local_plugin_source_path(marketplace_path, &path)?, + }), + RawMarketplaceManifestPluginSource::Object( + RawMarketplaceManifestPluginSourceObject::Url { + url, + path, + ref_name, + sha, + }, + ) => Ok(MarketplacePluginSource::Git { + url: normalize_git_plugin_source_url(marketplace_path, &url)?, + path: path + .as_deref() + .map(|path| normalize_remote_plugin_subdir(marketplace_path, path)) + .transpose()?, + ref_name: normalize_optional_git_selector(&ref_name), + sha: normalize_optional_git_selector(&sha), + }), + RawMarketplaceManifestPluginSource::Object( + RawMarketplaceManifestPluginSourceObject::GitSubdir { + url, + path, + ref_name, + sha, + }, + ) => Ok(MarketplacePluginSource::Git { + url: normalize_git_plugin_source_url(marketplace_path, &url)?, + path: Some(normalize_remote_plugin_subdir(marketplace_path, &path)?), + ref_name: normalize_optional_git_selector(&ref_name), + sha: normalize_optional_git_selector(&sha), + }), + RawMarketplaceManifestPluginSource::Unsupported(_) => { + unreachable!("unsupported plugin sources should be filtered before resolution") + } + } +} + +fn resolve_local_plugin_source_path( + marketplace_path: &AbsolutePathBuf, + path: &str, +) -> Result { + let Some(path) = path.strip_prefix("./") else { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must start with `./`".to_string(), + }); + }; + if path.is_empty() { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must not be empty".to_string(), + }); + } + + let relative_source_path = Path::new(path); + if relative_source_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must stay within the marketplace root".to_string(), + }); + } + + // `marketplace.json` lives under a supported marketplace layout beneath ``, + // but local plugin paths are resolved relative to ``. + Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path)) +} + +fn normalize_remote_plugin_subdir( + marketplace_path: &AbsolutePathBuf, + path: &str, +) -> Result { + let path = path.trim(); + let path = path.strip_prefix("./").unwrap_or(path); + if path.is_empty() { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "git plugin source path must not be empty".to_string(), + }); + } + let relative_path = Path::new(path); + if relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "git plugin source path must stay within the repository root".to_string(), + }); + } + Ok(path.to_string()) +} + +fn normalize_git_plugin_source_url( + marketplace_path: &AbsolutePathBuf, + url: &str, +) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "git plugin source url must not be empty".to_string(), + }); + } + if url.starts_with("http://") || url.starts_with("https://") { + return Ok(normalize_github_git_url(url)); + } + if url.starts_with("./") + || url.starts_with("../") + || url.starts_with(".\\") + || url.starts_with("..\\") + { + return normalize_relative_git_plugin_source_url(marketplace_path, url); + } + if url.starts_with("file://") || url.starts_with('/') { + return Ok(url.to_string()); + } + if url.starts_with("ssh://") || url.starts_with("git@") && url.contains(':') { + return Ok(url.to_string()); + } + if let Some(url) = normalize_github_shorthand_url(url) { + return Ok(url); + } + + Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: format!("invalid git plugin source url: {url}"), + }) +} + +fn normalize_relative_git_plugin_source_url( + marketplace_path: &AbsolutePathBuf, + url: &str, +) -> Result { + let mut normalized = marketplace_root_dir(marketplace_path)? + .as_path() + .to_path_buf(); + for segment in url.split(['/', '\\']) { + match segment { + "" | "." => {} + ".." => { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "relative git plugin source url must stay within the marketplace root" + .to_string(), + }); + } + segment => normalized.push(segment), + } + } + Ok(normalized.display().to_string()) +} + +fn normalize_optional_git_selector(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn normalize_github_git_url(url: &str) -> String { + if url.starts_with("https://github.com/") && !url.ends_with(".git") { + format!("{url}.git") + } else { + url.to_string() + } +} + +fn normalize_github_shorthand_url(source: &str) -> Option { + if !looks_like_github_shorthand(source) { + return None; + } + let mut segments = source.split('/'); + let owner = segments.next()?; + let repo = segments.next()?; + let repo = repo.strip_suffix(".git").unwrap_or(repo); + if repo.is_empty() { + return None; + } + Some(format!("https://github.com/{owner}/{repo}.git")) +} + +fn looks_like_github_shorthand(source: &str) -> bool { + let mut segments = source.split('/'); + let owner = segments.next(); + let repo = segments.next(); + let extra = segments.next(); + owner.is_some_and(is_github_shorthand_segment) + && repo.is_some_and(is_github_shorthand_segment) + && extra.is_none() +} + +fn is_github_shorthand_segment(segment: &str) -> bool { + !segment.is_empty() + && segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) +} + +pub fn plugin_interface_with_marketplace_category( + mut interface: Option, + category: Option, +) -> Option { + if let Some(category) = category { + // Marketplace taxonomy wins when both sources provide a category. + interface + .get_or_insert_with(PluginManifestInterface::default) + .category = Some(category); + } + interface +} + +fn marketplace_root_dir( + marketplace_path: &AbsolutePathBuf, +) -> Result { + for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS { + if let Some(marketplace_root) = + marketplace_root_from_layout(marketplace_path.as_path(), relative_path) + { + return AbsolutePathBuf::try_from(marketplace_root) + .map_err(|_| invalid_marketplace_layout_error(marketplace_path)); + } + } + + Err(invalid_marketplace_layout_error(marketplace_path)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMarketplaceManifest { + name: String, + #[serde(default)] + interface: Option, + plugins: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMarketplaceManifestInterface { + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMarketplaceManifestPlugin { + name: String, + source: RawMarketplaceManifestPluginSource, + #[serde(default)] + policy: RawMarketplaceManifestPluginPolicy, + #[serde(default)] + category: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMarketplaceManifestPluginPolicy { + #[serde(default)] + installation: MarketplacePluginInstallPolicy, + #[serde(default)] + authentication: MarketplacePluginAuthPolicy, + products: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawMarketplaceManifestPluginSource { + Path(String), + Object(RawMarketplaceManifestPluginSourceObject), + #[allow(dead_code)] + Unsupported(JsonValue), +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "source", rename_all = "lowercase")] +enum RawMarketplaceManifestPluginSourceObject { + Local { + path: String, + }, + Url { + url: String, + path: Option, + #[serde(rename = "ref")] + ref_name: Option, + sha: Option, + }, + #[serde(rename = "git-subdir")] + GitSubdir { + url: String, + path: String, + #[serde(rename = "ref")] + ref_name: Option, + sha: Option, + }, +} + +fn resolve_marketplace_interface( + interface: Option, +) -> Option { + let interface = interface?; + if interface.display_name.is_some() { + Some(MarketplaceInterface { + display_name: interface.display_name, + }) + } else { + None + } +} + +#[cfg(test)] +#[path = "marketplace_tests.rs"] +mod tests; diff --git a/code-rs/core-plugins/src/marketplace_add.rs b/code-rs/core-plugins/src/marketplace_add.rs new file mode 100644 index 00000000000..927d337d249 --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_add.rs @@ -0,0 +1,402 @@ +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use tempfile::Builder; + +mod install; +mod metadata; +mod source; + +use install::clone_git_source; +use install::ensure_marketplace_destination_is_inside_install_root; +use install::marketplace_staging_root; +use install::replace_marketplace_root; +use install::safe_marketplace_dir_name; +use metadata::MarketplaceInstallMetadata; +use metadata::find_marketplace_root_by_name; +use metadata::installed_marketplace_root_for_source; +use metadata::record_added_marketplace_entry; +use source::MarketplaceSource; +pub(crate) use source::parse_marketplace_source; +use source::stage_marketplace_source; +use source::validate_marketplace_source_root; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceAddRequest { + pub source: String, + pub ref_name: Option, + pub sparse_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceAddOutcome { + pub marketplace_name: String, + pub source_display: String, + pub installed_root: AbsolutePathBuf, + pub already_added: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum MarketplaceAddError { + #[error("{0}")] + InvalidRequest(String), + #[error("{0}")] + Internal(String), +} + +pub async fn add_marketplace( + codex_home: PathBuf, + request: MarketplaceAddRequest, +) -> Result { + tokio::task::spawn_blocking(move || add_marketplace_sync(codex_home.as_path(), request)) + .await + .map_err(|err| MarketplaceAddError::Internal(format!("failed to add marketplace: {err}")))? +} + +pub fn is_local_marketplace_source( + source: &str, + explicit_ref: Option, +) -> Result { + Ok(matches!( + parse_marketplace_source(source, explicit_ref)?, + source::MarketplaceSource::Local { .. } + )) +} + +fn add_marketplace_sync( + codex_home: &Path, + request: MarketplaceAddRequest, +) -> Result { + add_marketplace_sync_with_cloner(codex_home, request, clone_git_source) +} + +fn add_marketplace_sync_with_cloner( + codex_home: &Path, + request: MarketplaceAddRequest, + clone_source: F, +) -> Result +where + F: Fn(&str, Option<&str>, &[String], &Path) -> Result<(), MarketplaceAddError>, +{ + let MarketplaceAddRequest { + source, + ref_name, + sparse_paths, + } = request; + let source = parse_marketplace_source(&source, ref_name)?; + if !sparse_paths.is_empty() && !matches!(source, MarketplaceSource::Git { .. }) { + return Err(MarketplaceAddError::InvalidRequest( + "--sparse is only supported for git marketplace sources".to_string(), + )); + } + + let install_root = marketplace_install_root(codex_home); + fs::create_dir_all(&install_root).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to create marketplace install directory {}: {err}", + install_root.display() + )) + })?; + + let install_metadata = MarketplaceInstallMetadata::from_source(&source, &sparse_paths); + if let Some(existing_root) = + installed_marketplace_root_for_source(codex_home, &install_root, &install_metadata)? + { + let marketplace_name = validate_marketplace_source_root(&existing_root)?; + record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?; + return Ok(MarketplaceAddOutcome { + marketplace_name, + source_display: source.display(), + installed_root: AbsolutePathBuf::try_from(existing_root).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve installed marketplace root: {err}" + )) + })?, + already_added: true, + }); + } + + if let MarketplaceSource::Local { path } = &source { + let marketplace_name = validate_marketplace_source_root(path)?; + if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source" + ))); + } + if find_marketplace_root_by_name(codex_home, &install_root, &marketplace_name)?.is_some() { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace '{marketplace_name}' is already added from a different source; remove it before adding this source" + ))); + } + record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?; + return Ok(MarketplaceAddOutcome { + marketplace_name, + source_display: source.display(), + installed_root: AbsolutePathBuf::try_from(path.clone()).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve installed marketplace root: {err}" + )) + })?, + already_added: false, + }); + } + + let staging_root = marketplace_staging_root(&install_root); + fs::create_dir_all(&staging_root).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to create marketplace staging directory {}: {err}", + staging_root.display() + )) + })?; + let staged_root = Builder::new() + .prefix("marketplace-add-") + .tempdir_in(&staging_root) + .map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to create temporary marketplace directory in {}: {err}", + staging_root.display() + )) + })?; + let staged_root = staged_root.keep(); + + stage_marketplace_source(&source, &sparse_paths, &staged_root, clone_source)?; + + let marketplace_name = validate_marketplace_source_root(&staged_root)?; + if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source" + ))); + } + + let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?); + ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?; + if destination.exists() { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace '{marketplace_name}' is already added from a different source; remove it before adding this source" + ))); + } + + replace_marketplace_root(&staged_root, &destination).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to install marketplace at {}: {err}", + destination.display() + )) + })?; + if let Err(err) = + record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata) + { + if let Err(rollback_err) = fs::rename(&destination, &staged_root) { + return Err(MarketplaceAddError::Internal(format!( + "{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}", + destination.display() + ))); + } + return Err(err); + } + + Ok(MarketplaceAddOutcome { + marketplace_name, + source_display: source.display(), + installed_root: AbsolutePathBuf::try_from(destination).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve installed marketplace root: {err}" + )) + })?, + already_added: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn add_marketplace_sync_installs_marketplace_and_updates_config() -> Result<()> { + let codex_home = TempDir::new()?; + let source_root = TempDir::new()?; + write_marketplace_source(source_root.path(), "remote copy")?; + + let result = add_marketplace_sync_with_cloner( + codex_home.path(), + MarketplaceAddRequest { + source: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + sparse_paths: Vec::new(), + }, + |_url, _ref_name, _sparse_paths, destination| { + copy_dir_all(source_root.path(), destination) + .map_err(|err| MarketplaceAddError::Internal(err.to_string())) + }, + )?; + + assert_eq!(result.marketplace_name, "debug"); + assert_eq!(result.source_display, "https://github.com/owner/repo.git"); + assert!(!result.already_added); + assert!( + result + .installed_root + .as_path() + .join(".agents/plugins/marketplace.json") + .is_file() + ); + + let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?; + assert!(config.contains("[marketplaces.debug]")); + assert!(config.contains("source_type = \"git\"")); + assert!(config.contains("source = \"https://github.com/owner/repo.git\"")); + Ok(()) + } + + #[test] + fn add_marketplace_sync_installs_local_directory_source_and_updates_config() -> Result<()> { + let codex_home = TempDir::new()?; + let source_root = TempDir::new()?; + write_marketplace_source(source_root.path(), "local copy")?; + + let result = add_marketplace_sync_with_cloner( + codex_home.path(), + MarketplaceAddRequest { + source: source_root.path().display().to_string(), + ref_name: None, + sparse_paths: Vec::new(), + }, + |_url, _ref_name, _sparse_paths, _destination| { + panic!("git cloner should not be called for local marketplace sources") + }, + )?; + + let expected_source = source_root.path().canonicalize()?.display().to_string(); + assert_eq!(result.marketplace_name, "debug"); + assert_eq!(result.source_display, expected_source); + let expected_installed_root = + AbsolutePathBuf::from_absolute_path(source_root.path().canonicalize()?)?; + assert_eq!(result.installed_root, expected_installed_root); + assert!(!result.already_added); + assert!( + !marketplace_install_root(codex_home.path()) + .join("debug") + .exists() + ); + + let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?; + let config: toml::Value = toml::from_str(&config)?; + assert_eq!( + config["marketplaces"]["debug"]["source_type"].as_str(), + Some("local") + ); + assert_eq!( + config["marketplaces"]["debug"]["source"].as_str(), + Some(expected_source.as_str()) + ); + Ok(()) + } + + #[test] + fn add_marketplace_sync_rejects_sparse_checkout_for_local_directory_source() -> Result<()> { + let codex_home = TempDir::new()?; + let source_root = TempDir::new()?; + write_marketplace_source(source_root.path(), "local copy")?; + + let err = add_marketplace_sync_with_cloner( + codex_home.path(), + MarketplaceAddRequest { + source: source_root.path().display().to_string(), + ref_name: None, + sparse_paths: vec![".agents".to_string()], + }, + |_url, _ref_name, _sparse_paths, _destination| { + panic!("git cloner should not be called for local marketplace sources") + }, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "--sparse is only supported for git marketplace sources" + ); + assert!( + !codex_home + .path() + .join(codex_config::CONFIG_TOML_FILE) + .exists() + ); + Ok(()) + } + + #[test] + fn add_marketplace_sync_treats_existing_local_directory_source_as_already_added() -> Result<()> + { + let codex_home = TempDir::new()?; + let source_root = TempDir::new()?; + write_marketplace_source(source_root.path(), "local copy")?; + + let request = MarketplaceAddRequest { + source: source_root.path().display().to_string(), + ref_name: None, + sparse_paths: Vec::new(), + }; + let first_result = add_marketplace_sync_with_cloner(codex_home.path(), request.clone(), { + |_url, _ref_name, _sparse_paths, _destination| { + panic!("git cloner should not be called for local marketplace sources") + } + })?; + let second_result = add_marketplace_sync_with_cloner(codex_home.path(), request, { + |_url, _ref_name, _sparse_paths, _destination| { + panic!("git cloner should not be called for local marketplace sources") + } + })?; + + assert!(!first_result.already_added); + assert!(second_result.already_added); + assert_eq!(second_result.installed_root, first_result.installed_root); + + Ok(()) + } + + fn write_marketplace_source(source: &Path, marker: &str) -> std::io::Result<()> { + fs::create_dir_all(source.join(".agents/plugins"))?; + fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; + fs::write( + source.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + )?; + fs::write( + source.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + fs::write(source.join("plugins/sample/marker.txt"), marker)?; + Ok(()) + } + + fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_all(&source_path, &destination_path)?; + } else { + fs::copy(&source_path, &destination_path)?; + } + } + Ok(()) + } +} diff --git a/code-rs/core-plugins/src/marketplace_add/install.rs b/code-rs/core-plugins/src/marketplace_add/install.rs new file mode 100644 index 00000000000..1ecfa050d30 --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_add/install.rs @@ -0,0 +1,137 @@ +use super::MarketplaceAddError; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +pub(super) fn clone_git_source( + url: &str, + ref_name: Option<&str>, + sparse_paths: &[String], + destination: &Path, +) -> Result<(), MarketplaceAddError> { + let destination_string = destination.to_string_lossy().to_string(); + if sparse_paths.is_empty() { + run_git( + &["clone", url, destination_string.as_str()], + /*cwd*/ None, + )?; + if let Some(ref_name) = ref_name { + run_git( + &["checkout", ref_name], + Some(Path::new(&destination_string)), + )?; + } + return Ok(()); + } + + run_git( + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + url, + destination_string.as_str(), + ], + /*cwd*/ None, + )?; + let mut sparse_args = vec!["sparse-checkout", "set"]; + sparse_args.extend(sparse_paths.iter().map(String::as_str)); + run_git(&sparse_args, Some(destination))?; + run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?; + Ok(()) +} + +pub(super) fn safe_marketplace_dir_name( + marketplace_name: &str, +) -> Result { + let safe = marketplace_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); + let safe = safe.trim_matches('.').to_string(); + if safe.is_empty() || safe == ".." { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace name '{marketplace_name}' cannot be used as an install directory" + ))); + } + Ok(safe) +} + +pub(super) fn ensure_marketplace_destination_is_inside_install_root( + install_root: &Path, + destination: &Path, +) -> Result<(), MarketplaceAddError> { + let install_root = install_root.canonicalize().map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve marketplace install root {}: {err}", + install_root.display() + )) + })?; + let destination_parent = destination + .parent() + .ok_or_else(|| { + MarketplaceAddError::Internal("marketplace destination has no parent".to_string()) + })? + .canonicalize() + .map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve marketplace destination parent {}: {err}", + destination.display() + )) + })?; + if !destination_parent.starts_with(&install_root) { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace destination {} is outside install root {}", + destination.display(), + install_root.display() + ))); + } + Ok(()) +} + +pub(super) fn replace_marketplace_root( + staged_root: &Path, + destination: &Path, +) -> std::io::Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(staged_root, destination) +} + +pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf { + install_root.join(".staging") +} + +fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), MarketplaceAddError> { + let mut command = Command::new("git"); + command.args(args); + command.env("GIT_TERMINAL_PROMPT", "0"); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let output = command.output().map_err(|err| { + MarketplaceAddError::Internal(format!("failed to run git {}: {err}", args.join(" "))) + })?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(MarketplaceAddError::Internal(format!( + "git {} failed with status {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + output.status, + stdout.trim(), + stderr.trim() + ))) +} diff --git a/code-rs/core-plugins/src/marketplace_add/metadata.rs b/code-rs/core-plugins/src/marketplace_add/metadata.rs new file mode 100644 index 00000000000..66b17f6c402 --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_add/metadata.rs @@ -0,0 +1,315 @@ +use super::MarketplaceAddError; +use super::source::MarketplaceSource; +use crate::installed_marketplaces::resolve_configured_marketplace_root; +use crate::marketplace::validate_marketplace_root; +use codex_config::CONFIG_TOML_FILE; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct MarketplaceInstallMetadata { + source: InstalledMarketplaceSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InstalledMarketplaceSource { + Git { + url: String, + ref_name: Option, + sparse_paths: Vec, + }, + Local { + path: String, + }, +} + +pub(super) fn record_added_marketplace_entry( + codex_home: &Path, + marketplace_name: &str, + install_metadata: &MarketplaceInstallMetadata, +) -> Result<(), MarketplaceAddError> { + let source = install_metadata.config_source(); + let timestamp = utc_timestamp_now()?; + let update = MarketplaceConfigUpdate { + last_updated: ×tamp, + last_revision: None, + source_type: install_metadata.config_source_type(), + source: &source, + ref_name: install_metadata.ref_name(), + sparse_paths: install_metadata.sparse_paths(), + }; + + record_user_marketplace(codex_home, marketplace_name, &update).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to add marketplace '{marketplace_name}' to user config.toml: {err}" + )) + }) +} + +pub(super) fn installed_marketplace_root_for_source( + codex_home: &Path, + install_root: &Path, + install_metadata: &MarketplaceInstallMetadata, +) -> Result, MarketplaceAddError> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let config = match fs::read_to_string(&config_path) { + Ok(config) => config, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(MarketplaceAddError::Internal(format!( + "failed to read user config {}: {err}", + config_path.display() + ))); + } + }; + let config: toml::Value = toml::from_str(&config).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to parse user config {}: {err}", + config_path.display() + )) + })?; + let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else { + return Ok(None); + }; + + for (marketplace_name, marketplace) in marketplaces { + if !install_metadata.matches_config(marketplace) { + continue; + } + let Some(root) = + resolve_configured_marketplace_root(marketplace_name, marketplace, install_root) + else { + continue; + }; + if validate_marketplace_root(&root).is_ok() { + return Ok(Some(root)); + } + } + + Ok(None) +} + +pub(super) fn find_marketplace_root_by_name( + codex_home: &Path, + install_root: &Path, + marketplace_name: &str, +) -> Result, MarketplaceAddError> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let config = match fs::read_to_string(&config_path) { + Ok(config) => config, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(MarketplaceAddError::Internal(format!( + "failed to read user config {}: {err}", + config_path.display() + ))); + } + }; + let config: toml::Value = toml::from_str(&config).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to parse user config {}: {err}", + config_path.display() + )) + })?; + let Some(marketplace) = config + .get("marketplaces") + .and_then(toml::Value::as_table) + .and_then(|marketplaces| marketplaces.get(marketplace_name)) + else { + return Ok(None); + }; + + let Some(root) = + resolve_configured_marketplace_root(marketplace_name, marketplace, install_root) + else { + return Ok(None); + }; + if validate_marketplace_root(&root).is_ok() { + Ok(Some(root)) + } else { + Ok(None) + } +} + +impl MarketplaceInstallMetadata { + pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self { + let source = match source { + MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git { + url: url.clone(), + ref_name: ref_name.clone(), + sparse_paths: sparse_paths.to_vec(), + }, + MarketplaceSource::Local { path } => InstalledMarketplaceSource::Local { + path: path.display().to_string(), + }, + }; + Self { source } + } + + fn config_source_type(&self) -> &'static str { + match &self.source { + InstalledMarketplaceSource::Git { .. } => "git", + InstalledMarketplaceSource::Local { .. } => "local", + } + } + + fn config_source(&self) -> String { + match &self.source { + InstalledMarketplaceSource::Git { url, .. } => url.clone(), + InstalledMarketplaceSource::Local { path } => path.clone(), + } + } + + fn ref_name(&self) -> Option<&str> { + match &self.source { + InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(), + InstalledMarketplaceSource::Local { .. } => None, + } + } + + fn sparse_paths(&self) -> &[String] { + match &self.source { + InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths, + InstalledMarketplaceSource::Local { .. } => &[], + } + } + + fn matches_config(&self, marketplace: &toml::Value) -> bool { + marketplace.get("source_type").and_then(toml::Value::as_str) + == Some(self.config_source_type()) + && marketplace.get("source").and_then(toml::Value::as_str) + == Some(self.config_source().as_str()) + && marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name() + && config_sparse_paths(marketplace) == self.sparse_paths() + } +} + +fn config_sparse_paths(marketplace: &toml::Value) -> Vec { + marketplace + .get("sparse_paths") + .and_then(toml::Value::as_array) + .map(|paths| { + paths + .iter() + .filter_map(toml::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn utc_timestamp_now() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| { + MarketplaceAddError::Internal(format!("system clock is before Unix epoch: {err}")) + })?; + Ok(format_utc_timestamp(duration.as_secs() as i64)) +} + +fn format_utc_timestamp(seconds_since_epoch: i64) -> String { + const SECONDS_PER_DAY: i64 = 86_400; + let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY); + let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY); + let (year, month, day) = civil_from_days(days); + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + (year, month, day) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() { + assert_eq!( + format_utc_timestamp(/*seconds_since_epoch*/ 0), + "1970-01-01T00:00:00Z" + ); + assert_eq!( + format_utc_timestamp(/*seconds_since_epoch*/ 1_775_779_200), + "2026-04-10T00:00:00Z" + ); + } + + #[test] + fn installed_marketplace_root_for_source_propagates_config_read_errors() { + let codex_home = TempDir::new().unwrap(); + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + fs::create_dir(&config_path).unwrap(); + + let install_root = codex_home.path().join("marketplaces"); + let source = MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + }; + let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]); + + let err = installed_marketplace_root_for_source( + codex_home.path(), + &install_root, + &install_metadata, + ) + .unwrap_err(); + + assert!( + err.to_string().contains(&format!( + "failed to read user config {}:", + config_path.display() + )), + "unexpected error: {err}" + ); + } + + #[test] + fn installed_marketplace_root_for_source_uses_local_source_root() { + let codex_home = TempDir::new().unwrap(); + let install_root = codex_home.path().join("marketplaces"); + let source_root = codex_home.path().join("source"); + fs::create_dir_all(source_root.join(".agents/plugins")).unwrap(); + fs::write( + source_root.join(".agents/plugins/marketplace.json"), + r#"{"name":"debug","plugins":[]}"#, + ) + .unwrap(); + let source = MarketplaceSource::Local { + path: source_root.clone(), + }; + let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]); + record_added_marketplace_entry(codex_home.path(), "debug", &install_metadata).unwrap(); + + let root = installed_marketplace_root_for_source( + codex_home.path(), + &install_root, + &install_metadata, + ) + .unwrap(); + + assert_eq!(root, Some(source_root)); + } +} diff --git a/code-rs/core-plugins/src/marketplace_add/source.rs b/code-rs/core-plugins/src/marketplace_add/source.rs new file mode 100644 index 00000000000..3cec3094947 --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_add/source.rs @@ -0,0 +1,392 @@ +use super::MarketplaceAddError; +use crate::marketplace::validate_marketplace_root; +use codex_plugin::validate_plugin_segment; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MarketplaceSource { + Git { + url: String, + ref_name: Option, + }, + Local { + path: PathBuf, + }, +} + +pub(crate) fn parse_marketplace_source( + source: &str, + explicit_ref: Option, +) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(MarketplaceAddError::InvalidRequest( + "marketplace source must not be empty".to_string(), + )); + } + + let (base_source, parsed_ref) = split_source_ref(source); + let ref_name = explicit_ref.or(parsed_ref); + + if looks_like_local_path(&base_source) { + if ref_name.is_some() { + return Err(MarketplaceAddError::InvalidRequest( + "--ref is only supported for git marketplace sources".to_string(), + )); + } + let path = resolve_local_source_path(&base_source)?; + if path.is_file() { + return Err(MarketplaceAddError::InvalidRequest( + "local marketplace source must be a directory, not a file".to_string(), + )); + } + return Ok(MarketplaceSource::Local { path }); + } + + if is_ssh_git_url(&base_source) || is_git_url(&base_source) { + return Ok(MarketplaceSource::Git { + url: normalize_git_url(&base_source), + ref_name, + }); + } + + if looks_like_github_shorthand(&base_source) { + return Ok(MarketplaceSource::Git { + url: format!("https://github.com/{base_source}.git"), + ref_name, + }); + } + + Err(MarketplaceAddError::InvalidRequest( + "invalid marketplace source format; expected owner/repo, a git URL, or a local marketplace path" + .to_string(), + )) +} + +pub(super) fn stage_marketplace_source( + source: &MarketplaceSource, + sparse_paths: &[String], + staged_root: &Path, + clone_source: F, +) -> Result<(), MarketplaceAddError> +where + F: Fn(&str, Option<&str>, &[String], &Path) -> Result<(), MarketplaceAddError>, +{ + if !sparse_paths.is_empty() && !matches!(source, MarketplaceSource::Git { .. }) { + return Err(MarketplaceAddError::InvalidRequest( + "--sparse is only supported for git marketplace sources".to_string(), + )); + } + + match source { + MarketplaceSource::Git { url, ref_name } => { + clone_source(url, ref_name.as_deref(), sparse_paths, staged_root) + } + MarketplaceSource::Local { .. } => unreachable!( + "local marketplace sources are added without staging a copied install root" + ), + } +} + +pub(super) fn validate_marketplace_source_root(root: &Path) -> Result { + let marketplace_name = validate_marketplace_root(root) + .map_err(|err| MarketplaceAddError::InvalidRequest(err.to_string()))?; + validate_plugin_segment(&marketplace_name, "marketplace name") + .map_err(MarketplaceAddError::InvalidRequest)?; + Ok(marketplace_name) +} + +fn split_source_ref(source: &str) -> (String, Option) { + if let Some((base, ref_name)) = source.rsplit_once('#') { + return (base.to_string(), non_empty_ref(ref_name)); + } + if !source.contains("://") + && !is_ssh_git_url(source) + && let Some((base, ref_name)) = source.rsplit_once('@') + { + return (base.to_string(), non_empty_ref(ref_name)); + } + (source.to_string(), None) +} + +fn non_empty_ref(ref_name: &str) -> Option { + let ref_name = ref_name.trim(); + (!ref_name.is_empty()).then(|| ref_name.to_string()) +} + +fn normalize_git_url(url: &str) -> String { + let url = url.trim_end_matches('/'); + if url.starts_with("https://github.com/") && !url.ends_with(".git") { + format!("{url}.git") + } else { + url.to_string() + } +} + +fn looks_like_local_path(source: &str) -> bool { + Path::new(source).is_absolute() + || looks_like_windows_absolute_path(source) + || source.starts_with("./") + || source.starts_with(".\\") + || source.starts_with("../") + || source.starts_with("..\\") + || source.starts_with("~/") + || source == "." + || source == ".." +} + +fn looks_like_windows_absolute_path(source: &str) -> bool { + let bytes = source.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/') + || source.starts_with(r"\\") +} + +fn resolve_local_source_path(source: &str) -> Result { + let path = expand_tilde_path(source); + let path = if path.is_absolute() { + path + } else { + std::env::current_dir() + .map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to read current working directory for local marketplace source: {err}" + )) + })? + .join(path) + }; + + path.canonicalize().map_err(|err| { + MarketplaceAddError::InvalidRequest(format!( + "failed to resolve local marketplace source path: {err}" + )) + }) +} + +fn expand_tilde_path(source: &str) -> PathBuf { + let Some(rest) = source.strip_prefix("~/") else { + return PathBuf::from(source); + }; + let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else { + return PathBuf::from(source); + }; + PathBuf::from(home).join(rest) +} + +fn is_ssh_git_url(source: &str) -> bool { + source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':') +} + +fn is_git_url(source: &str) -> bool { + source.starts_with("http://") || source.starts_with("https://") +} + +fn looks_like_github_shorthand(source: &str) -> bool { + let mut segments = source.split('/'); + let owner = segments.next(); + let repo = segments.next(); + let extra = segments.next(); + owner.is_some_and(is_github_shorthand_segment) + && repo.is_some_and(is_github_shorthand_segment) + && extra.is_none() +} + +fn is_github_shorthand_segment(segment: &str) -> bool { + !segment.is_empty() + && segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) +} + +impl MarketplaceSource { + pub(super) fn display(&self) -> String { + match self { + Self::Git { url, ref_name } => match ref_name { + Some(ref_name) => format!("{url}#{ref_name}"), + None => url.clone(), + }, + Self::Local { path } => path.display().to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn github_shorthand_parses_ref_suffix() { + assert_eq!( + parse_marketplace_source("owner/repo@main", /*explicit_ref*/ None).unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: Some("main".to_string()), + } + ); + } + + #[test] + fn git_url_parses_fragment_ref() { + assert_eq!( + parse_marketplace_source( + "https://example.com/team/repo.git#v1", + /*explicit_ref*/ None + ) + .unwrap(), + MarketplaceSource::Git { + url: "https://example.com/team/repo.git".to_string(), + ref_name: Some("v1".to_string()), + } + ); + } + + #[test] + fn explicit_ref_overrides_source_ref() { + assert_eq!( + parse_marketplace_source("owner/repo@main", Some("release".to_string())).unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: Some("release".to_string()), + } + ); + } + + #[test] + fn github_shorthand_and_git_url_normalize_to_same_source() { + let shorthand = parse_marketplace_source("owner/repo", /*explicit_ref*/ None).unwrap(); + let git_url = parse_marketplace_source( + "https://github.com/owner/repo.git", + /*explicit_ref*/ None, + ) + .unwrap(); + + assert_eq!(shorthand, git_url); + assert_eq!( + shorthand, + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() { + assert_eq!( + parse_marketplace_source("https://github.com/owner/repo/", /*explicit_ref*/ None) + .unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn non_github_https_source_parses_as_git_url() { + assert_eq!( + parse_marketplace_source("https://gitlab.com/owner/repo", /*explicit_ref*/ None) + .unwrap(), + MarketplaceSource::Git { + url: "https://gitlab.com/owner/repo".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn file_url_source_is_rejected() { + let err = + parse_marketplace_source("file:///tmp/marketplace.git", /*explicit_ref*/ None) + .unwrap_err(); + + assert!( + err.to_string() + .contains("invalid marketplace source format"), + "unexpected error: {err}" + ); + } + + #[test] + fn local_path_source_parses() { + let source = parse_marketplace_source(".", /*explicit_ref*/ None).unwrap(); + + let MarketplaceSource::Local { path } = source else { + panic!("expected local path source"); + }; + assert!(path.is_absolute()); + } + + #[test] + fn windows_absolute_paths_look_like_local_paths_on_every_host() { + assert!(looks_like_local_path(r"C:\Users\alice\marketplace")); + assert!(looks_like_local_path("C:/Users/alice/marketplace")); + assert!(looks_like_local_path(r"\\server\share\marketplace")); + assert!(!looks_like_local_path(r"C:relative\path")); + } + + #[test] + fn local_file_source_is_rejected() { + let tempdir = TempDir::new().unwrap(); + let file = tempdir.path().join("marketplace.json"); + std::fs::write(&file, "{}").unwrap(); + + let err = + parse_marketplace_source(file.to_str().unwrap(), /*explicit_ref*/ None).unwrap_err(); + + assert!( + err.to_string() + .contains("local marketplace source must be a directory, not a file"), + "unexpected error: {err}" + ); + } + + #[test] + fn non_git_sources_reject_ref_override() { + let err = parse_marketplace_source("./marketplace", Some("main".to_string())).unwrap_err(); + + assert!( + err.to_string() + .contains("--ref is only supported for git marketplace sources"), + "unexpected error: {err}" + ); + } + + #[test] + fn non_git_sources_reject_sparse_checkout() { + let path = std::env::current_dir().unwrap(); + let err = stage_marketplace_source( + &MarketplaceSource::Local { path }, + &["plugins/foo".to_string()], + Path::new("/tmp"), + |_url, _ref_name, _sparse_paths, _staged_root| Ok(()), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("--sparse is only supported for git marketplace sources"), + "unexpected error: {err}" + ); + } + + #[test] + fn ssh_url_parses_as_git_url() { + assert_eq!( + parse_marketplace_source( + "ssh://git@github.com/owner/repo.git#main", + /*explicit_ref*/ None, + ) + .unwrap(), + MarketplaceSource::Git { + url: "ssh://git@github.com/owner/repo.git".to_string(), + ref_name: Some("main".to_string()), + } + ); + } +} diff --git a/code-rs/core-plugins/src/marketplace_remove.rs b/code-rs/core-plugins/src/marketplace_remove.rs new file mode 100644 index 00000000000..aa5a5078aac --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_remove.rs @@ -0,0 +1,313 @@ +use crate::installed_marketplaces::marketplace_install_root; +use codex_config::RemoveMarketplaceConfigOutcome; +use codex_config::remove_user_marketplace_config; +use codex_plugin::validate_plugin_segment; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceRemoveRequest { + pub marketplace_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceRemoveOutcome { + pub marketplace_name: String, + pub removed_installed_root: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum MarketplaceRemoveError { + #[error("{0}")] + InvalidRequest(String), + #[error("{0}")] + Internal(String), +} + +pub async fn remove_marketplace( + codex_home: PathBuf, + request: MarketplaceRemoveRequest, +) -> Result { + tokio::task::spawn_blocking(move || remove_marketplace_sync(codex_home.as_path(), request)) + .await + .map_err(|err| { + MarketplaceRemoveError::Internal(format!("failed to remove marketplace: {err}")) + })? +} + +fn remove_marketplace_sync( + codex_home: &Path, + request: MarketplaceRemoveRequest, +) -> Result { + let marketplace_name = request.marketplace_name; + validate_plugin_segment(&marketplace_name, "marketplace name") + .map_err(MarketplaceRemoveError::InvalidRequest)?; + + let destination = marketplace_install_root(codex_home).join(&marketplace_name); + let config_outcome = + remove_user_marketplace_config(codex_home, &marketplace_name).map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to remove marketplace '{marketplace_name}' from user config.toml: {err}" + )) + })?; + if let RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } = &config_outcome { + return Err(MarketplaceRemoveError::InvalidRequest(format!( + "marketplace `{marketplace_name}` does not match configured marketplace `{configured_name}` exactly" + ))); + } + + let removed_config = config_outcome == RemoveMarketplaceConfigOutcome::Removed; + let removed_installed_root = remove_marketplace_root(&destination)?; + + if removed_installed_root.is_none() && !removed_config { + return Err(MarketplaceRemoveError::InvalidRequest(format!( + "marketplace `{marketplace_name}` is not configured or installed" + ))); + } + + Ok(MarketplaceRemoveOutcome { + marketplace_name, + removed_installed_root, + }) +} + +fn remove_marketplace_root(root: &Path) -> Result, MarketplaceRemoveError> { + if !root.exists() { + return Ok(None); + } + + let removed_root = AbsolutePathBuf::try_from(root.to_path_buf()).map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to resolve installed marketplace root {}: {err}", + root.display() + )) + })?; + let metadata = fs::symlink_metadata(root).map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to inspect installed marketplace root {}: {err}", + root.display() + )) + })?; + let remove_result = if metadata.is_dir() { + fs::remove_dir_all(root) + } else { + fs::remove_file(root) + }; + remove_result.map_err(|err| { + MarketplaceRemoveError::Internal(format!( + "failed to remove installed marketplace root {}: {err}", + root.display() + )) + })?; + Ok(Some(removed_root)) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_config::MarketplaceConfigUpdate; + use codex_config::record_user_marketplace; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn remove_marketplace_sync_removes_config_and_installed_root() { + let codex_home = TempDir::new().unwrap(); + record_user_marketplace( + codex_home.path(), + "debug", + &MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(installed_root.join(".agents/plugins")).unwrap(); + fs::write( + installed_root.join(".agents/plugins/marketplace.json"), + "{}", + ) + .unwrap(); + + let outcome = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap(); + + assert_eq!(outcome.marketplace_name, "debug"); + assert_eq!( + outcome.removed_installed_root, + Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap()) + ); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains("[marketplaces.debug]")); + assert!(!installed_root.exists()); + } + + #[test] + fn remove_marketplace_sync_rejects_unknown_marketplace() { + let codex_home = TempDir::new().unwrap(); + + let err = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "marketplace `debug` is not configured or installed" + ); + } + + #[test] + fn remove_marketplace_sync_rejects_case_mismatched_configured_name() { + let codex_home = TempDir::new().unwrap(); + record_user_marketplace( + codex_home.path(), + "debug", + &MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(&installed_root).unwrap(); + + let err = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "Debug".to_string(), + }, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "marketplace `Debug` does not match configured marketplace `debug` exactly" + ); + assert!(installed_root.exists()); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(config.contains("[marketplaces.debug]")); + } + + #[test] + fn remove_marketplace_sync_keeps_installed_root_when_config_removal_fails() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(codex_config::CONFIG_TOML_FILE), + "[marketplaces.debug\n", + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(&installed_root).unwrap(); + + let err = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("failed to remove marketplace 'debug' from user config.toml") + ); + assert!(installed_root.exists()); + } + + #[test] + fn remove_marketplace_sync_removes_file_installed_root() { + let codex_home = TempDir::new().unwrap(); + record_user_marketplace( + codex_home.path(), + "debug", + &MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "git", + source: "https://github.com/owner/repo.git", + ref_name: Some("main"), + sparse_paths: &[], + }, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(installed_root.parent().unwrap()).unwrap(); + fs::write(&installed_root, "corrupt install root").unwrap(); + + let outcome = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap(); + + assert_eq!( + outcome, + MarketplaceRemoveOutcome { + marketplace_name: "debug".to_string(), + removed_installed_root: Some( + AbsolutePathBuf::try_from(installed_root.clone()).unwrap() + ), + } + ); + assert!(!installed_root.exists()); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains("[marketplaces.debug]")); + } + + #[test] + fn remove_marketplace_sync_removes_inline_config_entry() { + let codex_home = TempDir::new().unwrap(); + fs::write( + codex_home.path().join(codex_config::CONFIG_TOML_FILE), + r#" +marketplaces = { debug = { source_type = "git", source = "https://github.com/owner/repo.git" } } +"#, + ) + .unwrap(); + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + fs::create_dir_all(&installed_root).unwrap(); + + let outcome = remove_marketplace_sync( + codex_home.path(), + MarketplaceRemoveRequest { + marketplace_name: "debug".to_string(), + }, + ) + .unwrap(); + + assert_eq!(outcome.marketplace_name, "debug"); + assert_eq!( + outcome.removed_installed_root, + Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap()) + ); + assert!(!installed_root.exists()); + let config = + fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains("debug")); + } +} diff --git a/code-rs/core-plugins/src/marketplace_tests.rs b/code-rs/core-plugins/src/marketplace_tests.rs new file mode 100644 index 00000000000..2e93df375ae --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_tests.rs @@ -0,0 +1,1543 @@ +use super::*; +use codex_protocol::protocol::Product; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::tempdir; + +const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; +const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; + +fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf { + let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH); + fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap(); + fs::write(&marketplace_path, contents).unwrap(); + AbsolutePathBuf::try_from(marketplace_path).unwrap() +} + +fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) { + let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH); + fs::create_dir_all(manifest_path.parent().unwrap()).unwrap(); + fs::write(manifest_path, contents).unwrap(); +} + +#[test] +fn find_marketplace_plugin_finds_repo_marketplace_plugin() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join("nested")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./plugin-1" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved, + ResolvedMarketplacePlugin { + plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string()) + .unwrap(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + manifest: None, + } + ); +} + +#[test] +fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + let marketplace_path = write_alternate_marketplace( + &repo_root, + r#"{ + "name": "alternate-marketplace", + "plugins": [ + { + "name": "string-source-plugin", + "source": "./plugins/string-source-plugin" + } + ] +}"#, + ); + + let resolved = find_marketplace_plugin(&marketplace_path, "string-source-plugin").unwrap(); + + assert_eq!( + resolved, + ResolvedMarketplacePlugin { + plugin_id: PluginId::new( + "string-source-plugin".to_string(), + "alternate-marketplace".to_string() + ) + .unwrap(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin")) + .unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + manifest: None, + } + ); +} + +#[test] +fn find_marketplace_plugin_supports_git_subdir_sources() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "remote-plugin", + "source": { + "source": "git-subdir", + "url": "openai/joey_marketplace3", + "path": "plugins/toolkit", + "ref": "main", + "sha": "abc123" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "remote-plugin", + ) + .unwrap(); + + assert_eq!( + resolved, + ResolvedMarketplacePlugin { + plugin_id: PluginId::new("remote-plugin".to_string(), "codex-curated".to_string()) + .unwrap(), + source: MarketplacePluginSource::Git { + url: "https://github.com/openai/joey_marketplace3.git".to_string(), + path: Some("plugins/toolkit".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + manifest: None, + } + ); +} + +#[test] +fn find_marketplace_plugin_normalizes_github_shorthand_with_dot_git_suffix() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "remote-plugin", + "source": { + "source": "git-subdir", + "url": "openai/toolkit.git", + "path": "plugins/toolkit" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "remote-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source, + MarketplacePluginSource::Git { + url: "https://github.com/openai/toolkit.git".to_string(), + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + } + ); +} + +#[test] +fn find_marketplace_plugin_normalizes_relative_git_source_urls_to_marketplace_root() { + for source_url in ["./remotes/toolkit.git", ".\\remotes\\toolkit.git"] { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let remote_repo = repo_root.join("remotes").join("toolkit.git"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(&remote_repo).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "codex-curated", + "plugins": [ + {{ + "name": "remote-plugin", + "source": {{ + "source": "git-subdir", + "url": "{}", + "path": "plugins/toolkit" + }} + }} + ] +}}"#, + source_url.replace('\\', "\\\\") + ), + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "remote-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source, + MarketplacePluginSource::Git { + url: remote_repo.display().to_string(), + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + } + ); + } +} + +#[test] +fn normalize_relative_git_plugin_source_url_rejects_parent_traversal() { + for source_url in [ + "../toolkit.git", + "./../toolkit.git", + "..\\toolkit.git", + ".\\..\\toolkit.git", + ] { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let marketplace_path = repo_root.join(".agents/plugins/marketplace.json"); + let marketplace_path = AbsolutePathBuf::try_from(marketplace_path).unwrap(); + let err = + normalize_relative_git_plugin_source_url(&marketplace_path, source_url).unwrap_err(); + + assert_eq!( + err.to_string(), + format!( + "invalid marketplace file `{}`: relative git plugin source url must stay within the marketplace root", + marketplace_path.display() + ) + ); + } +} + +#[test] +fn find_marketplace_plugin_skips_root_equivalent_git_subdir_paths() { + for path in [".", "./", "plugins/.."] { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "codex-curated", + "plugins": [ + {{ + "name": "remote-plugin", + "source": {{ + "source": "git-subdir", + "url": "openai/toolkit", + "path": "{path}" + }} + }} + ] +}}"# + ), + ) + .unwrap(); + + let err = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "remote-plugin", + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `remote-plugin` was not found in marketplace `codex-curated`" + ); + } +} + +#[test] +fn find_marketplace_plugin_reports_missing_plugin() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{"name":"codex-curated","plugins":[]}"#, + ) + .unwrap(); + + let err = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "missing", + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `missing` was not found in marketplace `codex-curated`" + ); +} + +#[test] +fn list_marketplaces_supports_alternate_manifest_layout() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/string-source-plugin"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + write_alternate_plugin_manifest( + &plugin_root, + r#"{ + "name":"string-source-plugin", + "interface": { + "displayName": "String Source Plugin" + } +}"#, + ); + let marketplace_path = write_alternate_marketplace( + &repo_root, + r#"{ + "name": "alternate-marketplace", + "plugins": [ + { + "name": "string-source-plugin", + "source": "./plugins/string-source-plugin" + } + ] +}"#, + ); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "alternate-marketplace".to_string(), + path: marketplace_path, + interface: None, + plugins: vec![MarketplacePlugin { + name: "string-source-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin")) + .unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: Some(PluginManifestInterface { + display_name: Some("String Source Plugin".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + }), + keywords: Vec::new(), + }], + }] + ); +} + +#[test] +fn list_marketplaces_includes_plugins_without_discoverable_manifest() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + let marketplace_path = write_alternate_marketplace( + &repo_root, + r#"{ + "name": "alternate-marketplace", + "plugins": [ + { + "name": "missing-plugin", + "source": "./plugins/missing-plugin" + } + ] +}"#, + ); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "alternate-marketplace".to_string(), + path: marketplace_path, + interface: None, + plugins: vec![MarketplacePlugin { + name: "missing-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugins/missing-plugin"),) + .unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }], + }] + ); +} + +#[test] +fn list_marketplaces_prefers_first_supported_manifest_layout() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "agents-marketplace", + "plugins": [ + { + "name": "agents-plugin", + "source": { + "source": "local", + "path": "./plugins/agents-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_alternate_marketplace( + &repo_root, + r#"{ + "name": "alternate-marketplace", + "plugins": [ + { + "name": "string-source-plugin", + "source": "./plugins/string-source-plugin" + } + ] +}"#, + ); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!(marketplaces.len(), 1); + assert_eq!(marketplaces[0].name, "agents-marketplace"); + assert_eq!( + marketplaces[0].path, + AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap() + ); +} + +#[test] +fn list_marketplaces_returns_home_and_repo_marketplaces() { + let tmp = tempdir().unwrap(); + let home_root = tmp.path().join("home"); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(home_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + home_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./home-shared" + } + }, + { + "name": "home-only", + "source": { + "source": "local", + "path": "./home-only" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./repo-shared" + } + }, + { + "name": "repo-only", + "source": { + "source": "local", + "path": "./repo-only" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + Some(&home_root), + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![ + Marketplace { + name: "codex-curated".to_string(), + path: + AbsolutePathBuf::try_from(home_root.join(".agents/plugins/marketplace.json"),) + .unwrap(), + interface: None, + plugins: vec![ + MarketplacePlugin { + name: "shared-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }, + MarketplacePlugin { + name: "home-only".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }, + ], + }, + Marketplace { + name: "codex-curated".to_string(), + path: + AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"),) + .unwrap(), + interface: None, + plugins: vec![ + MarketplacePlugin { + name: "shared-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }, + MarketplacePlugin { + name: "repo-only".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }, + ], + }, + ] + ); +} + +#[test] +fn list_marketplaces_keeps_distinct_entries_for_same_name() { + let tmp = tempdir().unwrap(); + let home_root = tmp.path().join("home"); + let repo_root = tmp.path().join("repo"); + let home_marketplace = home_root.join(".agents/plugins/marketplace.json"); + let repo_marketplace = repo_root.join(".agents/plugins/marketplace.json"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(home_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + + fs::write( + home_marketplace.clone(), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./home-plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_marketplace.clone(), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./repo-plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + Some(&home_root), + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![ + Marketplace { + name: "codex-curated".to_string(), + path: AbsolutePathBuf::try_from(home_marketplace).unwrap(), + interface: None, + plugins: vec![MarketplacePlugin { + name: "local-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }], + }, + Marketplace { + name: "codex-curated".to_string(), + path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(), + interface: None, + plugins: vec![MarketplacePlugin { + name: "local-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }], + }, + ] + ); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_marketplace).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source, + MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), + } + ); +} + +#[test] +fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let nested_root = repo_root.join("nested/project"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(&nested_root).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[ + AbsolutePathBuf::try_from(repo_root.clone()).unwrap(), + AbsolutePathBuf::try_from(nested_root).unwrap(), + ], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "codex-curated".to_string(), + path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + interface: None, + plugins: vec![MarketplacePlugin { + name: "local-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }], + }] + ); +} + +#[test] +fn list_marketplaces_reads_marketplace_display_name() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "interface": { + "displayName": "ChatGPT Official" + }, + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces[0].interface, + Some(MarketplaceInterface { + display_name: Some("ChatGPT Official".to_string()), + }) + ); +} + +#[test] +fn list_marketplaces_skips_invalid_plugins_but_keeps_marketplace() { + let tmp = tempdir().unwrap(); + let valid_repo_root = tmp.path().join("valid-repo"); + let invalid_repo_root = tmp.path().join("invalid-repo"); + + fs::create_dir_all(valid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap(); + fs::write( + valid_repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "valid-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + invalid_repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "invalid-marketplace", + "plugins": [ + { + "name": "broken-plugin", + "source": { + "source": "local", + "path": "plugin-without-dot-slash" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[ + AbsolutePathBuf::try_from(valid_repo_root).unwrap(), + AbsolutePathBuf::try_from(invalid_repo_root).unwrap(), + ], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!(marketplaces.len(), 2); + assert_eq!(marketplaces[0].name, "valid-marketplace"); + assert_eq!(marketplaces[1].name, "invalid-marketplace"); + assert!(marketplaces[1].plugins.is_empty()); +} + +#[test] +fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "invalid-name-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./valid-plugin" + } + }, + { + "name": "invalid.plugin", + "source": { + "source": "local", + "path": "./invalid-plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "invalid-name-marketplace".to_string(), + path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + interface: None, + plugins: vec![MarketplacePlugin { + name: "valid-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("valid-plugin")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }], + }] + ); +} + +#[test] +fn list_marketplaces_reports_marketplace_load_errors() { + let tmp = tempdir().unwrap(); + let valid_repo_root = tmp.path().join("valid-repo"); + let invalid_repo_root = tmp.path().join("invalid-repo"); + + fs::create_dir_all(valid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap(); + fs::write( + valid_repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "valid-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + let invalid_marketplace_path = + AbsolutePathBuf::try_from(invalid_repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(); + fs::write(invalid_marketplace_path.as_path(), "{not json").unwrap(); + + let outcome = list_marketplaces_with_home( + &[ + AbsolutePathBuf::try_from(valid_repo_root).unwrap(), + AbsolutePathBuf::try_from(invalid_repo_root).unwrap(), + ], + /*home_dir*/ None, + ) + .unwrap(); + + assert_eq!(outcome.marketplaces.len(), 1); + assert_eq!(outcome.marketplaces[0].name, "valid-marketplace"); + assert_eq!(outcome.errors.len(), 1); + assert_eq!(outcome.errors[0].path, invalid_marketplace_path); + assert!( + outcome.errors[0] + .message + .contains("invalid marketplace file"), + "unexpected errors: {:?}", + outcome.errors + ); +} + +#[test] +fn list_marketplaces_keeps_remote_and_local_plugin_sources() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + write_alternate_marketplace( + &repo_root, + r#"{ + "name": "mixed-source-marketplace", + "plugins": [ + { + "name": "local-plugin", + "source": "./plugins/local-plugin" + }, + { + "name": "url-plugin", + "source": { + "source": "url", + "url": "https://github.com/example/plugin" + } + }, + { + "name": "git-subdir-plugin", + "source": { + "source": "git-subdir", + "url": "owner/repo", + "path": "plugins/example", + "ref": "main", + "sha": "abc123" + } + } + ] +}"#, + ); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!(marketplaces.len(), 1); + assert_eq!( + marketplaces[0].plugins, + vec![ + MarketplacePlugin { + name: "local-plugin".to_string(), + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugins/local-plugin")) + .unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }, + MarketplacePlugin { + name: "url-plugin".to_string(), + source: MarketplacePluginSource::Git { + url: "https://github.com/example/plugin.git".to_string(), + path: None, + ref_name: None, + sha: None, + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }, + MarketplacePlugin { + name: "git-subdir-plugin".to_string(), + source: MarketplacePluginSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + path: Some("plugins/example".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }, + ] + ); +} + +#[test] +fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/demo-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + "products": ["CODEX", "CHATGPT", "ATLAS"] + }, + "category": "Design" + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "interface": { + "displayName": "Demo", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/shot1.png"] + } +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces[0].plugins[0].policy.installation, + MarketplacePluginInstallPolicy::Available + ); + assert_eq!( + marketplaces[0].plugins[0].policy.authentication, + MarketplacePluginAuthPolicy::OnInstall + ); + assert_eq!( + marketplaces[0].plugins[0].policy.products, + Some(vec![Product::Codex, Product::Chatgpt, Product::Atlas]) + ); + assert_eq!( + marketplaces[0].plugins[0].interface, + Some(PluginManifestInterface { + display_name: Some("Demo".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Design".to_string()), + capabilities: vec!["Interactive".to_string(), "Write".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some( + AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(), + ), + logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + screenshots: vec![ + AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(), + ], + }) + ); +} + +#[test] +fn list_marketplaces_ignores_legacy_top_level_policy_fields() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + }, + "installPolicy": "NOT_AVAILABLE", + "authPolicy": "ON_USE" + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces[0].plugins[0].policy.installation, + MarketplacePluginInstallPolicy::Available + ); + assert_eq!( + marketplaces[0].plugins[0].policy.authentication, + MarketplacePluginAuthPolicy::OnInstall + ); + assert_eq!(marketplaces[0].plugins[0].policy.products, None); +} + +#[test] +fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/demo-plugin"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "interface": { + "displayName": "Demo", + "capabilities": ["Interactive"], + "composerIcon": "assets/icon.png", + "logo": "/tmp/logo.png", + "screenshots": ["assets/shot1.png"] + } +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces[0].plugins[0].interface, + Some(PluginManifestInterface { + display_name: Some("Demo".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Interactive".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + }) + ); + assert_eq!( + marketplaces[0].plugins[0].policy.installation, + MarketplacePluginInstallPolicy::Available + ); + assert_eq!( + marketplaces[0].plugins[0].policy.authentication, + MarketplacePluginAuthPolicy::OnInstall + ); + assert_eq!(marketplaces[0].plugins[0].policy.products, None); +} + +#[test] +fn find_marketplace_plugin_skips_invalid_local_paths() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "../plugin-1" + } + } + ] +}"#, + ) + .unwrap(); + + let err = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `local-plugin` was not found in marketplace `codex-curated`" + ); +} + +#[test] +fn find_marketplace_plugin_uses_first_duplicate_entry() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./first" + } + }, + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./second" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source, + MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("first")).unwrap(), + } + ); +} + +#[test] +fn find_installable_marketplace_plugin_rejects_disallowed_product() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "chatgpt-plugin", + "source": { + "source": "local", + "path": "./plugin" + }, + "policy": { + "products": ["CHATGPT"] + } + } + ] +}"#, + ) + .unwrap(); + + let err = find_installable_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "chatgpt-plugin", + Some(Product::Atlas), + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `chatgpt-plugin` is not available for install in marketplace `codex-curated`" + ); +} + +#[test] +fn find_marketplace_plugin_allows_missing_products_field() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "default-plugin", + "source": { + "source": "local", + "path": "./plugin" + }, + "policy": {} + } + ] +}"#, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "default-plugin", + ) + .unwrap(); + + assert_eq!(resolved.plugin_id.as_key(), "default-plugin@codex-curated"); +} + +#[test] +fn find_installable_marketplace_plugin_rejects_explicit_empty_products() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "disabled-plugin", + "source": { + "source": "local", + "path": "./plugin" + }, + "policy": { + "products": [] + } + } + ] +}"#, + ) + .unwrap(); + + let err = find_installable_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "disabled-plugin", + Some(Product::Codex), + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `disabled-plugin` is not available for install in marketplace `codex-curated`" + ); +} diff --git a/code-rs/core-plugins/src/marketplace_upgrade.rs b/code-rs/core-plugins/src/marketplace_upgrade.rs new file mode 100644 index 00000000000..81474cf66f1 --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_upgrade.rs @@ -0,0 +1,298 @@ +mod activation; +mod git; + +use self::activation::activate_marketplace_root; +use self::activation::installed_marketplace_metadata_matches; +use self::activation::write_installed_marketplace_metadata; +use self::git::clone_git_source; +use self::git::git_remote_revision; +use crate::marketplace::validate_marketplace_root; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerStack; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_config::types::MarketplaceConfig; +use codex_config::types::MarketplaceSourceType; +use codex_plugin::validate_plugin_segment; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use tracing::warn; + +const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces"; +const MARKETPLACE_UPGRADE_GIT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfiguredMarketplaceUpgradeError { + pub marketplace_name: String, + pub message: String, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ConfiguredMarketplaceUpgradeOutcome { + pub selected_marketplaces: Vec, + pub upgraded_roots: Vec, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConfiguredGitMarketplace { + name: String, + source: String, + ref_name: Option, + sparse_paths: Vec, + last_revision: Option, +} + +impl ConfiguredMarketplaceUpgradeOutcome { + pub fn all_succeeded(&self) -> bool { + self.errors.is_empty() + } +} + +pub fn configured_git_marketplace_names(config_layer_stack: &ConfigLayerStack) -> Vec { + let mut names = configured_git_marketplaces(config_layer_stack) + .into_iter() + .map(|marketplace| marketplace.name) + .collect::>(); + names.sort_unstable(); + names +} + +pub fn upgrade_configured_git_marketplaces( + codex_home: &Path, + config_layer_stack: &ConfigLayerStack, + marketplace_name: Option<&str>, +) -> ConfiguredMarketplaceUpgradeOutcome { + let marketplaces = configured_git_marketplaces(config_layer_stack) + .into_iter() + .filter(|marketplace| marketplace_name.is_none_or(|name| marketplace.name.as_str() == name)) + .collect::>(); + if marketplaces.is_empty() { + return ConfiguredMarketplaceUpgradeOutcome::default(); + } + + let install_root = marketplace_install_root(codex_home); + let selected_marketplaces = marketplaces + .iter() + .map(|marketplace| marketplace.name.clone()) + .collect(); + let mut upgraded_roots = Vec::new(); + let mut errors = Vec::new(); + for marketplace in marketplaces { + match upgrade_configured_git_marketplace(codex_home, &install_root, &marketplace) { + Ok(Some(upgraded_root)) => upgraded_roots.push(upgraded_root), + Ok(None) => {} + Err(err) => { + errors.push(ConfiguredMarketplaceUpgradeError { + marketplace_name: marketplace.name, + message: err, + }); + } + } + } + + ConfiguredMarketplaceUpgradeOutcome { + selected_marketplaces, + upgraded_roots, + errors, + } +} + +fn marketplace_install_root(codex_home: &Path) -> PathBuf { + codex_home.join(INSTALLED_MARKETPLACES_DIR) +} + +fn configured_git_marketplaces( + config_layer_stack: &ConfigLayerStack, +) -> Vec { + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return Vec::new(); + }; + let Some(marketplaces_value) = user_layer.config.get("marketplaces") else { + return Vec::new(); + }; + let marketplaces = match marketplaces_value + .clone() + .try_into::>() + { + Ok(marketplaces) => marketplaces, + Err(err) => { + warn!("invalid marketplaces config while preparing auto-upgrade: {err}"); + return Vec::new(); + } + }; + + let mut configured = marketplaces + .into_iter() + .filter_map(|(name, marketplace)| configured_git_marketplace_from_config(name, marketplace)) + .collect::>(); + configured.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + configured +} + +fn configured_git_marketplace_from_config( + name: String, + marketplace: MarketplaceConfig, +) -> Option { + let MarketplaceConfig { + last_updated: _, + last_revision, + source_type, + source, + ref_name, + sparse_paths, + } = marketplace; + if source_type != Some(MarketplaceSourceType::Git) { + return None; + } + let Some(source) = source else { + warn!( + marketplace = name, + "ignoring configured Git marketplace without source" + ); + return None; + }; + Some(ConfiguredGitMarketplace { + name, + source, + ref_name, + sparse_paths: sparse_paths.unwrap_or_default(), + last_revision, + }) +} + +fn upgrade_configured_git_marketplace( + codex_home: &Path, + install_root: &Path, + marketplace: &ConfiguredGitMarketplace, +) -> Result, String> { + validate_plugin_segment(&marketplace.name, "marketplace name")?; + let remote_revision = git_remote_revision( + &marketplace.source, + marketplace.ref_name.as_deref(), + MARKETPLACE_UPGRADE_GIT_TIMEOUT, + )?; + let destination = install_root.join(&marketplace.name); + if destination + .join(".agents/plugins/marketplace.json") + .is_file() + && marketplace.last_revision.as_deref() == Some(remote_revision.as_str()) + && installed_marketplace_metadata_matches(&destination, marketplace, &remote_revision) + { + return Ok(None); + } + + let staging_parent = install_root.join(".staging"); + std::fs::create_dir_all(&staging_parent).map_err(|err| { + format!( + "failed to create marketplace upgrade staging directory {}: {err}", + staging_parent.display() + ) + })?; + let staged_dir = tempfile::Builder::new() + .prefix("marketplace-upgrade-") + .tempdir_in(&staging_parent) + .map_err(|err| { + format!( + "failed to create temporary marketplace upgrade directory in {}: {err}", + staging_parent.display() + ) + })?; + + let activated_revision = clone_git_source( + &marketplace.source, + marketplace.ref_name.as_deref(), + &marketplace.sparse_paths, + staged_dir.path(), + MARKETPLACE_UPGRADE_GIT_TIMEOUT, + )?; + let marketplace_name = validate_marketplace_root(staged_dir.path()) + .map_err(|err| format!("failed to validate upgraded marketplace root: {err}"))?; + if marketplace_name != marketplace.name { + return Err(format!( + "upgraded marketplace name `{marketplace_name}` does not match configured marketplace `{}`", + marketplace.name + )); + } + write_installed_marketplace_metadata(staged_dir.path(), marketplace, &activated_revision)?; + + let last_updated = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let update = MarketplaceConfigUpdate { + last_updated: &last_updated, + last_revision: Some(&activated_revision), + source_type: "git", + source: &marketplace.source, + ref_name: marketplace.ref_name.as_deref(), + sparse_paths: &marketplace.sparse_paths, + }; + activate_marketplace_root(&destination, staged_dir, || { + ensure_configured_git_marketplace_unchanged(codex_home, marketplace)?; + record_user_marketplace(codex_home, &marketplace.name, &update).map_err(|err| { + format!( + "failed to record upgraded marketplace `{}` in user config.toml: {err}", + marketplace.name + ) + }) + })?; + + AbsolutePathBuf::try_from(destination) + .map(Some) + .map_err(|err| format!("upgraded marketplace path is not absolute: {err}")) +} +fn ensure_configured_git_marketplace_unchanged( + codex_home: &Path, + expected: &ConfiguredGitMarketplace, +) -> Result<(), String> { + let current = read_configured_git_marketplace(codex_home, &expected.name)?; + match current { + Some(current) if current == *expected => Ok(()), + Some(_) => Err(format!( + "configured marketplace `{}` changed while auto-upgrade was in flight", + expected.name + )), + None => Err(format!( + "configured marketplace `{}` was removed or is no longer a Git marketplace", + expected.name + )), + } +} + +fn read_configured_git_marketplace( + codex_home: &Path, + marketplace_name: &str, +) -> Result, String> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let raw_config = match std::fs::read_to_string(&config_path) { + Ok(raw_config) => raw_config, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(format!( + "failed to read user config {} while checking marketplace auto-upgrade: {err}", + config_path.display() + )); + } + }; + let config: toml::Value = toml::from_str(&raw_config).map_err(|err| { + format!( + "failed to parse user config {} while checking marketplace auto-upgrade: {err}", + config_path.display() + ) + })?; + let Some(marketplaces_value) = config.get("marketplaces") else { + return Ok(None); + }; + let mut marketplaces = marketplaces_value + .clone() + .try_into::>() + .map_err(|err| format!("invalid marketplaces config while checking auto-upgrade: {err}"))?; + let Some(marketplace) = marketplaces.remove(marketplace_name) else { + return Ok(None); + }; + Ok(configured_git_marketplace_from_config( + marketplace_name.to_string(), + marketplace, + )) +} diff --git a/code-rs/core-plugins/src/marketplace_upgrade/activation.rs b/code-rs/core-plugins/src/marketplace_upgrade/activation.rs new file mode 100644 index 00000000000..366b35fb43b --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_upgrade/activation.rs @@ -0,0 +1,167 @@ +use super::ConfiguredGitMarketplace; +use codex_config::types::MarketplaceSourceType; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::path::PathBuf; +use tempfile::TempDir; +use tracing::warn; + +const MARKETPLACE_INSTALL_METADATA_FILE: &str = ".codex-marketplace-install.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +struct InstalledMarketplaceMetadata { + source_type: MarketplaceSourceType, + source: String, + ref_name: Option, + sparse_paths: Vec, + revision: String, +} + +pub(super) fn installed_marketplace_metadata_matches( + root: &Path, + marketplace: &ConfiguredGitMarketplace, + revision: &str, +) -> bool { + let metadata = match std::fs::read_to_string(installed_marketplace_metadata_path(root)) { + Ok(metadata) => metadata, + Err(_) => return false, + }; + let metadata = match serde_json::from_str::(&metadata) { + Ok(metadata) => metadata, + Err(err) => { + warn!( + marketplace = marketplace.name, + error = %err, + "failed to parse activated marketplace metadata" + ); + return false; + } + }; + metadata == installed_marketplace_metadata(marketplace, revision) +} + +pub(super) fn write_installed_marketplace_metadata( + root: &Path, + marketplace: &ConfiguredGitMarketplace, + revision: &str, +) -> Result<(), String> { + let metadata = installed_marketplace_metadata(marketplace, revision); + let contents = serde_json::to_string_pretty(&metadata) + .map_err(|err| format!("failed to serialize activated marketplace metadata: {err}"))?; + std::fs::write(installed_marketplace_metadata_path(root), contents) + .map_err(|err| format!("failed to write activated marketplace metadata: {err}")) +} + +pub(super) fn activate_marketplace_root( + destination: &Path, + staged_dir: TempDir, + after_activate: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let staged_root = staged_dir.path(); + let Some(parent) = destination.parent() else { + return Err(format!( + "failed to determine marketplace install parent for {}", + destination.display() + )); + }; + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create marketplace install parent {}: {err}", + parent.display() + ) + })?; + + if destination.exists() { + let backup_dir = tempfile::Builder::new() + .prefix("marketplace-backup-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create marketplace backup directory in {}: {err}", + parent.display() + ) + })?; + let backup_root = backup_dir.path().join("root"); + std::fs::rename(destination, &backup_root).map_err(|err| { + format!( + "failed to move previous marketplace root out of the way at {}: {err}", + destination.display() + ) + })?; + + if let Err(err) = std::fs::rename(staged_root, destination) { + let rollback_result = std::fs::rename(&backup_root, destination); + return match rollback_result { + Ok(()) => Err(format!( + "failed to activate upgraded marketplace at {}: {err}", + destination.display() + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("root"); + Err(format!( + "failed to activate upgraded marketplace at {}: {err}; failed to restore previous marketplace root (left at {}): {rollback_err}", + destination.display(), + backup_path.display() + )) + } + }; + } + + if let Err(err) = after_activate() { + let remove_result = std::fs::remove_dir_all(destination); + let rollback_result = + remove_result.and_then(|()| std::fs::rename(&backup_root, destination)); + return match rollback_result { + Ok(()) => Err(err), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("root"); + Err(format!( + "{err}; failed to restore previous marketplace root at {} (left at {}): {rollback_err}", + destination.display(), + backup_path.display() + )) + } + }; + } + + return Ok(()); + } + + std::fs::rename(staged_root, destination).map_err(|err| { + format!( + "failed to activate upgraded marketplace at {}: {err}", + destination.display() + ) + })?; + if let Err(err) = after_activate() { + let remove_result = std::fs::remove_dir_all(destination); + return match remove_result { + Ok(()) => Err(err), + Err(remove_err) => Err(format!( + "{err}; failed to remove newly activated marketplace root at {}: {remove_err}", + destination.display() + )), + }; + } + + Ok(()) +} + +fn installed_marketplace_metadata( + marketplace: &ConfiguredGitMarketplace, + revision: &str, +) -> InstalledMarketplaceMetadata { + InstalledMarketplaceMetadata { + source_type: MarketplaceSourceType::Git, + source: marketplace.source.clone(), + ref_name: marketplace.ref_name.clone(), + sparse_paths: marketplace.sparse_paths.clone(), + revision: revision.to_string(), + } +} + +fn installed_marketplace_metadata_path(root: &Path) -> PathBuf { + root.join(MARKETPLACE_INSTALL_METADATA_FILE) +} diff --git a/code-rs/core-plugins/src/marketplace_upgrade/git.rs b/code-rs/core-plugins/src/marketplace_upgrade/git.rs new file mode 100644 index 00000000000..9a46465dbc7 --- /dev/null +++ b/code-rs/core-plugins/src/marketplace_upgrade/git.rs @@ -0,0 +1,285 @@ +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Output; +use std::process::Stdio; +use std::time::Duration; + +pub(super) fn git_remote_revision( + source: &str, + ref_name: Option<&str>, + timeout: Duration, +) -> Result { + if let Some(ref_name) = ref_name + && is_full_git_sha(ref_name) + { + return Ok(ref_name.to_string()); + } + + let ref_name = ref_name.unwrap_or("HEAD"); + let output = run_git_command_with_timeout( + git_command().arg("ls-remote").arg(source).arg(ref_name), + "git ls-remote marketplace source", + timeout, + )?; + ensure_git_success(&output, "git ls-remote marketplace source")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(first_line) = stdout.lines().next() else { + return Err("git ls-remote returned empty output for marketplace source".to_string()); + }; + let Some((revision, _)) = first_line.split_once('\t') else { + return Err(format!( + "unexpected git ls-remote output for marketplace source: {first_line}" + )); + }; + let revision = revision.trim(); + if revision.is_empty() { + return Err("git ls-remote returned empty revision for marketplace source".to_string()); + } + Ok(revision.to_string()) +} + +pub(super) fn clone_git_source( + source: &str, + ref_name: Option<&str>, + sparse_paths: &[String], + destination: &Path, + timeout: Duration, +) -> Result { + let git_destination = git_path_arg(destination); + if sparse_paths.is_empty() { + let output = run_git_command_with_timeout( + git_command().arg("clone").arg(source).arg(&git_destination), + "git clone marketplace source", + timeout, + )?; + ensure_git_success(&output, "git clone marketplace source")?; + if let Some(ref_name) = ref_name { + let output = run_git_command_with_timeout( + git_command() + .arg("-C") + .arg(&git_destination) + .arg("checkout") + .arg(ref_name), + "git checkout marketplace ref", + timeout, + )?; + ensure_git_success(&output, "git checkout marketplace ref")?; + } + return git_worktree_revision(&git_destination, timeout); + } + + let output = run_git_command_with_timeout( + git_command() + .arg("clone") + .arg("--filter=blob:none") + .arg("--no-checkout") + .arg(source) + .arg(&git_destination), + "git clone marketplace source", + timeout, + )?; + ensure_git_success(&output, "git clone marketplace source")?; + + let mut sparse_checkout = git_command(); + sparse_checkout + .arg("-C") + .arg(&git_destination) + .arg("sparse-checkout") + .arg("set") + .args(sparse_paths); + let output = run_git_command_with_timeout( + &mut sparse_checkout, + "git sparse-checkout marketplace source", + timeout, + )?; + ensure_git_success(&output, "git sparse-checkout marketplace source")?; + + let output = run_git_command_with_timeout( + git_command() + .arg("-C") + .arg(&git_destination) + .arg("checkout") + .arg(ref_name.unwrap_or("HEAD")), + "git checkout marketplace ref", + timeout, + )?; + ensure_git_success(&output, "git checkout marketplace ref")?; + git_worktree_revision(&git_destination, timeout) +} + +fn git_worktree_revision(destination: &Path, timeout: Duration) -> Result { + let output = run_git_command_with_timeout( + git_command() + .arg("-C") + .arg(destination) + .arg("rev-parse") + .arg("HEAD"), + "git rev-parse marketplace revision", + timeout, + )?; + ensure_git_success(&output, "git rev-parse marketplace revision")?; + + let revision = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if revision.is_empty() { + Err("git rev-parse returned empty revision for marketplace source".to_string()) + } else { + Ok(revision) + } +} + +fn is_full_git_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn git_command() -> Command { + let mut command = Command::new("git"); + command + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0"); + command +} + +#[cfg(windows)] +fn git_path_arg(path: &Path) -> PathBuf { + strip_windows_verbatim_path_prefix(&path.to_string_lossy()) + .map(PathBuf::from) + .unwrap_or_else(|| path.to_path_buf()) +} + +#[cfg(not(windows))] +fn git_path_arg(path: &Path) -> PathBuf { + path.to_path_buf() +} + +#[cfg(any(windows, test))] +fn strip_windows_verbatim_path_prefix(path: &str) -> Option { + let stripped = path.strip_prefix(r"\\?\")?; + let stripped = stripped + .strip_prefix(r"UNC\") + .map(|unc_path| format!(r"\\{unc_path}")) + .unwrap_or_else(|| stripped.to_string()); + Some(stripped) +} + +fn run_git_command_with_timeout( + command: &mut Command, + context: &str, + timeout: Duration, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to run {context}: {err}"))?; + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + if start.elapsed() >= timeout { + let _ = child.kill(); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err(format!("{context} timed out after {}s", timeout.as_secs())) + } else { + Err(format!( + "{context} timed out after {}s: {stderr}", + timeout.as_secs() + )) + }; + } + + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!("{context} failed with status {}", output.status)) + } else { + Err(format!( + "{context} failed with status {}: {stderr}", + output.status + )) + } +} + +#[cfg(test)] +mod tests { + use super::git_command; + use super::is_full_git_sha; + use super::strip_windows_verbatim_path_prefix; + use pretty_assertions::assert_eq; + use std::ffi::OsStr; + + #[test] + fn full_git_sha_ref_is_already_a_remote_revision() { + assert!(is_full_git_sha("0123456789abcdef0123456789abcdef01234567")); + assert!(!is_full_git_sha("main")); + assert!(!is_full_git_sha("0123456")); + } + + #[test] + fn git_command_uses_path_lookup_with_stable_noninteractive_env() { + let command = git_command(); + + assert_eq!(command.get_program(), OsStr::new("git")); + assert_eq!( + command_env(&command, "GIT_OPTIONAL_LOCKS"), + Some(Some(OsStr::new("0"))) + ); + assert_eq!( + command_env(&command, "GIT_TERMINAL_PROMPT"), + Some(Some(OsStr::new("0"))) + ); + assert_eq!(command_env(&command, "PATH"), None); + } + + #[test] + fn strips_windows_verbatim_disk_prefix_for_git() { + assert_eq!( + strip_windows_verbatim_path_prefix(r"\\?\C:\Users\alice\marketplace"), + Some(r"C:\Users\alice\marketplace".to_string()) + ); + } + + #[test] + fn strips_windows_verbatim_unc_prefix_for_git() { + assert_eq!( + strip_windows_verbatim_path_prefix(r"\\?\UNC\server\share\marketplace"), + Some(r"\\server\share\marketplace".to_string()) + ); + } + + #[test] + fn leaves_non_verbatim_path_without_rewrite() { + assert_eq!(strip_windows_verbatim_path_prefix(r"C:\Users\alice"), None); + } + + fn command_env<'a>( + command: &'a std::process::Command, + name: &str, + ) -> Option> { + command + .get_envs() + .find(|(key, _)| key == &OsStr::new(name)) + .map(|(_, value)| value) + } +} diff --git a/code-rs/core-plugins/src/remote.rs b/code-rs/core-plugins/src/remote.rs new file mode 100644 index 00000000000..43169762845 --- /dev/null +++ b/code-rs/core-plugins/src/remote.rs @@ -0,0 +1,1178 @@ +use crate::store::PLUGINS_CACHE_DIR; +use crate::store::PluginStore; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::SkillInterface; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; +use codex_plugin::PluginId; +use codex_utils_absolute_path::AbsolutePathBuf; +use reqwest::RequestBuilder; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; +use url::Url; + +mod remote_installed_plugin_sync; +mod share; + +pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError; +pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome; +pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard; +pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; +pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; +pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; +pub use share::RemotePluginShareAccessPolicy; +pub use share::RemotePluginShareDiscoverability; +pub use share::RemotePluginSharePrincipal; +pub use share::RemotePluginSharePrincipalType; +pub use share::RemotePluginShareSaveResult; +pub use share::RemotePluginShareTarget; +pub use share::RemotePluginShareUpdateDiscoverability; +pub use share::RemotePluginShareUpdateTargetsResult; +pub use share::delete_remote_plugin_share; +pub use share::list_remote_plugin_shares; +pub use share::load_plugin_share_remote_ids_by_local_path; +pub use share::save_remote_plugin_share; +pub use share::update_remote_plugin_share_targets; + +pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; +pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; +pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "shared-with-me"; +pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; +pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; +pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; + +const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; +const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginServiceConfig { + pub chatgpt_base_url: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteMarketplace { + pub name: String, + pub display_name: String, + pub plugins: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteMarketplaceSource { + Global, + WorkspaceDirectory, + SharedWithMe, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteInstalledPlugin { + pub marketplace_name: String, + pub id: String, + pub name: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginSummary { + pub id: String, + pub name: String, + pub share_context: Option, + pub installed: bool, + pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + pub availability: PluginAvailability, + pub interface: Option, + pub keywords: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareContext { + pub remote_plugin_id: String, + pub share_url: Option, + pub creator_account_user_id: Option, + pub creator_name: Option, + pub share_targets: Option>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginShareSummary { + pub summary: RemotePluginSummary, + pub share_url: Option, + pub local_plugin_path: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginDetail { + pub marketplace_name: String, + pub marketplace_display_name: String, + pub summary: RemotePluginSummary, + pub description: Option, + pub release_version: Option, + pub bundle_download_url: Option, + pub skills: Vec, + pub app_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginSkill { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginSkillDetail { + pub contents: Option, +} + +pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool { + !plugin_id.is_empty() + && plugin_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') +} + +pub fn validate_remote_plugin_id(plugin_id: &str) -> Result<(), JSONRPCErrorError> { + if !is_valid_remote_plugin_id(plugin_id) { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: + "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed" + .to_string(), + data: None, + }); + } + + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginCatalogError { + #[error("chatgpt authentication required for remote plugin catalog")] + AuthRequired, + + #[error( + "chatgpt authentication required for remote plugin catalog; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin catalog: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("failed to send remote plugin catalog request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin catalog request to {url} failed with status {status}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin catalog response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + + #[error("invalid remote plugin catalog base URL: {0}")] + InvalidBaseUrl(#[source] url::ParseError), + + #[error("invalid remote plugin catalog base URL path")] + InvalidBaseUrlPath, + + #[error("remote marketplace `{marketplace_name}` is not supported")] + UnknownMarketplace { marketplace_name: String }, + + #[error( + "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" + )] + UnexpectedPluginId { expected: String, actual: String }, + + #[error( + "remote plugin skill response returned unexpected skill name: expected `{expected}`, got `{actual}`" + )] + UnexpectedSkillName { expected: String, actual: String }, + + #[error( + "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + )] + UnexpectedEnabledState { + plugin_id: String, + expected_enabled: bool, + actual_enabled: bool, + }, + + #[error("invalid plugin path `{path}`: {reason}")] + InvalidPluginPath { path: PathBuf, reason: String }, + + #[error("failed to archive plugin at `{path}`: {source}")] + Archive { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to join plugin archive task: {0}")] + ArchiveJoin(#[source] tokio::task::JoinError), + + #[error( + "plugin archive would be {bytes} bytes, exceeding the maximum upload size of {max_bytes} bytes" + )] + ArchiveTooLarge { bytes: usize, max_bytes: usize }, + + #[error("workspace plugin upload response did not include an etag")] + MissingUploadEtag, + + #[error("{0}")] + UnexpectedResponse(String), + + #[error("{0}")] + CacheRemove(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +enum RemotePluginScope { + #[serde(rename = "GLOBAL")] + Global, + #[serde(rename = "WORKSPACE")] + Workspace, +} + +impl RemotePluginScope { + fn api_value(self) -> &'static str { + match self { + Self::Global => "GLOBAL", + Self::Workspace => "WORKSPACE", + } + } + + fn marketplace_name(self) -> &'static str { + match self { + Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME, + Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME, + } + } + + fn marketplace_display_name(self) -> &'static str { + match self { + Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, + } + } + + fn from_marketplace_name(name: &str) -> Option { + match name { + REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), + REMOTE_WORKSPACE_MARKETPLACE_NAME | REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME => { + Some(Self::Workspace) + } + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginPagination { + next_page_token: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginSkillInterfaceResponse { + display_name: Option, + short_description: Option, + brand_color: Option, + default_prompt: Option, + icon_small_url: Option, + icon_large_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginSkillResponse { + name: String, + description: String, + interface: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginSkillDetailResponse { + plugin_id: String, + name: String, + skill_md_contents: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginReleaseInterfaceResponse { + short_description: Option, + long_description: Option, + developer_name: Option, + category: Option, + #[serde(default)] + capabilities: Vec, + website_url: Option, + privacy_policy_url: Option, + terms_of_service_url: Option, + brand_color: Option, + default_prompt: Option, + composer_icon_url: Option, + logo_url: Option, + #[serde(default)] + screenshot_urls: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginReleaseResponse { + #[serde(default)] + version: Option, + display_name: String, + description: String, + #[serde(default)] + bundle_download_url: Option, + #[serde(default)] + app_ids: Vec, + #[serde(default)] + keywords: Vec, + interface: RemotePluginReleaseInterfaceResponse, + #[serde(default)] + skills: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginDirectoryItem { + id: String, + name: String, + scope: RemotePluginScope, + #[serde(default)] + creator_account_user_id: Option, + #[serde(default)] + creator_name: Option, + #[serde(default)] + share_url: Option, + #[serde(default)] + share_principals: Option>, + installation_policy: PluginInstallPolicy, + authentication_policy: PluginAuthPolicy, + #[serde(rename = "status", default)] + availability: PluginAvailability, + release: RemotePluginReleaseResponse, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginDirectorySharePrincipal { + principal_type: RemotePluginSharePrincipalType, + principal_id: String, + #[serde(default)] + role: Option, + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginInstalledItem { + #[serde(flatten)] + plugin: RemotePluginDirectoryItem, + enabled: bool, + #[serde(default)] + disabled_skill_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginListResponse { + plugins: Vec, + pagination: RemotePluginPagination, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginInstalledResponse { + plugins: Vec, + pagination: RemotePluginPagination, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginMutationResponse { + id: String, + enabled: bool, +} + +pub async fn fetch_remote_marketplaces( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + sources: &[RemoteMarketplaceSource], +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let mut marketplaces = Vec::new(); + let needs_workspace_installed = sources.iter().any(|source| { + matches!( + source, + RemoteMarketplaceSource::WorkspaceDirectory | RemoteMarketplaceSource::SharedWithMe + ) + }); + let workspace_installed_plugins = if needs_workspace_installed { + Some(fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace).await?) + } else { + None + }; + + for source in sources { + let marketplace = match source { + RemoteMarketplaceSource::Global => { + let scope = RemotePluginScope::Global; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope(config, auth, scope), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + installed_plugins, + /*include_installed_only*/ true, + ) + } + RemoteMarketplaceSource::WorkspaceDirectory => { + let scope = RemotePluginScope::Workspace; + let directory_plugins = + fetch_directory_plugins_for_scope(config, auth, scope).await?; + build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + ) + } + RemoteMarketplaceSource::SharedWithMe => build_remote_marketplace( + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME, + fetch_shared_workspace_plugins(config, auth).await?, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + ), + }; + if let Some(marketplace) = marketplace { + marketplaces.push(marketplace); + } + } + + Ok(marketplaces) +} + +fn build_remote_marketplace( + name: &str, + display_name: &str, + directory_plugins: Vec, + installed_plugins: Vec, + include_installed_only: bool, +) -> Option { + let directory_plugins = directory_plugins + .into_iter() + .map(|plugin| (plugin.id.clone(), plugin)) + .collect::>(); + let installed_plugins = installed_plugins + .into_iter() + .map(|plugin| (plugin.plugin.id.clone(), plugin)) + .collect::>(); + let plugin_ids = directory_plugins + .keys() + .chain( + include_installed_only + .then_some(&installed_plugins) + .into_iter() + .flat_map(|plugins| plugins.keys()), + ) + .cloned() + .collect::>(); + if plugin_ids.is_empty() { + return None; + } + + let mut plugins = plugin_ids + .into_iter() + .filter_map(|plugin_id| { + let directory_plugin = directory_plugins.get(&plugin_id); + let installed_plugin = installed_plugins.get(&plugin_id); + directory_plugin + .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) + .map(|plugin| build_remote_plugin_summary(plugin, installed_plugin)) + }) + .collect::>(); + plugins.sort_by(|left, right| { + remote_plugin_display_name(left) + .to_ascii_lowercase() + .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) + .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) + .then_with(|| left.id.cmp(&right.id)) + }); + Some(RemoteMarketplace { + name: name.to_string(), + display_name: display_name.to_string(), + plugins, + }) +} + +pub async fn fetch_remote_installed_plugins( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let global = async { + let scope = RemotePluginScope::Global; + let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; + Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) + }; + let workspace = async { + let scope = RemotePluginScope::Workspace; + let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; + Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) + }; + + let (global, workspace) = tokio::try_join!(global, workspace)?; + let mut installed_plugins = [global, workspace] + .into_iter() + .flat_map(|(scope, plugins)| { + plugins + .into_iter() + .map(move |plugin| remote_installed_plugin_to_info(scope, &plugin)) + }) + .collect::>(); + installed_plugins.sort_by(|left, right| { + left.marketplace_name + .cmp(&right.marketplace_name) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(installed_plugins) +} + +pub async fn fetch_remote_plugin_detail( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, +) -> Result { + fetch_remote_plugin_detail_with_download_url_option( + config, + auth, + marketplace_name, + plugin_id, + /*include_download_urls*/ false, + ) + .await +} + +pub async fn fetch_remote_plugin_detail_with_download_urls( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, +) -> Result { + fetch_remote_plugin_detail_with_download_url_option( + config, + auth, + marketplace_name, + plugin_id, + /*include_download_urls*/ true, + ) + .await +} + +pub async fn fetch_remote_plugin_skill_detail( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, + skill_name: &str, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() { + return Err(RemotePluginCatalogError::UnknownMarketplace { + marketplace_name: marketplace_name.to_string(), + }); + } + + let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?; + let client = build_reqwest_client(); + let request = authenticated_request(client.get(&url), auth)?; + let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?; + if response.plugin_id != plugin_id { + return Err(RemotePluginCatalogError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: response.plugin_id, + }); + } + if response.name != skill_name { + return Err(RemotePluginCatalogError::UnexpectedSkillName { + expected: skill_name.to_string(), + actual: response.name, + }); + } + + Ok(RemotePluginSkillDetail { + contents: response.skill_md_contents, + }) +} + +async fn fetch_remote_plugin_detail_with_download_url_option( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + _marketplace_name: &str, + plugin_id: &str, + include_download_urls: bool, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?; + let scope = plugin.scope; + let marketplace_name = scope.marketplace_name().to_string(); + // Remote plugin IDs uniquely identify remote plugins, so the caller-provided + // marketplace name is not validated here. The backend detail response is the + // source of truth for the plugin's actual scope/marketplace. + + build_remote_plugin_detail(config, auth, scope, marketplace_name, plugin_id, plugin).await +} + +async fn build_remote_plugin_detail( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + marketplace_name: String, + plugin_id: &str, + plugin: RemotePluginDirectoryItem, +) -> Result { + let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope) + .await? + .into_iter() + .find(|installed_plugin| installed_plugin.plugin.id == plugin_id); + let disabled_skill_names = installed_plugin + .as_ref() + .map(|plugin| { + plugin + .disabled_skill_names + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + let skills = plugin + .release + .skills + .iter() + .map(|skill| RemotePluginSkill { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.clone()), + interface: remote_skill_interface_to_info(skill.interface.clone()), + enabled: !disabled_skill_names.contains(&skill.name), + }) + .collect(); + + Ok(RemotePluginDetail { + marketplace_name, + marketplace_display_name: scope.marketplace_display_name().to_string(), + summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), + description: non_empty_string(Some(&plugin.release.description)), + release_version: plugin.release.version, + bundle_download_url: plugin.release.bundle_download_url, + skills, + app_ids: plugin.release.app_ids, + }) +} + +pub async fn install_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + _marketplace_name: &str, + plugin_id: &str, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + // Remote plugin IDs uniquely identify remote plugins, so the caller-provided + // marketplace name is not validated before sending the install mutation. + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?; + let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; + if response.id != plugin_id { + return Err(RemotePluginCatalogError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: response.id, + }); + } + if !response.enabled { + return Err(RemotePluginCatalogError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled: true, + actual_enabled: response.enabled, + }); + } + + Ok(()) +} + +pub async fn uninstall_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + codex_home: PathBuf, + plugin_id: &str, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let plugin = fetch_plugin_detail( + config, auth, plugin_id, /*include_download_urls*/ false, + ) + .await?; + let marketplace_name = plugin.scope.marketplace_name().to_string(); + let plugin_name = plugin.name; + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/{plugin_id}/uninstall"); + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?; + let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; + if response.id != plugin_id { + return Err(RemotePluginCatalogError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: response.id, + }); + } + if response.enabled { + return Err(RemotePluginCatalogError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled: false, + actual_enabled: response.enabled, + }); + } + + let legacy_plugin_id = plugin_id.to_string(); + tokio::task::spawn_blocking(move || { + remove_remote_plugin_cache(codex_home, marketplace_name, plugin_name, legacy_plugin_id) + }) + .await + .map_err(|err| { + RemotePluginCatalogError::CacheRemove(format!( + "failed to join remote plugin cache removal task: {err}" + )) + })? + .map_err(RemotePluginCatalogError::CacheRemove)?; + + Ok(()) +} + +fn remove_remote_plugin_cache( + codex_home: PathBuf, + marketplace_name: String, + plugin_name: String, + legacy_plugin_id: String, +) -> Result<(), String> { + let store = PluginStore::try_new(codex_home.clone()) + .map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?; + let plugin_id = + PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err(|err| { + format!( + "invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}" + ) + })?; + let plugin_cache_root = store.plugin_base_root(&plugin_id); + store.uninstall(&plugin_id).map_err(|err| { + format!( + "failed to remove remote plugin cache entry {}: {err}", + plugin_cache_root.display() + ) + })?; + + let legacy_remote_plugin_cache_root = codex_home + .join(PLUGINS_CACHE_DIR) + .join(marketplace_name) + .join(legacy_plugin_id); + if legacy_remote_plugin_cache_root != plugin_cache_root.as_path() + && legacy_remote_plugin_cache_root.exists() + { + let result = if legacy_remote_plugin_cache_root.is_dir() { + fs::remove_dir_all(&legacy_remote_plugin_cache_root) + } else { + fs::remove_file(&legacy_remote_plugin_cache_root) + }; + result.map_err(|err| { + format!( + "failed to remove remote plugin cache entry {}: {err}", + legacy_remote_plugin_cache_root.display() + ) + })?; + } + Ok(()) +} + +fn build_remote_plugin_summary( + plugin: &RemotePluginDirectoryItem, + installed_plugin: Option<&RemotePluginInstalledItem>, +) -> RemotePluginSummary { + RemotePluginSummary { + id: plugin.id.clone(), + name: plugin.name.clone(), + share_context: remote_plugin_share_context(plugin), + installed: installed_plugin.is_some(), + enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), + install_policy: plugin.installation_policy, + auth_policy: plugin.authentication_policy, + availability: plugin.availability, + interface: remote_plugin_interface_to_info(plugin), + keywords: plugin.release.keywords.clone(), + } +} + +fn remote_plugin_share_context( + plugin: &RemotePluginDirectoryItem, +) -> Option { + match plugin.scope { + RemotePluginScope::Global => None, + RemotePluginScope::Workspace => Some(RemotePluginShareContext { + remote_plugin_id: plugin.id.clone(), + share_url: plugin.share_url.clone(), + creator_account_user_id: plugin.creator_account_user_id.clone(), + creator_name: plugin.creator_name.clone(), + share_targets: plugin.share_principals.as_ref().map(|principals| { + principals + .iter() + .filter(|principal| principal.role.as_deref() == Some("reader")) + .map(|principal| RemotePluginSharePrincipal { + principal_type: principal.principal_type, + principal_id: principal.principal_id.clone(), + name: principal.name.clone(), + }) + .collect() + }), + }), + } +} + +fn remote_installed_plugin_to_info( + scope: RemotePluginScope, + installed_plugin: &RemotePluginInstalledItem, +) -> RemoteInstalledPlugin { + let plugin = &installed_plugin.plugin; + // Remote per-skill disabled state (`disabled_skill_names`) is intentionally + // not projected into skills/list yet; local skills.config remains the + // supported source for skill enablement. + RemoteInstalledPlugin { + marketplace_name: scope.marketplace_name().to_string(), + id: plugin.id.clone(), + name: plugin.name.clone(), + enabled: installed_plugin.enabled, + } +} + +fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option { + let interface = &plugin.release.interface; + let display_name = non_empty_string(Some(&plugin.release.display_name)); + let default_prompt = interface + .default_prompt + .as_ref() + .and_then(|prompt| normalize_remote_default_prompt(prompt)); + let result = PluginInterface { + display_name, + short_description: interface.short_description.clone(), + long_description: interface.long_description.clone(), + developer_name: interface.developer_name.clone(), + category: interface.category.clone(), + capabilities: interface.capabilities.clone(), + website_url: interface.website_url.clone(), + privacy_policy_url: interface.privacy_policy_url.clone(), + terms_of_service_url: interface.terms_of_service_url.clone(), + default_prompt, + brand_color: interface.brand_color.clone(), + composer_icon: None, + composer_icon_url: interface.composer_icon_url.clone(), + logo: None, + logo_url: interface.logo_url.clone(), + screenshots: Vec::new(), + screenshot_urls: interface.screenshot_urls.clone(), + }; + let has_fields = result.display_name.is_some() + || result.short_description.is_some() + || result.long_description.is_some() + || result.developer_name.is_some() + || result.category.is_some() + || !result.capabilities.is_empty() + || result.website_url.is_some() + || result.privacy_policy_url.is_some() + || result.terms_of_service_url.is_some() + || result.default_prompt.is_some() + || result.brand_color.is_some() + || result.composer_icon_url.is_some() + || result.logo_url.is_some() + || !result.screenshot_urls.is_empty(); + has_fields.then_some(result) +} + +fn remote_skill_interface_to_info( + interface: Option, +) -> Option { + interface.and_then(|interface| { + let result = SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: None, + icon_large: None, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + }; + let has_fields = result.display_name.is_some() + || result.short_description.is_some() + || result.brand_color.is_some() + || result.default_prompt.is_some(); + has_fields.then_some(result) + }) +} + +fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or(&plugin.name) +} + +fn non_empty_string(value: Option<&str>) -> Option { + value.and_then(|value| { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + }) +} + +fn normalize_remote_default_prompt(prompt: &str) -> Option> { + let prompt = prompt.trim(); + if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN { + return None; + } + Some(vec![prompt.to_string()]) +} + +async fn fetch_directory_plugins_for_scope( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_plugin_list_page(config, auth, scope, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + +async fn fetch_shared_workspace_plugins( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_shared_workspace_plugins_page(config, auth, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + +async fn fetch_installed_plugins_for_scope( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + fetch_installed_plugins_for_scope_with_download_url( + config, auth, scope, /*include_download_urls*/ false, + ) + .await +} + +async fn fetch_installed_plugins_for_scope_with_download_url( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + include_download_urls: bool, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = get_remote_plugin_installed_page( + config, + auth, + scope, + page_token.as_deref(), + include_download_urls, + ) + .await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + +async fn get_remote_plugin_list_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/list"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("scope", scope.api_value())]); + request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + +async fn get_remote_shared_workspace_plugins_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/workspace/shared"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + +async fn get_remote_plugin_installed_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + page_token: Option<&str>, + include_download_urls: bool, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/installed"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("scope", scope.api_value())]); + if include_download_urls { + request = request.query(&[("includeDownloadUrls", true)]); + } + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + +async fn fetch_plugin_detail( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + plugin_id: &str, + include_download_urls: bool, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/{plugin_id}"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + if include_download_urls { + request = request.query(&[("includeDownloadUrls", true)]); + } + send_and_decode(request, &url).await +} + +fn remote_plugin_skill_detail_url( + config: &RemotePluginServiceConfig, + plugin_id: &str, + skill_name: &str, +) -> Result { + let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + { + let mut segments = url + .path_segments_mut() + .map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?; + segments.pop_if_empty(); + segments.push("ps"); + segments.push("plugins"); + segments.push(plugin_id); + segments.push("skills"); + segments.push(skill_name); + } + Ok(url.to_string()) +} + +fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> { + let Some(auth) = auth else { + return Err(RemotePluginCatalogError::AuthRequired); + }; + if !auth.uses_codex_backend() { + return Err(RemotePluginCatalogError::UnsupportedAuthMode); + } + Ok(auth) +} + +fn authenticated_request( + request: RequestBuilder, + auth: &CodexAuth, +) -> Result { + Ok(request + .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())) +} + +async fn send_and_decode Deserialize<'de>>( + request: RequestBuilder, + url: &str, +) -> Result { + let response = request + .send() + .await + .map_err(|source| RemotePluginCatalogError::Request { + url: url.to_string(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginCatalogError::UnexpectedStatus { + url: url.to_string(), + status, + body, + }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginCatalogError::Decode { + url: url.to_string(), + source, + }) +} diff --git a/code-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/code-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs new file mode 100644 index 00000000000..4e5caca2944 --- /dev/null +++ b/code-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -0,0 +1,490 @@ +use super::REMOTE_GLOBAL_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use super::RemotePluginCatalogError; +use super::RemotePluginScope; +use super::RemotePluginServiceConfig; +use super::ensure_chatgpt_auth; +use super::fetch_installed_plugins_for_scope_with_download_url; +use crate::store::PLUGINS_CACHE_DIR; +use crate::store::PluginStore; +use crate::store::PluginStoreError; +use codex_login::CodexAuth; +use codex_plugin::PluginId; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::OnceLock; +use tracing::info; +use tracing::warn; + +static REMOTE_INSTALLED_PLUGIN_BUNDLE_SYNC_IN_FLIGHT: OnceLock< + Mutex>, +> = OnceLock::new(); +static REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT: OnceLock< + Mutex>, +> = OnceLock::new(); + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RemoteInstalledPluginBundleSyncOutcome { + pub installed_plugin_ids: Vec, + pub removed_cache_plugin_ids: Vec, + pub failed_remote_plugin_ids: Vec, +} + +impl RemoteInstalledPluginBundleSyncOutcome { + pub fn changed_local_cache(&self) -> bool { + !self.installed_plugin_ids.is_empty() || !self.removed_cache_plugin_ids.is_empty() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RemoteInstalledPluginBundleSyncError { + #[error("{0}")] + Catalog(#[from] RemotePluginCatalogError), + + #[error("{0}")] + Store(#[from] PluginStoreError), + + #[error("failed to join stale remote plugin cache cleanup task: {0}")] + Join(#[from] tokio::task::JoinError), + + #[error("failed to remove stale remote plugin cache entries: {0}")] + CacheRemove(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RemoteInstalledPluginBundleSyncKey { + plugin_cache_root: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RemotePluginCacheMutationKey { + plugin_cache_root: PathBuf, + marketplace_name: String, + plugin_name: String, +} + +pub struct RemotePluginCacheMutationGuard { + key: RemotePluginCacheMutationKey, +} + +pub fn maybe_start_remote_installed_plugin_bundle_sync( + codex_home: PathBuf, + config: RemotePluginServiceConfig, + auth: Option, + on_local_cache_changed: Option>, +) { + let Some(auth) = auth else { + return; + }; + let key = RemoteInstalledPluginBundleSyncKey { + plugin_cache_root: remote_plugin_cache_root(&codex_home), + }; + if !mark_remote_installed_plugin_bundle_sync_in_flight(key.clone()) { + return; + } + + tokio::spawn(async move { + let result = + sync_remote_installed_plugin_bundles_once(codex_home, &config, Some(&auth)).await; + match result { + Ok(outcome) => { + if outcome.changed_local_cache() + && let Some(on_local_cache_changed) = on_local_cache_changed + { + on_local_cache_changed(); + } + info!( + installed_plugin_ids = ?outcome.installed_plugin_ids, + removed_cache_plugin_ids = ?outcome.removed_cache_plugin_ids, + failed_remote_plugin_ids = ?outcome.failed_remote_plugin_ids, + "completed remote installed plugin bundle sync" + ); + } + Err(err) => { + warn!( + error = %err, + "remote installed plugin bundle sync failed" + ); + } + } + clear_remote_installed_plugin_bundle_sync_in_flight(&key); + }); +} + +pub async fn sync_remote_installed_plugin_bundles_once( + codex_home: PathBuf, + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let global = async { + let scope = RemotePluginScope::Global; + let installed_plugins = fetch_installed_plugins_for_scope_with_download_url( + config, auth, scope, /*include_download_urls*/ true, + ) + .await?; + Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) + }; + let workspace = async { + let scope = RemotePluginScope::Workspace; + let installed_plugins = fetch_installed_plugins_for_scope_with_download_url( + config, auth, scope, /*include_download_urls*/ true, + ) + .await?; + Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) + }; + + let (global, workspace) = tokio::try_join!(global, workspace)?; + let store = PluginStore::try_new(codex_home.clone())?; + let mut installed_plugin_names_by_marketplace = + BTreeMap::>::from_iter([ + (REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()), + ( + REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ]); + let mut installed_plugin_ids = BTreeSet::new(); + let mut failed_remote_plugin_ids = BTreeSet::new(); + + for (scope, installed_plugins) in [global, workspace] { + let marketplace_name = scope.marketplace_name().to_string(); + for installed_plugin in installed_plugins { + let plugin = installed_plugin.plugin; + installed_plugin_names_by_marketplace + .entry(marketplace_name.clone()) + .or_default() + .insert(plugin.name.clone()); + let plugin_id = match PluginId::new(plugin.name.clone(), marketplace_name.clone()) { + Ok(plugin_id) => plugin_id, + Err(err) => { + warn!( + remote_plugin_id = %plugin.id, + plugin = %plugin.name, + marketplace = %marketplace_name, + error = %err, + "skipping remote installed plugin with invalid local cache id" + ); + failed_remote_plugin_ids.insert(plugin.id); + continue; + } + }; + let release_version = plugin + .release + .version + .as_deref() + .map(str::trim) + .filter(|version| !version.is_empty()); + if store.active_plugin_version(&plugin_id).as_deref() == release_version { + continue; + } + + let bundle = match crate::remote_bundle::validate_remote_plugin_bundle( + &plugin.id, + &marketplace_name, + &plugin.name, + release_version, + plugin.release.bundle_download_url.as_deref(), + ) { + Ok(bundle) => bundle, + Err(err) => { + warn!( + remote_plugin_id = %plugin.id, + plugin = %plugin.name, + marketplace = %marketplace_name, + error = %err, + "skipping remote installed plugin bundle download" + ); + failed_remote_plugin_ids.insert(plugin.id); + continue; + } + }; + + match crate::remote_bundle::download_and_install_remote_plugin_bundle( + codex_home.clone(), + bundle, + ) + .await + { + Ok(result) => { + installed_plugin_ids.insert(result.plugin_id.as_key()); + } + Err(err) => { + warn!( + remote_plugin_id = %plugin.id, + plugin = %plugin.name, + marketplace = %marketplace_name, + error = %err, + "failed to download remote installed plugin bundle" + ); + failed_remote_plugin_ids.insert(plugin.id); + } + } + } + } + + let removed_cache_plugin_ids = tokio::task::spawn_blocking(move || { + remove_stale_remote_plugin_caches( + codex_home.as_path(), + &installed_plugin_names_by_marketplace, + ) + }) + .await? + .map_err(RemoteInstalledPluginBundleSyncError::CacheRemove)?; + + Ok(RemoteInstalledPluginBundleSyncOutcome { + installed_plugin_ids: installed_plugin_ids.into_iter().collect(), + removed_cache_plugin_ids, + failed_remote_plugin_ids: failed_remote_plugin_ids.into_iter().collect(), + }) +} + +pub fn mark_remote_plugin_cache_mutation_in_flight( + codex_home: &Path, + marketplace_name: &str, + plugin_name: &str, +) -> RemotePluginCacheMutationGuard { + let key = RemotePluginCacheMutationKey { + plugin_cache_root: remote_plugin_cache_root(codex_home), + marketplace_name: marketplace_name.to_string(), + plugin_name: plugin_name.to_string(), + }; + let mutations = + REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT.get_or_init(|| Mutex::new(HashMap::new())); + let mut mutations = match mutations.lock() { + Ok(mutations) => mutations, + Err(err) => err.into_inner(), + }; + *mutations.entry(key.clone()).or_default() += 1; + RemotePluginCacheMutationGuard { key } +} + +impl Drop for RemotePluginCacheMutationGuard { + fn drop(&mut self) { + let Some(mutations) = REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT.get() else { + return; + }; + let mut mutations = match mutations.lock() { + Ok(mutations) => mutations, + Err(err) => err.into_inner(), + }; + if let Some(count) = mutations.get_mut(&self.key) { + *count -= 1; + if *count == 0 { + mutations.remove(&self.key); + } + } + } +} + +fn remove_stale_remote_plugin_caches( + codex_home: &Path, + installed_plugin_names_by_marketplace: &BTreeMap>, +) -> Result, String> { + let mut removed_cache_plugin_ids = Vec::new(); + for marketplace_name in [ + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_WORKSPACE_MARKETPLACE_NAME, + ] { + let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name); + if !marketplace_root.exists() { + continue; + } + let installed_plugin_names = installed_plugin_names_by_marketplace + .get(marketplace_name) + .cloned() + .unwrap_or_default(); + for entry in fs::read_dir(&marketplace_root).map_err(|err| { + format!( + "failed to read remote plugin cache directory {}: {err}", + marketplace_root.display() + ) + })? { + let entry = entry.map_err(|err| { + format!( + "failed to enumerate remote plugin cache directory {}: {err}", + marketplace_root.display() + ) + })?; + let plugin_name = entry.file_name().into_string().map_err(|file_name| { + format!( + "remote plugin cache entry under {} is not valid UTF-8: {:?}", + marketplace_root.display(), + file_name + ) + })?; + if installed_plugin_names.contains(&plugin_name) { + continue; + } + if is_remote_plugin_cache_mutation_in_flight(codex_home, marketplace_name, &plugin_name) + { + continue; + } + + let cache_path = entry.path(); + if cache_path.is_dir() { + fs::remove_dir_all(&cache_path).map_err(|err| { + format!( + "failed to remove stale remote plugin cache entry {}: {err}", + cache_path.display() + ) + })?; + } else { + fs::remove_file(&cache_path).map_err(|err| { + format!( + "failed to remove stale remote plugin cache entry {}: {err}", + cache_path.display() + ) + })?; + } + let plugin_key = PluginId::new(plugin_name.clone(), marketplace_name.to_string()) + .map(|plugin_id| plugin_id.as_key()) + .unwrap_or_else(|_| format!("{plugin_name}@{marketplace_name}")); + removed_cache_plugin_ids.push(plugin_key); + } + } + + removed_cache_plugin_ids.sort(); + Ok(removed_cache_plugin_ids) +} + +fn remote_plugin_cache_root(codex_home: &Path) -> PathBuf { + codex_home.join(PLUGINS_CACHE_DIR) +} + +fn is_remote_plugin_cache_mutation_in_flight( + codex_home: &Path, + marketplace_name: &str, + plugin_name: &str, +) -> bool { + let Some(mutations) = REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT.get() else { + return false; + }; + let mutations = match mutations.lock() { + Ok(mutations) => mutations, + Err(err) => err.into_inner(), + }; + mutations.contains_key(&RemotePluginCacheMutationKey { + plugin_cache_root: remote_plugin_cache_root(codex_home), + marketplace_name: marketplace_name.to_string(), + plugin_name: plugin_name.to_string(), + }) +} + +fn mark_remote_installed_plugin_bundle_sync_in_flight( + key: RemoteInstalledPluginBundleSyncKey, +) -> bool { + let syncs = + REMOTE_INSTALLED_PLUGIN_BUNDLE_SYNC_IN_FLIGHT.get_or_init(|| Mutex::new(HashSet::new())); + let mut syncs = match syncs.lock() { + Ok(syncs) => syncs, + Err(err) => err.into_inner(), + }; + syncs.insert(key) +} + +fn clear_remote_installed_plugin_bundle_sync_in_flight(key: &RemoteInstalledPluginBundleSyncKey) { + let Some(syncs) = REMOTE_INSTALLED_PLUGIN_BUNDLE_SYNC_IN_FLIGHT.get() else { + return; + }; + let mut syncs = match syncs.lock() { + Ok(syncs) => syncs, + Err(err) => err.into_inner(), + }; + syncs.remove(key); +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn remote_installed_plugin_sync_in_flight_dedupes_by_cache_root() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let key = RemoteInstalledPluginBundleSyncKey { + plugin_cache_root: remote_plugin_cache_root(codex_home.path()), + }; + + assert!(mark_remote_installed_plugin_bundle_sync_in_flight( + key.clone() + )); + assert!(!mark_remote_installed_plugin_bundle_sync_in_flight( + key.clone() + )); + + clear_remote_installed_plugin_bundle_sync_in_flight(&key); + assert!(mark_remote_installed_plugin_bundle_sync_in_flight( + key.clone() + )); + clear_remote_installed_plugin_bundle_sync_in_flight(&key); + } + + #[test] + fn stale_remote_plugin_cleanup_skips_cache_mutations_in_progress() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_GLOBAL_MARKETPLACE_NAME) + .join("linear") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent")) + .expect("create cached plugin manifest parent"); + std::fs::write(&cached_manifest, r#"{"name":"linear"}"#) + .expect("write cached plugin manifest"); + let installed_plugin_names_by_marketplace = + BTreeMap::>::from_iter([ + (REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()), + ( + REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ]); + + let guard = mark_remote_plugin_cache_mutation_in_flight( + codex_home.path(), + REMOTE_GLOBAL_MARKETPLACE_NAME, + "linear", + ); + let second_guard = mark_remote_plugin_cache_mutation_in_flight( + codex_home.path(), + REMOTE_GLOBAL_MARKETPLACE_NAME, + "linear", + ); + let removed = remove_stale_remote_plugin_caches( + codex_home.path(), + &installed_plugin_names_by_marketplace, + ) + .expect("cleanup while install is guarded"); + assert_eq!(removed, Vec::::new()); + assert!(cached_manifest.is_file()); + + drop(guard); + let removed = remove_stale_remote_plugin_caches( + codex_home.path(), + &installed_plugin_names_by_marketplace, + ) + .expect("cleanup while second install guard is still active"); + assert_eq!(removed, Vec::::new()); + assert!(cached_manifest.is_file()); + + drop(second_guard); + let removed = remove_stale_remote_plugin_caches( + codex_home.path(), + &installed_plugin_names_by_marketplace, + ) + .expect("cleanup after install guard is dropped"); + assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]); + assert!(!cached_manifest.exists()); + } +} diff --git a/code-rs/core-plugins/src/remote/share.rs b/code-rs/core-plugins/src/remote/share.rs new file mode 100644 index 00000000000..d69d22ea523 --- /dev/null +++ b/code-rs/core-plugins/src/remote/share.rs @@ -0,0 +1,599 @@ +use super::*; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; +use codex_utils_absolute_path::AbsolutePathBuf; +use flate2::Compression; +use flate2::write::GzEncoder; +use reqwest::RequestBuilder; +use reqwest::StatusCode; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::io; +use std::io::Write; +use std::path::Path; +use tracing::warn; + +mod local_paths; + +const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareSaveResult { + pub remote_plugin_id: String, + pub share_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RemotePluginShareAccessPolicy { + pub discoverability: Option, + pub share_targets: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemotePluginShareDiscoverability { + Listed, + Unlisted, + Private, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemotePluginShareUpdateDiscoverability { + Unlisted, + Private, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginSharePrincipalType { + User, + Group, + Workspace, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemotePluginShareTarget { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginSharePrincipal { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareUpdateTargetsResult { + pub principals: Vec, + pub discoverability: RemotePluginShareDiscoverability, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemoteWorkspacePluginUploadUrlRequest<'a> { + filename: &'a str, + mime_type: &'a str, + size_bytes: usize, + #[serde(skip_serializing_if = "Option::is_none")] + plugin_id: Option<&'a str>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemoteWorkspacePluginUploadUrlResponse { + file_id: String, + upload_url: String, + etag: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemoteWorkspacePluginCreateRequest { + file_id: String, + etag: String, + #[serde(skip_serializing_if = "Option::is_none")] + discoverability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + share_targets: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemoteWorkspacePluginCreateResponse { + plugin_id: String, + share_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemotePluginShareUpdateTargetsRequest { + discoverability: RemotePluginShareUpdateDiscoverability, + targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginShareUpdateTargetsResponse { + principals: Vec, + discoverability: Option, +} + +pub async fn save_remote_plugin_share( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + codex_home: &Path, + plugin_path: &AbsolutePathBuf, + remote_plugin_id: Option<&str>, + access_policy: RemotePluginShareAccessPolicy, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let plugin_path_for_archive = plugin_path.as_path().to_path_buf(); + let (filename, archive_bytes) = tokio::task::spawn_blocking(move || { + let filename = archive_filename(&plugin_path_for_archive)?; + let archive_bytes = archive_plugin_for_upload(&plugin_path_for_archive)?; + Ok::<_, RemotePluginCatalogError>((filename, archive_bytes)) + }) + .await + .map_err(RemotePluginCatalogError::ArchiveJoin)??; + let upload = create_workspace_plugin_upload( + config, + auth, + &filename, + archive_bytes.len(), + remote_plugin_id, + ) + .await?; + let etag = upload + .etag + .ok_or(RemotePluginCatalogError::MissingUploadEtag)?; + put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?; + let share_targets = access_policy.share_targets; + let share_targets = + ensure_unlisted_workspace_target(auth, access_policy.discoverability, share_targets)?; + let response = finalize_workspace_plugin_upload( + config, + auth, + remote_plugin_id, + RemoteWorkspacePluginCreateRequest { + file_id: upload.file_id, + etag, + discoverability: access_policy.discoverability, + share_targets, + }, + ) + .await?; + if response.plugin_id.is_empty() { + return Err(RemotePluginCatalogError::UnexpectedResponse( + "workspace plugin create response did not include a plugin id".to_string(), + )); + } + + if let Err(err) = local_paths::record_plugin_share_local_path( + codex_home, + &response.plugin_id, + plugin_path.clone(), + ) { + warn!( + remote_plugin_id = %response.plugin_id, + "failed to record plugin share local path mapping: {err}" + ); + } + + Ok(RemotePluginShareSaveResult { + remote_plugin_id: response.plugin_id, + share_url: response.share_url, + }) +} + +pub async fn list_remote_plugin_shares( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + codex_home: &Path, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let created_plugins = fetch_created_workspace_plugins(config, auth).await?; + if created_plugins.is_empty() { + return Ok(Vec::new()); + } + + let installed_by_id = + fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace) + .await? + .into_iter() + .map(|plugin| (plugin.plugin.id.clone(), plugin)) + .collect::>(); + let local_plugin_paths = + local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| { + warn!("failed to load plugin share local path mapping: {err}"); + BTreeMap::new() + }); + + Ok(created_plugins + .into_iter() + .map(|plugin| { + let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)); + let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned(); + RemotePluginShareSummary { + summary, + share_url: plugin.share_url, + local_plugin_path, + } + }) + .collect()) +} + +pub fn load_plugin_share_remote_ids_by_local_path( + codex_home: &Path, +) -> io::Result> { + let local_paths = local_paths::load_plugin_share_local_paths(codex_home)?; + Ok(local_paths + .into_iter() + .map(|(remote_plugin_id, local_plugin_path)| (local_plugin_path, remote_plugin_id)) + .collect()) +} + +pub async fn delete_remote_plugin_share( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + codex_home: &Path, + remote_plugin_id: &str, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}"); + let client = build_reqwest_client(); + let request = authenticated_request(client.delete(&url), auth)?; + send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?; + if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) { + warn!( + remote_plugin_id = %remote_plugin_id, + "failed to remove plugin share local path mapping: {err}" + ); + } + Ok(()) +} + +pub async fn update_remote_plugin_share_targets( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + remote_plugin_id: &str, + targets: Vec, + discoverability: RemotePluginShareUpdateDiscoverability, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let target_discoverability = match discoverability { + RemotePluginShareUpdateDiscoverability::Unlisted => { + RemotePluginShareDiscoverability::Unlisted + } + RemotePluginShareUpdateDiscoverability::Private => { + RemotePluginShareDiscoverability::Private + } + }; + let targets = + ensure_unlisted_workspace_target(auth, Some(target_discoverability), Some(targets))? + .unwrap_or_default(); + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/shares"); + let client = build_reqwest_client(); + let request = authenticated_request(client.put(&url), auth)?.json( + &RemotePluginShareUpdateTargetsRequest { + discoverability, + targets, + }, + ); + let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; + Ok(RemotePluginShareUpdateTargetsResult { + principals: response.principals, + // TODO: Remove this fallback once deployed plugin-service responses always include + // discoverability per the API schema. + discoverability: response.discoverability.unwrap_or(target_discoverability), + }) +} + +fn ensure_unlisted_workspace_target( + auth: &CodexAuth, + discoverability: Option, + targets: Option>, +) -> Result>, RemotePluginCatalogError> { + if discoverability != Some(RemotePluginShareDiscoverability::Unlisted) { + return Ok(targets); + } + let account_id = auth.get_account_id().ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse( + "workspace plugin share requires an account id".to_string(), + ) + })?; + let mut targets = targets.unwrap_or_default(); + if !targets.iter().any(|target| { + target.principal_type == RemotePluginSharePrincipalType::Workspace + && target.principal_id == account_id + }) { + targets.push(RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Workspace, + principal_id: account_id, + }); + } + Ok(Some(targets)) +} + +async fn fetch_created_workspace_plugins( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_created_workspace_plugins_page(config, auth, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + +async fn get_created_workspace_plugins_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/workspace/created"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + +async fn create_workspace_plugin_upload( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + filename: &str, + size_bytes: usize, + remote_plugin_id: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/public/plugins/workspace/upload-url"); + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?.json( + &RemoteWorkspacePluginUploadUrlRequest { + filename, + mime_type: "application/gzip", + size_bytes, + plugin_id: remote_plugin_id, + }, + ); + send_and_decode(request, &url).await +} + +async fn put_workspace_plugin_upload( + upload_url: &str, + archive_bytes: Vec, +) -> Result<(), RemotePluginCatalogError> { + let client = build_reqwest_client(); + let request = client + .put(upload_url) + .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) + .header("x-ms-blob-type", "BlockBlob") + .header("Content-Type", "application/gzip") + .body(archive_bytes); + let response = request + .send() + .await + .map_err(|source| RemotePluginCatalogError::Request { + url: "workspace plugin upload URL".to_string(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if ![StatusCode::OK, StatusCode::CREATED].contains(&status) { + return Err(RemotePluginCatalogError::UnexpectedStatus { + url: "workspace plugin upload URL".to_string(), + status, + body, + }); + } + Ok(()) +} + +async fn finalize_workspace_plugin_upload( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + remote_plugin_id: Option<&str>, + body: RemoteWorkspacePluginCreateRequest, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = if let Some(remote_plugin_id) = remote_plugin_id { + format!("{base_url}/public/plugins/workspace/{remote_plugin_id}") + } else { + format!("{base_url}/public/plugins/workspace") + }; + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?.json(&body); + send_and_decode(request, &url).await +} + +fn archive_filename(plugin_path: &Path) -> Result { + let plugin_name = plugin_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| RemotePluginCatalogError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "plugin path must end in a valid UTF-8 directory name".to_string(), + })?; + Ok(format!("{plugin_name}.tar.gz")) +} + +fn archive_plugin_for_upload(plugin_path: &Path) -> Result, RemotePluginCatalogError> { + archive_plugin_for_upload_with_limit(plugin_path, REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES) +} + +fn archive_plugin_for_upload_with_limit( + plugin_path: &Path, + max_bytes: usize, +) -> Result, RemotePluginCatalogError> { + if !plugin_path.is_dir() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "expected a plugin directory".to_string(), + }); + } + if !plugin_path.join(".codex-plugin/plugin.json").is_file() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "missing .codex-plugin/plugin.json".to_string(), + }); + } + + let encoder = GzEncoder::new(SizeLimitedBuffer::new(max_bytes), Compression::default()); + let mut archive = tar::Builder::new(encoder); + append_plugin_tree(&mut archive, plugin_path, plugin_path) + .map_err(|source| archive_error(plugin_path, source))?; + let encoder = archive + .into_inner() + .map_err(|source| archive_error(plugin_path, source))?; + encoder + .finish() + .map(SizeLimitedBuffer::into_inner) + .map_err(|source| archive_error(plugin_path, source)) +} + +fn append_plugin_tree( + archive: &mut tar::Builder, + plugin_root: &Path, + current: &Path, +) -> io::Result<()> { + let mut entries = fs::read_dir(current)?.collect::, io::Error>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type()?; + let relative_path = path.strip_prefix(plugin_root).map_err(|err| { + io::Error::other(format!( + "failed to compute plugin archive path for `{}`: {err}", + path.display() + )) + })?; + if file_type.is_dir() { + archive.append_dir(relative_path, &path)?; + append_plugin_tree(archive, plugin_root, &path)?; + } else if file_type.is_file() { + archive.append_path_with_name(&path, relative_path)?; + } else { + return Err(io::Error::other(format!( + "unsupported plugin archive entry type: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn archive_error(plugin_path: &Path, source: io::Error) -> RemotePluginCatalogError { + if let Some(limit) = source + .get_ref() + .and_then(|err| err.downcast_ref::()) + { + return RemotePluginCatalogError::ArchiveTooLarge { + bytes: limit.bytes, + max_bytes: limit.max_bytes, + }; + } + + RemotePluginCatalogError::Archive { + path: plugin_path.to_path_buf(), + source, + } +} + +struct SizeLimitedBuffer { + bytes: Vec, + max_bytes: usize, +} + +impl SizeLimitedBuffer { + fn new(max_bytes: usize) -> Self { + Self { + bytes: Vec::new(), + max_bytes, + } + } + + fn into_inner(self) -> Vec { + self.bytes + } +} + +impl Write for SizeLimitedBuffer { + fn write(&mut self, buf: &[u8]) -> io::Result { + let next_len = self.bytes.len().checked_add(buf.len()).ok_or_else(|| { + io::Error::other(ArchiveSizeLimitExceeded { + bytes: usize::MAX, + max_bytes: self.max_bytes, + }) + })?; + if next_len > self.max_bytes { + return Err(io::Error::other(ArchiveSizeLimitExceeded { + bytes: next_len, + max_bytes: self.max_bytes, + })); + } + + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct ArchiveSizeLimitExceeded { + bytes: usize, + max_bytes: usize, +} + +impl fmt::Display for ArchiveSizeLimitExceeded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "archive would be {} bytes, exceeding maximum size of {} bytes", + self.bytes, self.max_bytes + ) + } +} + +impl std::error::Error for ArchiveSizeLimitExceeded {} + +async fn send_and_expect_status( + request: RequestBuilder, + url_for_error: &str, + expected_statuses: &[StatusCode], +) -> Result<(), RemotePluginCatalogError> { + let response = request + .send() + .await + .map_err(|source| RemotePluginCatalogError::Request { + url: url_for_error.to_string(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !expected_statuses.contains(&status) { + return Err(RemotePluginCatalogError::UnexpectedStatus { + url: url_for_error.to_string(), + status, + body, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/code-rs/core-plugins/src/remote/share/local_paths.rs b/code-rs/core-plugins/src/remote/share/local_paths.rs new file mode 100644 index 00000000000..50e8fba89f2 --- /dev/null +++ b/code-rs/core-plugins/src/remote/share/local_paths.rs @@ -0,0 +1,124 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::io; +use std::io::Write; +use std::path::Path; +use std::sync::Mutex; + +const PLUGIN_SHARE_LOCAL_PATHS_FILE: &str = ".tmp/plugin-share-local-paths-v1.json"; +static PLUGIN_SHARE_LOCAL_PATHS_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PluginShareLocalPaths { + #[serde(default)] + local_plugin_paths_by_remote_plugin_id: BTreeMap, +} + +pub(crate) fn load_plugin_share_local_paths( + codex_home: &Path, +) -> io::Result> { + let _guard = lock_plugin_share_local_paths()?; + read_plugin_share_local_paths(codex_home) +} + +pub(crate) fn record_plugin_share_local_path( + codex_home: &Path, + remote_plugin_id: &str, + plugin_path: AbsolutePathBuf, +) -> io::Result<()> { + let _guard = lock_plugin_share_local_paths()?; + let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?; + mapping.insert(remote_plugin_id.to_string(), plugin_path); + write_plugin_share_local_paths(codex_home, mapping) +} + +pub(crate) fn remove_plugin_share_local_path( + codex_home: &Path, + remote_plugin_id: &str, +) -> io::Result<()> { + let _guard = lock_plugin_share_local_paths()?; + let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?; + mapping.remove(remote_plugin_id); + write_plugin_share_local_paths(codex_home, mapping) +} + +fn lock_plugin_share_local_paths() -> io::Result> { + PLUGIN_SHARE_LOCAL_PATHS_LOCK + .lock() + .map_err(|err| io::Error::other(format!("plugin share local path lock poisoned: {err}"))) +} + +fn read_plugin_share_local_paths( + codex_home: &Path, +) -> io::Result> { + let path = plugin_share_local_paths_path(codex_home); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(err) => return Err(err), + }; + + let mapping = serde_json::from_str::(&contents).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "failed to parse plugin share local path mapping {}: {err}", + path.display() + ), + ) + })?; + Ok(mapping.local_plugin_paths_by_remote_plugin_id) +} + +fn read_plugin_share_local_paths_for_update( + codex_home: &Path, +) -> io::Result> { + match read_plugin_share_local_paths(codex_home) { + Ok(mapping) => Ok(mapping), + // This is a best-effort cache under .tmp, so malformed state should not + // permanently block future share saves or deletes. + Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()), + Err(err) => Err(err), + } +} + +fn write_plugin_share_local_paths( + codex_home: &Path, + mapping: BTreeMap, +) -> io::Result<()> { + let path = plugin_share_local_paths_path(codex_home); + if mapping.is_empty() { + match std::fs::remove_file(&path) { + Ok(()) => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + } + } + + let contents = serde_json::to_string_pretty(&PluginShareLocalPaths { + local_plugin_paths_by_remote_plugin_id: mapping, + }) + .map_err(io::Error::other)?; + write_atomically(&path, &format!("{contents}\n")) +} + +fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> { + let parent = write_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path {} has no parent directory", write_path.display()), + ) + })?; + std::fs::create_dir_all(parent)?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(contents.as_bytes())?; + tmp.persist(write_path).map_err(|err| err.error)?; + Ok(()) +} + +fn plugin_share_local_paths_path(codex_home: &Path) -> std::path::PathBuf { + codex_home.join(PLUGIN_SHARE_LOCAL_PATHS_FILE) +} diff --git a/code-rs/core-plugins/src/remote/share/tests.rs b/code-rs/core-plugins/src/remote/share/tests.rs new file mode 100644 index 00000000000..35909a8b197 --- /dev/null +++ b/code-rs/core-plugins/src/remote/share/tests.rs @@ -0,0 +1,715 @@ +use super::*; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_login::CodexAuth; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; +use wiremock::matchers::query_param_is_missing; + +fn test_config(server: &MockServer) -> RemotePluginServiceConfig { + RemotePluginServiceConfig { + chatgpt_base_url: format!("{}/backend-api", server.uri()), + } +} + +fn test_auth() -> CodexAuth { + CodexAuth::create_dummy_chatgpt_auth_for_testing() +} + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf { + let plugin_path = root.join(plugin_name); + write_file( + &plugin_path.join(".codex-plugin/plugin.json"), + &format!(r#"{{"name":"{plugin_name}"}}"#), + ); + write_file( + &plugin_path.join("skills/example/SKILL.md"), + "# Example\n\nA test skill.\n", + ); + plugin_path +} + +fn write_plugin_share_local_path_mapping( + codex_home: &Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) { + write_file( + &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + &format!( + "{}\n", + serde_json::to_string_pretty(&json!({ + "localPluginPathsByRemotePluginId": { + remote_plugin_id: plugin_path, + }, + })) + .unwrap() + ), + ); +} + +fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap> { + let decoder = flate2::read::GzDecoder::new(archive_bytes); + let mut archive = tar::Archive::new(decoder); + archive + .entries() + .unwrap() + .filter_map(|entry| { + let mut entry = entry.unwrap(); + if !entry.header().entry_type().is_file() { + return None; + } + let path = entry.path().unwrap().to_string_lossy().into_owned(); + let mut contents = Vec::new(); + entry.read_to_end(&mut contents).unwrap(); + Some((path, contents)) + }) + .collect() +} + +fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { + json!({ + "id": plugin_id, + "name": "demo-plugin", + "scope": "WORKSPACE", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Demo Plugin", + "description": "Demo plugin description", + "interface": { + "short_description": "A demo plugin", + "capabilities": ["Read", "Write"] + }, + "skills": [] + } + }) +} + +fn remote_plugin_json_with_share_url_and_principals( + plugin_id: &str, + share_url: Option<&str>, + share_principals: serde_json::Value, +) -> serde_json::Value { + let mut plugin = remote_plugin_json(plugin_id); + let serde_json::Value::Object(fields) = &mut plugin else { + unreachable!("plugin json should be an object"); + }; + fields.insert("share_url".to_string(), json!(share_url)); + fields.insert("share_principals".to_string(), share_principals); + plugin +} + +fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value { + let mut plugin = remote_plugin_json(plugin_id); + let serde_json::Value::Object(fields) = &mut plugin else { + unreachable!("plugin json should be an object"); + }; + fields.insert("enabled".to_string(), json!(true)); + fields.insert("disabled_skill_names".to_string(), json!([])); + plugin +} + +fn empty_pagination_json() -> serde_json::Value { + json!({ + "next_page_token": null + }) +} + +fn expected_plugin_interface() -> PluginInterface { + PluginInterface { + display_name: Some("Demo Plugin".to_string()), + short_description: Some("A demo plugin".to_string()), + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Read".to_string(), "Write".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + } +} + +#[tokio::test] +async fn save_remote_plugin_share_creates_workspace_plugin() { + let codex_home = TempDir::new().unwrap(); + let temp_dir = TempDir::new().unwrap(); + let plugin_path = + AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap(); + let archive_size = archive_plugin_for_upload(plugin_path.as_path()) + .unwrap() + .len(); + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "filename": "demo-plugin.tar.gz", + "mime_type": "application/gzip", + "size_bytes": archive_size, + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .and(header("x-ms-blob-type", "BlockBlob")) + .and(header("content-type", "application/gzip")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + "discoverability": "UNLISTED", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "account_id", + }, + ], + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let result = save_remote_plugin_share( + &config, + Some(&auth), + codex_home.path(), + &plugin_path, + /*remote_plugin_id*/ None, + RemotePluginShareAccessPolicy { + discoverability: Some(RemotePluginShareDiscoverability::Unlisted), + share_targets: Some(vec![RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }]), + }, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareSaveResult { + remote_plugin_id: "plugins_123".to_string(), + share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), + } + ); + assert_eq!( + local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(), + BTreeMap::from([("plugins_123".to_string(), plugin_path)]) + ); + + let requests = server.received_requests().await.unwrap_or_default(); + let upload_request = requests + .iter() + .find(|request| request.method == "PUT" && request.url.path() == "/upload/file_123") + .unwrap(); + let archive_files = archive_file_entries(&upload_request.body); + assert_eq!( + archive_files + .get(".codex-plugin/plugin.json") + .map(Vec::as_slice), + Some(br#"{"name":"demo-plugin"}"#.as_slice()) + ); + assert_eq!( + archive_files + .get("skills/example/SKILL.md") + .map(Vec::as_slice), + Some(b"# Example\n\nA test skill.\n".as_slice()) + ); +} + +#[test] +fn archive_plugin_for_upload_rejects_archives_over_limit() { + let temp_dir = TempDir::new().unwrap(); + let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); + write_file( + &plugin_path.join("large.txt"), + &"0123456789abcdef".repeat(1024), + ); + + let err = archive_plugin_for_upload_with_limit(&plugin_path, /*max_bytes*/ 16) + .expect_err("oversized plugin archive should fail"); + + assert!(matches!( + err, + RemotePluginCatalogError::ArchiveTooLarge { .. } + )); +} + +#[test] +fn archive_plugin_for_upload_places_manifest_at_archive_root() { + let temp_dir = TempDir::new().unwrap(); + let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); + + let archive_bytes = archive_plugin_for_upload(&plugin_path).unwrap(); + let archive_files = archive_file_entries(&archive_bytes); + + assert_eq!( + archive_files.keys().cloned().collect::>(), + vec![ + ".codex-plugin/plugin.json".to_string(), + "skills/example/SKILL.md".to_string() + ] + ); + assert_eq!( + archive_files + .get(".codex-plugin/plugin.json") + .map(Vec::as_slice), + Some(br#"{"name":"demo-plugin"}"#.as_slice()) + ); + assert_eq!( + archive_files + .get("skills/example/SKILL.md") + .map(Vec::as_slice), + Some(b"# Example\n\nA test skill.\n".as_slice()) + ); +} + +#[tokio::test] +async fn save_remote_plugin_share_updates_existing_workspace_plugin() { + let codex_home = TempDir::new().unwrap(); + let temp_dir = TempDir::new().unwrap(); + let plugin_path = + AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap(); + let archive_size = archive_plugin_for_upload(plugin_path.as_path()) + .unwrap() + .len(); + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(body_json(json!({ + "filename": "demo-plugin.tar.gz", + "mime_type": "application/gzip", + "size_bytes": archive_size, + "plugin_id": "plugins_123", + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_456", + "upload_url": format!("{}/upload/file_456", server.uri()), + "etag": "\"upload_etag_456\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_456")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_456\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/plugins_123")) + .and(body_json(json!({ + "file_id": "file_456", + "etag": "\"upload_etag_456\"", + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugin_id": "plugins_123", + }))) + .expect(1) + .mount(&server) + .await; + + let result = save_remote_plugin_share( + &config, + Some(&auth), + codex_home.path(), + &plugin_path, + Some("plugins_123"), + RemotePluginShareAccessPolicy::default(), + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareSaveResult { + remote_plugin_id: "plugins_123".to_string(), + share_url: None, + } + ); +} + +#[tokio::test] +async fn update_remote_plugin_share_targets_updates_targets() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("PUT")) + .and(path("/backend-api/ps/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "discoverability": "UNLISTED", + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "group", + "principal_id": "group-1", + }, + { + "principal_type": "workspace", + "principal_id": "account_id", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + { + "principal_type": "group", + "principal_id": "group-1", + "name": "Engineering", + }, + ], + "discoverability": "UNLISTED", + }))) + .expect(1) + .mount(&server) + .await; + + let result = update_remote_plugin_share_targets( + &config, + Some(&auth), + "plugins_123", + vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }, + ], + RemotePluginShareUpdateDiscoverability::Unlisted, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareUpdateTargetsResult { + principals: vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + name: "Engineering".to_string(), + }, + ], + discoverability: RemotePluginShareDiscoverability::Unlisted, + } + ); +} + +#[tokio::test] +async fn update_remote_plugin_share_targets_falls_back_to_requested_discoverability() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("PUT")) + .and(path("/backend-api/ps/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "discoverability": "PRIVATE", + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let result = update_remote_plugin_share_targets( + &config, + Some(&auth), + "plugins_123", + vec![RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }], + RemotePluginShareUpdateDiscoverability::Private, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareUpdateTargetsResult { + principals: vec![RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + discoverability: RemotePluginShareDiscoverability::Private, + } + ); +} + +#[tokio::test] +async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { + let codex_home = TempDir::new().unwrap(); + let local_plugin_path = + AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap(); + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path); + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(query_param( + "limit", + REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(), + )) + .and(query_param_is_missing("pageToken")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json_with_share_url_and_principals( + "plugins_123", + Some("https://chatgpt.example/plugins/share/share-key-1"), + json!([ + { + "principal_type": "user", + "principal_id": "user-owner", + "role": "owner", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-reader", + "role": "reader", + "name": "Reader", + }, + ]), + )], + "pagination": { + "next_page_token": "page-2" + }, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(query_param( + "limit", + REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(), + )) + .and(query_param("pageToken", "page-2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json_with_share_url_and_principals( + "plugins_456", + /*share_url*/ None, + json!([ + { + "principal_type": "user", + "principal_id": "user-owner", + "role": "owner", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-editor", + "role": "editor", + "name": "Editor", + }, + { + "principal_type": "user", + "principal_id": "user-missing-role", + "name": "Missing Role", + }, + ]), + )], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_456")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let result = list_remote_plugin_shares(&config, Some(&auth), codex_home.path()) + .await + .unwrap(); + + assert_eq!( + result, + vec![ + RemotePluginShareSummary { + summary: RemotePluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + share_context: Some(RemotePluginShareContext { + remote_plugin_id: "plugins_123".to_string(), + share_url: Some( + "https://chatgpt.example/plugins/share/share-key-1".to_string(), + ), + creator_account_user_id: None, + creator_name: None, + share_targets: Some(vec![RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-reader".to_string(), + name: "Reader".to_string(), + }]), + }), + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + keywords: Vec::new(), + }, + share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), + local_plugin_path: Some(local_plugin_path), + }, + RemotePluginShareSummary { + summary: RemotePluginSummary { + id: "plugins_456".to_string(), + name: "demo-plugin".to_string(), + share_context: Some(RemotePluginShareContext { + remote_plugin_id: "plugins_456".to_string(), + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_targets: Some(Vec::new()), + }), + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + keywords: Vec::new(), + }, + share_url: None, + local_plugin_path: None, + } + ] + ); +} + +#[tokio::test] +async fn delete_remote_plugin_share_deletes_workspace_plugin() { + let codex_home = TempDir::new().unwrap(); + let local_plugin_path = + AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap(); + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path); + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("DELETE")) + .and(path("/backend-api/public/plugins/workspace/plugins_123")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + delete_remote_plugin_share(&config, Some(&auth), codex_home.path(), "plugins_123") + .await + .unwrap(); + assert_eq!( + local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(), + BTreeMap::new() + ); +} diff --git a/code-rs/core-plugins/src/remote_bundle.rs b/code-rs/core-plugins/src/remote_bundle.rs new file mode 100644 index 00000000000..92d56138469 --- /dev/null +++ b/code-rs/core-plugins/src/remote_bundle.rs @@ -0,0 +1,848 @@ +use crate::store::PluginInstallResult; +use crate::store::PluginStore; +use crate::store::PluginStoreError; +use crate::store::validate_plugin_version_segment; +use codex_login::default_client::build_reqwest_client; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::find_plugin_manifest_path; +use flate2::read::GzDecoder; +use reqwest::Response; +use reqwest::StatusCode; +use std::fs; +use std::io; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use tar::Archive; +use url::Host; +use url::Url; + +const REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +const REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES: u64 = 8 * 1024; +const REMOTE_PLUGIN_BUNDLE_MAX_EXTRACTED_BYTES: u64 = 250 * 1024 * 1024; +const REMOTE_PLUGIN_INSTALL_STAGING_DIR: &str = "plugins/.remote-plugin-install-staging"; +#[cfg(debug_assertions)] +const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; + +#[derive(Debug, Clone)] +pub struct ValidatedRemotePluginBundle { + pub plugin_id: PluginId, + pub plugin_version: String, + bundle_download_url: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginBundleInstallError { + #[error("backend did not return a release version for remote plugin `{remote_plugin_id}`")] + MissingReleaseVersion { remote_plugin_id: String }, + + #[error( + "backend returned an invalid release version for remote plugin `{remote_plugin_id}`: {message}" + )] + InvalidReleaseVersion { + remote_plugin_id: String, + message: String, + }, + + #[error("backend did not return a download URL for remote plugin `{remote_plugin_id}`")] + MissingBundleDownloadUrl { remote_plugin_id: String }, + + #[error( + "backend returned an invalid download URL for remote plugin `{remote_plugin_id}`: {url}" + )] + InvalidBundleDownloadUrl { + remote_plugin_id: String, + url: String, + #[source] + source: url::ParseError, + }, + + #[error( + "backend returned an unsupported download URL scheme for remote plugin `{remote_plugin_id}`: {scheme}" + )] + UnsupportedBundleDownloadUrlScheme { + remote_plugin_id: String, + scheme: String, + }, + + #[error( + "backend returned an invalid local plugin id for remote plugin `{remote_plugin_id}`: {source}" + )] + InvalidPluginId { + remote_plugin_id: String, + #[source] + source: PluginIdError, + }, + + #[error("failed to send remote plugin bundle download request to {url}: {source}")] + DownloadRequest { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin bundle download from {url} failed with status {status}: {body}")] + DownloadStatus { + url: String, + status: StatusCode, + body: String, + }, + + #[error("failed to read remote plugin bundle download response from {url}: {source}")] + DownloadBody { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin bundle download from {url} exceeded maximum size of {max_bytes} bytes")] + DownloadTooLarge { url: String, max_bytes: u64 }, + + #[error("remote plugin bundle download from {url} redirected to unsupported URL {final_url}")] + UnsupportedBundleDownloadFinalUrl { url: String, final_url: String }, + + #[error( + "remote plugin bundle extracted size would be {bytes} bytes, exceeding the maximum total size of {max_bytes} bytes" + )] + ExtractedBundleTooLarge { bytes: u64, max_bytes: u64 }, + + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + #[error("{0}")] + InvalidBundle(String), + + #[error("{0}")] + Store(#[from] PluginStoreError), +} + +impl RemotePluginBundleInstallError { + fn io(context: &'static str, source: io::Error) -> Self { + Self::Io { context, source } + } +} + +pub fn validate_remote_plugin_bundle( + remote_plugin_id: &str, + remote_marketplace_name: &str, + plugin_name: &str, + release_version: Option<&str>, + bundle_download_url: Option<&str>, +) -> Result { + let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string()) + .map_err(|source| RemotePluginBundleInstallError::InvalidPluginId { + remote_plugin_id: remote_plugin_id.to_string(), + source, + })?; + let plugin_version = release_version + .map(str::trim) + .filter(|version| !version.is_empty()) + .ok_or_else(|| RemotePluginBundleInstallError::MissingReleaseVersion { + remote_plugin_id: remote_plugin_id.to_string(), + })? + .to_string(); + validate_plugin_version_segment(&plugin_version).map_err(|message| { + RemotePluginBundleInstallError::InvalidReleaseVersion { + remote_plugin_id: remote_plugin_id.to_string(), + message, + } + })?; + let bundle_download_url = bundle_download_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else( + || RemotePluginBundleInstallError::MissingBundleDownloadUrl { + remote_plugin_id: remote_plugin_id.to_string(), + }, + )? + .to_string(); + let parsed_bundle_url = Url::parse(&bundle_download_url).map_err(|source| { + RemotePluginBundleInstallError::InvalidBundleDownloadUrl { + remote_plugin_id: remote_plugin_id.to_string(), + url: bundle_download_url.clone(), + source, + } + })?; + if !is_allowed_bundle_download_url( + &parsed_bundle_url, + allow_test_loopback_http_bundle_downloads(), + ) { + return Err( + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { + remote_plugin_id: remote_plugin_id.to_string(), + scheme: parsed_bundle_url.scheme().to_string(), + }, + ); + } + + Ok(ValidatedRemotePluginBundle { + plugin_id, + plugin_version, + bundle_download_url, + }) +} + +fn allow_test_loopback_http_bundle_downloads() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV) { + return value == "1"; + } + } + + false +} + +fn is_allowed_bundle_download_url(url: &Url, allow_loopback_http: bool) -> bool { + match url.scheme() { + "https" => true, + "http" => allow_loopback_http && is_loopback_url(url), + _ => false, + } +} + +fn is_loopback_url(url: &Url) -> bool { + match url.host() { + Some(Host::Ipv4(addr)) => addr.is_loopback(), + Some(Host::Ipv6(addr)) => addr.is_loopback(), + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +pub async fn download_and_install_remote_plugin_bundle( + codex_home: PathBuf, + bundle: ValidatedRemotePluginBundle, +) -> Result { + let bundle_bytes = download_remote_plugin_bundle_with_limit( + &bundle.bundle_download_url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, + ) + .await?; + tokio::task::spawn_blocking(move || { + install_remote_plugin_bundle(codex_home, bundle, bundle_bytes) + }) + .await + .map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to join remote plugin bundle install task: {err}" + )) + })? +} + +async fn download_remote_plugin_bundle_with_limit( + bundle_download_url: &str, + max_bytes: u64, +) -> Result, RemotePluginBundleInstallError> { + let client = build_reqwest_client(); + let response = client + .get(bundle_download_url) + .timeout(REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT) + .send() + .await + .map_err(|source| RemotePluginBundleInstallError::DownloadRequest { + url: bundle_download_url.to_string(), + source, + })?; + + let final_url = response.url().clone(); + // reqwest may already have followed redirects here. For backend-issued bundle URLs, keep the + // shared client policy and fail unsupported final schemes before caching. + if !is_allowed_bundle_download_url(&final_url, allow_test_loopback_http_bundle_downloads()) { + return Err( + RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { + url: bundle_download_url.to_string(), + final_url: final_url.to_string(), + }, + ); + } + + let url = final_url.to_string(); + let status = response.status(); + if !status.is_success() { + let body = read_response_body_with_limit( + response, + &url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES, + ) + .await?; + let body = String::from_utf8_lossy(&body).to_string(); + return Err(RemotePluginBundleInstallError::DownloadStatus { url, status, body }); + } + + read_response_body_with_limit(response, &url, max_bytes).await +} + +async fn read_response_body_with_limit( + mut response: Response, + url: &str, + max_bytes: u64, +) -> Result, RemotePluginBundleInstallError> { + if let Some(content_length) = response.content_length() { + enforce_download_size_limit(url, content_length, max_bytes)?; + } + + let mut body = Vec::new(); + while let Some(chunk) = + response + .chunk() + .await + .map_err(|source| RemotePluginBundleInstallError::DownloadBody { + url: url.to_string(), + source, + })? + { + let next_len = body.len() as u64 + chunk.len() as u64; + enforce_download_size_limit(url, next_len, max_bytes)?; + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +fn enforce_download_size_limit( + url: &str, + bytes: u64, + max_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + if bytes > max_bytes { + return Err(RemotePluginBundleInstallError::DownloadTooLarge { + url: url.to_string(), + max_bytes, + }); + } + Ok(()) +} + +fn install_remote_plugin_bundle( + codex_home: PathBuf, + bundle: ValidatedRemotePluginBundle, + bundle_bytes: Vec, +) -> Result { + let staging_root = codex_home.join(REMOTE_PLUGIN_INSTALL_STAGING_DIR); + fs::create_dir_all(&staging_root).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle staging directory", + source, + ) + })?; + let extract_dir = tempfile::Builder::new() + .prefix("remote-plugin-bundle-") + .tempdir_in(&staging_root) + .map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?; + let plugin_root = find_extracted_plugin_root(extract_dir.path())?; + let plugin_root = AbsolutePathBuf::try_from(plugin_root).map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to resolve extracted remote plugin bundle root: {err}" + )) + })?; + + let store = PluginStore::try_new(codex_home)?; + store + .install_with_version(plugin_root, bundle.plugin_id, bundle.plugin_version) + .map_err(RemotePluginBundleInstallError::from) +} + +fn extract_plugin_bundle_tar_gz( + bytes: &[u8], + destination: &Path, +) -> Result<(), RemotePluginBundleInstallError> { + extract_plugin_bundle_tar_gz_with_limits( + bytes, + destination, + REMOTE_PLUGIN_BUNDLE_MAX_EXTRACTED_BYTES, + ) +} + +fn extract_plugin_bundle_tar_gz_with_limits( + bytes: &[u8], + destination: &Path, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + fs::create_dir_all(destination).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + let archive = GzDecoder::new(std::io::Cursor::new(bytes)); + let mut archive = Archive::new(archive); + extract_plugin_bundle_tar(&mut archive, destination, max_total_bytes) +} + +fn extract_plugin_bundle_tar( + archive: &mut Archive, + destination: &Path, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + let mut extracted_bytes = 0u64; + let entries = archive.entries().map_err(|source| { + RemotePluginBundleInstallError::io("failed to read remote plugin bundle tar", source) + })?; + let entries = entries.raw(true); + for entry in entries { + let mut entry = entry.map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to read remote plugin bundle tar entry", + source, + ) + })?; + let entry_type = entry.header().entry_type(); + let entry_size = entry.size(); + let entry_path = entry.path().map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to read remote plugin bundle tar entry path", + source, + ) + })?; + let entry_path = entry_path.into_owned(); + let output_path = checked_tar_output_path(destination, &entry_path)?; + + if entry_type.is_dir() { + fs::create_dir_all(&output_path).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle directory", + source, + ) + })?; + continue; + } + + if entry_type.is_file() { + enforce_total_extracted_size(entry_size, &mut extracted_bytes, max_total_bytes)?; + let Some(parent) = output_path.parent() else { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle output path has no parent: {}", + output_path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle directory", + source, + ) + })?; + entry.unpack(&output_path).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to unpack remote plugin bundle entry", + source, + ) + })?; + continue; + } + + if entry_type.is_hard_link() || entry_type.is_symlink() { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` is a link", + entry_path.display() + ))); + } + + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` has unsupported type {:?}", + entry_path.display(), + entry_type + ))); + } + + Ok(()) +} + +fn checked_tar_output_path( + destination: &Path, + entry_name: &Path, +) -> Result { + let mut output_path = destination.to_path_buf(); + let mut has_component = false; + for component in entry_name.components() { + match component { + std::path::Component::Normal(component) => { + has_component = true; + output_path.push(component); + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` escapes extraction root", + entry_name.display() + ))); + } + } + } + if !has_component { + return Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle tar entry has an empty path".to_string(), + )); + } + Ok(output_path) +} + +fn enforce_total_extracted_size( + entry_size: u64, + extracted_bytes: &mut u64, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + let next_total = extracted_bytes.checked_add(entry_size).ok_or( + RemotePluginBundleInstallError::ExtractedBundleTooLarge { + bytes: u64::MAX, + max_bytes: max_total_bytes, + }, + )?; + if next_total > max_total_bytes { + return Err(RemotePluginBundleInstallError::ExtractedBundleTooLarge { + bytes: next_total, + max_bytes: max_total_bytes, + }); + } + *extracted_bytes = next_total; + Ok(()) +} + +fn find_extracted_plugin_root( + extraction_root: &Path, +) -> Result { + if is_standard_plugin_root(extraction_root) { + return Ok(extraction_root.to_path_buf()); + } + + Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle did not contain a standard plugin root with plugin.json".to_string(), + )) +} + +fn is_standard_plugin_root(path: &Path) -> bool { + find_plugin_manifest_path(path).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000"; + + #[test] + fn validate_remote_plugin_bundle_uses_detail_name_for_local_plugin_id() { + let bundle = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect("valid install plan"); + + assert_eq!(bundle.plugin_id.plugin_name, "linear"); + assert_eq!(bundle.plugin_id.marketplace_name, "chatgpt-global"); + assert_eq!(bundle.plugin_version, "1.2.3"); + assert_eq!( + bundle.bundle_download_url.as_str(), + "https://example.com/linear.tar.gz" + ); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_missing_release_version() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + /*release_version*/ None, + Some("https://example.com/linear.tar.gz"), + ) + .expect_err("missing release version should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::MissingReleaseVersion { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_invalid_release_version() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("../1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect_err("invalid release version should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::InvalidReleaseVersion { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_missing_download_url() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + /*bundle_download_url*/ None, + ) + .expect_err("missing bundle download URL should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::MissingBundleDownloadUrl { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_unsupported_download_url_scheme() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("http://example.com/linear.tar.gz"), + ) + .expect_err("plain HTTP URLs should be rejected before cloud install"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { .. } + )); + } + + #[test] + fn download_size_limit_rejects_oversized_bundle() { + let err = enforce_download_size_limit( + "https://example.com/linear.tar.gz", + /*bytes*/ 5, + /*max_bytes*/ 4, + ) + .expect_err("oversized bundle download should fail"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::DownloadTooLarge { .. } + )); + } + + #[test] + fn install_rejects_invalid_tar_gz_bundle() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let err = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + b"not a tar.gz".to_vec(), + ) + .expect_err("invalid tar.gz should be rejected"); + + assert!(format!("{err}").contains("failed to read remote plugin bundle tar")); + } + + #[test] + fn install_rejects_bundle_without_standard_plugin_root() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let err = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[("README.md", b"missing plugin manifest", /*mode*/ 0o644)]), + ) + .expect_err("bundle without plugin root should be rejected"); + + assert!( + format!("{err}").contains("did not contain a standard plugin root with plugin.json") + ); + } + + #[test] + fn find_extracted_plugin_root_uses_local_manifest_discovery() { + let extraction_root = tempdir().expect("tempdir"); + std::fs::create_dir_all(extraction_root.path().join(".codex-plugin")) + .expect("create manifest dir"); + std::fs::write( + extraction_root.path().join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .expect("write manifest"); + + assert_eq!( + find_extracted_plugin_root(extraction_root.path()).expect("plugin root"), + extraction_root.path() + ); + } + + #[test] + fn find_extracted_plugin_root_rejects_nested_plugin_root() { + let extraction_root = tempdir().expect("tempdir"); + let plugin_root = extraction_root.path().join("linear"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .expect("write manifest"); + + let err = find_extracted_plugin_root(extraction_root.path()) + .expect_err("nested plugin root should be rejected"); + + assert!( + format!("{err}").contains("did not contain a standard plugin root with plugin.json") + ); + } + + #[test] + fn extraction_rejects_tar_path_traversal() { + let destination = tempdir().expect("tempdir"); + let err = checked_tar_output_path(destination.path(), Path::new("../evil.txt")) + .expect_err("tar path traversal should be rejected"); + + assert!(format!("{err}").contains("escapes extraction root")); + } + + #[test] + fn extraction_rejects_total_size_over_limit() { + let destination = tempdir().expect("tempdir"); + let err = extract_plugin_bundle_tar_gz_with_limits( + &tar_gz_bytes(&[ + ("a.txt", b"1234", /*mode*/ 0o644), + ("b.txt", b"5678", /*mode*/ 0o644), + ]), + destination.path(), + /*max_total_bytes*/ 6, + ) + .expect_err("oversized extracted bundle should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::ExtractedBundleTooLarge { .. } + )); + } + + #[test] + fn extraction_rejects_pax_metadata_entries() { + let destination = tempdir().expect("tempdir"); + let err = extract_plugin_bundle_tar_gz( + &tar_gz_bytes_with_entry_type( + tar::EntryType::XHeader, + "PaxHeaders.0/linear", + b"18 path=linear\n", + /*mode*/ 0o644, + ), + destination.path(), + ) + .expect_err("pax metadata entries should be rejected"); + + assert!(format!("{err}").contains("unsupported type")); + } + + #[cfg(unix)] + #[test] + fn extraction_preserves_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + + let destination = tempdir().expect("tempdir"); + extract_plugin_bundle_tar_gz( + &tar_gz_bytes(&[ + ( + ".codex-plugin/plugin.json", + b"{\"name\":\"linear\"}", + /*mode*/ 0o644, + ), + ("bin/helper", b"#!/bin/sh\n", /*mode*/ 0o755), + ]), + destination.path(), + ) + .expect("extract bundle"); + + let mode = std::fs::metadata(destination.path().join("bin/helper")) + .expect("helper metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + } + + fn valid_remote_plugin_bundle() -> ValidatedRemotePluginBundle { + validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect("valid install plan") + } + + fn tar_gz_bytes(entries: &[(&str, &[u8], u32)]) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in entries { + append_tar_entry(&mut tar, tar::EntryType::Regular, path, contents, *mode); + } + finish_tar_gz(tar) + } + + fn tar_gz_bytes_with_entry_type( + entry_type: tar::EntryType, + path: &str, + contents: &[u8], + mode: u32, + ) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + append_tar_entry(&mut tar, entry_type, path, contents, mode); + finish_tar_gz(tar) + } + + fn append_tar_entry( + tar: &mut tar::Builder, + entry_type: tar::EntryType, + path: &str, + contents: &[u8], + mode: u32, + ) { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + if let Err(error) = tar.append_data(&mut header, path, contents) { + panic!("failed to append tar test data: {error}"); + } + } + + fn finish_tar_gz(tar: tar::Builder>>) -> Vec { + let encoder = match tar.into_inner() { + Ok(encoder) => encoder, + Err(error) => panic!("failed to finish tar test data: {error}"), + }; + match encoder.finish() { + Ok(bytes) => bytes, + Err(error) => panic!("failed to finish gzip test data: {error}"), + } + } +} diff --git a/code-rs/core-plugins/src/remote_legacy.rs b/code-rs/core-plugins/src/remote_legacy.rs new file mode 100644 index 00000000000..dcf9f79eb85 --- /dev/null +++ b/code-rs/core-plugins/src/remote_legacy.rs @@ -0,0 +1,298 @@ +use crate::remote::RemotePluginServiceConfig; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; +use codex_protocol::protocol::Product; +use serde::Deserialize; +use std::time::Duration; +use url::Url; + +const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; +const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginStatusSummary { + pub name: String, + #[serde(default = "default_remote_marketplace_name")] + pub marketplace_name: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemotePluginMutationResponse { + pub id: String, + pub enabled: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginMutationError { + #[error("chatgpt authentication required for remote plugin mutation")] + AuthRequired, + + #[error( + "chatgpt authentication required for remote plugin mutation; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin mutation: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("invalid chatgpt base url for remote plugin mutation: {0}")] + InvalidBaseUrl(#[source] url::ParseError), + + #[error("chatgpt base url cannot be used for plugin mutation")] + InvalidBaseUrlPath, + + #[error("failed to send remote plugin mutation request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin mutation failed with status {status} from {url}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin mutation response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + + #[error( + "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" + )] + UnexpectedPluginId { expected: String, actual: String }, + + #[error( + "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + )] + UnexpectedEnabledState { + plugin_id: String, + expected_enabled: bool, + actual_enabled: bool, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginFetchError { + #[error("chatgpt authentication required to sync remote plugins")] + AuthRequired, + + #[error( + "chatgpt authentication required to sync remote plugins; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin sync: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("failed to send remote plugin sync request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin sync response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, +} + +pub async fn fetch_remote_plugin_status( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginFetchError> { + let Some(auth) = auth else { + return Err(RemotePluginFetchError::AuthRequired); + }; + if !auth.uses_codex_backend() { + return Err(RemotePluginFetchError::UnsupportedAuthMode); + } + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/list"); + let client = build_reqwest_client(); + let request = client + .get(&url) + .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + + let response = request + .send() + .await + .map_err(|source| RemotePluginFetchError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { + url: url.clone(), + source, + }) +} + +pub async fn fetch_remote_featured_plugin_ids( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + product: Option, +) -> Result, RemotePluginFetchError> { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/featured"); + let client = build_reqwest_client(); + let mut request = client + .get(&url) + .query(&[( + "platform", + product.unwrap_or(Product::Codex).to_app_platform(), + )]) + .timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT); + + if let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) { + request = + request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + } + + let response = request + .send() + .await + .map_err(|source| RemotePluginFetchError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { + url: url.clone(), + source, + }) +} + +pub async fn enable_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?; + Ok(()) +} + +pub async fn uninstall_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?; + Ok(()) +} + +fn ensure_codex_backend_auth( + auth: Option<&CodexAuth>, +) -> Result<&CodexAuth, RemotePluginMutationError> { + let Some(auth) = auth else { + return Err(RemotePluginMutationError::AuthRequired); + }; + if !auth.uses_codex_backend() { + return Err(RemotePluginMutationError::UnsupportedAuthMode); + } + Ok(auth) +} + +fn default_remote_marketplace_name() -> String { + DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() +} + +async fn post_remote_plugin_mutation( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, + action: &str, +) -> Result { + let auth = ensure_codex_backend_auth(auth)?; + let url = remote_plugin_mutation_url(config, plugin_id, action)?; + let client = build_reqwest_client(); + let request = client + .post(url.clone()) + .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + + let response = request + .send() + .await + .map_err(|source| RemotePluginMutationError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body }); + } + + let parsed: RemotePluginMutationResponse = + serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode { + url: url.clone(), + source, + })?; + let expected_enabled = action == "enable"; + if parsed.id != plugin_id { + return Err(RemotePluginMutationError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: parsed.id, + }); + } + if parsed.enabled != expected_enabled { + return Err(RemotePluginMutationError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled, + actual_enabled: parsed.enabled, + }); + } + + Ok(parsed) +} + +fn remote_plugin_mutation_url( + config: &RemotePluginServiceConfig, + plugin_id: &str, + action: &str, +) -> Result { + let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) + .map_err(RemotePluginMutationError::InvalidBaseUrl)?; + { + let mut segments = url + .path_segments_mut() + .map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?; + segments.pop_if_empty(); + segments.push("plugins"); + segments.push(plugin_id); + segments.push(action); + } + Ok(url.to_string()) +} diff --git a/code-rs/core-plugins/src/startup_remote_sync.rs b/code-rs/core-plugins/src/startup_remote_sync.rs new file mode 100644 index 00000000000..90c0e119c0c --- /dev/null +++ b/code-rs/core-plugins/src/startup_remote_sync.rs @@ -0,0 +1,100 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use crate::manager::PluginsConfigInput; +use crate::manager::PluginsManager; +use crate::startup_sync::has_local_curated_plugins_snapshot; +use codex_login::AuthManager; +use tracing::info; +use tracing::warn; + +const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; +const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10); + +pub(crate) fn start_startup_remote_plugin_sync_once( + manager: Arc, + codex_home: PathBuf, + config: PluginsConfigInput, + auth_manager: Arc, +) { + let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path()); + if marker_path.is_file() { + return; + } + + tokio::spawn(async move { + if marker_path.is_file() { + return; + } + + if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await { + warn!( + codex_home = %codex_home.display(), + "skipping startup remote plugin sync because curated marketplace is not ready" + ); + return; + } + + let auth = auth_manager.auth().await; + match manager + .sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true) + .await + { + Ok(sync_result) => { + info!( + installed_plugin_ids = ?sync_result.installed_plugin_ids, + enabled_plugin_ids = ?sync_result.enabled_plugin_ids, + disabled_plugin_ids = ?sync_result.disabled_plugin_ids, + uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids, + "completed startup remote plugin sync" + ); + if let Err(err) = + write_startup_remote_plugin_sync_marker(codex_home.as_path()).await + { + warn!( + error = %err, + path = %marker_path.display(), + "failed to persist startup remote plugin sync marker" + ); + } + } + Err(err) => { + warn!( + error = %err, + "startup remote plugin sync failed; will retry on next app-server start" + ); + } + } + }); +} + +fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf { + codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE) +} + +async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool { + let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT; + loop { + if has_local_curated_plugins_snapshot(codex_home) { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> { + let marker_path = startup_remote_plugin_sync_marker_path(codex_home); + if let Some(parent) = marker_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(marker_path, b"ok\n").await +} + +#[cfg(test)] +#[path = "startup_remote_sync_tests.rs"] +mod tests; diff --git a/code-rs/core-plugins/src/startup_remote_sync_tests.rs b/code-rs/core-plugins/src/startup_remote_sync_tests.rs new file mode 100644 index 00000000000..bdbc5e1a7a1 --- /dev/null +++ b/code-rs/core-plugins/src/startup_remote_sync_tests.rs @@ -0,0 +1,91 @@ +use super::*; +use crate::PluginsManager; +use crate::startup_sync::curated_plugins_repo_path; +use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; +use crate::test_support::load_plugins_config; +use crate::test_support::write_curated_plugin_sha; +use crate::test_support::write_file; +use crate::test_support::write_openai_curated_marketplace; +use codex_config::CONFIG_TOML_FILE; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::time::Duration; +use tempfile::tempdir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { + let tmp = tempdir().expect("tempdir"); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_curated_plugin_sha(tmp.path()); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = false +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/list")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"[ + {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} +]"#, + )) + .mount(&server) + .await; + + let mut config = load_plugins_config(tmp.path(), tmp.path()).await; + config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); + let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf())); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + + start_startup_remote_plugin_sync_once( + Arc::clone(&manager), + tmp.path().to_path_buf(), + config, + auth_manager, + ); + + let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if marker_path.is_file() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("marker should be written"); + + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}" + )) + .is_dir() + ); + let config = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist"); + assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); + assert!(config.contains("enabled = true")); + + let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable"); + assert_eq!(marker_contents, "ok\n"); +} diff --git a/code-rs/core-plugins/src/startup_sync.rs b/code-rs/core-plugins/src/startup_sync.rs new file mode 100644 index 00000000000..f03aff93f63 --- /dev/null +++ b/code-rs/core-plugins/src/startup_sync.rs @@ -0,0 +1,938 @@ +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Output; +use std::process::Stdio; +use std::time::Duration; + +use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC; +use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_METRIC; +use reqwest::Client; +use serde::Deserialize; +use tempfile::TempDir; +use tracing::warn; +use zip::ZipArchive; + +use codex_login::default_client::build_reqwest_client; + +const GITHUB_API_BASE_URL: &str = "https://api.github.com"; +const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; +const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = + "https://chatgpt.com/backend-api/plugins/export/curated"; +const OPENAI_PLUGINS_OWNER: &str = "openai"; +const OPENAI_PLUGINS_REPO: &str = "plugins"; +const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; +const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; +const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); +const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT: Duration = Duration::from_secs(30); +// Keep this comfortably above a normal sync attempt so we do not race another Codex process. +const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60); + +#[derive(Debug, Deserialize)] +struct GitHubRepositorySummary { + default_branch: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefSummary { + object: GitHubGitRefObject, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefObject { + sha: String, +} + +#[derive(Debug, Deserialize)] +struct CuratedPluginsBackupArchiveResponse { + download_url: String, +} + +pub fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { + codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) +} + +pub fn read_curated_plugins_sha(codex_home: &Path) -> Option { + read_sha_file(curated_plugins_sha_path(codex_home).as_path()) +} + +fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf { + codex_home.join(CURATED_PLUGINS_SHA_FILE) +} + +pub fn sync_openai_plugins_repo(codex_home: &Path) -> Result { + sync_openai_plugins_repo_with_transport_overrides( + codex_home, + "git", + GITHUB_API_BASE_URL, + CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL, + ) +} + +fn sync_openai_plugins_repo_with_transport_overrides( + codex_home: &Path, + git_binary: &str, + api_base_url: &str, + backup_archive_api_url: &str, +) -> Result { + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { + Ok(remote_sha) => { + emit_curated_plugins_startup_sync_metric("git", "success"); + emit_curated_plugins_startup_sync_final_metric("git", "success"); + Ok(remote_sha) + } + Err(err) => { + emit_curated_plugins_startup_sync_metric("git", "failure"); + warn!( + error = %err, + git_binary, + "git sync failed for curated plugin sync; falling back to GitHub HTTP" + ); + match sync_openai_plugins_repo_via_http(codex_home, api_base_url) { + Ok(remote_sha) => { + emit_curated_plugins_startup_sync_metric("http", "success"); + emit_curated_plugins_startup_sync_final_metric("http", "success"); + Ok(remote_sha) + } + Err(http_err) => { + emit_curated_plugins_startup_sync_metric("http", "failure"); + if has_local_curated_plugins_snapshot(codex_home) { + emit_curated_plugins_startup_sync_final_metric("http", "failure"); + warn!( + error = %http_err, + "GitHub HTTP sync failed for curated plugin sync; skipping export archive fallback because a local curated plugins snapshot already exists" + ); + Err(format!( + "git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive fallback skipped because a local curated plugins snapshot already exists" + )) + } else { + // The export archive is a lagging backup path. Only use it to bootstrap a + // missing local curated snapshot, never to refresh an existing one. + warn!( + error = %http_err, + backup_archive_api_url, + "GitHub HTTP sync failed for curated plugin sync; falling back to export archive" + ); + let result = sync_openai_plugins_repo_via_backup_archive( + codex_home, + backup_archive_api_url, + ); + let status = if result.is_ok() { "success" } else { "failure" }; + emit_curated_plugins_startup_sync_metric("export_archive", status); + emit_curated_plugins_startup_sync_final_metric("export_archive", status); + result.map_err(|export_err| { + format!( + "git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive sync failed for curated plugin sync: {export_err}" + ) + }) + } + } + } + } + } +} + +fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); + let remote_sha = git_ls_remote_head_sha(git_binary)?; + let local_sha = read_local_git_or_sha_file(&repo_path, &sha_path, git_binary); + + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() { + return Ok(remote_sha); + } + + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let clone_output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("clone") + .arg("--depth") + .arg("1") + .arg("https://github.com/openai/plugins.git") + .arg(staged_repo_dir.path()), + "git clone curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&clone_output, "git clone curated plugins repo")?; + + let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; + if cloned_sha != remote_sha { + return Err(format!( + "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + )); + } + + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + activate_curated_repo(&repo_path, staged_repo_dir)?; + write_curated_plugins_sha(&sha_path, &remote_sha)?; + Ok(remote_sha) +} + +fn sync_openai_plugins_repo_via_http( + codex_home: &Path, + api_base_url: &str, +) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; + let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?; + let local_sha = read_sha_file(&sha_path); + + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() { + return Ok(remote_sha); + } + + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; + extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + activate_curated_repo(&repo_path, staged_repo_dir)?; + write_curated_plugins_sha(&sha_path, &remote_sha)?; + Ok(remote_sha) +} + +fn sync_openai_plugins_repo_via_backup_archive( + codex_home: &Path, + backup_archive_api_url: &str, +) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = curated_plugins_sha_path(codex_home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_backup_archive_zip( + backup_archive_api_url, + ))?; + extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + let export_version = read_extracted_backup_archive_git_sha(staged_repo_dir.path())? + .unwrap_or_else(|| CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION.to_string()); + activate_curated_repo(&repo_path, staged_repo_dir)?; + write_curated_plugins_sha(&sha_path, &export_version)?; + Ok(export_version) +} + +pub fn has_local_curated_plugins_snapshot(codex_home: &Path) -> bool { + curated_plugins_repo_path(codex_home) + .join(".agents/plugins/marketplace.json") + .is_file() + && codex_home.join(CURATED_PLUGINS_SHA_FILE).is_file() +} + +fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result { + let Some(parent) = repo_path.parent() else { + return Err(format!( + "failed to determine curated plugins parent directory for {}", + repo_path.display() + )); + }; + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins parent directory {}: {err}", + parent.display() + ) + })?; + remove_stale_curated_repo_temp_dirs(parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE); + + let clone_dir = tempfile::Builder::new() + .prefix("plugins-clone-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create temporary curated plugins directory in {}: {err}", + parent.display() + ) + })?; + Ok(clone_dir) +} + +fn remove_stale_curated_repo_temp_dirs(parent: &Path, max_age: Duration) { + let entries = match std::fs::read_dir(parent) { + Ok(entries) => entries, + Err(err) => { + warn!( + error = %err, + parent = %parent.display(), + "failed to list curated plugins temp directory parent for stale cleanup" + ); + return; + } + }; + + for entry in entries.flatten() { + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + warn!( + error = %err, + path = %entry.path().display(), + "failed to inspect curated plugins temp directory entry" + ); + continue; + } + }; + if !file_type.is_dir() { + continue; + } + + let path = entry.path(); + let is_plugins_clone_dir = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("plugins-clone-")); + if !is_plugins_clone_dir { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to read curated plugins temp directory metadata" + ); + continue; + } + }; + let modified = match metadata.modified() { + Ok(modified) => modified, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to read curated plugins temp directory modification time" + ); + continue; + } + }; + let age = match modified.elapsed() { + Ok(age) => age, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to compute curated plugins temp directory age" + ); + continue; + } + }; + if age < max_age { + continue; + } + + if let Err(err) = std::fs::remove_dir_all(&path) { + warn!( + error = %err, + path = %path.display(), + "failed to remove stale curated plugins temp directory" + ); + } + } +} + +fn emit_curated_plugins_startup_sync_metric(transport: &'static str, status: &'static str) { + emit_curated_plugins_startup_sync_counter( + CURATED_PLUGINS_STARTUP_SYNC_METRIC, + transport, + status, + ); +} + +fn emit_curated_plugins_startup_sync_final_metric(transport: &'static str, status: &'static str) { + emit_curated_plugins_startup_sync_counter( + CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC, + transport, + status, + ); +} + +fn emit_curated_plugins_startup_sync_counter( + metric_name: &str, + transport: &'static str, + status: &'static str, +) { + let Some(metrics) = codex_otel::global() else { + return; + }; + let tags = [("transport", transport), ("status", status)]; + let _ = metrics.counter(metric_name, /*inc*/ 1, &tags); +} + +fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> { + if repo_path.join(".agents/plugins/marketplace.json").is_file() { + return Ok(()); + } + Err(format!( + "curated plugins archive missing marketplace manifest at {}", + repo_path.join(".agents/plugins/marketplace.json").display() + )) +} + +fn activate_curated_repo(repo_path: &Path, staged_repo_dir: TempDir) -> Result<(), String> { + let staged_repo_path = staged_repo_dir.path(); + if repo_path.exists() { + let parent = repo_path.parent().ok_or_else(|| { + format!( + "failed to determine curated plugins parent directory for {}", + repo_path.display() + ) + })?; + let backup_dir = tempfile::Builder::new() + .prefix("plugins-backup-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create curated plugins backup directory in {}: {err}", + parent.display() + ) + })?; + let backup_repo_path = backup_dir.path().join("repo"); + + std::fs::rename(repo_path, &backup_repo_path).map_err(|err| { + format!( + "failed to move previous curated plugins repo out of the way at {}: {err}", + repo_path.display() + ) + })?; + + if let Err(err) = std::fs::rename(staged_repo_path, repo_path) { + let rollback_result = std::fs::rename(&backup_repo_path, repo_path); + return match rollback_result { + Ok(()) => Err(format!( + "failed to activate new curated plugins repo at {}: {err}", + repo_path.display() + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("repo"); + Err(format!( + "failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}", + repo_path.display(), + backup_path.display() + )) + } + }; + } + } else { + std::fs::rename(staged_repo_path, repo_path).map_err(|err| { + format!( + "failed to activate curated plugins repo at {}: {err}", + repo_path.display() + ) + })?; + } + + Ok(()) +} + +fn write_curated_plugins_sha(sha_path: &Path, remote_sha: &str) -> Result<(), String> { + if let Some(parent) = sha_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins sha directory {}: {err}", + parent.display() + ) + })?; + } + std::fs::write(sha_path, format!("{remote_sha}\n")).map_err(|err| { + format!( + "failed to write curated plugins sha file {}: {err}", + sha_path.display() + ) + }) +} + +fn read_local_git_or_sha_file( + repo_path: &Path, + sha_path: &Path, + git_binary: &str, +) -> Option { + if repo_path.join(".git").is_dir() + && let Ok(sha) = git_head_sha(repo_path, git_binary) + { + return Some(sha); + } + + read_sha_file(sha_path) +} + +fn git_ls_remote_head_sha(git_binary: &str) -> Result { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("ls-remote") + .arg("https://github.com/openai/plugins.git") + .arg("HEAD"), + "git ls-remote curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, "git ls-remote curated plugins repo")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(first_line) = stdout.lines().next() else { + return Err("git ls-remote returned empty output for curated plugins repo".to_string()); + }; + let Some((sha, _)) = first_line.split_once('\t') else { + return Err(format!( + "unexpected git ls-remote output for curated plugins repo: {first_line}" + )); + }; + if sha.is_empty() { + return Err("git ls-remote returned empty sha for curated plugins repo".to_string()); + } + Ok(sha.to_string()) +} + +fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result { + let output = Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .arg("rev-parse") + .arg("HEAD") + .output() + .map_err(|err| { + format!( + "failed to run git rev-parse HEAD in {}: {err}", + repo_path.display() + ) + })?; + ensure_git_success(&output, "git rev-parse HEAD")?; + + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if sha.is_empty() { + return Err(format!( + "git rev-parse HEAD returned empty output in {}", + repo_path.display() + )); + } + Ok(sha) +} + +fn run_git_command_with_timeout( + command: &mut Command, + context: &str, + timeout: Duration, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to run {context}: {err}"))?; + + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + if start.elapsed() >= timeout { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + let _ = child.kill(); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err(format!("{context} timed out after {}s", timeout.as_secs())) + } else { + Err(format!( + "{context} timed out after {}s: {stderr}", + timeout.as_secs() + )) + }; + } + + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!("{context} failed with status {}", output.status)) + } else { + Err(format!( + "{context} failed with status {}: {stderr}", + output.status + )) + } +} + +async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let client = build_reqwest_client(); + let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?; + let repo_summary: GitHubRepositorySummary = + serde_json::from_str(&repo_body).map_err(|err| { + format!("failed to parse curated plugins repository response from {repo_url}: {err}") + })?; + if repo_summary.default_branch.is_empty() { + return Err(format!( + "curated plugins repository response from {repo_url} did not include a default branch" + )); + } + + let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch); + let git_ref_body = + fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?; + let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| { + format!("failed to parse curated plugins ref response from {git_ref_url}: {err}") + })?; + if git_ref.object.sha.is_empty() { + return Err(format!( + "curated plugins ref response from {git_ref_url} did not include a HEAD sha" + )); + } + + Ok(git_ref.object.sha) +} + +async fn fetch_curated_repo_zipball( + api_base_url: &str, + remote_sha: &str, +) -> Result, String> { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let zipball_url = format!("{repo_url}/zipball/{remote_sha}"); + let client = build_reqwest_client(); + fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await +} + +async fn fetch_curated_repo_backup_archive_zip( + backup_archive_api_url: &str, +) -> Result, String> { + let client = build_reqwest_client(); + let export_body = fetch_public_text( + &client, + backup_archive_api_url, + "get curated plugins export archive metadata", + ) + .await?; + let export_response: CuratedPluginsBackupArchiveResponse = serde_json::from_str(&export_body) + .map_err(|err| { + format!( + "failed to parse curated plugins backup archive response from {backup_archive_api_url}: {err}" + ) + })?; + if export_response.download_url.is_empty() { + return Err(format!( + "curated plugins backup archive response from {backup_archive_api_url} did not include a download URL" + )); + } + + fetch_public_bytes( + &client, + &export_response.download_url, + "download curated plugins export archive", + ) + .await +} + +fn read_extracted_backup_archive_git_sha(repo_path: &Path) -> Result, String> { + let git_dir = repo_path.join(".git"); + if !git_dir.is_dir() { + return Ok(None); + } + + let head_path = git_dir.join("HEAD"); + let head = std::fs::read_to_string(&head_path).map_err(|err| { + format!( + "failed to read curated plugins backup archive git HEAD {}: {err}", + head_path.display() + ) + })?; + let head = head.trim(); + if head.is_empty() { + return Err(format!( + "curated plugins backup archive git HEAD is empty at {}", + head_path.display() + )); + } + + if let Some(reference) = head.strip_prefix("ref: ") { + let reference = validate_backup_archive_git_ref(reference.trim())?; + return read_git_ref_sha(&git_dir, reference).map(Some); + } + + Ok(Some(head.to_string())) +} + +fn validate_backup_archive_git_ref(reference: &str) -> Result<&str, String> { + if !reference.starts_with("refs/") { + return Err(format!( + "curated plugins backup archive git ref must stay under refs/: {reference}" + )); + } + + let path = Path::new(reference); + if path.is_absolute() { + return Err(format!( + "curated plugins backup archive git ref must be relative: {reference}" + )); + } + + for component in path.components() { + match component { + std::path::Component::Normal(_) => {} + _ => { + return Err(format!( + "curated plugins backup archive git ref contains invalid path components: {reference}" + )); + } + } + } + + Ok(reference) +} + +fn read_git_ref_sha(git_dir: &Path, reference: &str) -> Result { + let ref_path = git_dir.join(reference); + if let Ok(sha) = std::fs::read_to_string(&ref_path) { + let sha = sha.trim(); + if sha.is_empty() { + return Err(format!( + "curated plugins backup archive git ref {reference} is empty at {}", + ref_path.display() + )); + } + return Ok(sha.to_string()); + } + + let packed_refs_path = git_dir.join("packed-refs"); + if let Ok(packed_refs) = std::fs::read_to_string(&packed_refs_path) + && let Some(sha) = packed_refs.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('^') { + return None; + } + let (sha, candidate_ref) = trimmed.split_once(' ')?; + (candidate_ref == reference).then_some(sha.to_string()) + }) + { + return Ok(sha); + } + + Err(format!( + "failed to resolve curated plugins backup archive git ref {reference} from {}", + git_dir.display() + )) +} + +async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "{context} from {url} failed with status {status}: {body}" + )); + } + Ok(body) +} + +async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result, String> { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + return Err(format!( + "{context} from {url} failed with status {status}: {body_text}" + )); + } + Ok(body.to_vec()) +} + +async fn fetch_public_text(client: &Client, url: &str, context: &str) -> Result { + let response = client + .get(url) + .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "{context} from {url} failed with status {status}: {body}" + )); + } + Ok(body) +} + +async fn fetch_public_bytes(client: &Client, url: &str, context: &str) -> Result, String> { + let response = client + .get(url) + .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + return Err(format!( + "{context} from {url} failed with status {status}: {body_text}" + )); + } + Ok(body.to_vec()) +} + +fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder { + client + .get(url) + .timeout(CURATED_PLUGINS_HTTP_TIMEOUT) + .header("accept", GITHUB_API_ACCEPT_HEADER) + .header("x-github-api-version", GITHUB_API_VERSION_HEADER) +} + +fn read_sha_file(sha_path: &Path) -> Option { + std::fs::read_to_string(sha_path) + .ok() + .map(|sha| sha.trim().to_string()) + .filter(|sha| !sha.is_empty()) +} + +fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> { + std::fs::create_dir_all(destination).map_err(|err| { + format!( + "failed to create curated plugins extraction directory {}: {err}", + destination.display() + ) + })?; + + let cursor = std::io::Cursor::new(bytes); + let mut archive = ZipArchive::new(cursor) + .map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?; + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?; + let Some(relative_path) = entry.enclosed_name() else { + return Err(format!( + "curated plugins zip entry `{}` escapes extraction root", + entry.name() + )); + }; + + let mut components = relative_path.components(); + let Some(std::path::Component::Normal(_)) = components.next() else { + continue; + }; + + let output_relative = components.fold(PathBuf::new(), |mut path, component| { + if let std::path::Component::Normal(segment) = component { + path.push(segment); + } + path + }); + if output_relative.as_os_str().is_empty() { + continue; + } + + let output_path = destination.join(&output_relative); + if entry.is_dir() { + std::fs::create_dir_all(&output_path).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + output_path.display() + ) + })?; + continue; + } + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + parent.display() + ) + })?; + } + let mut output = std::fs::File::create(&output_path).map_err(|err| { + format!( + "failed to create curated plugins file {}: {err}", + output_path.display() + ) + })?; + std::io::copy(&mut entry, &mut output).map_err(|err| { + format!( + "failed to write curated plugins file {}: {err}", + output_path.display() + ) + })?; + apply_zip_permissions(&entry, &output_path)?; + } + + Ok(()) +} + +#[cfg(unix)] +fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let Some(mode) = entry.unix_mode() else { + return Ok(()); + }; + std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|err| { + format!( + "failed to set permissions on curated plugins file {}: {err}", + output_path.display() + ) + }) +} + +#[cfg(not(unix))] +fn apply_zip_permissions( + _entry: &zip::read::ZipFile<'_>, + _output_path: &Path, +) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +#[path = "startup_sync_tests.rs"] +mod tests; diff --git a/code-rs/core-plugins/src/startup_sync_tests.rs b/code-rs/core-plugins/src/startup_sync_tests.rs new file mode 100644 index 00000000000..a9388e3fce8 --- /dev/null +++ b/code-rs/core-plugins/src/startup_sync_tests.rs @@ -0,0 +1,769 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use tempfile::tempdir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn write_file(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + std::fs::write(path, contents).unwrap(); +} + +fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!(r#"{{"name":"{plugin_name}"}}"#), + ); +} + +fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "openai-curated", + "plugins": [ +{plugins} + ] +}}"# + ), + ); + for plugin_name in plugin_names { + write_curated_plugin(root, plugin_name); + } +} + +fn write_curated_plugin_sha(codex_home: &Path) { + write_file( + &codex_home.join(".tmp/plugins.sha"), + &format!("{TEST_CURATED_PLUGIN_SHA}\n"), + ); +} + +fn has_plugins_clone_dirs(codex_home: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(codex_home.join(".tmp")) else { + return false; + }; + + entries.flatten().any(|entry| { + let path = entry.path(); + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("plugins-clone-")) + }) +} + +#[cfg(unix)] +fn write_executable_script(path: &Path, contents: &str) { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, contents).expect("write script"); + #[cfg(unix)] + { + let mut permissions = std::fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("chmod"); + } +} + +async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(server) + .await; +} + +async fn mount_github_zipball(server: &MockServer, sha: &str, bytes: Vec) { + Mock::given(method("GET")) + .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(bytes), + ) + .mount(server) + .await; +} + +async fn mount_export_archive(server: &MockServer, bytes: Vec) -> String { + let export_api_url = format!("{}/backend-api/plugins/export/curated", server.uri()); + Mock::given(method("GET")) + .and(path("/backend-api/plugins/export/curated")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + r#"{{"download_url":"{}/files/curated-plugins.zip"}}"#, + server.uri() + ))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/files/curated-plugins.zip")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(bytes), + ) + .mount(server) + .await; + export_api_url +} + +async fn run_sync_with_transport_overrides( + codex_home: PathBuf, + git_binary: impl Into, + api_base_url: impl Into, + backup_archive_api_url: impl Into, +) -> Result { + let git_binary = git_binary.into(); + let api_base_url = api_base_url.into(); + let backup_archive_api_url = backup_archive_api_url.into(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_transport_overrides( + codex_home.as_path(), + &git_binary, + &api_base_url, + &backup_archive_api_url, + ) + }) + .await + .expect("sync task should join") +} + +async fn run_http_sync( + codex_home: PathBuf, + api_base_url: impl Into, +) -> Result { + let api_base_url = api_base_url.into(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_via_http(codex_home.as_path(), &api_base_url) + }) + .await + .expect("sync task should join") +} + +fn assert_curated_gmail_repo(repo_path: &Path) { + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); + assert!( + repo_path + .join("plugins/gmail/.codex-plugin/plugin.json") + .is_file() + ); +} + +#[test] +fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { + let tmp = tempdir().expect("tempdir"); + assert_eq!( + curated_plugins_repo_path(tmp.path()), + tmp.path().join(".tmp/plugins") + ); +} + +#[test] +fn read_curated_plugins_sha_reads_trimmed_sha_file() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + std::fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha"); + + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some("abc123") + ); +} + +#[cfg(unix)] +#[test] +fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { + use std::os::unix::ffi::OsStrExt; + use std::time::SystemTime; + + fn set_dir_mtime(path: &Path, age: Duration) -> Result<(), Box> { + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?; + let modified_at = now.saturating_sub(age); + let tv_sec = i64::try_from(modified_at.as_secs())?; + let ts = libc::timespec { tv_sec, tv_nsec: 0 }; + let times = [ts, ts]; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?; + let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) }; + if result != 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) + } + + let tmp = tempdir().expect("tempdir"); + let parent = tmp.path().join(".tmp"); + let stale_clone_dir = parent.join("plugins-clone-stale"); + let fresh_clone_dir = parent.join("plugins-clone-fresh"); + let unrelated_dir = parent.join("plugins-cache"); + + std::fs::create_dir_all(&stale_clone_dir).expect("create stale clone dir"); + std::fs::create_dir_all(&fresh_clone_dir).expect("create fresh clone dir"); + std::fs::create_dir_all(&unrelated_dir).expect("create unrelated dir"); + set_dir_mtime( + &stale_clone_dir, + CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE + Duration::from_secs(60), + ) + .expect("age stale clone dir"); + set_dir_mtime(&fresh_clone_dir, Duration::ZERO).expect("age fresh clone dir"); + + remove_stale_curated_repo_temp_dirs(&parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE); + + assert!(!stale_clone_dir.exists()); + assert!(fresh_clone_dir.is_dir()); + assert!(unrelated_dir.is_dir()); +} + +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_prefers_git_when_available() { + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{sha}" + exit 0 +fi +if [ "$1" = "clone" ]; then + dest="$5" + mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" + cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +{{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} +EOF + printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then + printf '%s\n' "{sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"# + ), + ); + + let synced_sha = sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .expect("git sync should succeed"); + + assert_eq!(synced_sha, sha); + let repo_path = curated_plugins_repo_path(tmp.path()); + assert!(repo_path.join(".git").is_dir()); + assert_curated_gmail_repo(&repo_path); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { + let tmp = tempdir().expect("tempdir"); + let repo_root = tempfile::Builder::new() + .prefix("curated-repo-success-") + .tempdir() + .expect("tempdir"); + let work_repo = repo_root.path().join("work/plugins"); + let remote_repo = repo_root.path().join("remotes/openai/plugins.git"); + std::fs::create_dir_all(work_repo.join(".agents/plugins")).expect("create marketplace dir"); + std::fs::create_dir_all(work_repo.join("plugins/gmail/.codex-plugin")) + .expect("create plugin dir"); + std::fs::write( + work_repo.join(".agents/plugins/marketplace.json"), + r#"{"name":"openai-curated","plugins":[{"name":"gmail","source":{"source":"local","path":"./plugins/gmail"}}]}"#, + ) + .expect("write marketplace"); + std::fs::write( + work_repo.join("plugins/gmail/.codex-plugin/plugin.json"), + r#"{"name":"gmail"}"#, + ) + .expect("write plugin manifest"); + + let init_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("init") + .status() + .expect("run git init"); + assert!(init_status.success()); + + let add_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("add") + .arg(".") + .status() + .expect("run git add"); + assert!(add_status.success()); + + let commit_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("-c") + .arg("user.name=Codex Test") + .arg("-c") + .arg("user.email=codex@example.com") + .arg("commit") + .arg("-m") + .arg("init") + .status() + .expect("run git commit"); + assert!(commit_status.success()); + + std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) + .expect("create remote parent"); + let clone_status = Command::new("git") + .arg("clone") + .arg("--bare") + .arg(&work_repo) + .arg(&remote_repo) + .status() + .expect("run git clone --bare"); + assert!(clone_status.success()); + + let sha_output = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("run git rev-parse"); + assert!(sha_output.status.success()); + let sha = String::from_utf8_lossy(&sha_output.stdout) + .trim() + .to_string(); + + let git_config_path = repo_root.path().join("git-rewrite.conf"); + std::fs::write( + &git_config_path, + format!( + "[url \"file://{}/\"]\n insteadOf = https://github.com/\n", + repo_root.path().join("remotes").display() + ), + ) + .expect("write git config"); + + let bin_dir = tempfile::Builder::new() + .prefix("git-rewrite-wrapper-") + .tempdir() + .expect("tempdir"); + let git_wrapper = bin_dir.path().join("git"); + write_executable_script( + &git_wrapper, + &format!( + "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + git_config_path.display() + ), + ); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("git sync should succeed"); + + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&curated_plugins_repo_path(tmp.path())); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(sha.as_str()) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await; + + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .await + .expect("fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&repo_path); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[cfg(unix)] +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() { + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-fail-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + write_executable_script( + &git_path, + r#"#!/bin/sh +echo "simulated git failure" >&2 +exit 1 +"#, + ); + + let server = MockServer::start().await; + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await; + + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + git_path.to_str().expect("utf8 path"), + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .await + .expect("fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&repo_path); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-partial-fail-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{sha}" + exit 0 +fi +if [ "$1" = "clone" ]; then + dest="$5" + mkdir -p "$dest/.git" + echo "fatal: early EOF" >&2 + exit 128 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"# + ), + ); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("git sync should fail"); + + assert!(err.contains("fatal: early EOF")); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, b"not a zip archive".to_vec()).await; + + let err = run_http_sync(tmp.path().to_path_buf(), server.uri()) + .await + .expect_err("http sync should fail"); + + assert!(err.contains("failed to open curated plugins zip archive")); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + std::fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo"); + std::fs::write( + repo_path.join(".agents/plugins/marketplace.json"), + r#"{"name":"openai-curated","plugins":[]}"#, + ) + .expect("write marketplace"); + std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + let sha = "fedcba9876543210fedcba9876543210fedcba98"; + std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha"); + + let server = MockServer::start().await; + mount_github_repo_and_ref(&server, sha).await; + + run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .await + .expect("sync should succeed"); + + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_export_archive_when_no_snapshot_exists() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let export_sha = "1111111111111111111111111111111111111111"; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed")) + .mount(&server) + .await; + let export_api_url = + mount_export_archive(&server, curated_repo_backup_archive_zip_bytes(export_sha)).await; + + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + export_api_url, + ) + .await + .expect("export fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, export_sha); + assert_curated_gmail_repo(&repo_path); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(export_sha) + ); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_skips_export_archive_when_snapshot_exists() { + let tmp = tempdir().expect("tempdir"); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_curated_plugin_sha(tmp.path()); + + let plugin_manifest_path = curated_root.join("plugins/linear/.codex-plugin/plugin.json"); + let original_manifest = + std::fs::read_to_string(&plugin_manifest_path).expect("read existing plugin manifest"); + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed")) + .mount(&server) + .await; + let export_api_url = mount_export_archive( + &server, + curated_repo_backup_archive_zip_bytes("2222222222222222222222222222222222222222"), + ) + .await; + + let err = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + export_api_url, + ) + .await + .expect_err("existing snapshot should suppress export fallback"); + + assert!(err.contains("export archive fallback skipped")); + assert_eq!( + std::fs::read_to_string(&plugin_manifest_path).expect("read plugin manifest after sync"), + original_manifest + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); +} + +#[test] +fn read_extracted_backup_archive_git_sha_reads_head_ref_from_extracted_repo() { + let tmp = tempdir().expect("tempdir"); + let git_dir = tmp.path().join(".git/refs/heads"); + std::fs::create_dir_all(&git_dir).expect("create git ref dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write( + git_dir.join("main"), + "3333333333333333333333333333333333333333\n", + ) + .expect("write main ref"); + + assert_eq!( + read_extracted_backup_archive_git_sha(tmp.path()) + .expect("read extracted backup archive git sha"), + Some("3333333333333333333333333333333333333333".to_string()) + ); +} + +#[test] +fn read_extracted_backup_archive_git_sha_rejects_non_refs_head_target() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: HEAD\n").expect("write HEAD"); + + let err = read_extracted_backup_archive_git_sha(tmp.path()) + .expect_err("non-refs target should be rejected"); + + assert!(err.contains("must stay under refs/")); +} + +#[test] +fn read_extracted_backup_archive_git_sha_rejects_path_traversal_ref() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/../../evil\n") + .expect("write HEAD"); + + let err = read_extracted_backup_archive_git_sha(tmp.path()) + .expect_err("path traversal ref should be rejected"); + + assert!(err.contains("invalid path components")); +} + +fn curated_repo_zipball_bytes(sha: &str) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + let root = format!("openai-plugins-{sha}"); + writer + .start_file(format!("{root}/.agents/plugins/marketplace.json"), options) + .expect("start marketplace entry"); + writer + .write_all( + br#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + } + } + ] +}"#, + ) + .expect("write marketplace"); + writer + .start_file( + format!("{root}/plugins/gmail/.codex-plugin/plugin.json"), + options, + ) + .expect("start plugin manifest entry"); + writer + .write_all(br#"{"name":"gmail"}"#) + .expect("write plugin manifest"); + + writer.finish().expect("finish zip writer").into_inner() +} + +fn curated_repo_backup_archive_zip_bytes(sha: &str) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + + writer + .start_file("plugins/.git/HEAD", options) + .expect("start HEAD entry"); + writer + .write_all(b"ref: refs/heads/main\n") + .expect("write HEAD"); + writer + .start_file("plugins/.git/refs/heads/main", options) + .expect("start main ref entry"); + writer + .write_all(format!("{sha}\n").as_bytes()) + .expect("write main ref"); + writer + .start_file("plugins/.agents/plugins/marketplace.json", options) + .expect("start marketplace entry"); + writer + .write_all( + br#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + } + } + ] +}"#, + ) + .expect("write marketplace"); + writer + .start_file("plugins/plugins/gmail/.codex-plugin/plugin.json", options) + .expect("start plugin manifest entry"); + writer + .write_all(br#"{"name":"gmail"}"#) + .expect("write plugin manifest"); + + writer.finish().expect("finish zip writer").into_inner() +} diff --git a/code-rs/core-plugins/src/store.rs b/code-rs/core-plugins/src/store.rs new file mode 100644 index 00000000000..fe662a142ed --- /dev/null +++ b/code-rs/core-plugins/src/store.rs @@ -0,0 +1,353 @@ +use crate::manifest::PluginManifest; +use crate::manifest::load_plugin_manifest; +use codex_plugin::PluginId; +use codex_plugin::validate_plugin_segment; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::find_plugin_manifest_path; +use serde::Deserialize; +use serde_json::Value as JsonValue; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +pub const DEFAULT_PLUGIN_VERSION: &str = "local"; +pub const PLUGINS_CACHE_DIR: &str = "plugins/cache"; +pub const PLUGINS_DATA_DIR: &str = "plugins/data"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstallResult { + pub plugin_id: PluginId, + pub plugin_version: String, + pub installed_path: AbsolutePathBuf, +} + +#[derive(Debug, Clone)] +pub struct PluginStore { + root: AbsolutePathBuf, + data_root: AbsolutePathBuf, +} + +impl PluginStore { + pub fn new(codex_home: PathBuf) -> Self { + Self::try_new(codex_home) + .unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")) + } + + pub fn try_new(codex_home: PathBuf) -> Result { + let root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_CACHE_DIR)) + .map_err(|err| PluginStoreError::io("failed to resolve plugin cache root", err))?; + let data_root = + AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR)) + .map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?; + + Ok(Self { root, data_root }) + } + + pub fn root(&self) -> &AbsolutePathBuf { + &self.root + } + + pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf { + self.root + .join(&plugin_id.marketplace_name) + .join(&plugin_id.plugin_name) + } + + pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf { + self.plugin_base_root(plugin_id).join(plugin_version) + } + + pub fn plugin_data_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf { + self.data_root.join(format!( + "{}-{}", + plugin_id.plugin_name, plugin_id.marketplace_name + )) + } + + pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option { + let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path()) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + entry.file_type().ok().filter(std::fs::FileType::is_dir)?; + entry.file_name().into_string().ok() + }) + .filter(|version| validate_plugin_version_segment(version).is_ok()) + .collect::>(); + discovered_versions.sort_unstable(); + if discovered_versions.is_empty() { + None + } else if discovered_versions + .iter() + .any(|version| version == DEFAULT_PLUGIN_VERSION) + { + Some(DEFAULT_PLUGIN_VERSION.to_string()) + } else { + discovered_versions.pop() + } + } + + pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option { + self.active_plugin_version(plugin_id) + .map(|plugin_version| self.plugin_root(plugin_id, &plugin_version)) + } + + pub fn is_installed(&self, plugin_id: &PluginId) -> bool { + self.active_plugin_version(plugin_id).is_some() + } + + pub fn install( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + ) -> Result { + let plugin_version = plugin_version_for_source(source_path.as_path())?; + self.install_with_version(source_path, plugin_id, plugin_version) + } + + pub fn install_with_version( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + plugin_version: String, + ) -> Result { + if !source_path.as_path().is_dir() { + return Err(PluginStoreError::Invalid(format!( + "plugin source path is not a directory: {}", + source_path.display() + ))); + } + + let plugin_name = plugin_name_for_source(source_path.as_path())?; + if plugin_name != plugin_id.plugin_name { + return Err(PluginStoreError::Invalid(format!( + "plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`", + plugin_id.plugin_name + ))); + } + validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?; + let installed_path = self.plugin_root(&plugin_id, &plugin_version); + replace_plugin_root_atomically( + source_path.as_path(), + self.plugin_base_root(&plugin_id).as_path(), + &plugin_version, + )?; + + Ok(PluginInstallResult { + plugin_id, + plugin_version, + installed_path, + }) + } + + pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> { + remove_existing_target(self.plugin_base_root(plugin_id).as_path()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PluginStoreError { + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + #[error("{0}")] + Invalid(String), +} + +impl PluginStoreError { + fn io(context: &'static str, source: io::Error) -> Self { + Self::Io { context, source } + } +} + +pub fn plugin_version_for_source(source_path: &Path) -> Result { + let plugin_version = plugin_manifest_version_for_source(source_path)? + .unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string()); + validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?; + Ok(plugin_version) +} + +pub fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> { + if plugin_version.is_empty() { + return Err("invalid plugin version: must not be empty".to_string()); + } + if matches!(plugin_version, "." | "..") { + return Err("invalid plugin version: path traversal is not allowed".to_string()); + } + if !plugin_version + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+')) + { + return Err( + "invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed" + .to_string(), + ); + } + Ok(()) +} + +fn plugin_manifest_for_source(source_path: &Path) -> Result { + load_plugin_manifest(source_path) + .ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string())) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifestVersion { + #[serde(default)] + version: Option, +} + +fn plugin_manifest_version_for_source( + source_path: &Path, +) -> Result, PluginStoreError> { + let manifest_path = find_plugin_manifest_path(source_path) + .ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?; + + let contents = fs::read_to_string(&manifest_path) + .map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?; + let manifest: RawPluginManifestVersion = serde_json::from_str(&contents) + .map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?; + let Some(version) = manifest.version else { + return Ok(None); + }; + let Some(version) = version.as_str() else { + return Err(PluginStoreError::Invalid( + "invalid plugin version in plugin.json: expected string".to_string(), + )); + }; + let version = version.trim(); + if version.is_empty() { + return Err(PluginStoreError::Invalid( + "invalid plugin version in plugin.json: must not be blank".to_string(), + )); + } + Ok(Some(version.to_string())) +} + +fn plugin_name_for_source(source_path: &Path) -> Result { + let manifest = plugin_manifest_for_source(source_path)?; + + let plugin_name = manifest.name; + validate_plugin_segment(&plugin_name, "plugin name") + .map_err(PluginStoreError::Invalid) + .map(|_| plugin_name) +} + +fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> { + if !path.exists() { + return Ok(()); + } + + if path.is_dir() { + fs::remove_dir_all(path).map_err(|err| { + PluginStoreError::io("failed to remove existing plugin cache entry", err) + }) + } else { + fs::remove_file(path).map_err(|err| { + PluginStoreError::io("failed to remove existing plugin cache entry", err) + }) + } +} + +fn replace_plugin_root_atomically( + source: &Path, + target_root: &Path, + plugin_version: &str, +) -> Result<(), PluginStoreError> { + let Some(parent) = target_root.parent() else { + return Err(PluginStoreError::Invalid(format!( + "plugin cache path has no parent: {}", + target_root.display() + ))); + }; + + fs::create_dir_all(parent) + .map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?; + + let Some(plugin_dir_name) = target_root.file_name() else { + return Err(PluginStoreError::Invalid(format!( + "plugin cache path has no directory name: {}", + target_root.display() + ))); + }; + let staged_dir = tempfile::Builder::new() + .prefix("plugin-install-") + .tempdir_in(parent) + .map_err(|err| { + PluginStoreError::io("failed to create temporary plugin cache directory", err) + })?; + let staged_root = staged_dir.path().join(plugin_dir_name); + let staged_version_root = staged_root.join(plugin_version); + copy_dir_recursive(source, &staged_version_root)?; + + if target_root.exists() { + let backup_dir = tempfile::Builder::new() + .prefix("plugin-backup-") + .tempdir_in(parent) + .map_err(|err| { + PluginStoreError::io("failed to create plugin cache backup directory", err) + })?; + let backup_root = backup_dir.path().join(plugin_dir_name); + fs::rename(target_root, &backup_root) + .map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?; + + if let Err(err) = fs::rename(&staged_root, target_root) { + let rollback_result = fs::rename(&backup_root, target_root); + return match rollback_result { + Ok(()) => Err(PluginStoreError::io( + "failed to activate updated plugin cache entry", + err, + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join(plugin_dir_name); + Err(PluginStoreError::Invalid(format!( + "failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}", + target_root.display(), + backup_path.display() + ))) + } + }; + } + } else { + fs::rename(&staged_root, target_root) + .map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?; + } + + Ok(()) +} + +fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> { + fs::create_dir_all(target) + .map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?; + + for entry in fs::read_dir(source) + .map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))? + { + let entry = + entry.map_err(|err| PluginStoreError::io("failed to enumerate plugin source", err))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?; + + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + } else if file_type.is_file() { + fs::copy(&source_path, &target_path) + .map_err(|err| PluginStoreError::io("failed to copy plugin file", err))?; + } + } + + Ok(()) +} + +#[cfg(test)] +#[path = "store_tests.rs"] +mod tests; diff --git a/code-rs/core-plugins/src/store_tests.rs b/code-rs/core-plugins/src/store_tests.rs new file mode 100644 index 00000000000..0ba6b0d2c6e --- /dev/null +++ b/code-rs/core-plugins/src/store_tests.rs @@ -0,0 +1,330 @@ +use super::*; +use codex_plugin::PluginId; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +fn write_plugin_with_version( + root: &Path, + dir_name: &str, + manifest_name: &str, + manifest_version: Option<&str>, +) { + let plugin_root = root.join(dir_name); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(plugin_root.join("skills")).unwrap(); + let version = manifest_version + .map(|manifest_version| format!(r#","version":"{manifest_version}""#)) + .unwrap_or_default(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{manifest_name}"{version}}}"#), + ) + .unwrap(); + fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); + fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); +} + +fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { + write_plugin_with_version( + root, + dir_name, + manifest_name, + /*manifest_version*/ None, + ); +} + +#[test] +fn try_new_rejects_relative_codex_home() { + let err = PluginStore::try_new(PathBuf::from("relative")) + .expect_err("relative codex home should fail"); + let err = err.to_string().replace('\\', "/"); + + assert_eq!( + err, + "failed to resolve plugin cache root: path is not absolute: relative/plugins/cache" + ); +} + +#[test] +fn install_copies_plugin_into_default_marketplace() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); + assert!(installed_path.join("skills/SKILL.md").is_file()); +} + +#[test] +fn install_uses_manifest_name_for_destination_and_key() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "source-dir", "manifest-name"); + let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from( + tmp.path().join("plugins/cache/market/manifest-name/local"), + ) + .unwrap(), + } + ); +} + +#[test] +fn plugin_root_derives_path_from_key_and_version() { + let tmp = tempdir().unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.plugin_root(&plugin_id, "local").as_path(), + tmp.path().join("plugins/cache/debug/sample/local") + ); +} + +#[test] +fn plugin_data_root_derives_path_from_key() { + let tmp = tempdir().unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.plugin_data_root(&plugin_id).as_path(), + tmp.path().join("plugins/data/sample-debug") + ); +} + +#[test] +fn install_with_version_uses_requested_cache_version() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = + PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap(); + let plugin_version = "0123456789abcdef".to_string(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install_with_version( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + plugin_version.clone(), + ) + .unwrap(); + + let installed_path = tmp.path().join(format!( + "plugins/cache/openai-curated/sample-plugin/{plugin_version}" + )); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version, + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + +#[test] +fn install_uses_manifest_version_when_present() { + let tmp = tempdir().unwrap(); + write_plugin_with_version( + tmp.path(), + "sample-plugin", + "sample-plugin", + Some("1.2.3-beta+7"), + ); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + let result = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + let installed_path = tmp + .path() + .join("plugins/cache/debug/sample-plugin/1.2.3-beta+7"); + assert_eq!( + result, + PluginInstallResult { + plugin_id, + plugin_version: "1.2.3-beta+7".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + } + ); + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); +} + +#[test] +fn install_rejects_blank_manifest_version() { + let tmp = tempdir().unwrap(); + write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" ")); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + let err = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id, + ) + .expect_err("blank manifest version should be rejected"); + let err = err.to_string().replace('\\', "/"); + + assert_eq!( + err, + "invalid plugin version in plugin.json: must not be blank" + ); +} + +#[test] +fn active_plugin_version_reads_version_directory_name() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("local".to_string()) + ); + assert_eq!( + store.active_plugin_root(&plugin_id).unwrap().as_path(), + tmp.path().join("plugins/cache/debug/sample-plugin/local") + ); +} + +#[test] +fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("local".to_string()) + ); +} + +#[test] +fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/fedcba9876543210", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("fedcba9876543210".to_string()) + ); +} + +#[test] +fn plugin_root_rejects_path_separators_in_key_segments() { + let err = PluginId::parse("../../etc@debug").unwrap_err(); + assert_eq!( + err.to_string(), + "invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed in `../../etc@debug`" + ); + + let err = PluginId::parse("sample@../../etc").unwrap_err(); + assert_eq!( + err.to_string(), + "invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`" + ); +} + +#[test] +fn install_rejects_manifest_names_with_path_separators() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "source-dir", "../../etc"); + + let err = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), + PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(), + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed" + ); +} + +#[test] +fn install_rejects_marketplace_names_with_path_separators() { + let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed" + ); +} + +#[test] +fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "source-dir", "manifest-name"); + + let err = PluginStore::new(tmp.path().to_path_buf()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), + PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(), + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin.json name `manifest-name` does not match marketplace plugin name `different-name`" + ); +} diff --git a/code-rs/core-plugins/src/test_support.rs b/code-rs/core-plugins/src/test_support.rs new file mode 100644 index 00000000000..6be2fbf0db6 --- /dev/null +++ b/code-rs/core-plugins/src/test_support.rs @@ -0,0 +1,139 @@ +use std::fs; +use std::path::Path; + +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::PluginsConfigInput; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_config::loader::load_config_layers_state; +use codex_exec_server::LOCAL_FS; +use codex_utils_absolute_path::AbsolutePathBuf; +use toml::Value; + +pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; +pub(crate) const TEST_CURATED_PLUGIN_CACHE_VERSION: &str = "01234567"; + +pub(crate) fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors" +}}"# + ), + ); + write_file( + &plugin_root.join("skills/SKILL.md"), + "---\nname: sample\ndescription: sample\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample-docs": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "connector_calendar" + } + } +}"#, + ); +} + +pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ +{plugins} + ] +}}"# + ), + ); + for plugin_name in plugin_names { + write_curated_plugin(root, plugin_name); + } +} + +pub(crate) fn write_curated_plugin_sha(codex_home: &Path) { + write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA); +} + +pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) { + write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); +} + +pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { + let codex_home = AbsolutePathBuf::try_from(codex_home).expect("codex home should be absolute"); + let cwd = AbsolutePathBuf::try_from(cwd).expect("cwd should be absolute"); + let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), + codex_home.as_path(), + Some(cwd), + &[], + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + &NoopThreadConfigLoader, + ) + .await + .expect("config should load"); + let effective_config = config_layer_stack.effective_config(); + PluginsConfigInput::new( + config_layer_stack, + feature_enabled(&effective_config, "plugins", /*default_enabled*/ true), + feature_enabled( + &effective_config, + "remote_plugin", + /*default_enabled*/ false, + ), + feature_enabled( + &effective_config, + "plugin_hooks", + /*default_enabled*/ false, + ), + "https://chatgpt.com/backend-api/".to_string(), + ) +} + +fn feature_enabled(config: &Value, key: &str, default_enabled: bool) -> bool { + config + .get("features") + .and_then(Value::as_table) + .and_then(|features| features.get(key)) + .and_then(Value::as_bool) + .unwrap_or(default_enabled) +} diff --git a/code-rs/core-plugins/src/toggles.rs b/code-rs/core-plugins/src/toggles.rs new file mode 100644 index 00000000000..215943dc168 --- /dev/null +++ b/code-rs/core-plugins/src/toggles.rs @@ -0,0 +1,100 @@ +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; + +pub fn collect_plugin_enabled_candidates<'a>( + edits: impl Iterator, +) -> BTreeMap { + let mut pending_changes = BTreeMap::new(); + for (key_path, value) in edits { + let segments = key_path + .split('.') + .map(str::to_string) + .collect::>(); + match segments.as_slice() { + [plugins, plugin_id, enabled] + if plugins == "plugins" && enabled == "enabled" && value.is_boolean() => + { + if let Some(enabled) = value.as_bool() { + pending_changes.insert(plugin_id.clone(), enabled); + } + } + [plugins, plugin_id] if plugins == "plugins" => { + if let Some(enabled) = value.get("enabled").and_then(JsonValue::as_bool) { + pending_changes.insert(plugin_id.clone(), enabled); + } + } + [plugins] if plugins == "plugins" => { + let Some(entries) = value.as_object() else { + continue; + }; + for (plugin_id, plugin_value) in entries { + let Some(enabled) = plugin_value.get("enabled").and_then(JsonValue::as_bool) + else { + continue; + }; + pending_changes.insert(plugin_id.clone(), enabled); + } + } + _ => {} + } + } + + pending_changes +} + +#[cfg(test)] +mod tests { + use super::collect_plugin_enabled_candidates; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::collections::BTreeMap; + + #[test] + fn collect_plugin_enabled_candidates_tracks_direct_and_table_writes() { + let candidates = collect_plugin_enabled_candidates( + [ + (&"plugins.sample@test.enabled".to_string(), &json!(true)), + ( + &"plugins.other@test".to_string(), + &json!({ "enabled": false, "ignored": true }), + ), + ( + &"plugins".to_string(), + &json!({ + "nested@test": { "enabled": true }, + "skip@test": { "name": "skip" }, + }), + ), + ] + .into_iter(), + ); + + assert_eq!( + candidates, + BTreeMap::from([ + ("nested@test".to_string(), true), + ("other@test".to_string(), false), + ("sample@test".to_string(), true), + ]) + ); + } + + #[test] + fn collect_plugin_enabled_candidates_uses_last_write_for_same_plugin() { + let candidates = collect_plugin_enabled_candidates( + [ + (&"plugins.sample@test.enabled".to_string(), &json!(true)), + ( + &"plugins.sample@test".to_string(), + &json!({ "enabled": false }), + ), + ] + .into_iter(), + ); + + assert_eq!( + candidates, + BTreeMap::from([("sample@test".to_string(), false)]) + ); + } +} diff --git a/code-rs/core-skills/BUILD.bazel b/code-rs/core-skills/BUILD.bazel new file mode 100644 index 00000000000..e80412a554a --- /dev/null +++ b/code-rs/core-skills/BUILD.bazel @@ -0,0 +1,15 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "core-skills", + crate_name = "codex_core_skills", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), +) diff --git a/code-rs/core-skills/Cargo.toml b/code-rs/core-skills/Cargo.toml new file mode 100644 index 00000000000..4324d29dee9 --- /dev/null +++ b/code-rs/core-skills/Cargo.toml @@ -0,0 +1,42 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-core-skills" +version.workspace = true + +[lib] +doctest = false +name = "codex_core_skills" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-analytics = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-config = { workspace = true } +codex-exec-server = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +codex-skills = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-output-truncation = { workspace = true } +codex-utils-plugins = { workspace = true } +dirs = { workspace = true } +dunce = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +shlex = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros", "rt"] } +toml = { workspace = true } +tracing = { workspace = true } +zip = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/code-rs/core-skills/src/config_rules.rs b/code-rs/core-skills/src/config_rules.rs new file mode 100644 index 00000000000..92ad2ab1a68 --- /dev/null +++ b/code-rs/core-skills/src/config_rules.rs @@ -0,0 +1,128 @@ +use std::collections::HashSet; + +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::SkillConfig; +use codex_config::SkillsConfig; +use codex_utils_absolute_path::AbsolutePathBuf; +use tracing::warn; + +use crate::SkillMetadata; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SkillConfigRuleSelector { + Name(String), + Path(AbsolutePathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SkillConfigRule { + pub selector: SkillConfigRuleSelector, + pub enabled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct SkillConfigRules { + pub entries: Vec, +} + +pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> SkillConfigRules { + let mut entries = Vec::new(); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) { + if !matches!( + layer.name, + ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags + ) { + continue; + } + + let Some(skills_value) = layer.config.get("skills") else { + continue; + }; + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + continue; + } + }; + + for entry in skills.config { + let Some(selector) = skill_config_rule_selector(&entry) else { + continue; + }; + // Preserve layer order so a later name selector can override an earlier path selector + // for the same loaded skill. + entries.retain(|entry: &SkillConfigRule| entry.selector != selector); + entries.push(SkillConfigRule { + selector, + enabled: entry.enabled, + }); + } + } + + SkillConfigRules { entries } +} + +pub fn resolve_disabled_skill_paths( + skills: &[SkillMetadata], + rules: &SkillConfigRules, +) -> HashSet { + let mut disabled_paths = HashSet::new(); + + for entry in &rules.entries { + match &entry.selector { + SkillConfigRuleSelector::Path(path) => { + if entry.enabled { + disabled_paths.remove(path); + } else { + disabled_paths.insert(path.clone()); + } + } + SkillConfigRuleSelector::Name(name) => { + for path in skills + .iter() + .filter(|skill| skill.name == *name) + .map(|skill| skill.path_to_skills_md.clone()) + { + if entry.enabled { + disabled_paths.remove(&path); + } else { + disabled_paths.insert(path); + } + } + } + } + } + + disabled_paths +} + +fn skill_config_rule_selector(entry: &SkillConfig) -> Option { + match (entry.path.as_ref(), entry.name.as_deref()) { + (Some(path), None) => Some(SkillConfigRuleSelector::Path( + path.canonicalize().unwrap_or_else(|_| path.clone()), + )), + (None, Some(name)) => { + let name = name.trim(); + if name.is_empty() { + warn!("ignoring empty skills.config name override"); + None + } else { + Some(SkillConfigRuleSelector::Name(name.to_string())) + } + } + (Some(_), Some(_)) => { + warn!("ignoring skills.config entry with both path and name selectors"); + None + } + (None, None) => { + warn!("ignoring skills.config entry without a path or name selector"); + None + } + } +} diff --git a/code-rs/core-skills/src/env_var_dependencies.rs b/code-rs/core-skills/src/env_var_dependencies.rs new file mode 100644 index 00000000000..4d8e57164cd --- /dev/null +++ b/code-rs/core-skills/src/env_var_dependencies.rs @@ -0,0 +1,30 @@ +use crate::SkillMetadata; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillDependencyInfo { + pub skill_name: String, + pub name: String, + pub description: Option, +} + +pub fn collect_env_var_dependencies( + mentioned_skills: &[SkillMetadata], +) -> Vec { + let mut dependencies = Vec::new(); + for skill in mentioned_skills { + let Some(skill_dependencies) = &skill.dependencies else { + continue; + }; + for tool in &skill_dependencies.tools { + if tool.r#type != "env_var" || tool.value.is_empty() { + continue; + } + dependencies.push(SkillDependencyInfo { + skill_name: skill.name.clone(), + name: tool.value.clone(), + description: tool.description.clone(), + }); + } + } + dependencies +} diff --git a/code-rs/core-skills/src/injection.rs b/code-rs/core-skills/src/injection.rs new file mode 100644 index 00000000000..df62f42e85a --- /dev/null +++ b/code-rs/core-skills/src/injection.rs @@ -0,0 +1,512 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +use crate::SkillLoadOutcome; +use crate::SkillMetadata; +use crate::build_skill_name_counts; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::InvocationType; +use codex_analytics::SkillInvocation; +use codex_analytics::TrackEventsContext; +use codex_exec_server::LOCAL_FS; +use codex_otel::SessionTelemetry; +use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; + +#[derive(Debug, Default)] +pub struct SkillInjections { + pub items: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillInjection { + pub name: String, + pub path: String, + pub contents: String, +} + +pub async fn build_skill_injections( + mentioned_skills: &[SkillMetadata], + loaded_skills: Option<&SkillLoadOutcome>, + otel: Option<&SessionTelemetry>, + analytics_client: &AnalyticsEventsClient, + tracking: TrackEventsContext, +) -> SkillInjections { + if mentioned_skills.is_empty() { + return SkillInjections::default(); + } + + let mut result = SkillInjections { + items: Vec::with_capacity(mentioned_skills.len()), + warnings: Vec::new(), + }; + let mut invocations = Vec::new(); + + for skill in mentioned_skills { + let fs = loaded_skills + .and_then(|outcome| outcome.file_system_for_skill(skill)) + .unwrap_or_else(|| Arc::clone(&LOCAL_FS)); + match fs + .read_file_text(&skill.path_to_skills_md, /*sandbox*/ None) + .await + { + Ok(contents) => { + emit_skill_injected_metric(otel, skill, "ok"); + invocations.push(SkillInvocation { + skill_name: skill.name.clone(), + skill_scope: skill.scope, + skill_path: skill.path_to_skills_md.to_path_buf(), + plugin_id: skill.plugin_id.clone(), + invocation_type: InvocationType::Explicit, + }); + result.items.push(SkillInjection { + name: skill.name.clone(), + path: skill.path_to_skills_md.to_string_lossy().into_owned(), + contents, + }); + } + Err(err) => { + emit_skill_injected_metric(otel, skill, "error"); + let message = format!( + "Failed to load skill {name} at {path}: {err:#}", + name = skill.name, + path = skill.path_to_skills_md.display() + ); + result.warnings.push(message); + } + } + } + + analytics_client.track_skill_invocations(tracking, invocations); + + result +} + +fn emit_skill_injected_metric( + otel: Option<&SessionTelemetry>, + skill: &SkillMetadata, + status: &str, +) { + let Some(otel) = otel else { + return; + }; + + otel.counter( + "codex.skill.injected", + /*inc*/ 1, + &[("status", status), ("skill", skill.name.as_str())], + ); +} + +/// Collect explicitly mentioned skills from structured and text mentions. +/// +/// Structured `UserInput::Skill` selections are resolved first by path against +/// enabled skills. Text inputs are then scanned to extract `$skill-name` tokens, and we +/// iterate `skills` in their existing order to preserve prior ordering semantics. +/// Explicit links are resolved by path and plain names are only used when the match +/// is unambiguous. +/// +/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where: +/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs, +/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input. +pub fn collect_explicit_skill_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], + disabled_paths: &HashSet, + connector_slug_counts: &HashMap, +) -> Vec { + let skill_name_counts = build_skill_name_counts(skills, disabled_paths).0; + + let selection_context = SkillSelectionContext { + skills, + disabled_paths, + skill_name_counts: &skill_name_counts, + connector_slug_counts, + }; + let mut selected: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + let mut seen_paths: HashSet = HashSet::new(); + let mut blocked_plain_names: HashSet = HashSet::new(); + + for input in inputs { + if let UserInput::Skill { name, path } = input { + blocked_plain_names.insert(name.clone()); + let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else { + continue; + }; + + if selection_context.disabled_paths.contains(&path) || seen_paths.contains(&path) { + continue; + } + + if let Some(skill) = selection_context + .skills + .iter() + .find(|skill| skill.path_to_skills_md == path) + { + seen_paths.insert(skill.path_to_skills_md.clone()); + seen_names.insert(skill.name.clone()); + selected.push(skill.clone()); + } + } + } + + for input in inputs { + if let UserInput::Text { text, .. } = input { + let mentioned_names = extract_tool_mentions(text); + select_skills_from_mentions( + &selection_context, + &blocked_plain_names, + &mentioned_names, + &mut seen_names, + &mut seen_paths, + &mut selected, + ); + } + } + + selected +} + +struct SkillSelectionContext<'a> { + skills: &'a [SkillMetadata], + disabled_paths: &'a HashSet, + skill_name_counts: &'a HashMap, + connector_slug_counts: &'a HashMap, +} + +pub struct ToolMentions<'a> { + names: HashSet<&'a str>, + paths: HashSet<&'a str>, + plain_names: HashSet<&'a str>, +} + +impl<'a> ToolMentions<'a> { + fn is_empty(&self) -> bool { + self.names.is_empty() && self.paths.is_empty() + } + + pub fn plain_names(&self) -> impl Iterator + '_ { + self.plain_names.iter().copied() + } + + pub fn paths(&self) -> impl Iterator + '_ { + self.paths.iter().copied() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolMentionKind { + App, + Mcp, + Plugin, + Skill, + Other, +} + +const APP_PATH_PREFIX: &str = "app://"; +const MCP_PATH_PREFIX: &str = "mcp://"; +const PLUGIN_PATH_PREFIX: &str = "plugin://"; +const SKILL_PATH_PREFIX: &str = "skill://"; +const SKILL_FILENAME: &str = "SKILL.md"; + +pub fn tool_kind_for_path(path: &str) -> ToolMentionKind { + if path.starts_with(APP_PATH_PREFIX) { + ToolMentionKind::App + } else if path.starts_with(MCP_PATH_PREFIX) { + ToolMentionKind::Mcp + } else if path.starts_with(PLUGIN_PATH_PREFIX) { + ToolMentionKind::Plugin + } else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) { + ToolMentionKind::Skill + } else { + ToolMentionKind::Other + } +} + +fn is_skill_filename(path: &str) -> bool { + let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path); + file_name.eq_ignore_ascii_case(SKILL_FILENAME) +} + +pub fn app_id_from_path(path: &str) -> Option<&str> { + path.strip_prefix(APP_PATH_PREFIX) + .filter(|value| !value.is_empty()) +} + +pub fn plugin_config_name_from_path(path: &str) -> Option<&str> { + path.strip_prefix(PLUGIN_PATH_PREFIX) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn normalize_skill_path(path: &str) -> &str { + path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path) +} + +/// Extract `$tool-name` mentions from a single text input. +/// +/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a +/// resource path is present, it is captured for exact path matching while also tracking +/// the name for fallback matching. +pub fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { + extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL) +} + +pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> { + let text_bytes = text.as_bytes(); + let mut mentioned_names: HashSet<&str> = HashSet::new(); + let mut mentioned_paths: HashSet<&str> = HashSet::new(); + let mut plain_names: HashSet<&str> = HashSet::new(); + + let mut index = 0; + while index < text_bytes.len() { + let byte = text_bytes[index]; + if byte == b'[' + && let Some((name, path, end_index)) = + parse_linked_tool_mention(text, text_bytes, index, sigil) + { + if !is_common_env_var(name) { + if !matches!( + tool_kind_for_path(path), + ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin + ) { + mentioned_names.insert(name); + } + mentioned_paths.insert(path); + } + index = end_index; + continue; + } + + if byte != sigil as u8 { + index += 1; + continue; + } + + let name_start = index + 1; + let Some(first_name_byte) = text_bytes.get(name_start) else { + index += 1; + continue; + }; + if !is_mention_name_char(*first_name_byte) { + index += 1; + continue; + } + + let mut name_end = name_start + 1; + while let Some(next_byte) = text_bytes.get(name_end) + && is_mention_name_char(*next_byte) + { + name_end += 1; + } + + let name = &text[name_start..name_end]; + if !is_common_env_var(name) { + mentioned_names.insert(name); + plain_names.insert(name); + } + index = name_end; + } + + ToolMentions { + names: mentioned_names, + paths: mentioned_paths, + plain_names, + } +} + +/// Select mentioned skills while preserving the order of `skills`. +fn select_skills_from_mentions( + selection_context: &SkillSelectionContext<'_>, + blocked_plain_names: &HashSet, + mentions: &ToolMentions<'_>, + seen_names: &mut HashSet, + seen_paths: &mut HashSet, + selected: &mut Vec, +) { + if mentions.is_empty() { + return; + } + + let mention_skill_paths: HashSet<&str> = mentions + .paths() + .filter(|path| { + !matches!( + tool_kind_for_path(path), + ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin + ) + }) + .map(normalize_skill_path) + .collect(); + + for skill in selection_context.skills { + if selection_context + .disabled_paths + .contains(&skill.path_to_skills_md) + || seen_paths.contains(&skill.path_to_skills_md) + { + continue; + } + + let path_str = skill.path_to_skills_md.to_string_lossy(); + if mention_skill_paths.contains(path_str.as_ref()) { + seen_paths.insert(skill.path_to_skills_md.clone()); + seen_names.insert(skill.name.clone()); + selected.push(skill.clone()); + } + } + + for skill in selection_context.skills { + if selection_context + .disabled_paths + .contains(&skill.path_to_skills_md) + || seen_paths.contains(&skill.path_to_skills_md) + { + continue; + } + + if blocked_plain_names.contains(skill.name.as_str()) { + continue; + } + if !mentions.plain_names.contains(skill.name.as_str()) { + continue; + } + + let skill_count = selection_context + .skill_name_counts + .get(skill.name.as_str()) + .copied() + .unwrap_or(0); + let connector_count = selection_context + .connector_slug_counts + .get(&skill.name.to_ascii_lowercase()) + .copied() + .unwrap_or(0); + if skill_count != 1 || connector_count != 0 { + continue; + } + + if seen_names.insert(skill.name.clone()) { + seen_paths.insert(skill.path_to_skills_md.clone()); + selected.push(skill.clone()); + } + } +} + +fn parse_linked_tool_mention<'a>( + text: &'a str, + text_bytes: &[u8], + start: usize, + sigil: char, +) -> Option<(&'a str, &'a str, usize)> { + let sigil_index = start + 1; + if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { + return None; + } + + let name_start = sigil_index + 1; + let first_name_byte = text_bytes.get(name_start)?; + if !is_mention_name_char(*first_name_byte) { + return None; + } + + let mut name_end = name_start + 1; + while let Some(next_byte) = text_bytes.get(name_end) + && is_mention_name_char(*next_byte) + { + name_end += 1; + } + + if text_bytes.get(name_end) != Some(&b']') { + return None; + } + + let mut path_start = name_end + 1; + while let Some(next_byte) = text_bytes.get(path_start) + && next_byte.is_ascii_whitespace() + { + path_start += 1; + } + if text_bytes.get(path_start) != Some(&b'(') { + return None; + } + + let mut path_end = path_start + 1; + while let Some(next_byte) = text_bytes.get(path_end) + && *next_byte != b')' + { + path_end += 1; + } + if text_bytes.get(path_end) != Some(&b')') { + return None; + } + + let path = text[path_start + 1..path_end].trim(); + if path.is_empty() { + return None; + } + + let name = &text[name_start..name_end]; + Some((name, path, path_end + 1)) +} + +fn is_common_env_var(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + matches!( + upper.as_str(), + "PATH" + | "HOME" + | "USER" + | "SHELL" + | "PWD" + | "TMPDIR" + | "TEMP" + | "TMP" + | "LANG" + | "TERM" + | "XDG_CONFIG_HOME" + ) +} + +#[cfg(test)] +fn text_mentions_skill(text: &str, skill_name: &str) -> bool { + if skill_name.is_empty() { + return false; + } + + let text_bytes = text.as_bytes(); + let skill_bytes = skill_name.as_bytes(); + + for (index, byte) in text_bytes.iter().copied().enumerate() { + if byte != b'$' { + continue; + } + + let name_start = index + 1; + let Some(rest) = text_bytes.get(name_start..) else { + continue; + }; + if !rest.starts_with(skill_bytes) { + continue; + } + + let after_index = name_start + skill_bytes.len(); + let after = text_bytes.get(after_index).copied(); + if after.is_none_or(|b| !is_mention_name_char(b)) { + return true; + } + } + + false +} + +fn is_mention_name_char(byte: u8) -> bool { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':') +} + +#[cfg(test)] +#[path = "injection_tests.rs"] +mod tests; diff --git a/code-rs/core-skills/src/injection_tests.rs b/code-rs/core-skills/src/injection_tests.rs new file mode 100644 index 00000000000..78aa1958952 --- /dev/null +++ b/code-rs/core-skills/src/injection_tests.rs @@ -0,0 +1,358 @@ +use super::*; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::collections::HashSet; + +fn make_skill(name: &str, path: &str) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: format!("{name} skill"), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf(path).abs(), + scope: codex_protocol::protocol::SkillScope::User, + plugin_id: None, + } +} + +fn set<'a>(items: &'a [&'a str]) -> HashSet<&'a str> { + items.iter().copied().collect() +} + +fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) { + let mentions = extract_tool_mentions(text); + assert_eq!(mentions.names, set(expected_names)); + assert_eq!(mentions.paths, set(expected_paths)); +} + +fn linked_skill_mention(name: &str, unix_path: &str) -> String { + format!("[${name}]({})", test_path_buf(unix_path).display()) +} + +fn collect_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], + disabled_paths: &HashSet, + connector_slug_counts: &HashMap, +) -> Vec { + collect_explicit_skill_mentions(inputs, skills, disabled_paths, connector_slug_counts) +} + +#[test] +fn text_mentions_skill_requires_exact_boundary() { + assert_eq!( + true, + text_mentions_skill("use $notion-research-doc please", "notion-research-doc") + ); + assert_eq!( + true, + text_mentions_skill("($notion-research-doc)", "notion-research-doc") + ); + assert_eq!( + true, + text_mentions_skill("$notion-research-doc.", "notion-research-doc") + ); + assert_eq!( + false, + text_mentions_skill("$notion-research-docs", "notion-research-doc") + ); + assert_eq!( + false, + text_mentions_skill("$notion-research-doc_extra", "notion-research-doc") + ); +} + +#[test] +fn text_mentions_skill_handles_end_boundary_and_near_misses() { + assert_eq!(true, text_mentions_skill("$alpha-skill", "alpha-skill")); + assert_eq!(false, text_mentions_skill("$alpha-skillx", "alpha-skill")); + assert_eq!( + true, + text_mentions_skill("$alpha-skillx and later $alpha-skill ", "alpha-skill") + ); +} + +#[test] +fn text_mentions_skill_handles_many_dollars_without_looping() { + let prefix = "$".repeat(256); + let text = format!("{prefix} not-a-mention"); + assert_eq!(false, text_mentions_skill(&text, "alpha-skill")); +} + +#[test] +fn extract_tool_mentions_handles_plain_and_linked_mentions() { + assert_mentions( + "use $alpha and [$beta](/tmp/beta)", + &["alpha", "beta"], + &["/tmp/beta"], + ); +} + +#[test] +fn extract_tool_mentions_skips_common_env_vars() { + assert_mentions("use $PATH and $alpha", &["alpha"], &[]); + assert_mentions("use [$HOME](/tmp/skill)", &[], &[]); + assert_mentions("use $XDG_CONFIG_HOME and $beta", &["beta"], &[]); +} + +#[test] +fn extract_tool_mentions_requires_link_syntax() { + assert_mentions("[beta](/tmp/beta)", &[], &[]); + assert_mentions("[$beta] /tmp/beta", &["beta"], &[]); + assert_mentions("[$beta]()", &["beta"], &[]); +} + +#[test] +fn extract_tool_mentions_trims_linked_paths_and_allows_spacing() { + assert_mentions("use [$beta] ( /tmp/beta )", &["beta"], &["/tmp/beta"]); +} + +#[test] +fn extract_tool_mentions_stops_at_non_name_chars() { + assert_mentions( + "use $alpha.skill and $beta_extra", + &["alpha", "beta_extra"], + &[], + ); +} + +#[test] +fn extract_tool_mentions_keeps_plugin_skill_namespaces() { + assert_mentions( + "use $slack:search and $alpha", + &["alpha", "slack:search"], + &[], + ); +} + +#[test] +fn collect_explicit_skill_mentions_text_respects_skill_order() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let beta = make_skill("beta-skill", "/tmp/beta"); + let skills = vec![beta.clone(), alpha.clone()]; + let inputs = vec![UserInput::Text { + text: "first $alpha-skill then $beta-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + // Text scanning should not change the previous selection ordering semantics. + assert_eq!(selected, vec![beta, alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_prioritizes_structured_inputs() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let beta = make_skill("beta-skill", "/tmp/beta"); + let skills = vec![alpha.clone(), beta.clone()]; + let inputs = vec![ + UserInput::Text { + text: "please run $alpha-skill".to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "beta-skill".to_string(), + path: test_path_buf("/tmp/beta"), + }, + ]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![beta, alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_invalid_structured_and_blocks_plain_fallback() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![ + UserInput::Text { + text: "please run $alpha-skill".to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "alpha-skill".to_string(), + path: test_path_buf("/tmp/missing"), + }, + ]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_skips_disabled_structured_and_blocks_plain_fallback() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![ + UserInput::Text { + text: "please run $alpha-skill".to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "alpha-skill".to_string(), + path: test_path_buf("/tmp/alpha"), + }, + ]; + let disabled = HashSet::from([test_path_buf("/tmp/alpha").abs()]); + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_dedupes_by_path() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha.clone()]; + let mention = linked_skill_mention("alpha-skill", "/tmp/alpha"); + let inputs = vec![UserInput::Text { + text: format!("use {mention} and {mention}"), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_ambiguous_name() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta]; + let inputs = vec![UserInput::Text { + text: "use $demo-skill and again $demo-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_prefers_linked_path_over_name() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta.clone()]; + let inputs = vec![UserInput::Text { + text: format!( + "use $demo-skill and {}", + linked_skill_mention("demo-skill", "/tmp/beta") + ), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![beta]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: "use $alpha-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha.clone()]; + let inputs = vec![UserInput::Text { + text: format!("use {}", linked_skill_mention("alpha-skill", "/tmp/alpha")), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![alpha]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta]; + let inputs = vec![UserInput::Text { + text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/alpha")), + text_elements: Vec::new(), + }]; + let disabled = HashSet::from([test_path_buf("/tmp/alpha").abs()]); + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_prefers_resource_path() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta.clone()]; + let inputs = vec![UserInput::Text { + text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/beta")), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![beta]); +} + +#[test] +fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let beta = make_skill("demo-skill", "/tmp/beta"); + let skills = vec![alpha, beta]; + let inputs = vec![UserInput::Text { + text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/missing")), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} + +#[test] +fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: format!("use {}", linked_skill_mention("demo-skill", "/tmp/missing")), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); +} diff --git a/code-rs/core-skills/src/invocation_utils.rs b/code-rs/core-skills/src/invocation_utils.rs new file mode 100644 index 00000000000..4c9d0a4119e --- /dev/null +++ b/code-rs/core-skills/src/invocation_utils.rs @@ -0,0 +1,145 @@ +use std::collections::HashMap; +use std::path::Path; + +use crate::SkillLoadOutcome; +use crate::SkillMetadata; +use codex_utils_absolute_path::AbsolutePathBuf; + +pub(crate) fn build_implicit_skill_path_indexes( + skills: Vec, +) -> ( + HashMap, + HashMap, +) { + let mut by_scripts_dir = HashMap::new(); + let mut by_skill_doc_path = HashMap::new(); + for skill in skills { + let skill_doc_path = canonicalize_if_exists(&skill.path_to_skills_md); + by_skill_doc_path.insert(skill_doc_path, skill.clone()); + + if let Some(skill_dir) = skill.path_to_skills_md.parent() { + let scripts_dir = canonicalize_if_exists(&skill_dir.join("scripts")); + by_scripts_dir.insert(scripts_dir, skill); + } + } + + (by_scripts_dir, by_skill_doc_path) +} + +pub fn detect_implicit_skill_invocation_for_command( + outcome: &SkillLoadOutcome, + command: &str, + workdir: &AbsolutePathBuf, +) -> Option { + let workdir = canonicalize_if_exists(workdir); + let tokens = tokenize_command(command); + + if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), &workdir) { + return Some(candidate); + } + + detect_skill_doc_read(outcome, tokens.as_slice(), &workdir) +} + +fn tokenize_command(command: &str) -> Vec { + shlex::split(command) + .unwrap_or_else(|| command.split_whitespace().map(str::to_string).collect()) +} + +fn script_run_token(tokens: &[String]) -> Option<&str> { + const RUNNERS: [&str; 10] = [ + "python", "python3", "bash", "zsh", "sh", "node", "deno", "ruby", "perl", "pwsh", + ]; + const SCRIPT_EXTENSIONS: [&str; 7] = [".py", ".sh", ".js", ".ts", ".rb", ".pl", ".ps1"]; + + let runner_token = tokens.first()?; + let runner = command_basename(runner_token).to_ascii_lowercase(); + let runner = runner.strip_suffix(".exe").unwrap_or(&runner); + if !RUNNERS.contains(&runner) { + return None; + } + + let mut script_token = None; + for token in tokens.iter().skip(1) { + if token == "--" || token.starts_with('-') { + continue; + } + script_token = Some(token.as_str()); + break; + } + let script_token = script_token?; + if SCRIPT_EXTENSIONS + .iter() + .any(|extension| script_token.to_ascii_lowercase().ends_with(extension)) + { + return Some(script_token); + } + + None +} + +fn detect_skill_script_run( + outcome: &SkillLoadOutcome, + tokens: &[String], + workdir: &AbsolutePathBuf, +) -> Option { + let script_token = script_run_token(tokens)?; + let script_path = Path::new(script_token); + let script_path = canonicalize_if_exists(&workdir.join(script_path)); + + for path in script_path.ancestors() { + if let Some(candidate) = outcome.implicit_skills_by_scripts_dir.get(&path) { + return Some(candidate.clone()); + } + } + + None +} + +fn detect_skill_doc_read( + outcome: &SkillLoadOutcome, + tokens: &[String], + workdir: &AbsolutePathBuf, +) -> Option { + if !command_reads_file(tokens) { + return None; + } + + for token in tokens.iter().skip(1) { + if token.starts_with('-') { + continue; + } + let path = Path::new(token); + let candidate_path = canonicalize_if_exists(&workdir.join(path)); + if let Some(candidate) = outcome.implicit_skills_by_doc_path.get(&candidate_path) { + return Some(candidate.clone()); + } + } + + None +} + +fn command_reads_file(tokens: &[String]) -> bool { + const READERS: [&str; 8] = ["cat", "sed", "head", "tail", "less", "more", "bat", "awk"]; + let Some(program) = tokens.first() else { + return false; + }; + let program = command_basename(program).to_ascii_lowercase(); + READERS.contains(&program.as_str()) +} + +fn command_basename(command: &str) -> String { + Path::new(command) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(command) + .to_string() +} + +fn canonicalize_if_exists(path: &AbsolutePathBuf) -> AbsolutePathBuf { + path.canonicalize().unwrap_or_else(|_| path.clone()) +} + +#[cfg(test)] +#[path = "invocation_utils_tests.rs"] +mod tests; diff --git a/code-rs/core-skills/src/invocation_utils_tests.rs b/code-rs/core-skills/src/invocation_utils_tests.rs new file mode 100644 index 00000000000..f6e3883c16d --- /dev/null +++ b/code-rs/core-skills/src/invocation_utils_tests.rs @@ -0,0 +1,123 @@ +use super::SkillLoadOutcome; +use super::SkillMetadata; +use super::canonicalize_if_exists; +use super::detect_skill_doc_read; +use super::detect_skill_script_run; +use super::script_run_token; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::sync::Arc; + +fn test_skill_metadata(skill_doc_path: AbsolutePathBuf) -> SkillMetadata { + SkillMetadata { + name: "test-skill".to_string(), + description: "test".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: skill_doc_path, + scope: codex_protocol::protocol::SkillScope::User, + plugin_id: None, + } +} + +fn test_path_display(unix_path: &str) -> String { + test_path_buf(unix_path).display().to_string() +} + +#[test] +fn script_run_detection_matches_runner_plus_extension() { + let tokens = vec![ + "python3".to_string(), + "-u".to_string(), + "scripts/fetch_comments.py".to_string(), + ]; + + assert_eq!(script_run_token(&tokens).is_some(), true); +} + +#[test] +fn script_run_detection_excludes_python_c() { + let tokens = vec![ + "python3".to_string(), + "-c".to_string(), + "print(1)".to_string(), + ]; + + assert_eq!(script_run_token(&tokens).is_some(), false); +} + +#[test] +fn skill_doc_read_detection_matches_absolute_path() { + let skill_doc_path = test_path_buf("/tmp/skill-test/SKILL.md").abs(); + let normalized_skill_doc_path = canonicalize_if_exists(&skill_doc_path); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::new()), + implicit_skills_by_doc_path: Arc::new(HashMap::from([(normalized_skill_doc_path, skill)])), + ..Default::default() + }; + + let tokens = vec![ + "cat".to_string(), + test_path_display("/tmp/skill-test/SKILL.md"), + "|".to_string(), + "head".to_string(), + ]; + let found = detect_skill_doc_read(&outcome, &tokens, &test_path_buf("/tmp").abs()); + + assert_eq!( + found.map(|value| value.name), + Some("test-skill".to_string()) + ); +} + +#[test] +fn skill_script_run_detection_matches_relative_path_from_skill_root() { + let skill_doc_path = test_path_buf("/tmp/skill-test/SKILL.md").abs(); + let scripts_dir = canonicalize_if_exists(&test_path_buf("/tmp/skill-test/scripts").abs()); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])), + implicit_skills_by_doc_path: Arc::new(HashMap::new()), + ..Default::default() + }; + let tokens = vec![ + "python3".to_string(), + "scripts/fetch_comments.py".to_string(), + ]; + + let found = detect_skill_script_run(&outcome, &tokens, &test_path_buf("/tmp/skill-test").abs()); + + assert_eq!( + found.map(|value| value.name), + Some("test-skill".to_string()) + ); +} + +#[test] +fn skill_script_run_detection_matches_absolute_path_from_any_workdir() { + let skill_doc_path = test_path_buf("/tmp/skill-test/SKILL.md").abs(); + let scripts_dir = canonicalize_if_exists(&test_path_buf("/tmp/skill-test/scripts").abs()); + let skill = test_skill_metadata(skill_doc_path); + let outcome = SkillLoadOutcome { + implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])), + implicit_skills_by_doc_path: Arc::new(HashMap::new()), + ..Default::default() + }; + let tokens = vec![ + "python3".to_string(), + test_path_display("/tmp/skill-test/scripts/fetch_comments.py"), + ]; + + let found = detect_skill_script_run(&outcome, &tokens, &test_path_buf("/tmp/other").abs()); + + assert_eq!( + found.map(|value| value.name), + Some("test-skill".to_string()) + ); +} diff --git a/code-rs/core-skills/src/lib.rs b/code-rs/core-skills/src/lib.rs new file mode 100644 index 00000000000..eec3a5f054f --- /dev/null +++ b/code-rs/core-skills/src/lib.rs @@ -0,0 +1,34 @@ +pub mod config_rules; +mod env_var_dependencies; +pub mod injection; +pub(crate) mod invocation_utils; +pub mod loader; +pub mod manager; +mod mention_counts; +pub mod model; +pub mod remote; +pub mod render; +pub mod system; + +pub use env_var_dependencies::SkillDependencyInfo; +pub use env_var_dependencies::collect_env_var_dependencies; +pub(crate) use invocation_utils::build_implicit_skill_path_indexes; +pub use invocation_utils::detect_implicit_skill_invocation_for_command; +pub use manager::SkillsLoadInput; +pub use manager::SkillsManager; +pub use mention_counts::build_skill_name_counts; +pub use model::SkillError; +pub use model::SkillLoadOutcome; +pub use model::SkillMetadata; +pub use model::SkillPolicy; +pub use model::filter_skill_load_outcome_for_product; +pub use render::AvailableSkills; +pub use render::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS; +pub use render::SKILLS_HOW_TO_USE_WITH_ALIASES; +pub use render::SKILLS_INTRO_WITH_ABSOLUTE_PATHS; +pub use render::SKILLS_INTRO_WITH_ALIASES; +pub use render::SkillMetadataBudget; +pub use render::SkillRenderReport; +pub use render::build_available_skills; +pub use render::default_skill_metadata_budget; +pub use render::render_available_skills_body; diff --git a/code-rs/core-skills/src/loader.rs b/code-rs/core-skills/src/loader.rs new file mode 100644 index 00000000000..2473f7108cf --- /dev/null +++ b/code-rs/core-skills/src/loader.rs @@ -0,0 +1,991 @@ +use crate::model::SkillDependencies; +use crate::model::SkillError; +use crate::model::SkillFileSystemsByPath; +use crate::model::SkillInterface; +use crate::model::SkillLoadOutcome; +use crate::model::SkillMetadata; +use crate::model::SkillPolicy; +use crate::model::SkillToolDependency; +use crate::system::system_cache_root_dir; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::default_project_root_markers; +use codex_config::merge_toml_values; +use codex_config::project_root_markers_from_config; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; +use codex_protocol::protocol::Product; +use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_plugins::PluginSkillRoot; +use codex_utils_plugins::plugin_namespace_for_skill_path; +use dirs::home_dir; +use serde::Deserialize; +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::error::Error; +use std::fmt; +use std::io; +use std::path::Component; +use std::path::PathBuf; +use std::sync::Arc; +use toml::Value as TomlValue; +use tracing::error; + +#[derive(Debug, Deserialize)] +struct SkillFrontmatter { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + metadata: SkillFrontmatterMetadata, +} + +#[derive(Debug, Default, Deserialize)] +struct SkillFrontmatterMetadata { + #[serde(default, rename = "short-description")] + short_description: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct SkillMetadataFile { + #[serde(default)] + interface: Option, + #[serde(default)] + dependencies: Option, + #[serde(default)] + policy: Option, +} + +#[derive(Default)] +struct LoadedSkillMetadata { + interface: Option, + dependencies: Option, + policy: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct Interface { + display_name: Option, + short_description: Option, + icon_small: Option, + icon_large: Option, + brand_color: Option, + default_prompt: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct Dependencies { + #[serde(default)] + tools: Vec, +} + +#[derive(Debug, Deserialize)] +struct Policy { + #[serde(default)] + allow_implicit_invocation: Option, + #[serde(default)] + products: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct DependencyTool { + #[serde(rename = "type")] + kind: Option, + value: Option, + description: Option, + transport: Option, + command: Option, + url: Option, +} + +const SKILLS_FILENAME: &str = "SKILL.md"; +const AGENTS_DIR_NAME: &str = ".agents"; +const SKILLS_METADATA_DIR: &str = "agents"; +const SKILLS_METADATA_FILENAME: &str = "openai.yaml"; +const SKILLS_DIR_NAME: &str = "skills"; +const MAX_NAME_LEN: usize = 64; +const MAX_DESCRIPTION_LEN: usize = 1024; +const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN; +const MAX_DEFAULT_PROMPT_LEN: usize = MAX_DESCRIPTION_LEN; +const MAX_DEPENDENCY_TYPE_LEN: usize = MAX_NAME_LEN; +const MAX_DEPENDENCY_TRANSPORT_LEN: usize = MAX_NAME_LEN; +const MAX_DEPENDENCY_VALUE_LEN: usize = MAX_DESCRIPTION_LEN; +const MAX_DEPENDENCY_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN; +const MAX_DEPENDENCY_COMMAND_LEN: usize = MAX_DESCRIPTION_LEN; +const MAX_DEPENDENCY_URL_LEN: usize = MAX_DESCRIPTION_LEN; +// Traversal depth from the skills root. +const MAX_SCAN_DEPTH: usize = 6; +const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000; + +#[derive(Debug)] +enum SkillParseError { + Read(std::io::Error), + MissingFrontmatter, + InvalidYaml(serde_yaml::Error), + MissingField(&'static str), + InvalidField { field: &'static str, reason: String }, +} + +impl fmt::Display for SkillParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SkillParseError::Read(e) => write!(f, "failed to read file: {e}"), + SkillParseError::MissingFrontmatter => { + write!(f, "missing YAML frontmatter delimited by ---") + } + SkillParseError::InvalidYaml(e) => write!(f, "invalid YAML: {e}"), + SkillParseError::MissingField(field) => write!(f, "missing field `{field}`"), + SkillParseError::InvalidField { field, reason } => { + write!(f, "invalid {field}: {reason}") + } + } + } +} + +impl Error for SkillParseError {} + +pub struct SkillRoot { + pub path: AbsolutePathBuf, + pub scope: SkillScope, + pub file_system: Arc, + pub plugin_id: Option, +} + +pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome +where + I: IntoIterator, +{ + let mut outcome = SkillLoadOutcome::default(); + let mut skill_roots: Vec = Vec::new(); + let mut skill_root_by_path: HashMap = HashMap::new(); + let mut file_systems_by_skill_path: HashMap> = + HashMap::new(); + for root in roots { + let root_path = canonicalize_for_skill_identity(&root.path); + let fs = root.file_system; + let skills_before_root = outcome.skills.len(); + discover_skills_under_root( + fs.as_ref(), + &root_path, + root.scope, + root.plugin_id.as_deref(), + &mut outcome, + ) + .await; + for skill in &outcome.skills[skills_before_root..] { + if !skill_roots.contains(&root_path) { + skill_roots.push(root_path.clone()); + } + skill_root_by_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| root_path.clone()); + file_systems_by_skill_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| Arc::clone(&fs)); + } + } + + let mut seen: HashSet = HashSet::new(); + outcome + .skills + .retain(|skill| seen.insert(skill.path_to_skills_md.clone())); + let retained_skill_paths: HashSet = outcome + .skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect(); + skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); + let used_roots: HashSet = skill_root_by_path.values().cloned().collect(); + skill_roots.retain(|root| used_roots.contains(root)); + file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); + outcome.skill_roots = skill_roots; + outcome.skill_root_by_path = Arc::new(skill_root_by_path); + outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); + + fn scope_rank(scope: SkillScope) -> u8 { + // Higher-priority scopes first (matches root scan order for dedupe). + match scope { + SkillScope::Repo => 0, + SkillScope::User => 1, + SkillScope::System => 2, + SkillScope::Admin => 3, + } + } + + outcome.skills.sort_by(|a, b| { + scope_rank(a.scope) + .cmp(&scope_rank(b.scope)) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) + }); + + outcome +} + +pub(crate) async fn skill_roots( + fs: Option>, + config_layer_stack: &ConfigLayerStack, + cwd: &AbsolutePathBuf, + plugin_skill_roots: Vec, +) -> Vec { + let home_dir = + home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok()); + skill_roots_with_home_dir( + fs, + config_layer_stack, + cwd, + home_dir.as_ref(), + plugin_skill_roots, + ) + .await +} + +async fn skill_roots_with_home_dir( + fs: Option>, + config_layer_stack: &ConfigLayerStack, + cwd: &AbsolutePathBuf, + home_dir: Option<&AbsolutePathBuf>, + plugin_skill_roots: Vec, +) -> Vec { + let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone()); + roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot { + path: root.path, + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some(root.plugin_id), + })); + roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); + dedupe_skill_roots_by_path(&mut roots); + roots +} + +fn skill_roots_from_layer_stack_inner( + config_layer_stack: &ConfigLayerStack, + home_dir: Option<&AbsolutePathBuf>, + repo_fs: Option>, +) -> Vec { + let mut roots = Vec::new(); + + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::HighestPrecedenceFirst, + /*include_disabled*/ true, + ) { + let Some(config_folder) = layer.config_folder() else { + continue; + }; + + match &layer.name { + ConfigLayerSource::Project { .. } => { + if let Some(repo_fs) = &repo_fs { + roots.push(SkillRoot { + path: config_folder.join(SKILLS_DIR_NAME), + scope: SkillScope::Repo, + file_system: Arc::clone(repo_fs), + plugin_id: None, + }); + } + } + ConfigLayerSource::User { .. } => { + // Deprecated user skills location (`$CODEX_HOME/skills`), kept for backward + // compatibility. + roots.push(SkillRoot { + path: config_folder.join(SKILLS_DIR_NAME), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }); + + // `$HOME/.agents/skills` (user-installed skills). + if let Some(home_dir) = home_dir { + roots.push(SkillRoot { + path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }); + } + + // Embedded system skills are cached under `$CODEX_HOME/skills/.system` and are a + // special case (not a config layer). + roots.push(SkillRoot { + path: system_cache_root_dir(&config_folder), + scope: SkillScope::System, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }); + } + ConfigLayerSource::System { .. } => { + // The system config layer lives under `/etc/codex/` on Unix, so treat + // `/etc/codex/skills` as admin-scoped skills. + roots.push(SkillRoot { + path: config_folder.join(SKILLS_DIR_NAME), + scope: SkillScope::Admin, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }); + } + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::SessionFlags + | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {} + } + } + + roots +} + +async fn repo_agents_skill_roots( + fs: Option>, + config_layer_stack: &ConfigLayerStack, + cwd: &AbsolutePathBuf, +) -> Vec { + let Some(fs) = fs else { + return Vec::new(); + }; + let project_root_markers = project_root_markers_from_stack(config_layer_stack); + let project_root = find_project_root(fs.as_ref(), cwd, &project_root_markers).await; + let dirs = dirs_between_project_root_and_cwd(cwd, &project_root); + let mut roots = Vec::new(); + for dir in dirs { + let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME); + match fs.get_metadata(&agents_skills, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_directory => roots.push(SkillRoot { + path: agents_skills, + scope: SkillScope::Repo, + file_system: Arc::clone(&fs), + plugin_id: None, + }), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + tracing::warn!( + "failed to stat repo skills root {}: {err:#}", + agents_skills.display() + ); + } + } + } + roots +} + +fn project_root_markers_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec { + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + if matches!(layer.name, ConfigLayerSource::Project { .. }) { + continue; + } + merge_toml_values(&mut merged, &layer.config); + } + + match project_root_markers_from_config(&merged) { + Ok(Some(markers)) => markers, + Ok(None) => default_project_root_markers(), + Err(err) => { + tracing::warn!("invalid project_root_markers: {err}"); + default_project_root_markers() + } + } +} + +async fn find_project_root( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, + project_root_markers: &[String], +) -> AbsolutePathBuf { + if project_root_markers.is_empty() { + return cwd.clone(); + } + + for ancestor in cwd.ancestors() { + for marker in project_root_markers { + let marker_path = ancestor.join(marker); + match fs.get_metadata(&marker_path, /*sandbox*/ None).await { + Ok(_) => return ancestor, + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + tracing::warn!( + "failed to stat project root marker {}: {err:#}", + marker_path.display() + ); + } + } + } + } + + cwd.clone() +} + +fn dirs_between_project_root_and_cwd( + cwd: &AbsolutePathBuf, + project_root: &AbsolutePathBuf, +) -> Vec { + let mut dirs = cwd + .ancestors() + .scan(false, |done, dir| { + if *done { + None + } else { + if &dir == project_root { + *done = true; + } + Some(dir) + } + }) + .collect::>(); + dirs.reverse(); + dirs +} + +fn dedupe_skill_roots_by_path(roots: &mut Vec) { + let mut seen: HashSet = HashSet::new(); + roots.retain(|root| seen.insert(root.path.clone())); +} + +fn canonicalize_for_skill_identity(path: &AbsolutePathBuf) -> AbsolutePathBuf { + path.canonicalize().unwrap_or_else(|_| path.clone()) +} + +async fn discover_skills_under_root( + fs: &dyn ExecutorFileSystem, + root: &AbsolutePathBuf, + scope: SkillScope, + plugin_id: Option<&str>, + outcome: &mut SkillLoadOutcome, +) { + let root = canonicalize_for_skill_identity(root); + + match fs.get_metadata(&root, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_directory => {} + Ok(_) => return, + Err(err) if err.kind() == io::ErrorKind::NotFound => return, + Err(err) => { + error!("failed to stat skills root {}: {err:#}", root.display()); + return; + } + } + + fn enqueue_dir( + queue: &mut VecDeque<(AbsolutePathBuf, usize)>, + visited_dirs: &mut HashSet, + truncated_by_dir_limit: &mut bool, + path: AbsolutePathBuf, + depth: usize, + ) { + if depth > MAX_SCAN_DEPTH { + return; + } + if visited_dirs.len() >= MAX_SKILLS_DIRS_PER_ROOT { + *truncated_by_dir_limit = true; + return; + } + if visited_dirs.insert(path.clone()) { + queue.push_back((path, depth)); + } + } + + // Follow symlinked directories for user, admin, and repo skills. System skills are written by Codex itself. + let follow_symlinks = matches!( + scope, + SkillScope::Repo | SkillScope::User | SkillScope::Admin + ); + + let mut visited_dirs: HashSet = HashSet::new(); + visited_dirs.insert(root.clone()); + + let mut queue: VecDeque<(AbsolutePathBuf, usize)> = VecDeque::from([(root.clone(), 0)]); + let mut truncated_by_dir_limit = false; + + while let Some((dir, depth)) = queue.pop_front() { + let entries = match fs.read_directory(&dir, /*sandbox*/ None).await { + Ok(entries) => entries, + Err(e) => { + error!("failed to read skills dir {}: {e:#}", dir.display()); + continue; + } + }; + + for entry in entries { + let file_name = entry.file_name; + if file_name.starts_with('.') { + continue; + } + + let path = dir.join(&file_name); + let metadata = match fs.get_metadata(&path, /*sandbox*/ None).await { + Ok(metadata) => metadata, + Err(e) => { + error!("failed to stat skills path {}: {e:#}", path.display()); + continue; + } + }; + + if metadata.is_symlink { + if !follow_symlinks { + continue; + } + match fs.read_directory(&path, /*sandbox*/ None).await { + Ok(_) => { + let resolved_dir = canonicalize_for_skill_identity(&path); + enqueue_dir( + &mut queue, + &mut visited_dirs, + &mut truncated_by_dir_limit, + resolved_dir, + depth + 1, + ); + } + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotADirectory | io::ErrorKind::NotFound + ) => {} + Err(err) => { + error!( + "failed to read skills symlink dir {}: {err:#}", + path.display() + ); + } + } + continue; + } + + if metadata.is_directory { + let resolved_dir = canonicalize_for_skill_identity(&path); + enqueue_dir( + &mut queue, + &mut visited_dirs, + &mut truncated_by_dir_limit, + resolved_dir, + depth + 1, + ); + continue; + } + + if metadata.is_file && file_name == SKILLS_FILENAME { + match parse_skill_file(fs, &path, scope, plugin_id).await { + Ok(skill) => { + outcome.skills.push(skill); + } + Err(err) => { + if scope != SkillScope::System { + outcome.errors.push(SkillError { + path: path.clone(), + message: err.to_string(), + }); + } + } + } + } + } + } + + if truncated_by_dir_limit { + tracing::warn!( + "skills scan truncated after {} directories (root: {})", + MAX_SKILLS_DIRS_PER_ROOT, + root.display() + ); + } +} + +async fn parse_skill_file( + fs: &dyn ExecutorFileSystem, + path: &AbsolutePathBuf, + scope: SkillScope, + plugin_id: Option<&str>, +) -> Result { + let contents = fs + .read_file_text(path, /*sandbox*/ None) + .await + .map_err(SkillParseError::Read)?; + + let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?; + + let parsed: SkillFrontmatter = + serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?; + + let base_name = parsed + .name + .as_deref() + .map(sanitize_single_line) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default_skill_name(path)); + let name = namespaced_skill_name(fs, path, &base_name).await; + let description = parsed + .description + .as_deref() + .map(sanitize_single_line) + .unwrap_or_default(); + let short_description = parsed + .metadata + .short_description + .as_deref() + .map(sanitize_single_line) + .filter(|value| !value.is_empty()); + let LoadedSkillMetadata { + interface, + dependencies, + policy, + } = load_skill_metadata(fs, path).await; + + validate_len(&name, MAX_NAME_LEN, "name")?; + validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; + if let Some(short_description) = short_description.as_deref() { + validate_len( + short_description, + MAX_SHORT_DESCRIPTION_LEN, + "metadata.short-description", + )?; + } + + let resolved_path = canonicalize_for_skill_identity(path); + + Ok(SkillMetadata { + name, + description, + short_description, + interface, + dependencies, + policy, + path_to_skills_md: resolved_path, + scope, + plugin_id: plugin_id.map(str::to_string), + }) +} + +fn default_skill_name(path: &AbsolutePathBuf) -> String { + path.parent() + .and_then(|parent| { + parent + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_single_line) + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "skill".to_string()) +} + +async fn namespaced_skill_name( + fs: &dyn ExecutorFileSystem, + path: &AbsolutePathBuf, + base_name: &str, +) -> String { + plugin_namespace_for_skill_path(fs, path) + .await + .map(|namespace| format!("{namespace}:{base_name}")) + .unwrap_or_else(|| base_name.to_string()) +} + +async fn load_skill_metadata( + fs: &dyn ExecutorFileSystem, + skill_path: &AbsolutePathBuf, +) -> LoadedSkillMetadata { + // Fail open: optional metadata should not block loading SKILL.md. + let Some(skill_dir) = skill_path.parent() else { + return LoadedSkillMetadata::default(); + }; + let metadata_path = skill_dir + .join(SKILLS_METADATA_DIR) + .join(SKILLS_METADATA_FILENAME); + match fs.get_metadata(&metadata_path, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_file => {} + Ok(_) => return LoadedSkillMetadata::default(), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return LoadedSkillMetadata::default(); + } + Err(error) => { + tracing::warn!( + "ignoring {path}: failed to stat {label}: {error}", + path = metadata_path.display(), + label = SKILLS_METADATA_FILENAME + ); + return LoadedSkillMetadata::default(); + } + } + + let contents = match fs.read_file_text(&metadata_path, /*sandbox*/ None).await { + Ok(contents) => contents, + Err(error) => { + tracing::warn!( + "ignoring {path}: failed to read {label}: {error}", + path = metadata_path.display(), + label = SKILLS_METADATA_FILENAME + ); + return LoadedSkillMetadata::default(); + } + }; + + let parsed: SkillMetadataFile = { + let _guard = AbsolutePathBufGuard::new(skill_dir.as_path()); + match serde_yaml::from_str(&contents) { + Ok(parsed) => parsed, + Err(error) => { + tracing::warn!( + "ignoring {path}: invalid {label}: {error}", + path = metadata_path.display(), + label = SKILLS_METADATA_FILENAME + ); + return LoadedSkillMetadata::default(); + } + } + }; + + let SkillMetadataFile { + interface, + dependencies, + policy, + } = parsed; + LoadedSkillMetadata { + interface: resolve_interface(interface, &skill_dir), + dependencies: resolve_dependencies(dependencies), + policy: resolve_policy(policy), + } +} + +fn resolve_interface( + interface: Option, + skill_dir: &AbsolutePathBuf, +) -> Option { + let interface = interface?; + let interface = SkillInterface { + display_name: resolve_str( + interface.display_name, + MAX_NAME_LEN, + "interface.display_name", + ), + short_description: resolve_str( + interface.short_description, + MAX_SHORT_DESCRIPTION_LEN, + "interface.short_description", + ), + icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small), + icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large), + brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"), + default_prompt: resolve_str( + interface.default_prompt, + MAX_DEFAULT_PROMPT_LEN, + "interface.default_prompt", + ), + }; + let has_fields = interface.display_name.is_some() + || interface.short_description.is_some() + || interface.icon_small.is_some() + || interface.icon_large.is_some() + || interface.brand_color.is_some() + || interface.default_prompt.is_some(); + if has_fields { Some(interface) } else { None } +} + +fn resolve_dependencies(dependencies: Option) -> Option { + let dependencies = dependencies?; + let tools: Vec = dependencies + .tools + .into_iter() + .filter_map(resolve_dependency_tool) + .collect(); + if tools.is_empty() { + None + } else { + Some(SkillDependencies { tools }) + } +} + +fn resolve_policy(policy: Option) -> Option { + policy.map(|policy| SkillPolicy { + allow_implicit_invocation: policy.allow_implicit_invocation, + products: policy.products, + }) +} + +fn resolve_dependency_tool(tool: DependencyTool) -> Option { + let r#type = resolve_required_str( + tool.kind, + MAX_DEPENDENCY_TYPE_LEN, + "dependencies.tools.type", + )?; + let value = resolve_required_str( + tool.value, + MAX_DEPENDENCY_VALUE_LEN, + "dependencies.tools.value", + )?; + let description = resolve_str( + tool.description, + MAX_DEPENDENCY_DESCRIPTION_LEN, + "dependencies.tools.description", + ); + let transport = resolve_str( + tool.transport, + MAX_DEPENDENCY_TRANSPORT_LEN, + "dependencies.tools.transport", + ); + let command = resolve_str( + tool.command, + MAX_DEPENDENCY_COMMAND_LEN, + "dependencies.tools.command", + ); + let url = resolve_str(tool.url, MAX_DEPENDENCY_URL_LEN, "dependencies.tools.url"); + + Some(SkillToolDependency { + r#type, + value, + description, + transport, + command, + url, + }) +} + +fn resolve_asset_path( + skill_dir: &AbsolutePathBuf, + field: &'static str, + path: Option, +) -> Option { + // Icons must be relative paths under the skill's assets/ directory; otherwise return None. + let path = path?; + if path.as_os_str().is_empty() { + return None; + } + + let assets_dir = skill_dir.join("assets"); + if path.is_absolute() { + tracing::warn!( + "ignoring {field}: icon must be a relative assets path (not {})", + assets_dir.display() + ); + return None; + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(component) => normalized.push(component), + Component::ParentDir => { + tracing::warn!("ignoring {field}: icon path must not contain '..'"); + return None; + } + _ => { + tracing::warn!("ignoring {field}: icon path must be under assets/"); + return None; + } + } + } + + let mut components = normalized.components(); + match components.next() { + Some(Component::Normal(component)) if component == "assets" => {} + _ => { + tracing::warn!("ignoring {field}: icon path must be under assets/"); + return None; + } + } + + Some(skill_dir.join(normalized)) +} + +fn sanitize_single_line(raw: &str) -> String { + raw.split_whitespace().collect::>().join(" ") +} + +fn validate_len( + value: &str, + max_len: usize, + field_name: &'static str, +) -> Result<(), SkillParseError> { + if value.is_empty() { + return Err(SkillParseError::MissingField(field_name)); + } + if value.chars().count() > max_len { + return Err(SkillParseError::InvalidField { + field: field_name, + reason: format!("exceeds maximum length of {max_len} characters"), + }); + } + Ok(()) +} + +fn resolve_str(value: Option, max_len: usize, field: &'static str) -> Option { + let value = value?; + let value = sanitize_single_line(&value); + if value.is_empty() { + tracing::warn!("ignoring {field}: value is empty"); + return None; + } + if value.chars().count() > max_len { + tracing::warn!("ignoring {field}: exceeds maximum length of {max_len} characters"); + return None; + } + Some(value) +} + +fn resolve_required_str( + value: Option, + max_len: usize, + field: &'static str, +) -> Option { + let Some(value) = value else { + tracing::warn!("ignoring {field}: value is missing"); + return None; + }; + resolve_str(Some(value), max_len, field) +} + +fn resolve_color_str(value: Option, field: &'static str) -> Option { + let value = value?; + let value = value.trim(); + if value.is_empty() { + tracing::warn!("ignoring {field}: value is empty"); + return None; + } + let mut chars = value.chars(); + if value.len() == 7 && chars.next() == Some('#') && chars.all(|c| c.is_ascii_hexdigit()) { + Some(value.to_string()) + } else { + tracing::warn!("ignoring {field}: expected #RRGGBB, got {value}"); + None + } +} + +fn extract_frontmatter(contents: &str) -> Option { + let mut lines = contents.lines(); + if !matches!(lines.next(), Some(line) if line.trim() == "---") { + return None; + } + + let mut frontmatter_lines: Vec<&str> = Vec::new(); + let mut found_closing = false; + for line in lines.by_ref() { + if line.trim() == "---" { + found_closing = true; + break; + } + frontmatter_lines.push(line); + } + + if frontmatter_lines.is_empty() || !found_closing { + return None; + } + + Some(frontmatter_lines.join("\n")) +} +#[cfg(test)] +pub(crate) async fn skill_roots_from_layer_stack( + fs: Arc, + config_layer_stack: &ConfigLayerStack, + cwd: &AbsolutePathBuf, + home_dir: Option<&AbsolutePathBuf>, +) -> Vec { + skill_roots_with_home_dir(Some(fs), config_layer_stack, cwd, home_dir, Vec::new()).await +} + +#[cfg(test)] +#[path = "loader_tests.rs"] +mod tests; diff --git a/code-rs/core-skills/src/loader_tests.rs b/code-rs/core-skills/src/loader_tests.rs new file mode 100644 index 00000000000..c80585871ed --- /dev/null +++ b/code-rs/core-skills/src/loader_tests.rs @@ -0,0 +1,1801 @@ +use super::*; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_exec_server::LOCAL_FS; +use codex_protocol::protocol::Product; +use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::PathExt; +use dunce::canonicalize as canonicalize_path; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; +use toml::Value as TomlValue; + +const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex"; + +struct TestConfig { + cwd: AbsolutePathBuf, + config_layer_stack: ConfigLayerStack, +} + +async fn make_config(codex_home: &TempDir) -> TestConfig { + make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await +} + +fn config_file(path: PathBuf) -> AbsolutePathBuf { + path.abs() +} + +fn project_layers_for_cwd(cwd: &Path) -> Vec { + let cwd_dir = if cwd.is_dir() { + cwd.to_path_buf() + } else { + cwd.parent() + .expect("file cwd should have a parent directory") + .to_path_buf() + }; + let project_root = cwd_dir + .ancestors() + .find(|ancestor| ancestor.join(".git").exists()) + .unwrap_or(cwd_dir.as_path()) + .to_path_buf(); + + let mut layers = cwd_dir + .ancestors() + .scan(false, |done, dir| { + if *done { + None + } else { + if dir == project_root { + *done = true; + } + Some(dir.to_path_buf()) + } + }) + .collect::>(); + layers.reverse(); + + layers + .into_iter() + .filter_map(|dir| { + let dot_codex = dir.join(REPO_ROOT_CONFIG_DIR_NAME); + dot_codex.is_dir().then(|| { + ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: dot_codex.abs(), + }, + TomlValue::Table(toml::map::Map::new()), + ) + }) + }) + .collect() +} + +async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> TestConfig { + let user_config_path = codex_home.path().join(CONFIG_TOML_FILE); + let system_config_path = codex_home.path().join("etc/codex/config.toml"); + fs::create_dir_all( + system_config_path + .parent() + .expect("system config path should have a parent"), + ) + .expect("create fake system config dir"); + + let mut layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::System { + file: config_file(system_config_path), + }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_file(user_config_path), + }, + TomlValue::Table(toml::map::Map::new()), + ), + ]; + layers.extend(project_layers_for_cwd(&cwd)); + + let cwd_abs = cwd.abs(); + TestConfig { + cwd: cwd_abs, + config_layer_stack: ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"), + } +} + +async fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome { + // Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`. + super::load_skills_from_roots( + super::skill_roots_from_layer_stack( + Arc::clone(&LOCAL_FS), + &config.config_layer_stack, + &config.cwd, + /*home_dir*/ None, + ) + .await, + ) + .await +} + +fn mark_as_git_repo(dir: &Path) { + // Config/project-root discovery only checks for the presence of `.git` (file or dir), + // so we can avoid shelling out to `git init` in tests. + fs::write(dir.join(".git"), "gitdir: fake\n").unwrap(); +} + +fn normalized(path: &Path) -> AbsolutePathBuf { + canonicalize_path(path) + .unwrap_or_else(|_| path.to_path_buf()) + .abs() +} + +#[tokio::test] +async fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin() +-> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let system_folder = tmp.path().join("etc/codex"); + let home_folder = tmp.path().join("home"); + let user_folder = home_folder.join("codex"); + fs::create_dir_all(&system_folder)?; + fs::create_dir_all(&user_folder)?; + + // The file path doesn't need to exist; it's only used to derive the config folder. + let system_file = system_folder.join("config.toml").abs(); + let user_file = user_folder.join("config.toml").abs(); + + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::System { file: system_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let home_folder_abs = home_folder.abs(); + let got = skill_roots_from_layer_stack( + Arc::clone(&LOCAL_FS), + &stack, + &home_folder_abs, + Some(&home_folder_abs), + ) + .await + .into_iter() + .map(|root| (root.scope, root.path.to_path_buf())) + .collect::>(); + + assert_eq!( + got, + vec![ + (SkillScope::User, user_folder.join("skills")), + ( + SkillScope::User, + home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) + ), + ( + SkillScope::System, + user_folder.join("skills").join(".system") + ), + (SkillScope::Admin, system_folder.join("skills")), + ] + ); + + Ok(()) +} + +#[tokio::test] +async fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let home_folder = tmp.path().join("home"); + let user_folder = home_folder.join("codex"); + fs::create_dir_all(&user_folder)?; + + let project_root = tmp.path().join("repo"); + let dot_codex = project_root.join(".codex"); + fs::create_dir_all(&dot_codex)?; + + let user_file = user_folder.join("config.toml").abs(); + let project_dot_codex = dot_codex.abs(); + + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + TomlValue::Table(toml::map::Map::new()), + "marked untrusted", + ), + ]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let home_folder_abs = home_folder.abs(); + let project_root_abs = project_root.abs(); + let got = skill_roots_from_layer_stack( + Arc::clone(&LOCAL_FS), + &stack, + &project_root_abs, + Some(&home_folder_abs), + ) + .await + .into_iter() + .map(|root| (root.scope, root.path.to_path_buf())) + .collect::>(); + + assert_eq!( + got, + vec![ + (SkillScope::Repo, dot_codex.join("skills")), + (SkillScope::User, user_folder.join("skills")), + ( + SkillScope::User, + home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) + ), + ( + SkillScope::System, + user_folder.join("skills").join(".system") + ), + ] + ); + + Ok(()) +} + +#[tokio::test] +async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let home_folder = tmp.path().join("home"); + let user_folder = home_folder.join("codex"); + fs::create_dir_all(&user_folder)?; + + let user_file = user_folder.join("config.toml").abs(); + let layers = vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + )]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let skill_path = write_skill_at( + &home_folder.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), + "agents-home", + "agents-home-skill", + "from home agents", + ); + + let home_folder_abs = home_folder.abs(); + let roots = skill_roots_from_layer_stack( + Arc::clone(&LOCAL_FS), + &stack, + &home_folder_abs, + Some(&home_folder_abs), + ) + .await; + let outcome = load_skills_from_roots(roots).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "agents-home-skill".to_string(), + description: "from home agents".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); + + Ok(()) +} + +fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { + write_skill_at(&codex_home.path().join("skills"), dir, name, description) +} + +fn write_system_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { + write_skill_at( + &codex_home.path().join("skills/.system"), + dir, + name, + description, + ) +} + +fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf { + let skill_dir = root.join(dir); + fs::create_dir_all(&skill_dir).unwrap(); + let indented_description = description.replace('\n', "\n "); + let content = + format!("---\nname: {name}\ndescription: |-\n {indented_description}\n---\n\n# Body\n"); + let path = skill_dir.join(SKILLS_FILENAME); + fs::write(&path, content).unwrap(); + path +} + +fn write_raw_skill_at(root: &Path, dir: &str, frontmatter: &str) -> PathBuf { + let skill_dir = root.join(dir); + fs::create_dir_all(&skill_dir).unwrap(); + let path = skill_dir.join(SKILLS_FILENAME); + let content = format!("---\n{frontmatter}\n---\n\n# Body\n"); + fs::write(&path, content).unwrap(); + path +} + +fn write_skill_metadata_at(skill_dir: &Path, contents: &str) -> PathBuf { + let path = skill_dir + .join(SKILLS_METADATA_DIR) + .join(SKILLS_METADATA_FILENAME); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, contents).unwrap(); + path +} + +fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf { + write_skill_metadata_at(skill_dir, contents) +} + +#[tokio::test] +async fn loads_skill_dependencies_metadata_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "dep-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +{ + "dependencies": { + "tools": [ + { + "type": "env_var", + "value": "GITHUB_TOKEN", + "description": "GitHub API token with repo scopes" + }, + { + "type": "mcp", + "value": "github", + "description": "GitHub MCP server", + "transport": "streamable_http", + "url": "https://example.com/mcp" + }, + { + "type": "cli", + "value": "gh", + "description": "GitHub CLI" + }, + { + "type": "mcp", + "value": "local-gh", + "description": "Local GH MCP server", + "transport": "stdio", + "command": "gh-mcp" + } + ] + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "dep-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: None, + dependencies: Some(SkillDependencies { + tools: vec![ + SkillToolDependency { + r#type: "env_var".to_string(), + value: "GITHUB_TOKEN".to_string(), + description: Some("GitHub API token with repo scopes".to_string()), + transport: None, + command: None, + url: None, + }, + SkillToolDependency { + r#type: "mcp".to_string(), + value: "github".to_string(), + description: Some("GitHub MCP server".to_string()), + transport: Some("streamable_http".to_string()), + command: None, + url: Some("https://example.com/mcp".to_string()), + }, + SkillToolDependency { + r#type: "cli".to_string(), + value: "gh".to_string(), + description: Some("GitHub CLI".to_string()), + transport: None, + command: None, + url: None, + }, + SkillToolDependency { + r#type: "mcp".to_string(), + value: "local-gh".to_string(), + description: Some("Local GH MCP server".to_string()), + transport: Some("stdio".to_string()), + command: Some("gh-mcp".to_string()), + url: None, + }, + ], + }), + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_skill_interface_metadata_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + + write_skill_interface_at( + skill_dir, + r##" +interface: + display_name: "UI Skill" + short_description: " short desc " + icon_small: "./assets/small-400px.png" + icon_large: "./assets/large-logo.svg" + brand_color: "#3B82F6" + default_prompt: " default prompt " +"##, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let user_skills: Vec = outcome + .skills + .into_iter() + .filter(|skill| skill.scope == SkillScope::User) + .collect(); + assert_eq!( + user_skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: Some("short desc".to_string()), + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), + icon_large: Some(normalized_skill_dir.join("assets/large-logo.svg")), + brand_color: Some("#3B82F6".to_string()), + default_prompt: Some("default prompt".to_string()), + }), + dependencies: None, + policy: None, + path_to_skills_md: normalized(skill_path.as_path()), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_skill_policy_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "policy-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +policy: + allow_implicit_invocation: false +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].policy, + Some(SkillPolicy { + allow_implicit_invocation: Some(false), + products: vec![], + }) + ); + assert!(outcome.allowed_skills_for_implicit_invocation().is_empty()); +} + +#[tokio::test] +async fn empty_skill_policy_defaults_to_allow_implicit_invocation() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "policy-empty", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +policy: {} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].policy, + Some(SkillPolicy { + allow_implicit_invocation: None, + products: vec![], + }) + ); + assert_eq!( + outcome.allowed_skills_for_implicit_invocation(), + outcome.skills + ); +} + +#[tokio::test] +async fn loads_skill_policy_products_from_yaml() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "policy-products", "from yaml"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_metadata_at( + skill_dir, + r#" +policy: + products: + - codex + - CHATGPT + - atlas +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!( + outcome.skills[0].policy, + Some(SkillPolicy { + allow_implicit_invocation: None, + products: vec![Product::Codex, Product::Chatgpt, Product::Atlas], + }) + ); +} + +#[tokio::test] +async fn accepts_icon_paths_under_assets_dir() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + + write_skill_interface_at( + skill_dir, + r#" +{ + "interface": { + "display_name": "UI Skill", + "icon_small": "assets/icon.png", + "icon_large": "./assets/logo.svg" + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: None, + icon_small: Some(normalized_skill_dir.join("assets/icon.png")), + icon_large: Some(normalized_skill_dir.join("assets/logo.svg")), + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn ignores_invalid_brand_color() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_interface_at( + skill_dir, + r#" +{ + "interface": { + "brand_color": "blue" + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn ignores_default_prompt_over_max_length() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); + + write_skill_interface_at( + skill_dir, + &format!( + r##" +{{ + "interface": {{ + "display_name": "UI Skill", + "icon_small": "./assets/small-400px.png", + "default_prompt": "{too_long}" + }} +}} +"## + ), + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: None, + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), + icon_large: None, + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn drops_interface_when_icons_are_invalid() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + + write_skill_interface_at( + skill_dir, + r#" +{ + "interface": { + "icon_small": "icon.png", + "icon_large": "./assets/../logo.svg" + } +} +"#, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[cfg(unix)] +fn symlink_dir(target: &Path, link: &Path) { + std::os::unix::fs::symlink(target, link).unwrap(); +} + +#[cfg(unix)] +fn symlink_file(target: &Path, link: &Path) { + std::os::unix::fs::symlink(target, link).unwrap(); +} + +#[tokio::test] +#[cfg(unix)] +async fn loads_skills_via_symlinked_subdir_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + let shared_skill_path = write_skill_at(shared.path(), "demo", "linked-skill", "from link"); + + fs::create_dir_all(codex_home.path().join("skills")).unwrap(); + symlink_dir(shared.path(), &codex_home.path().join("skills/shared")); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "linked-skill".to_string(), + description: "from link".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&shared_skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn ignores_symlinked_skill_file_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + let shared_skill_path = write_skill_at(shared.path(), "demo", "linked-file-skill", "from link"); + + let skill_dir = codex_home.path().join("skills/demo"); + fs::create_dir_all(&skill_dir).unwrap(); + symlink_file(&shared_skill_path, &skill_dir.join(SKILLS_FILENAME)); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills, Vec::new()); +} + +#[tokio::test] +#[cfg(unix)] +async fn does_not_loop_on_symlink_cycle_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + + // Create a cycle: + // $CODEX_HOME/skills/cycle/loop -> $CODEX_HOME/skills/cycle + let cycle_dir = codex_home.path().join("skills/cycle"); + fs::create_dir_all(&cycle_dir).unwrap(); + symlink_dir(&cycle_dir, &cycle_dir.join("loop")); + + let skill_path = write_skill_at(&cycle_dir, "demo", "cycle-skill", "still loads"); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "cycle-skill".to_string(), + description: "still loads".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn loads_skills_via_symlinked_subdir_for_admin_scope() { + let admin_root = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + let shared_skill_path = + write_skill_at(shared.path(), "demo", "admin-linked-skill", "from link"); + fs::create_dir_all(admin_root.path()).unwrap(); + symlink_dir(shared.path(), &admin_root.path().join("shared")); + + let outcome = load_skills_from_roots([SkillRoot { + path: admin_root.path().abs(), + scope: SkillScope::Admin, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "admin-linked-skill".to_string(), + description: "from link".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&shared_skill_path), + scope: SkillScope::Admin, + plugin_id: None, + }] + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn loads_skills_via_symlinked_subdir_for_repo_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + let shared = tempfile::tempdir().expect("tempdir"); + + let linked_skill_path = write_skill_at(shared.path(), "demo", "repo-linked-skill", "from link"); + let repo_skills_root = repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME); + fs::create_dir_all(&repo_skills_root).unwrap(); + symlink_dir(shared.path(), &repo_skills_root.join("shared")); + + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repo-linked-skill".to_string(), + description: "from link".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&linked_skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }] + ); +} + +#[tokio::test] +#[cfg(unix)] +async fn system_scope_ignores_symlinked_subdir() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let shared = tempfile::tempdir().expect("tempdir"); + + write_skill_at(shared.path(), "demo", "system-linked-skill", "from link"); + + let system_root = codex_home.path().join("skills/.system"); + fs::create_dir_all(&system_root).unwrap(); + symlink_dir(shared.path(), &system_root.join("shared")); + + let outcome = load_skills_from_roots([SkillRoot { + path: system_root.abs(), + scope: SkillScope::System, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }]) + .await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 0); +} + +#[tokio::test] +async fn respects_max_scan_depth_for_user_scope() { + let codex_home = tempfile::tempdir().expect("tempdir"); + + let within_depth_path = write_skill( + &codex_home, + "d0/d1/d2/d3/d4/d5", + "within-depth-skill", + "loads", + ); + let _too_deep_path = write_skill( + &codex_home, + "d0/d1/d2/d3/d4/d5/d6", + "too-deep-skill", + "should not load", + ); + + let skills_root = codex_home.path().join("skills"); + let outcome = load_skills_from_roots([SkillRoot { + path: skills_root.abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "within-depth-skill".to_string(), + description: "loads".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&within_depth_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_valid_skill() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully"); + let cfg = make_config(&codex_home).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "demo-skill".to_string(), + description: "does things carefully".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn falls_back_to_directory_name_when_skill_name_is_missing() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_raw_skill_at( + &codex_home.path().join("skills"), + "directory-derived", + "description: fallback name", + ); + let cfg = make_config(&codex_home).await; + + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "directory-derived".to_string(), + description: "fallback name".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn namespaces_plugin_skills_using_plugin_name() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/sample"); + let skill_path = write_raw_skill_at( + &plugin_root.join("skills"), + "sample-search", + "description: search sample data", + ); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .unwrap(); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "sample:sample-search".to_string(), + description: "search sample data".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("sample@test".to_string()), + }] + ); +} + +#[tokio::test] +async fn loads_short_description_from_metadata() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_dir = codex_home.path().join("skills/demo"); + fs::create_dir_all(&skill_dir).unwrap(); + let contents = "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: short summary\n---\n\n# Body\n"; + let skill_path = skill_dir.join(SKILLS_FILENAME); + fs::write(&skill_path, contents).unwrap(); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "demo-skill".to_string(), + description: "long description".to_string(), + short_description: Some("short summary".to_string()), + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn enforces_short_description_length_limits() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_dir = codex_home.path().join("skills/demo"); + fs::create_dir_all(&skill_dir).unwrap(); + let too_long = "x".repeat(MAX_SHORT_DESCRIPTION_LEN + 1); + let contents = format!( + "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: {too_long}\n---\n\n# Body\n" + ); + fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap(); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert_eq!(outcome.skills.len(), 0); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0] + .message + .contains("invalid metadata.short-description"), + "expected length error, got: {:?}", + outcome.errors + ); +} + +#[tokio::test] +async fn skips_hidden_and_invalid() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let hidden_dir = codex_home.path().join("skills/.hidden"); + fs::create_dir_all(&hidden_dir).unwrap(); + fs::write( + hidden_dir.join(SKILLS_FILENAME), + "---\nname: hidden\ndescription: hidden\n---\n", + ) + .unwrap(); + + // Invalid because missing closing frontmatter. + let invalid_dir = codex_home.path().join("skills/invalid"); + fs::create_dir_all(&invalid_dir).unwrap(); + fs::write(invalid_dir.join(SKILLS_FILENAME), "---\nname: bad").unwrap(); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills_for_test(&cfg).await; + assert_eq!(outcome.skills.len(), 0); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0] + .message + .contains("missing YAML frontmatter"), + "expected frontmatter error" + ); +} + +#[tokio::test] +async fn enforces_length_limits() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let max_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN); + write_skill(&codex_home, "max-len", "max-len", &max_desc); + let cfg = make_config(&codex_home).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + + let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1); + write_skill(&codex_home, "too-long", "too-long", &too_long_desc); + let outcome = load_skills_for_test(&cfg).await; + assert_eq!(outcome.skills.len(), 1); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0].message.contains("invalid description"), + "expected length error" + ); +} + +#[tokio::test] +async fn loads_skills_from_repo_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let skills_root = repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME); + let skill_path = write_skill_at(&skills_root, "repo", "repo-skill", "from repo"); + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repo-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_skills_from_agents_dir_without_codex_dir() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let skill_path = write_skill_at( + &repo_dir.path().join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), + "agents", + "agents-skill", + "from agents", + ); + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "agents-skill".to_string(), + description: "from agents".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn loads_skills_from_all_codex_dirs_under_project_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let nested_dir = repo_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); + + let root_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "root", + "root-skill", + "from root", + ); + let nested_skill_path = write_skill_at( + &repo_dir + .path() + .join("nested") + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "nested", + "nested-skill", + "from nested", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![ + SkillMetadata { + name: "nested-skill".to_string(), + description: "from nested".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&nested_skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }, + SkillMetadata { + name: "root-skill".to_string(), + description: "from root".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&root_skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }, + ] + ); +} + +#[tokio::test] +async fn loads_skills_from_codex_dir_when_not_git_repo() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let work_dir = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_skill_at( + &work_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "local", + "local-skill", + "from cwd", + ); + + let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "local-skill".to_string(), + description: "from cwd".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn deduplicates_by_path_preferring_first_root() { + let root = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo"); + + let outcome = load_skills_from_roots([ + SkillRoot { + path: root.path().abs(), + scope: SkillScope::Repo, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }, + SkillRoot { + path: root.path().abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + }, + ]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn keeps_duplicate_names_from_repo_and_user() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user"); + let repo_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "repo", + "dupe-skill", + "from repo", + ); + + let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&repo_skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: "from user".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&user_skill_path), + scope: SkillScope::User, + plugin_id: None, + }, + ] + ); +} + +#[tokio::test] +async fn keeps_duplicate_names_from_nested_codex_dirs() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let nested_dir = repo_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); + + let root_skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "root", + "dupe-skill", + "from root", + ); + let nested_skill_path = write_skill_at( + &repo_dir + .path() + .join("nested") + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "nested", + "dupe-skill", + "from nested", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; + let outcome = load_skills_for_test(&cfg).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let root_path = normalized(&root_skill_path); + let nested_path = normalized(&nested_skill_path); + let (first_path, second_path, first_description, second_description) = + if root_path <= nested_path { + (root_path, nested_path, "from root", "from nested") + } else { + (nested_path, root_path, "from nested", "from root") + }; + assert_eq!( + outcome.skills, + vec![ + SkillMetadata { + name: "dupe-skill".to_string(), + description: first_description.to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: first_path, + scope: SkillScope::Repo, + plugin_id: None, + }, + SkillMetadata { + name: "dupe-skill".to_string(), + description: second_description.to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: second_path, + scope: SkillScope::Repo, + plugin_id: None, + }, + ] + ); +} + +#[tokio::test] +async fn repo_skills_search_does_not_escape_repo_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let outer_dir = tempfile::tempdir().expect("tempdir"); + let repo_dir = outer_dir.path().join("repo"); + fs::create_dir_all(&repo_dir).unwrap(); + + let _skill_path = write_skill_at( + &outer_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "outer", + "outer-skill", + "from outer", + ); + mark_as_git_repo(&repo_dir); + + let cfg = make_config_for_cwd(&codex_home, repo_dir).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 0); +} + +#[tokio::test] +async fn loads_skills_when_cwd_is_file_in_repo() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + mark_as_git_repo(repo_dir.path()); + + let skill_path = write_skill_at( + &repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "repo", + "repo-skill", + "from repo", + ); + let file_path = repo_dir.path().join("some-file.txt"); + fs::write(&file_path, "contents").unwrap(); + + let cfg = make_config_for_cwd(&codex_home, file_path).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "repo-skill".to_string(), + description: "from repo".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::Repo, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn non_git_repo_skills_search_does_not_walk_parents() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let outer_dir = tempfile::tempdir().expect("tempdir"); + let nested_dir = outer_dir.path().join("nested/inner"); + fs::create_dir_all(&nested_dir).unwrap(); + + write_skill_at( + &outer_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + "outer", + "outer-skill", + "from outer", + ); + + let cfg = make_config_for_cwd(&codex_home, nested_dir).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 0); +} + +#[tokio::test] +async fn loads_skills_from_system_cache_when_present() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let work_dir = tempfile::tempdir().expect("tempdir"); + + let skill_path = write_system_skill(&codex_home, "system", "system-skill", "from system"); + + let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await; + + let outcome = load_skills_for_test(&cfg).await; + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "system-skill".to_string(), + description: "from system".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::System, + plugin_id: None, + }] + ); +} + +#[tokio::test] +async fn skill_roots_include_admin_with_lowest_priority() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cfg = make_config(&codex_home).await; + + let scopes: Vec = super::skill_roots( + Some(Arc::clone(&LOCAL_FS)), + &cfg.config_layer_stack, + &cfg.cwd, + Vec::new(), + ) + .await + .into_iter() + .map(|root| root.scope) + .collect(); + let mut expected = vec![SkillScope::User, SkillScope::System]; + if home_dir().is_some() { + expected.insert(1, SkillScope::User); + } + expected.push(SkillScope::Admin); + assert_eq!(scopes, expected); +} diff --git a/code-rs/core-skills/src/manager.rs b/code-rs/core-skills/src/manager.rs new file mode 100644 index 00000000000..db19322acfc --- /dev/null +++ b/code-rs/core-skills/src/manager.rs @@ -0,0 +1,282 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::RwLock; + +use codex_config::ConfigLayerStack; +use codex_exec_server::ExecutorFileSystem; +use codex_protocol::protocol::Product; +use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; +use tracing::info; +use tracing::warn; + +use crate::SkillLoadOutcome; +use crate::build_implicit_skill_path_indexes; +use crate::config_rules::SkillConfigRules; +use crate::config_rules::resolve_disabled_skill_paths; +use crate::config_rules::skill_config_rules_from_stack; +use crate::loader::SkillRoot; +use crate::loader::load_skills_from_roots; +use crate::loader::skill_roots; +use crate::system::install_system_skills; +use crate::system::uninstall_system_skills; +use codex_config::SkillsConfig; + +#[derive(Debug, Clone)] +pub struct SkillsLoadInput { + pub cwd: AbsolutePathBuf, + pub effective_skill_roots: Vec, + pub config_layer_stack: ConfigLayerStack, + pub bundled_skills_enabled: bool, +} + +impl SkillsLoadInput { + pub fn new( + cwd: AbsolutePathBuf, + effective_skill_roots: Vec, + config_layer_stack: ConfigLayerStack, + bundled_skills_enabled: bool, + ) -> Self { + Self { + cwd, + effective_skill_roots, + config_layer_stack, + bundled_skills_enabled, + } + } +} + +pub struct SkillsManager { + codex_home: AbsolutePathBuf, + restriction_product: Option, + cache_by_cwd: RwLock>, + cache_by_config: RwLock>, +} + +impl SkillsManager { + pub fn new(codex_home: AbsolutePathBuf, bundled_skills_enabled: bool) -> Self { + Self::new_with_restriction_product(codex_home, bundled_skills_enabled, Some(Product::Codex)) + } + + pub fn new_with_restriction_product( + codex_home: AbsolutePathBuf, + bundled_skills_enabled: bool, + restriction_product: Option, + ) -> Self { + let manager = Self { + codex_home, + restriction_product, + cache_by_cwd: RwLock::new(HashMap::new()), + cache_by_config: RwLock::new(HashMap::new()), + }; + if !bundled_skills_enabled { + // The loader caches bundled skills under `skills/.system`. Clearing that directory is + // best-effort cleanup; root selection still enforces the config even if removal fails. + uninstall_system_skills(&manager.codex_home); + } else if let Err(err) = install_system_skills(&manager.codex_home) { + tracing::error!("failed to install system skills: {err}"); + } + manager + } + + /// Load skills for an already-constructed [`Config`], avoiding any additional config-layer + /// loading. + /// + /// This path uses a cache keyed by the effective skill-relevant config state rather than just + /// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen + /// to share a directory. + pub async fn skills_for_config( + &self, + input: &SkillsLoadInput, + fs: Option>, + ) -> SkillLoadOutcome { + let roots = self.skill_roots_for_config(input, fs).await; + let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); + let cache_key = config_skills_cache_key(&roots, &skill_config_rules); + if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { + return outcome; + } + + let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; + let mut cache = self + .cache_by_config + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cache.insert(cache_key, outcome.clone()); + outcome + } + + pub async fn skill_roots_for_config( + &self, + input: &SkillsLoadInput, + fs: Option>, + ) -> Vec { + let mut roots = skill_roots( + fs, + &input.config_layer_stack, + &input.cwd, + input.effective_skill_roots.clone(), + ) + .await; + if !input.bundled_skills_enabled { + roots.retain(|root| root.scope != SkillScope::System); + } + roots + } + + pub async fn skills_for_cwd( + &self, + input: &SkillsLoadInput, + force_reload: bool, + fs: Option>, + ) -> SkillLoadOutcome { + let use_cwd_cache = fs.is_some(); + if use_cwd_cache + && !force_reload + && let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd) + { + return outcome; + } + + let mut roots = skill_roots( + fs.clone(), + &input.config_layer_stack, + &input.cwd, + input.effective_skill_roots.clone(), + ) + .await; + if !bundled_skills_enabled_from_stack(&input.config_layer_stack) { + roots.retain(|root| root.scope != SkillScope::System); + } + let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); + let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; + if use_cwd_cache { + let mut cache = self + .cache_by_cwd + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cache.insert(input.cwd.clone(), outcome.clone()); + } + outcome + } + + async fn build_skill_outcome( + &self, + roots: Vec, + skill_config_rules: &SkillConfigRules, + ) -> SkillLoadOutcome { + let outcome = crate::filter_skill_load_outcome_for_product( + load_skills_from_roots(roots).await, + self.restriction_product, + ); + let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules); + finalize_skill_outcome(outcome, disabled_paths) + } + + pub fn clear_cache(&self) { + let cleared_cwd = { + let mut cache = self + .cache_by_cwd + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let cleared = cache.len(); + cache.clear(); + cleared + }; + let cleared_config = { + let mut cache = self + .cache_by_config + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let cleared = cache.len(); + cache.clear(); + cleared + }; + let cleared = cleared_cwd + cleared_config; + info!("skills cache cleared ({cleared} entries)"); + } + + fn cached_outcome_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option { + match self.cache_by_cwd.read() { + Ok(cache) => cache.get(cwd).cloned(), + Err(err) => err.into_inner().get(cwd).cloned(), + } + } + + fn cached_outcome_for_config( + &self, + cache_key: &ConfigSkillsCacheKey, + ) -> Option { + match self.cache_by_config.read() { + Ok(cache) => cache.get(cache_key).cloned(), + Err(err) => err.into_inner().get(cache_key).cloned(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ConfigSkillsCacheKey { + roots: Vec<(AbsolutePathBuf, u8, Option)>, + skill_config_rules: SkillConfigRules, +} + +pub fn bundled_skills_enabled_from_stack( + config_layer_stack: &codex_config::ConfigLayerStack, +) -> bool { + let effective_config = config_layer_stack.effective_config(); + let Some(skills_value) = effective_config + .as_table() + .and_then(|table| table.get("skills")) + else { + return true; + }; + + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + return true; + } + }; + + skills.bundled.unwrap_or_default().enabled +} + +fn config_skills_cache_key( + roots: &[SkillRoot], + skill_config_rules: &SkillConfigRules, +) -> ConfigSkillsCacheKey { + ConfigSkillsCacheKey { + roots: roots + .iter() + .map(|root| { + let scope_rank = match root.scope { + SkillScope::Repo => 0, + SkillScope::User => 1, + SkillScope::System => 2, + SkillScope::Admin => 3, + }; + (root.path.clone(), scope_rank, root.plugin_id.clone()) + }) + .collect(), + skill_config_rules: skill_config_rules.clone(), + } +} + +fn finalize_skill_outcome( + mut outcome: SkillLoadOutcome, + disabled_paths: HashSet, +) -> SkillLoadOutcome { + outcome.disabled_paths = disabled_paths; + let (by_scripts_dir, by_doc_path) = + build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation()); + outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir); + outcome.implicit_skills_by_doc_path = Arc::new(by_doc_path); + outcome +} + +#[cfg(test)] +#[path = "manager_tests.rs"] +mod tests; diff --git a/code-rs/core-skills/src/manager_tests.rs b/code-rs/core-skills/src/manager_tests.rs new file mode 100644 index 00000000000..338da0c3981 --- /dev/null +++ b/code-rs/core-skills/src/manager_tests.rs @@ -0,0 +1,669 @@ +use super::*; +use crate::SkillMetadata; +use crate::config_rules::resolve_disabled_skill_paths; +use crate::config_rules::skill_config_rules_from_stack; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirementsToml; +use codex_exec_server::LOCAL_FS; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::PathExt; +use codex_utils_plugins::PluginSkillRoot; +use pretty_assertions::assert_eq; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; + +fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) { + let skill_dir = codex_home.path().join("skills").join(dir); + fs::create_dir_all(&skill_dir).unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); +} + +fn write_plugin_skill( + codex_home: &TempDir, + marketplace: &str, + plugin_name: &str, + dir: &str, + name: &str, + description: &str, +) -> PathBuf { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace) + .join(plugin_name) + .join("local"); + let skill_dir = plugin_root.join("skills").join(dir); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + let skill_path = skill_dir.join("SKILL.md"); + fs::write(&skill_path, content).unwrap(); + skill_path +} + +fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: "test".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: path + .abs() + .canonicalize() + .expect("skill path should canonicalize"), + scope: SkillScope::User, + plugin_id: None, + } +} + +fn write_demo_skill(tempdir: &TempDir) -> PathBuf { + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + fs::create_dir_all(skill_path.parent().expect("skill path should have parent")) + .expect("create skill dir"); + fs::write( + &skill_path, + "---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n", + ) + .expect("write skill"); + skill_path +} + +fn user_config_layer(codex_home: &TempDir, config_toml: &str) -> ConfigLayerEntry { + let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)) + .expect("user config path should be absolute"); + ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + toml::from_str(config_toml).expect("user layer toml"), + ) +} + +fn config_stack(codex_home: &TempDir, user_config_toml: &str) -> ConfigLayerStack { + ConfigLayerStack::new( + vec![user_config_layer(codex_home, user_config_toml)], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack") +} + +fn config_stack_with_session_flags( + codex_home: &TempDir, + user_config_toml: &str, + session_flags_toml: &str, +) -> ConfigLayerStack { + ConfigLayerStack::new( + vec![ + user_config_layer(codex_home, user_config_toml), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(session_flags_toml).expect("session layer toml"), + ), + ], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack") +} + +fn path_toggle_config(path: &std::path::Path, enabled: bool) -> String { + format!( + r#"[[skills.config]] +path = "{}" +enabled = {enabled} +"#, + path.display() + ) +} + +fn name_toggle_config(name: &str, enabled: bool) -> String { + format!( + r#"[[skills.config]] +name = "{name}" +enabled = {enabled} +"# + ) +} + +async fn skills_for_config_with_stack( + skills_manager: &SkillsManager, + cwd: &TempDir, + config_layer_stack: &ConfigLayerStack, + effective_skill_roots: &[AbsolutePathBuf], +) -> SkillLoadOutcome { + let skills_input = SkillsLoadInput::new( + cwd.path().abs(), + effective_skill_roots + .iter() + .cloned() + .map(|path| PluginSkillRoot { + path, + plugin_id: "test-plugin@test".to_string(), + }) + .collect(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(config_layer_stack), + ); + skills_manager + .skills_for_config(&skills_input, Some(Arc::clone(&LOCAL_FS))) + .await +} + +#[test] +fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let stale_system_skill_dir = codex_home.path().join("skills/.system/stale-skill"); + fs::create_dir_all(&stale_system_skill_dir).expect("create stale system skill dir"); + fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n") + .expect("write stale system skill"); + + let _skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ false, + ); + + assert!( + !codex_home.path().join("skills/.system").exists(), + "expected disabling system skills to remove stale cached bundled skills" + ); +} + +#[tokio::test] +async fn skills_for_config_reuses_cache_for_same_effective_config() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + write_user_skill(&codex_home, "a", "skill-a", "from a"); + let outcome1 = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + outcome1.skills.iter().any(|s| s.name == "skill-a"), + "expected skill-a to be discovered" + ); + + // Write a new skill after the first call; the second call should reuse the config-aware cache + // entry because the effective skill config is unchanged. + write_user_skill(&codex_home, "b", "skill-b", "from b"); + let outcome2 = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert_eq!(outcome2.errors, outcome1.errors); + assert_eq!(outcome2.skills, outcome1.skills); +} + +#[tokio::test] +async fn skills_for_config_disables_plugin_skills_by_name() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let skill_path = write_plugin_skill( + &codex_home, + "test", + "sample", + "sample-search", + "sample-search", + "search sample data", + ); + let config_layer_stack = config_stack( + &codex_home, + &name_toggle_config("sample:sample-search", /*enabled*/ false), + ); + let plugin_skill_root = skill_path + .parent() + .and_then(std::path::Path::parent) + .expect("plugin skill should live under a skills root") + .abs(); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + let outcome = skills_for_config_with_stack( + &skills_manager, + &cwd, + &config_layer_stack, + &[plugin_skill_root], + ) + .await; + let skill = outcome + .skills + .iter() + .find(|skill| skill.name == "sample:sample-search") + .expect("plugin skill should load"); + let skill_path = dunce::canonicalize(skill_path) + .expect("skill path should canonicalize") + .abs(); + + assert_eq!(skill.path_to_skills_md, skill_path); + assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md)); + assert!( + !outcome + .allowed_skills_for_implicit_invocation() + .iter() + .any(|allowed_skill| allowed_skill.path_to_skills_md == skill.path_to_skills_md) + ); +} + +#[tokio::test] +async fn skills_for_cwd_loads_repo_and_user_roots_with_local_fs() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let repo_dot_codex = cwd.path().join(".codex"); + fs::create_dir_all(&repo_dot_codex).expect("create repo config dir"); + + write_user_skill(&codex_home, "user", "user-skill", "from local user root"); + let repo_skill_dir = repo_dot_codex.join("skills/repo"); + fs::create_dir_all(&repo_skill_dir).expect("create repo skill dir"); + fs::write( + repo_skill_dir.join("SKILL.md"), + "---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n", + ) + .expect("write repo skill"); + + let config_layer_stack = ConfigLayerStack::new( + vec![ + user_config_layer(&codex_home, ""), + ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: repo_dot_codex.abs(), + }, + toml::Value::Table(toml::map::Map::new()), + ), + ], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + let skills_input = SkillsLoadInput::new( + cwd.path().abs(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + let outcome = skills_manager + .skills_for_cwd( + &skills_input, + /*force_reload*/ true, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let loaded_names = outcome + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>(); + assert!(loaded_names.contains("user-skill")); + assert!(loaded_names.contains("repo-skill")); +} + +#[tokio::test] +async fn skills_for_cwd_without_fs_skips_repo_roots() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let repo_dot_codex = cwd.path().join(".codex"); + fs::create_dir_all(&repo_dot_codex).expect("create repo config dir"); + + write_user_skill(&codex_home, "user", "user-skill", "from local user root"); + let repo_skill_dir = repo_dot_codex.join("skills/repo"); + fs::create_dir_all(&repo_skill_dir).expect("create repo skill dir"); + fs::write( + repo_skill_dir.join("SKILL.md"), + "---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n", + ) + .expect("write repo skill"); + + let config_layer_stack = ConfigLayerStack::new( + vec![ + user_config_layer(&codex_home, ""), + ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: repo_dot_codex.abs(), + }, + toml::Value::Table(toml::map::Map::new()), + ), + ], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + let skills_input = SkillsLoadInput::new( + cwd.path().abs(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + let outcome = skills_manager + .skills_for_cwd(&skills_input, /*force_reload*/ true, /*fs*/ None) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let loaded_names = outcome + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>(); + assert!(loaded_names.contains("user-skill")); + assert!(!loaded_names.contains("repo-skill")); +} + +#[tokio::test] +async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let bundled_skill_dir = codex_home.path().join("skills/.system/bundled-skill"); + fs::create_dir_all(&bundled_skill_dir).expect("create bundled skill dir"); + fs::write( + bundled_skill_dir.join("SKILL.md"), + "---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n", + ) + .expect("write bundled skill"); + let config_layer_stack = config_stack(&codex_home, "[skills.bundled]\nenabled = false\n"); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ false, + ); + + // Recreate the cached bundled skill after startup cleanup so this assertion exercises + // root selection rather than relying on directory removal succeeding. + fs::create_dir_all(&bundled_skill_dir).expect("recreate bundled skill dir"); + fs::write( + bundled_skill_dir.join("SKILL.md"), + "---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n", + ) + .expect("rewrite bundled skill"); + + let outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + outcome + .skills + .iter() + .all(|skill| skill.name != "bundled-skill") + ); + assert!( + outcome + .skills + .iter() + .all(|skill| skill.scope != SkillScope::System) + ); +} + +#[tokio::test] +async fn skills_for_cwd_uses_cached_result_until_force_reload() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + let base_input = SkillsLoadInput::new( + cwd.path().abs(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let outcome_a = skills_manager + .skills_for_cwd( + &base_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + outcome_a + .skills + .iter() + .all(|skill| skill.name != "late-skill") + ); + + write_user_skill(&codex_home, "late", "late-skill", "added after cache"); + + let outcome_b = skills_manager + .skills_for_cwd( + &base_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + outcome_b + .skills + .iter() + .all(|skill| skill.name != "late-skill") + ); + + let outcome_reloaded = skills_manager + .skills_for_cwd( + &base_input, + /*force_reload*/ true, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + outcome_reloaded + .skills + .iter() + .any(|skill| skill.name == "late-skill") + ); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = write_demo_skill(&tempdir); + let skill = test_skill("demo-skill", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ true)) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = write_demo_skill(&tempdir); + let skill = test_skill("demo-skill", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ true)) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::from([skill_path + .abs() + .canonicalize() + .expect("skill path should canonicalize")]) + ); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_disables_matching_name_selectors() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = write_demo_skill(&tempdir); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&name_toggle_config("github:yeet", /*enabled*/ false)) + .expect("user layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::from([skill_path + .abs() + .canonicalize() + .expect("skill path should canonicalize")]) + ); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = write_demo_skill(&tempdir); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(&name_toggle_config("github:yeet", /*enabled*/ true)) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); +} + +#[cfg_attr(windows, ignore)] +#[tokio::test] +async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let skill_dir = codex_home.path().join("skills").join("demo"); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + let skill_path = skill_dir.join("SKILL.md"); + fs::write( + &skill_path, + "---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n", + ) + .expect("write skill"); + let disabled_skill_config = path_toggle_config(&skill_path, /*enabled*/ false); + let enabled_skill_config = path_toggle_config(&skill_path, /*enabled*/ true); + let parent_stack = config_stack(&codex_home, &disabled_skill_config); + let child_stack = + config_stack_with_session_flags(&codex_home, &disabled_skill_config, &enabled_skill_config); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + let parent_input = SkillsLoadInput::new( + cwd.path().abs(), + Vec::new(), + parent_stack.clone(), + bundled_skills_enabled_from_stack(&parent_stack), + ); + + let parent_outcome = skills_manager + .skills_for_cwd( + &parent_input, + /*force_reload*/ true, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + let parent_skill = parent_outcome + .skills + .iter() + .find(|skill| skill.name == "demo-skill") + .expect("demo skill should be discovered"); + assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false); + + let child_outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]).await; + let child_skill = child_outcome + .skills + .iter() + .find(|skill| skill.name == "demo-skill") + .expect("demo skill should be discovered"); + assert_eq!(child_outcome.is_skill_enabled(child_skill), true); +} diff --git a/code-rs/core-skills/src/mention_counts.rs b/code-rs/core-skills/src/mention_counts.rs new file mode 100644 index 00000000000..b7482ca36ec --- /dev/null +++ b/code-rs/core-skills/src/mention_counts.rs @@ -0,0 +1,24 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use super::SkillMetadata; +use codex_utils_absolute_path::AbsolutePathBuf; + +/// Counts how often each skill name appears (exact and ASCII-lowercase), excluding disabled paths. +pub fn build_skill_name_counts( + skills: &[SkillMetadata], + disabled_paths: &HashSet, +) -> (HashMap, HashMap) { + let mut exact_counts: HashMap = HashMap::new(); + let mut lower_counts: HashMap = HashMap::new(); + for skill in skills { + if disabled_paths.contains(&skill.path_to_skills_md) { + continue; + } + *exact_counts.entry(skill.name.clone()).or_insert(0) += 1; + *lower_counts + .entry(skill.name.to_ascii_lowercase()) + .or_insert(0) += 1; + } + (exact_counts, lower_counts) +} diff --git a/code-rs/core-skills/src/model.rs b/code-rs/core-skills/src/model.rs new file mode 100644 index 00000000000..fc8e9f5917d --- /dev/null +++ b/code-rs/core-skills/src/model.rs @@ -0,0 +1,212 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt; +use std::sync::Arc; + +use codex_exec_server::ExecutorFileSystem; +use codex_protocol::protocol::Product; +use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; + +#[derive(Debug, Clone, PartialEq)] +pub struct SkillMetadata { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, + pub dependencies: Option, + pub policy: Option, + /// Path to the SKILLS.md file that declares this skill. + pub path_to_skills_md: AbsolutePathBuf, + pub scope: SkillScope, + pub plugin_id: Option, +} + +impl SkillMetadata { + fn allow_implicit_invocation(&self) -> bool { + self.policy + .as_ref() + .and_then(|policy| policy.allow_implicit_invocation) + .unwrap_or(true) + } + + pub fn matches_product_restriction_for_product( + &self, + restriction_product: Option, + ) -> bool { + match &self.policy { + Some(policy) => { + policy.products.is_empty() + || restriction_product.is_some_and(|product| { + product.matches_product_restriction(&policy.products) + }) + } + None => true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SkillPolicy { + pub allow_implicit_invocation: Option, + // TODO: Enforce product gating in Codex skill selection/injection instead of only parsing and + // storing this metadata. + pub products: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillInterface { + pub display_name: Option, + pub short_description: Option, + pub icon_small: Option, + pub icon_large: Option, + pub brand_color: Option, + pub default_prompt: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillDependencies { + pub tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillToolDependency { + pub r#type: String, + pub value: String, + pub description: Option, + pub transport: Option, + pub command: Option, + pub url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillError { + pub path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SkillLoadOutcome { + pub skills: Vec, + pub errors: Vec, + pub disabled_paths: HashSet, + pub(crate) skill_roots: Vec, + pub(crate) skill_root_by_path: Arc>, + pub(crate) file_systems_by_skill_path: SkillFileSystemsByPath, + pub(crate) implicit_skills_by_scripts_dir: Arc>, + pub(crate) implicit_skills_by_doc_path: Arc>, +} + +impl SkillLoadOutcome { + pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool { + !self.disabled_paths.contains(&skill.path_to_skills_md) + } + + pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool { + self.is_skill_enabled(skill) && skill.allow_implicit_invocation() + } + + pub fn allowed_skills_for_implicit_invocation(&self) -> Vec { + self.skills + .iter() + .filter(|skill| self.is_skill_allowed_for_implicit_invocation(skill)) + .cloned() + .collect() + } + + pub fn skills_with_enabled(&self) -> impl Iterator { + self.skills + .iter() + .map(|skill| (skill, self.is_skill_enabled(skill))) + } + + pub(crate) fn file_system_for_skill( + &self, + skill: &SkillMetadata, + ) -> Option> { + self.file_systems_by_skill_path + .get(&skill.path_to_skills_md) + } +} + +#[derive(Clone, Default)] +pub(crate) struct SkillFileSystemsByPath { + values: Arc>>, +} + +impl SkillFileSystemsByPath { + pub(crate) fn new(values: HashMap>) -> Self { + Self { + values: Arc::new(values), + } + } + + fn get(&self, path: &AbsolutePathBuf) -> Option> { + self.values.get(path).map(Arc::clone) + } + + fn retain_paths(&mut self, paths: &HashSet) { + self.values = Arc::new( + self.values + .iter() + .filter(|(path, _)| paths.contains(*path)) + .map(|(path, fs)| (path.clone(), Arc::clone(fs))) + .collect(), + ); + } +} + +impl fmt::Debug for SkillFileSystemsByPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SkillFileSystemsByPath") + .field("len", &self.values.len()) + .finish() + } +} + +pub fn filter_skill_load_outcome_for_product( + mut outcome: SkillLoadOutcome, + restriction_product: Option, +) -> SkillLoadOutcome { + outcome + .skills + .retain(|skill| skill.matches_product_restriction_for_product(restriction_product)); + let retained_paths: HashSet = outcome + .skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect(); + outcome + .file_systems_by_skill_path + .retain_paths(&retained_paths); + outcome.skill_root_by_path = Arc::new( + outcome + .skill_root_by_path + .iter() + .filter(|(path, _)| retained_paths.contains(*path)) + .map(|(path, root)| (path.clone(), root.clone())) + .collect(), + ); + let retained_roots: HashSet = + outcome.skill_root_by_path.values().cloned().collect(); + outcome + .skill_roots + .retain(|root| retained_roots.contains(root)); + outcome.implicit_skills_by_scripts_dir = Arc::new( + outcome + .implicit_skills_by_scripts_dir + .iter() + .filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product)) + .map(|(path, skill)| (path.clone(), skill.clone())) + .collect(), + ); + outcome.implicit_skills_by_doc_path = Arc::new( + outcome + .implicit_skills_by_doc_path + .iter() + .filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product)) + .map(|(path, skill)| (path.clone(), skill.clone())) + .collect(), + ); + outcome +} diff --git a/code-rs/core-skills/src/remote.rs b/code-rs/core-skills/src/remote.rs new file mode 100644 index 00000000000..1ca7cd0cb76 --- /dev/null +++ b/code-rs/core-skills/src/remote.rs @@ -0,0 +1,259 @@ +use anyhow::Context; +use anyhow::Result; +use serde::Deserialize; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; + +const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); + +// Low-level client for the remote skill API. This is intentionally kept around for +// future wiring, but it is not used yet by any active product surface. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteSkillScope { + WorkspaceShared, + AllShared, + Personal, + Example, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteSkillProductSurface { + Chatgpt, + Codex, + Api, + Atlas, +} + +fn as_query_scope(scope: RemoteSkillScope) -> Option<&'static str> { + match scope { + RemoteSkillScope::WorkspaceShared => Some("workspace-shared"), + RemoteSkillScope::AllShared => Some("all-shared"), + RemoteSkillScope::Personal => Some("personal"), + RemoteSkillScope::Example => Some("example"), + } +} + +fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'static str { + match product_surface { + RemoteSkillProductSurface::Chatgpt => "chatgpt", + RemoteSkillProductSurface::Codex => "codex", + RemoteSkillProductSurface::Api => "api", + RemoteSkillProductSurface::Atlas => "atlas", + } +} + +fn ensure_codex_backend_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> { + let Some(auth) = auth else { + anyhow::bail!("chatgpt authentication required for remote skill scopes"); + }; + if !auth.uses_codex_backend() { + anyhow::bail!( + "chatgpt authentication required for remote skill scopes; api key auth is not supported" + ); + } + Ok(auth) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSkillSummary { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSkillDownloadResult { + pub id: String, + pub path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct RemoteSkillsResponse { + #[serde(rename = "hazelnuts")] + skills: Vec, +} + +#[derive(Debug, Deserialize)] +struct RemoteSkill { + id: String, + name: String, + description: String, +} + +pub async fn list_remote_skills( + chatgpt_base_url: String, + auth: Option<&CodexAuth>, + scope: RemoteSkillScope, + product_surface: RemoteSkillProductSurface, + enabled: Option, +) -> Result> { + let base_url = chatgpt_base_url.trim_end_matches('/'); + let auth = ensure_codex_backend_auth(auth)?; + + let url = format!("{base_url}/hazelnuts"); + let product_surface = as_query_product_surface(product_surface); + let mut query_params = vec![("product_surface", product_surface)]; + if let Some(scope) = as_query_scope(scope) { + query_params.push(("scope", scope)); + } + if let Some(enabled) = enabled { + let enabled = if enabled { "true" } else { "false" }; + query_params.push(("enabled", enabled)); + } + + let client = build_reqwest_client(); + let request = client + .get(&url) + .timeout(REMOTE_SKILLS_API_TIMEOUT) + .query(&query_params) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + let response = request + .send() + .await + .with_context(|| format!("Failed to send request to {url}"))?; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Request failed with status {status} from {url}: {body}"); + } + + let parsed: RemoteSkillsResponse = + serde_json::from_str(&body).context("Failed to parse skills response")?; + + Ok(parsed + .skills + .into_iter() + .map(|skill| RemoteSkillSummary { + id: skill.id, + name: skill.name, + description: skill.description, + }) + .collect()) +} + +pub async fn export_remote_skill( + chatgpt_base_url: String, + codex_home: PathBuf, + auth: Option<&CodexAuth>, + skill_id: &str, +) -> Result { + let auth = ensure_codex_backend_auth(auth)?; + + let client = build_reqwest_client(); + let base_url = chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/hazelnuts/{skill_id}/export"); + let request = client + .get(&url) + .timeout(REMOTE_SKILLS_API_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + + let response = request + .send() + .await + .with_context(|| format!("Failed to send download request to {url}"))?; + + let status = response.status(); + let body = response.bytes().await.context("Failed to read download")?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + anyhow::bail!("Download failed with status {status} from {url}: {body_text}"); + } + + if !is_zip_payload(&body) { + anyhow::bail!("Downloaded remote skill payload is not a zip archive"); + } + + let output_dir = codex_home.join("skills").join(skill_id); + tokio::fs::create_dir_all(&output_dir) + .await + .context("Failed to create downloaded skills directory")?; + + let zip_bytes = body.to_vec(); + let output_dir_clone = output_dir.clone(); + let prefix_candidates = vec![skill_id.to_string()]; + tokio::task::spawn_blocking(move || { + extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates) + }) + .await + .context("Zip extraction task failed")??; + + Ok(RemoteSkillDownloadResult { + id: skill_id.to_string(), + path: output_dir, + }) +} + +fn safe_join(base: &Path, name: &str) -> Result { + let path = Path::new(name); + for component in path.components() { + match component { + Component::Normal(_) => {} + _ => { + anyhow::bail!("Invalid file path in remote skill payload: {name}"); + } + } + } + Ok(base.join(path)) +} + +fn is_zip_payload(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK\x03\x04") + || bytes.starts_with(b"PK\x05\x06") + || bytes.starts_with(b"PK\x07\x08") +} + +fn extract_zip_to_dir( + bytes: Vec, + output_dir: &Path, + prefix_candidates: &[String], +) -> Result<()> { + let cursor = std::io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(cursor).context("Failed to open zip archive")?; + for i in 0..archive.len() { + let mut file = archive.by_index(i).context("Failed to read zip entry")?; + if file.is_dir() { + continue; + } + let raw_name = file.name().to_string(); + let normalized = normalize_zip_name(&raw_name, prefix_candidates); + let Some(normalized) = normalized else { + continue; + }; + let file_path = safe_join(output_dir, &normalized)?; + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent dir for {normalized}"))?; + } + let mut out = std::fs::File::create(&file_path) + .with_context(|| format!("Failed to create file {normalized}"))?; + std::io::copy(&mut file, &mut out) + .with_context(|| format!("Failed to write skill file {normalized}"))?; + } + Ok(()) +} + +fn normalize_zip_name(name: &str, prefix_candidates: &[String]) -> Option { + let mut trimmed = name.trim_start_matches("./"); + for prefix in prefix_candidates { + if prefix.is_empty() { + continue; + } + let prefix = format!("{prefix}/"); + if let Some(rest) = trimmed.strip_prefix(&prefix) { + trimmed = rest; + break; + } + } + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/code-rs/core-skills/src/render.rs b/code-rs/core-skills/src/render.rs new file mode 100644 index 00000000000..28617fb6c42 --- /dev/null +++ b/code-rs/core-skills/src/render.rs @@ -0,0 +1,1512 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Component; +use std::path::Path; + +use crate::model::SkillLoadOutcome; +use crate::model::SkillMetadata; +use codex_otel::SessionTelemetry; +use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC; +use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC; +use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC; +use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC; +use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_output_truncation::approx_token_count; + +const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000; +const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2; +const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100; +const APPROX_BYTES_PER_TOKEN: usize = 4; +pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."; +pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT: &str = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."; +pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str = + "Exceeded skills context budget. All skill descriptions were removed and"; +pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill."; +pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table."; +pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed. + 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; +pub const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r###"- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, expand the listed short `path` with the matching alias from `### Skill roots`, then open its `SKILL.md`. Read only enough to follow the workflow. + 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed. + 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; + +pub fn render_available_skills_body(skill_root_lines: &[String], skill_lines: &[String]) -> String { + let mut lines: Vec = Vec::new(); + lines.push("## Skills".to_string()); + if skill_root_lines.is_empty() { + lines.push(SKILLS_INTRO_WITH_ABSOLUTE_PATHS.to_string()); + } else { + lines.push(SKILLS_INTRO_WITH_ALIASES.to_string()); + lines.push("### Skill roots".to_string()); + lines.extend(skill_root_lines.iter().cloned()); + } + lines.push("### Available skills".to_string()); + lines.extend(skill_lines.iter().cloned()); + + lines.push("### How to use skills".to_string()); + let how_to_use = if skill_root_lines.is_empty() { + SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS + } else { + SKILLS_HOW_TO_USE_WITH_ALIASES + }; + lines.push(how_to_use.to_string()); + + format!("\n{}\n", lines.join("\n")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillMetadataBudget { + Tokens(usize), + Characters(usize), +} + +impl SkillMetadataBudget { + fn limit(self) -> usize { + match self { + Self::Tokens(limit) | Self::Characters(limit) => limit, + } + } + + fn cost(self, text: &str) -> usize { + match self { + Self::Tokens(_) => approx_token_count(text), + Self::Characters(_) => text.chars().count(), + } + } + + fn cost_from_counts(self, chars: usize, bytes: usize) -> usize { + match self { + Self::Tokens(_) => approx_token_count_from_bytes(bytes), + Self::Characters(_) => chars, + } + } +} + +fn approx_token_count_from_bytes(bytes: usize) -> usize { + bytes.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillRenderReport { + pub total_count: usize, + pub included_count: usize, + pub omitted_count: usize, + pub truncated_description_chars: usize, + pub truncated_description_count: usize, +} + +#[derive(Clone, Copy)] +pub enum SkillRenderSideEffects<'a> { + None, + ThreadStart { + session_telemetry: &'a SessionTelemetry, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvailableSkills { + pub skill_root_lines: Vec, + pub skill_lines: Vec, + pub report: SkillRenderReport, + pub warning_message: Option, +} + +pub fn default_skill_metadata_budget(context_window: Option) -> SkillMetadataBudget { + context_window + .and_then(|window| usize::try_from(window).ok()) + .filter(|window| *window > 0) + .map(|window| { + SkillMetadataBudget::Tokens( + window + .saturating_mul(SKILL_METADATA_CONTEXT_WINDOW_PERCENT) + .saturating_div(100) + .max(1), + ) + }) + .unwrap_or(SkillMetadataBudget::Characters( + DEFAULT_SKILL_METADATA_CHAR_BUDGET, + )) +} + +pub fn build_available_skills( + outcome: &SkillLoadOutcome, + budget: SkillMetadataBudget, + side_effects: SkillRenderSideEffects<'_>, +) -> Option { + let skills = outcome.allowed_skills_for_implicit_invocation(); + if skills.is_empty() { + record_skill_render_side_effects( + side_effects, + /*total_count*/ 0, + /*included_count*/ 0, + /*omitted_count*/ 0, + /*truncated_description_chars*/ 0, + ); + return None; + } + + let absolute_lines = ordered_absolute_skill_lines(&skills); + let absolute = build_available_skills_from_lines( + absolute_lines, + skills.len(), + budget, + SkillPathAliases::default(), + )?; + + let selected = + if absolute.report.omitted_count == 0 && absolute.report.truncated_description_chars == 0 { + absolute + } else if let Some(aliased) = build_aliased_available_skills(outcome, &skills, budget) { + if aliased_render_is_better(&aliased, &absolute, budget) { + aliased + } else { + absolute + } + } else { + absolute + }; + + record_available_skills_side_effects(&selected, budget, side_effects); + Some(selected) +} + +fn build_available_skills_from_lines( + skill_lines: Vec>, + total_count: usize, + budget: SkillMetadataBudget, + path_aliases: SkillPathAliases, +) -> Option { + if total_count == 0 { + return None; + } + + let (skill_lines, report) = render_skill_lines_from_lines(skill_lines, total_count, budget); + let warning_message = if report.omitted_count > 0 { + let skill_word = if report.omitted_count == 1 { + "skill" + } else { + "skills" + }; + let verb = if report.omitted_count == 1 { + "was" + } else { + "were" + }; + Some(format!( + "{} {} additional {} {} not included in the model-visible skills list.", + budget_warning_prefix(budget, SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX), + report.omitted_count, + skill_word, + verb + )) + } else if report.average_truncated_description_chars() + > SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS + { + Some( + match budget { + SkillMetadataBudget::Tokens(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT, + SkillMetadataBudget::Characters(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING, + } + .to_string(), + ) + } else { + None + }; + let available = AvailableSkills { + skill_root_lines: path_aliases.skill_root_lines, + skill_lines, + report, + warning_message, + }; + Some(available) +} + +fn record_available_skills_side_effects( + available: &AvailableSkills, + budget: SkillMetadataBudget, + side_effects: SkillRenderSideEffects<'_>, +) { + record_skill_render_side_effects( + side_effects, + available.report.total_count, + available.report.included_count, + available.report.omitted_count, + available.report.truncated_description_chars, + ); + if available.report.omitted_count > 0 || available.report.truncated_description_chars > 0 { + tracing::info!( + budget_limit = budget.limit(), + total_skills = available.report.total_count, + included_skills = available.report.included_count, + omitted_skills = available.report.omitted_count, + truncated_description_chars_per_skill = + available.report.average_truncated_description_chars(), + truncated_skill_descriptions = available.report.truncated_description_count, + "truncated skill metadata to fit skills context budget" + ); + } +} + +fn budget_warning_prefix(budget: SkillMetadataBudget, prefix: &str) -> String { + match budget { + SkillMetadataBudget::Tokens(_) => prefix.replacen( + "Exceeded skills context budget.", + "Exceeded skills context budget of 2%.", + 1, + ), + SkillMetadataBudget::Characters(_) => prefix.to_string(), + } +} + +fn record_skill_render_side_effects( + side_effects: SkillRenderSideEffects<'_>, + total_count: usize, + included_count: usize, + omitted_count: usize, + truncated_description_chars: usize, +) { + match side_effects { + SkillRenderSideEffects::None => {} + SkillRenderSideEffects::ThreadStart { session_telemetry } => { + session_telemetry.histogram( + THREAD_SKILLS_ENABLED_TOTAL_METRIC, + i64::try_from(total_count).unwrap_or(i64::MAX), + &[], + ); + session_telemetry.histogram( + THREAD_SKILLS_KEPT_TOTAL_METRIC, + i64::try_from(included_count).unwrap_or(i64::MAX), + &[], + ); + session_telemetry.histogram( + THREAD_SKILLS_TRUNCATED_METRIC, + if omitted_count > 0 { 1 } else { 0 }, + &[], + ); + session_telemetry.histogram( + THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC, + i64::try_from(truncated_description_chars).unwrap_or(i64::MAX), + &[], + ); + } + } +} + +fn render_skill_lines_from_lines( + skill_lines: Vec>, + total_count: usize, + budget: SkillMetadataBudget, +) -> (Vec, SkillRenderReport) { + let full_cost = skill_lines.iter().fold(0usize, |used, line| { + used.saturating_add(line.full_cost(budget)) + }); + if full_cost <= budget.limit() { + let included = skill_lines + .iter() + .map(SkillLine::render_full) + .collect::>(); + + return ( + included, + skill_render_report( + total_count, + /*included_count*/ skill_lines.len(), + /*omitted_count*/ 0, + /*truncated_description_chars*/ 0, + /*truncated_description_count*/ 0, + ), + ); + } + + let minimum_cost = skill_lines.iter().fold(0usize, |used, line| { + used.saturating_add(line.minimum_cost(budget)) + }); + if minimum_cost <= budget.limit() { + let rendered = render_lines_with_description_budget( + budget, + &skill_lines, + budget.limit().saturating_sub(minimum_cost), + ); + let (truncated_description_chars, truncated_description_count) = + sum_description_truncation(&rendered); + let included = rendered + .into_iter() + .map(|rendered| rendered.line) + .collect::>(); + + return ( + included, + skill_render_report( + total_count, + /*included_count*/ skill_lines.len(), + /*omitted_count*/ 0, + truncated_description_chars, + truncated_description_count, + ), + ); + } + + render_minimum_skill_lines_until_budget(budget, skill_lines, total_count) +} + +fn render_minimum_skill_lines_until_budget( + budget: SkillMetadataBudget, + skill_lines: Vec>, + total_count: usize, +) -> (Vec, SkillRenderReport) { + let mut included = Vec::new(); + let mut used = 0usize; + let mut omitted_count = 0usize; + let mut truncated_description_chars = 0usize; + let mut truncated_description_count = 0usize; + for line in skill_lines { + let line_cost = line.minimum_cost(budget); + let description_char_count = line.description_char_count(); + if used.saturating_add(line_cost) <= budget.limit() { + used = used.saturating_add(line_cost); + included.push(line.render_minimum()); + } else { + omitted_count = omitted_count.saturating_add(1); + } + + truncated_description_chars = + truncated_description_chars.saturating_add(description_char_count); + if description_char_count > 0 { + truncated_description_count = truncated_description_count.saturating_add(1); + } + } + + let report = skill_render_report( + total_count, + included.len(), + omitted_count, + truncated_description_chars, + truncated_description_count, + ); + + (included, report) +} + +fn skill_render_report( + total_count: usize, + included_count: usize, + omitted_count: usize, + truncated_description_chars: usize, + truncated_description_count: usize, +) -> SkillRenderReport { + SkillRenderReport { + total_count, + included_count, + omitted_count, + truncated_description_chars, + truncated_description_count, + } +} + +impl SkillRenderReport { + fn average_truncated_description_chars(&self) -> usize { + if self.total_count == 0 || self.truncated_description_chars == 0 { + return 0; + } + + self.truncated_description_chars + .saturating_add(self.total_count.saturating_sub(1)) + / self.total_count + } +} + +struct SkillLine<'a> { + name: &'a str, + description: &'a str, + path: String, +} + +struct RenderedSkillLine { + line: String, + truncated_chars: usize, +} + +struct DescriptionBudgetLine<'a> { + line: &'a SkillLine<'a>, + description_char_count: usize, + extra_costs: Vec, +} + +fn sum_description_truncation(rendered: &[RenderedSkillLine]) -> (usize, usize) { + rendered + .iter() + .fold((0usize, 0usize), |(chars, count), line| { + if line.truncated_chars == 0 { + (chars, count) + } else { + ( + chars.saturating_add(line.truncated_chars), + count.saturating_add(1), + ) + } + }) +} + +impl<'a> SkillLine<'a> { + fn new(skill: &'a SkillMetadata) -> Self { + Self::with_path( + skill, + skill.path_to_skills_md.to_string_lossy().replace('\\', "/"), + ) + } + + fn with_path(skill: &'a SkillMetadata, path: String) -> Self { + Self { + name: skill.name.as_str(), + description: skill.description.as_str(), + path, + } + } + + fn full_cost(&self, budget: SkillMetadataBudget) -> usize { + line_cost(budget, &self.render_full()) + } + + fn minimum_cost(&self, budget: SkillMetadataBudget) -> usize { + line_cost(budget, &self.render_minimum()) + } + + fn description_char_count(&self) -> usize { + self.description.chars().count() + } + + fn render_full(&self) -> String { + self.render_with_description(self.description) + } + + fn render_minimum(&self) -> String { + self.render_with_description("") + } + + fn rendered_description_prefix_len(&self, description_chars: usize) -> usize { + self.description + .char_indices() + .nth(description_chars) + .map_or(self.description.len(), |(idx, _)| idx) + } + + fn render_with_description_chars(&self, description_chars: usize) -> String { + if description_chars == 0 { + format!("- {}: (file: {})", self.name, self.path) + } else { + let end = self.rendered_description_prefix_len(description_chars); + let description = &self.description[..end]; + format!("- {}: {} (file: {})", self.name, description, self.path) + } + } + + fn render_with_description(&self, description: &str) -> String { + if description.is_empty() { + format!("- {}: (file: {})", self.name, self.path) + } else { + format!("- {}: {} (file: {})", self.name, description, self.path) + } + } +} + +impl<'a> DescriptionBudgetLine<'a> { + fn new(line: &'a SkillLine<'a>, budget: SkillMetadataBudget) -> Self { + let minimum_line = line.render_minimum(); + let minimum_chars = minimum_line.chars().count().saturating_add(1); + let minimum_bytes = minimum_line.len().saturating_add(1); + let minimum_cost = budget.cost_from_counts(minimum_chars, minimum_bytes); + + let description_char_count = line.description_char_count(); + let mut extra_costs = Vec::with_capacity(description_char_count.saturating_add(1)); + extra_costs.push(0); + + let mut prefix_chars = 0usize; + let mut prefix_bytes = 0usize; + for ch in line.description.chars() { + prefix_chars = prefix_chars.saturating_add(1); + prefix_bytes = prefix_bytes.saturating_add(ch.len_utf8()); + let rendered_chars = minimum_chars.saturating_add(prefix_chars).saturating_add(1); + let rendered_bytes = minimum_bytes.saturating_add(prefix_bytes).saturating_add(1); + let cost = budget + .cost_from_counts(rendered_chars, rendered_bytes) + .saturating_sub(minimum_cost); + extra_costs.push(cost); + } + + Self { + line, + description_char_count, + extra_costs, + } + } +} + +fn line_cost(budget: SkillMetadataBudget, line: &str) -> usize { + budget.cost(&format!("{line}\n")) +} + +fn lines_cost(budget: SkillMetadataBudget, lines: &[String]) -> usize { + lines.iter().fold(0usize, |used, line| { + used.saturating_add(line_cost(budget, line)) + }) +} + +fn render_lines_with_description_budget( + budget: SkillMetadataBudget, + skill_lines: &[SkillLine<'_>], + limit: usize, +) -> Vec { + let budget_lines = skill_lines + .iter() + .map(|line| DescriptionBudgetLine::new(line, budget)) + .collect::>(); + let mut char_allocations = vec![0usize; budget_lines.len()]; + let mut current_extra_costs = vec![0usize; budget_lines.len()]; + let mut remaining = limit; + + // Distribute description space one character at a time across skills. + // Short descriptions naturally drop out, so their unused share can go to + // longer descriptions instead of being stranded in a fixed per-skill quota. + loop { + let mut changed = false; + for (index, line) in budget_lines.iter().enumerate() { + if char_allocations[index] >= line.description_char_count { + continue; + } + + let current_cost = current_extra_costs[index]; + let next_chars = char_allocations[index].saturating_add(1); + let next_cost = line.extra_costs[next_chars]; + let delta = next_cost.saturating_sub(current_cost); + if delta <= remaining { + char_allocations[index] = next_chars; + current_extra_costs[index] = next_cost; + remaining = remaining.saturating_sub(delta); + changed = true; + } + } + + if !changed { + break; + } + } + + budget_lines + .iter() + .zip(char_allocations) + .map(|(line, description_chars)| { + let truncated_chars = line + .description_char_count + .saturating_sub(description_chars); + RenderedSkillLine { + line: line.line.render_with_description_chars(description_chars), + truncated_chars, + } + }) + .collect() +} + +fn build_aliased_available_skills( + outcome: &SkillLoadOutcome, + skills: &[SkillMetadata], + budget: SkillMetadataBudget, +) -> Option { + let plan = build_alias_plan(outcome, skills, budget)?; + if plan.table_cost >= budget.limit() { + return None; + } + + let adjusted_limit = budget.limit().saturating_sub(plan.table_cost); + let adjusted_budget = match budget { + SkillMetadataBudget::Tokens(_) => SkillMetadataBudget::Tokens(adjusted_limit), + SkillMetadataBudget::Characters(_) => SkillMetadataBudget::Characters(adjusted_limit), + }; + let ordered_skills = ordered_skills_for_budget(skills); + let skill_lines = ordered_skills + .into_iter() + .map(|skill| SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan))) + .collect::>(); + build_available_skills_from_lines(skill_lines, skills.len(), adjusted_budget, plan.aliases) +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SkillPathAliases { + skill_root_lines: Vec, +} + +struct AliasPlan { + aliases: SkillPathAliases, + root_aliases: HashMap, + alias_root_by_path: HashMap, + table_cost: usize, +} + +fn build_alias_plan( + outcome: &SkillLoadOutcome, + skills: &[SkillMetadata], + budget: SkillMetadataBudget, +) -> Option { + let skill_paths = skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + let skill_root_by_path = outcome + .skill_root_by_path + .iter() + .filter(|(path, _)| skill_paths.contains(*path)) + .map(|(path, root)| (path.clone(), root.clone())) + .collect::>(); + let used_roots = outcome + .skill_roots + .iter() + .filter(|root| { + skill_root_by_path + .values() + .any(|skill_root| skill_root == *root) + }) + .cloned() + .collect::>(); + if used_roots.is_empty() { + return None; + } + + let plugin_version_skill_counts = + plugin_version_skill_counts_for_skill_roots(skill_root_by_path.values()); + let alias_root_by_skill_root = used_roots + .iter() + .map(|root| { + ( + root.clone(), + alias_root_for_skill_root(root, &plugin_version_skill_counts), + ) + }) + .collect::>(); + let alias_roots = ordered_alias_roots(&used_roots, &alias_root_by_skill_root)?; + let root_aliases = alias_roots + .iter() + .enumerate() + .map(|(index, alias_root)| (alias_root.clone(), format!("r{index}"))) + .collect::>(); + let alias_root_by_path = skill_root_by_path + .iter() + .filter_map(|(path, skill_root)| { + alias_root_by_skill_root + .get(skill_root) + .map(|alias_root| (path.clone(), alias_root.clone())) + }) + .collect::>(); + let skill_root_lines = build_skill_root_lines(&alias_roots); + let table_cost = aliased_metadata_overhead_cost(budget, &skill_root_lines); + + Some(AliasPlan { + aliases: SkillPathAliases { skill_root_lines }, + root_aliases, + alias_root_by_path, + table_cost, + }) +} + +fn ordered_alias_roots( + used_roots: &[AbsolutePathBuf], + alias_root_by_skill_root: &HashMap, +) -> Option> { + let mut seen = HashSet::new(); + let mut alias_roots = Vec::new(); + for root in used_roots { + let alias_root = alias_root_by_skill_root.get(root)?.clone(); + if seen.insert(alias_root.clone()) { + alias_roots.push(alias_root); + } + } + Some(alias_roots) +} + +fn alias_root_for_skill_root( + root: &AbsolutePathBuf, + plugin_version_skill_counts: &HashMap, +) -> AbsolutePathBuf { + let Some(plugin_version_base) = plugin_version_base(root.as_path()) else { + return root.clone(); + }; + let skill_count = plugin_version_skill_counts + .get(&plugin_version_base) + .copied() + .unwrap_or_default(); + if skill_count > 1 { + root.clone() + } else { + plugin_marketplace_base(root.as_path()).unwrap_or_else(|| root.clone()) + } +} + +fn plugin_version_skill_counts_for_skill_roots<'a>( + skill_roots: impl Iterator, +) -> HashMap { + let mut counts = HashMap::new(); + for root in skill_roots { + if let Some(plugin_version_base) = plugin_version_base(root.as_path()) { + let count = counts.entry(plugin_version_base).or_insert(0usize); + *count = count.saturating_add(1); + } + } + counts +} + +fn aliased_metadata_overhead_cost( + budget: SkillMetadataBudget, + skill_root_lines: &[String], +) -> usize { + let empty_skill_lines: &[String] = &[]; + let absolute_body = render_available_skills_body(&[], empty_skill_lines); + let aliased_body = render_available_skills_body(skill_root_lines, empty_skill_lines); + budget + .cost(&aliased_body) + .saturating_sub(budget.cost(&absolute_body)) +} + +fn build_skill_root_lines(roots: &[AbsolutePathBuf]) -> Vec { + roots + .iter() + .enumerate() + .map(|(index, root)| { + let root_str = root.to_string_lossy().replace('\\', "/"); + format!("- `r{index}` = `{root_str}`") + }) + .collect() +} + +fn plugin_marketplace_base(path: &Path) -> Option { + let mut candidate = path; + while let Some(parent) = candidate.parent() { + if parent.file_name()?.to_str()? == "cache" + && parent.parent()?.file_name()?.to_str()? == "plugins" + { + return AbsolutePathBuf::from_absolute_path(candidate).ok(); + } + candidate = parent; + } + None +} + +fn plugin_version_base(path: &Path) -> Option { + let marketplace_base = plugin_marketplace_base(path)?; + let mut relative_components = path + .strip_prefix(marketplace_base.as_path()) + .ok()? + .components(); + let plugin = match relative_components.next()? { + Component::Normal(plugin) => plugin, + _ => return None, + }; + let version = match relative_components.next()? { + Component::Normal(version) => version, + _ => return None, + }; + AbsolutePathBuf::from_absolute_path(marketplace_base.join(plugin).join(version)).ok() +} + +fn render_skill_path_with_aliases(skill: &SkillMetadata, plan: &AliasPlan) -> String { + outcome_relative_skill_path(skill, plan) + .unwrap_or_else(|| skill.path_to_skills_md.to_string_lossy().replace('\\', "/")) +} + +fn outcome_relative_skill_path(skill: &SkillMetadata, plan: &AliasPlan) -> Option { + let alias_root = plan.alias_root_by_path.get(&skill.path_to_skills_md)?; + let alias = plan.root_aliases.get(alias_root)?; + let relative_path = skill + .path_to_skills_md + .as_path() + .strip_prefix(alias_root.as_path()) + .ok()?; + let relative_path = relative_path.to_string_lossy().replace('\\', "/"); + Some(format!("{alias}/{relative_path}")) +} + +fn aliased_render_is_better( + aliased: &AvailableSkills, + absolute: &AvailableSkills, + budget: SkillMetadataBudget, +) -> bool { + if aliased.report.included_count != absolute.report.included_count { + return aliased.report.included_count > absolute.report.included_count; + } + if aliased.report.truncated_description_chars != absolute.report.truncated_description_chars { + return aliased.report.truncated_description_chars + < absolute.report.truncated_description_chars; + } + available_skills_cost(budget, aliased) < available_skills_cost(budget, absolute) +} + +fn available_skills_cost(budget: SkillMetadataBudget, available: &AvailableSkills) -> usize { + let metadata_cost = if available.skill_root_lines.is_empty() { + 0 + } else { + aliased_metadata_overhead_cost(budget, &available.skill_root_lines) + }; + metadata_cost.saturating_add(lines_cost(budget, &available.skill_lines)) +} + +fn ordered_absolute_skill_lines(skills: &[SkillMetadata]) -> Vec> { + ordered_skills_for_budget(skills) + .into_iter() + .map(SkillLine::new) + .collect() +} + +fn ordered_skills_for_budget(skills: &[SkillMetadata]) -> Vec<&SkillMetadata> { + let mut ordered = skills.iter().collect::>(); + ordered.sort_by(|a, b| { + prompt_scope_rank(a.scope) + .cmp(&prompt_scope_rank(b.scope)) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) + }); + ordered +} + +fn prompt_scope_rank(scope: SkillScope) -> u8 { + match scope { + SkillScope::System => 0, + SkillScope::Admin => 1, + SkillScope::Repo => 2, + SkillScope::User => 3, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Arc; + + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + + fn make_skill(name: &str, scope: SkillScope) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(), + scope, + plugin_id: None, + } + } + + fn make_skill_with_description( + name: &str, + scope: SkillScope, + description: &str, + ) -> SkillMetadata { + let mut skill = make_skill(name, scope); + skill.description = description.to_string(); + skill + } + + fn expected_skill_line(skill: &SkillMetadata, description: &str) -> String { + SkillLine::new(skill).render_with_description(description) + } + + fn normalized_path(path: &AbsolutePathBuf) -> String { + path.to_string_lossy().replace('\\', "/") + } + + fn outcome_with_roots( + skills: Vec, + roots: Vec, + ) -> SkillLoadOutcome { + let skill_root_by_path = skills + .iter() + .filter_map(|skill| { + roots + .iter() + .find(|root| { + skill + .path_to_skills_md + .as_path() + .starts_with(root.as_path()) + }) + .map(|root| (skill.path_to_skills_md.clone(), root.clone())) + }) + .collect::>(); + SkillLoadOutcome { + skills, + skill_roots: roots, + skill_root_by_path: Arc::new(skill_root_by_path), + ..Default::default() + } + } + + fn build_available_skills_from_metadata( + skills: &[SkillMetadata], + budget: SkillMetadataBudget, + ) -> Option { + build_available_skills_from_lines( + ordered_absolute_skill_lines(skills), + skills.len(), + budget, + SkillPathAliases::default(), + ) + } + + #[test] + fn default_budget_uses_two_percent_of_full_context_window() { + assert_eq!( + default_skill_metadata_budget(Some(200_000)), + SkillMetadataBudget::Tokens(4_000) + ); + assert_eq!( + default_skill_metadata_budget(Some(99)), + SkillMetadataBudget::Tokens(1) + ); + } + + #[test] + fn default_budget_falls_back_to_characters_without_context_window() { + assert_eq!( + default_skill_metadata_budget(/*context_window*/ None), + SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET) + ); + assert_eq!( + default_skill_metadata_budget(Some(-1)), + SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET) + ); + } + + #[test] + fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() { + let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef"); + let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyz"); + let minimum_cost = SkillLine::new(&alpha) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 6); + + let rendered = build_available_skills_from_metadata(&[beta.clone(), alpha.clone()], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.report.truncated_description_chars, 8); + assert_eq!(rendered.warning_message, None); + assert_eq!( + rendered.skill_lines, + vec![ + expected_skill_line(&alpha, "ab"), + expected_skill_line(&beta, "uv"), + ] + ); + } + + #[test] + fn budgeted_rendering_does_not_warn_when_average_description_truncation_is_within_threshold() { + let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdefghij"); + let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyzabcd"); + let minimum_cost = SkillLine::new(&alpha) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 6); + + let rendered = build_available_skills_from_metadata(&[alpha, beta], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.report.truncated_description_chars, 16); + assert_eq!(rendered.report.truncated_description_count, 2); + assert_eq!(rendered.warning_message, None); + } + + #[test] + fn budgeted_rendering_warns_when_average_description_truncation_exceeds_threshold() { + let long_description = "a".repeat(250); + let long_skill = + make_skill_with_description("long-skill", SkillScope::Repo, &long_description); + let empty_skill = make_skill_with_description("empty-skill", SkillScope::Repo, ""); + let minimum_cost = SkillLine::new(&long_skill) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&empty_skill) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 49); + + let rendered = build_available_skills_from_metadata(&[long_skill, empty_skill], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.total_count, 2); + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.report.truncated_description_chars, 202); + assert_eq!(rendered.report.truncated_description_count, 1); + assert_eq!( + rendered.warning_message, + Some( + "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest." + .to_string() + ) + ); + } + + #[test] + fn budgeted_rendering_token_budget_truncation_warning_mentions_two_percent() { + let long_description = "a".repeat(1000); + let long_skill = + make_skill_with_description("long-skill", SkillScope::Repo, &long_description); + let minimum_cost = + SkillLine::new(&long_skill).minimum_cost(SkillMetadataBudget::Tokens(usize::MAX)); + let budget = SkillMetadataBudget::Tokens(minimum_cost + 1); + + let rendered = build_available_skills_from_metadata(&[long_skill], budget) + .expect("skills should render"); + + assert_eq!( + rendered.warning_message, + Some(SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT.to_string()) + ); + } + + #[test] + fn budgeted_rendering_redistributes_unused_description_budget() { + let short = make_skill_with_description("short-skill", SkillScope::Repo, "x"); + let long = make_skill_with_description("long-skill", SkillScope::Repo, "abcdefghi"); + let minimum_cost = SkillLine::new(&short) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&long).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 11); + + let rendered = build_available_skills_from_metadata(&[short.clone(), long.clone()], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.warning_message, None); + assert_eq!( + rendered.skill_lines, + vec![ + expected_skill_line(&long, "abcdefgh"), + expected_skill_line(&short, "x"), + ] + ); + } + + #[test] + fn budgeted_rendering_preserves_prompt_priority_when_minimum_lines_exceed_budget() { + let system = make_skill("system-skill", SkillScope::System); + let user = make_skill("user-skill", SkillScope::User); + let repo = make_skill("repo-skill", SkillScope::Repo); + let admin = make_skill("admin-skill", SkillScope::Admin); + let system_cost = SkillMetadataBudget::Characters(usize::MAX) + .cost(&format!("{}\n", SkillLine::new(&system).render_minimum())); + let admin_cost = SkillMetadataBudget::Characters(usize::MAX) + .cost(&format!("{}\n", SkillLine::new(&admin).render_minimum())); + let budget = SkillMetadataBudget::Characters(system_cost + admin_cost); + + let rendered = build_available_skills_from_metadata(&[system, user, repo, admin], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 2); + assert_eq!( + rendered.warning_message, + Some( + "Exceeded skills context budget. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list." + .to_string() + ) + ); + let rendered_text = rendered.skill_lines.join("\n"); + assert!(rendered_text.contains("- system-skill:")); + assert!(rendered_text.contains("- admin-skill:")); + assert!(!rendered_text.contains("desc")); + assert!(!rendered_text.contains("- repo-skill:")); + assert!(!rendered_text.contains("- user-skill:")); + } + + #[test] + fn budgeted_rendering_keeps_scanning_after_oversized_entry() { + let mut oversized = make_skill("oversized-system-skill", SkillScope::System); + oversized.description = "desc ".repeat(100); + let repo = make_skill("repo-skill", SkillScope::Repo); + let repo_cost = SkillMetadataBudget::Characters(usize::MAX) + .cost(&format!("{}\n", SkillLine::new(&repo).render_full())); + let budget = SkillMetadataBudget::Characters(repo_cost); + + let rendered = build_available_skills_from_metadata(&[oversized, repo], budget) + .expect("skills render"); + + assert_eq!(rendered.report.included_count, 1); + assert_eq!(rendered.report.omitted_count, 1); + assert_eq!( + rendered.warning_message, + Some( + "Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list." + .to_string() + ) + ); + let rendered_text = rendered.skill_lines.join("\n"); + assert!(!rendered_text.contains("- oversized-system-skill:")); + assert!(rendered_text.contains("- repo-skill:")); + } + + #[test] + fn outcome_rendering_omits_aliases_when_absolute_plan_has_no_budget_pressure() { + let root = test_path_buf("/tmp/skills").abs(); + let alpha_path = root.join("alpha/SKILL.md"); + let beta_path = root.join("beta/SKILL.md"); + let outcome = outcome_with_roots( + vec![ + skill_with_path("alpha-skill", &alpha_path), + skill_with_path("beta-skill", &beta_path), + ], + vec![root], + ); + + let rendered = build_available_skills( + &outcome, + SkillMetadataBudget::Characters(usize::MAX), + SkillRenderSideEffects::None, + ) + .expect("skills should render"); + + assert!(rendered.skill_root_lines.is_empty()); + assert_eq!(rendered.report.included_count, 2); + } + + #[test] + fn outcome_rendering_uses_aliases_when_they_allow_more_skills_to_fit() { + let root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix", + ) + .abs(); + let skills = (0..12) + .map(|index| { + let name = format!("shared-root-skill-{index}"); + skill_with_path(&name, &root.join(format!("skill-{index}/SKILL.md"))) + }) + .collect::>(); + let outcome = outcome_with_roots(skills.clone(), vec![root]); + let absolute_minimum = skills.iter().fold(0usize, |cost, skill| { + cost.saturating_add( + SkillLine::new(skill).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)), + ) + }); + let plan = build_alias_plan( + &outcome, + &skills, + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + let alias_minimum = skills.iter().fold(plan.table_cost, |cost, skill| { + cost.saturating_add( + SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan)) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)), + ) + }); + assert!( + alias_minimum < absolute_minimum, + "test fixture should make aliases cheaper" + ); + + let rendered = build_available_skills( + &outcome, + SkillMetadataBudget::Characters(alias_minimum), + SkillRenderSideEffects::None, + ) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, skills.len()); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!( + rendered.skill_root_lines, + vec![format!( + "- `r0` = `{}`", + normalized_path( + &test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix" + ) + .abs() + ) + )] + ); + let rendered_text = rendered.skill_lines.join("\n"); + assert!(rendered_text.contains("r0/skill-0/SKILL.md")); + assert!(rendered_text.contains("r0/skill-11/SKILL.md")); + } + + #[test] + fn outcome_rendering_uses_marketplace_root_for_single_skill_plugin_versions() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let outcome = outcome_with_roots(vec![github.clone()], vec![github_root.clone()]); + let plan = build_alias_plan( + &outcome, + &[github], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_uses_skill_root_for_multiple_skills_in_one_plugin_version() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![github_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&github_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md")), + &plan + ), + "r0/yeet/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_counts_plugin_version_skills_before_budget_omission() { + let root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix", + ) + .abs(); + let alpha = skill_with_path("alpha-skill", &root.join("alpha/SKILL.md")); + let beta = skill_with_path("beta-skill", &root.join("beta/SKILL.md")); + let outcome = outcome_with_roots(vec![alpha.clone(), beta.clone()], vec![root.clone()]); + let plan = build_alias_plan( + &outcome, + &[alpha.clone(), beta.clone()], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + let alpha_cost = SkillMetadataBudget::Characters(usize::MAX).cost(&format!( + "{}\n", + SkillLine::with_path(&alpha, render_skill_path_with_aliases(&alpha, &plan)) + .render_minimum() + )); + let rendered = build_aliased_available_skills( + &outcome, + &[alpha, beta], + SkillMetadataBudget::Characters(plan.table_cost + alpha_cost), + ) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 1); + assert_eq!( + rendered.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&root))] + ); + assert_eq!( + rendered.skill_lines, + vec!["- alpha-skill: (file: r0/alpha/SKILL.md)"] + ); + } + + #[test] + fn outcome_rendering_uses_each_skill_root_for_multiple_roots_in_one_plugin_version() { + let skills_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let extra_root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/extra-skills", + ) + .abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![skills_root.clone(), extra_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![ + format!("- `r0` = `{}`", normalized_path(&skills_root)), + format!("- `r1` = `{}`", normalized_path(&extra_root)), + ] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")), + &plan + ), + "r1/yeet/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_extracts_plugin_marketplace_root_for_multiple_plugins() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let slack_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/slack/hash456/skills") + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let slack = skill_with_path( + "slack:daily-digest", + &slack_root.join("daily-digest/SKILL.md"), + ); + let outcome = outcome_with_roots( + vec![github.clone(), slack.clone()], + vec![github_root.clone(), slack_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[github, slack], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path( + "slack:daily-digest", + &slack_root.join("daily-digest/SKILL.md") + ), + &plan + ), + "r0/slack/hash456/skills/daily-digest/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_uses_one_marketplace_root_for_multiple_plugin_versions() { + let skills_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let extra_root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/github/hash456/extra-skills", + ) + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![skills_root.clone(), extra_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")), + &plan + ), + "r0/github/hash456/extra-skills/yeet/SKILL.md" + ); + } + + fn skill_with_path(name: &str, path: &AbsolutePathBuf) -> SkillMetadata { + let mut skill = make_skill(name, SkillScope::User); + skill.path_to_skills_md = path.clone(); + skill + } +} diff --git a/code-rs/core-skills/src/system.rs b/code-rs/core-skills/src/system.rs new file mode 100644 index 00000000000..5eec94c7296 --- /dev/null +++ b/code-rs/core-skills/src/system.rs @@ -0,0 +1,8 @@ +pub(crate) use codex_skills::install_system_skills; +pub(crate) use codex_skills::system_cache_root_dir; + +use codex_utils_absolute_path::AbsolutePathBuf; + +pub(crate) fn uninstall_system_skills(codex_home: &AbsolutePathBuf) { + let _ = std::fs::remove_dir_all(system_cache_root_dir(codex_home)); +} diff --git a/code-rs/core/BUILD.bazel b/code-rs/core/BUILD.bazel new file mode 100644 index 00000000000..c78750576bf --- /dev/null +++ b/code-rs/core/BUILD.bazel @@ -0,0 +1,61 @@ +load("//:defs.bzl", "codex_rust_crate") + +filegroup( + name = "model_availability_nux_fixtures", + srcs = [ + "tests/cli_responses_fixture.sse", + ], + visibility = ["//visibility:public"], +) + +codex_rust_crate( + name = "core", + crate_name = "codex_core", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), + rustc_env = { + # Keep manifest-root path lookups inside the Bazel execroot for code + # that relies on env!("CARGO_MANIFEST_DIR"). + "CARGO_MANIFEST_DIR": "codex-rs/core", + }, + integration_compile_data_extra = [ + "//codex-rs/apply-patch:apply_patch_tool_instructions.md", + "templates/realtime/backend_prompt.md", + ], + integration_test_timeout = "long", + test_data_extra = [ + "config.schema.json", + ] + glob([ + "src/**/snapshots/**", + ]) + [ + # This is a bit of a hack, but empirically, some of our integration tests + # are relying on the presence of this file as a repo root marker. When + # running tests locally, this "just works," but in remote execution, + # the working directory is different and so the file is not found unless it + # is explicitly added as test data. + # + # TODO(aibrahim): Update the tests so that `just bazel-remote-test` + # succeeds without this workaround. + "//:AGENTS.md", + ], + test_shard_counts = { + "core-all-test": 16, + "core-unit-tests": 8, + }, + test_tags = ["no-sandbox"], + unit_test_timeout = "long", + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + "//codex-rs/linux-sandbox:codex-linux-sandbox", + "//codex-rs/rmcp-client:test_stdio_server", + "//codex-rs/rmcp-client:test_streamable_http_server", + "//codex-rs/cli:codex", + ], +) diff --git a/code-rs/core/Cargo.toml b/code-rs/core/Cargo.toml index 4aa0404841c..5e799b259f5 100644 --- a/code-rs/core/Cargo.toml +++ b/code-rs/core/Cargo.toml @@ -1,76 +1,107 @@ [package] -edition = "2024" -name = "code-core" -version = { workspace = true } +edition.workspace = true +license.workspace = true +name = "codex-core" +version.workspace = true [lib] -doctest = false -name = "code_core" +name = "codex_core" path = "src/lib.rs" +[[bin]] +name = "codex-write-config-schema" +path = "src/bin/config_schema.rs" + [lints] workspace = true [dependencies] anyhow = { workspace = true } -askama = { workspace = true } +arc-swap = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } -bytes = { workspace = true } +bm25 = { workspace = true } chrono = { workspace = true, features = ["serde"] } -chardetng = { workspace = true } -code-apply-patch = { workspace = true } -code-file-search = { workspace = true } -code-utils-absolute-path = { workspace = true } -code-protocol = { workspace = true } -code-rmcp-client = { workspace = true } -code-app-server-protocol = { workspace = true } -code-otel = { workspace = true, features = ["otel"] } -code-browser = { path = "../browser" } -code-version = { path = "../code-version" } -agent-client-protocol = "0.4.3" +clap = { workspace = true, features = ["derive"] } +codex-analytics = { workspace = true } +codex-api = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-apply-patch = { workspace = true } +codex-async-utils = { workspace = true } +codex-code-mode = { workspace = true } +codex-connectors = { workspace = true } +codex-config = { workspace = true } +codex-core-plugins = { workspace = true } +codex-core-skills = { workspace = true } +codex-exec-server = { workspace = true } +codex-features = { workspace = true } +codex-feedback = { workspace = true } +codex-login = { workspace = true } +codex-memories-read = { workspace = true } +codex-mcp = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-models-manager = { workspace = true } +codex-shell-command = { workspace = true } +codex-execpolicy = { workspace = true } +codex-git-utils = { workspace = true } +codex-hooks = { workspace = true } +codex-network-proxy = { workspace = true } +codex-otel = { workspace = true } +codex-plugin = { workspace = true } +codex-model-provider = { workspace = true } +codex-protocol = { workspace = true } +codex-response-debug-context = { workspace = true } +codex-rollout = { workspace = true } +codex-rollout-trace = { workspace = true } +codex-rmcp-client = { workspace = true } +codex-sandboxing = { workspace = true } +codex-state = { workspace = true } +codex-terminal-detection = { workspace = true } +codex-thread-store = { workspace = true } +codex-tools = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-cache = { workspace = true } +codex-utils-image = { workspace = true } +codex-utils-home-dir = { workspace = true } +codex-utils-output-truncation = { workspace = true } +codex-utils-path = { workspace = true } +codex-utils-plugins = { workspace = true } +codex-utils-pty = { workspace = true } +codex-utils-readiness = { workspace = true } +codex-utils-string = { workspace = true } +codex-utils-stream-parser = { workspace = true } +codex-utils-template = { workspace = true } +codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } +csv = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } -encoding_rs = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } -futures-util = "0.3" +http = { workspace = true } +iana-time-zone = { workspace = true } +image = { workspace = true, features = ["jpeg", "png", "webp"] } indexmap = { workspace = true } -lazy_static = { workspace = true } libc = { workspace = true } -mcp-types = { workspace = true } -mime_guess = { workspace = true } -os_info = { workspace = true } -path-clean = { workspace = true } -fs2 = "0.4" -htmd = "0.1" -httpdate = "1" -img_hash = "3" +notify = { workspace = true } once_cell = { workspace = true } -portable-pty = { workspace = true } rand = { workspace = true } regex-lite = { workspace = true } -reqwest = { workspace = true, features = ["json", "stream", "cookies"] } -schemars = "0.8.22" +reqwest = { workspace = true, features = ["json", "stream"] } +rmcp = { workspace = true, default-features = false, features = [ + "base64", + "macros", + "schemars", + "server", +] } serde = { workspace = true, features = ["derive"] } -serde_bytes = "0.11" -serde_ignored = "0.1" serde_json = { workspace = true } -serde_yaml = "0.9" sha1 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } -strum_macros = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } -time = { workspace = true, features = [ - "formatting", - "parsing", - "local-offset", - "macros", -] } tokio = { workspace = true, features = [ "io-std", "macros", @@ -78,52 +109,54 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } -tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] } -tokio-util = { workspace = true } -crc32fast = { workspace = true } -tokio-stream = { workspace = true } +tokio-util = { workspace = true, features = ["rt"] } +tokio-tungstenite = { workspace = true } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } -tree-sitter = { workspace = true } -tree-sitter-bash = { workspace = true } -uuid = { workspace = true, features = ["serde", "v4"] } -url = "2" +url = { workspace = true } +uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } -wildmatch = { workspace = true } - -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "0.9" - - -[target.'cfg(target_os = "linux")'.dependencies] -landlock = "0.4.1" -seccompiler = "0.5.0" +whoami = { workspace = true } # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] -openssl-sys = { version = "*", features = ["vendored"] } +openssl-sys = { workspace = true, features = ["vendored"] } # Build OpenSSL from source for musl builds. [target.aarch64-unknown-linux-musl.dependencies] -openssl-sys = { version = "*", features = ["vendored"] } +openssl-sys = { workspace = true, features = ["vendored"] } -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61.2", features = [ - "Win32_Foundation", - "Win32_System_Threading", -] } +[target.'cfg(unix)'.dependencies] +codex-shell-escalation = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } -filetime = { workspace = true } +assert_matches = { workspace = true } +codex-otel = { workspace = true } +codex-test-binary-support = { workspace = true } +codex-utils-cargo-bin = { workspace = true } +core_test_support = { workspace = true } +ctor = { workspace = true } +insta = { workspace = true } maplit = { workspace = true } -serial_test = "3.2.0" +opentelemetry = { workspace = true } +predicates = { workspace = true } pretty_assertions = { workspace = true } -tokio-test = { workspace = true } -wiremock = { workspace = true } -code-git-tooling = { path = "../git-tooling" } +test-case = "3.3.1" +opentelemetry_sdk = { workspace = true, features = [ + "experimental_metrics_custom_reader", + "metrics", +] } +serial_test = { workspace = true } +tempfile = { workspace = true } +test-log = { workspace = true } +tracing-opentelemetry = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-test = { workspace = true, features = ["no-env-filter"] } walkdir = { workspace = true } +wiremock = { workspace = true } +zstd = { workspace = true } [package.metadata.cargo-shear] ignored = ["openssl-sys"] diff --git a/code-rs/core/README.md b/code-rs/core/README.md index 67c1498861f..3283ba2c3e4 100644 --- a/code-rs/core/README.md +++ b/code-rs/core/README.md @@ -2,21 +2,83 @@ This crate implements the business logic for Codex. It is designed to be used by the various Codex UIs written in Rust. -See also: [Slash Commands](../../docs/slash-commands.md) for a complete list of -interactive commands available in the TUI. - ## Dependencies -Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this +Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this support matrix is: ### macOS Expects `/usr/bin/sandbox-exec` to be present. +When using the workspace-write sandbox policy, the Seatbelt profile allows +writes under the configured writable roots while keeping `.git` (directory or +pointer file), the resolved `gitdir:` target, and `.codex` read-only. + +Network access and filesystem read/write roots are controlled by +`SandboxPolicy`. Seatbelt consumes the resolved policy and enforces it. + +Seatbelt also keeps the legacy default preferences read access +(`user-preference-read`) needed for cfprefs-backed macOS behavior. + ### Linux -Expects the binary containing `codex-core` to run the equivalent of `codex debug landlock` when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. +Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. + +Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. +They can continue to use the legacy Landlock path when the split filesystem +policy is sandbox-equivalent to the legacy model after `cwd` resolution. +Split filesystem policies that need direct `FileSystemSandboxPolicy` +enforcement, such as read-only or denied carveouts under a broader writable +root, automatically route through bubblewrap. The legacy Landlock path is used +only when the split filesystem policy round-trips through the legacy +`SandboxPolicy` model without changing semantics. That includes overlapping +cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the +more specific writable child must reopen under a denied parent. + +The Linux sandbox helper prefers the first `bwrap` found on `PATH` outside the +current working directory whenever it is available. If `bwrap` is present but +too old to support `--argv0`, the helper keeps using system bubblewrap and +switches to a no-`--argv0` compatibility path for the inner re-exec. If +`bwrap` is missing, it falls back to the bundled `codex-resources/bwrap` +binary shipped with Codex and Codex surfaces a startup warning through its +normal notification path instead of printing directly from the sandbox helper. +Codex also surfaces a startup warning when bubblewrap cannot create user +namespaces. WSL2 uses the normal Linux bubblewrap path. WSL1 is not supported +for bubblewrap sandboxing because it cannot create the required user +namespaces, so Codex rejects sandboxed shell commands that would enter the +bubblewrap path before invoking `bwrap`. + +### Windows + +Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on +Windows. Legacy `read-only` and `workspace-write` policies imply full +filesystem read access; exact readable roots are represented by split +filesystem policies instead. + +The elevated Windows sandbox also supports: + +- legacy `ReadOnly` and `WorkspaceWrite` behavior +- split filesystem policies that need exact readable roots, exact writable + roots, or extra read-only carveouts under writable roots +- backend-managed system read roots required for basic execution, such as + `C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, and + `C:\ProgramData`, when a split filesystem policy requests platform defaults + +The unelevated restricted-token backend still supports the legacy full-read +Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also +supports a narrow split-filesystem subset: full-read split policies whose +writable roots still match the legacy `WorkspaceWrite` root set, but add extra +read-only carveouts under those writable roots. + +New `[permissions]` / split filesystem policies remain supported on Windows +only when they can be enforced directly by the selected Windows backend or +round-trip through the legacy `SandboxPolicy` model without changing semantics. +Policies that would require direct explicit unreadable carveouts (`none`) or +reopened writable descendants under read-only carveouts still fail closed +instead of running with weaker enforcement. ### All Platforms -Expects the binary containing `codex-core` to simulate the virtual `apply_patch` CLI when `arg1` is `--codex-run-as-apply-patch`. See the `codex-arg0` crate for details. +Expects the binary containing `codex-core` to simulate the virtual +`apply_patch` CLI when `arg1` is `--codex-run-as-apply-patch`. See the +`codex-arg0` crate for details. diff --git a/code-rs/core/config.schema.json b/code-rs/core/config.schema.json new file mode 100644 index 00000000000..ecbd73093c9 --- /dev/null +++ b/code-rs/core/config.schema.json @@ -0,0 +1,4613 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AgentRoleToml": { + "additionalProperties": false, + "properties": { + "config_file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path to a role-specific config layer. Relative paths are resolved relative to the `config.toml` that defines them." + }, + "description": { + "description": "Human-facing role documentation used in spawn tool guidance. Required unless supplied by the referenced agent role file.", + "type": "string" + }, + "nickname_candidates": { + "description": "Candidate nicknames for agents spawned with this role.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "AgentsToml": { + "additionalProperties": { + "$ref": "#/definitions/AgentRoleToml" + }, + "properties": { + "interrupt_message": { + "description": "Whether to record a model-visible message when an agent turn is interrupted. Defaults to true.", + "type": "boolean" + }, + "job_max_runtime_seconds": { + "description": "Default maximum runtime in seconds for agent job workers.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + }, + "max_depth": { + "description": "Maximum nesting depth allowed for spawned agent threads. Root sessions start at depth 0.", + "format": "int32", + "minimum": 1.0, + "type": "integer" + }, + "max_threads": { + "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", + "format": "uint", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, + "AltScreenMode": { + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", + "oneOf": [ + { + "description": "Auto-detect: disable alternate screen in Zellij, enable elsewhere.", + "enum": [ + "auto" + ], + "type": "string" + }, + { + "description": "Always use alternate screen (original behavior).", + "enum": [ + "always" + ], + "type": "string" + }, + { + "description": "Never use alternate screen (inline mode only).", + "enum": [ + "never" + ], + "type": "string" + } + ] + }, + "AnalyticsConfigToml": { + "additionalProperties": false, + "description": "Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.", + "properties": { + "enabled": { + "description": "When `false`, disables analytics across Codex product surfaces in this profile.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppConfig": { + "additionalProperties": false, + "description": "Config values for a single app/connector.", + "properties": { + "default_tools_approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for tools in this app unless a tool override exists." + }, + "default_tools_enabled": { + "description": "Whether tools are enabled by default for this app.", + "type": "boolean" + }, + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed for this app.", + "type": "boolean" + }, + "enabled": { + "default": true, + "description": "When `false`, Codex does not surface this app.", + "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed for this app.", + "type": "boolean" + }, + "tools": { + "allOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + } + ], + "description": "Per-tool settings for this app." + } + }, + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "additionalProperties": false, + "description": "Per-tool settings for a single app tool.", + "properties": { + "approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for this tool." + }, + "enabled": { + "description": "Whether this tool is enabled. `Some(true)` explicitly allows this tool.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppToolsConfig": { + "additionalProperties": { + "$ref": "#/definitions/AppToolConfig" + }, + "description": "Tool settings for a single app.", + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AppsConfigToml": { + "additionalProperties": { + "$ref": "#/definitions/AppConfig" + }, + "description": "App/connector settings loaded from `config.toml`.", + "properties": { + "_default": { + "allOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + } + ], + "description": "Default settings for all apps." + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "additionalProperties": false, + "description": "Default settings that apply to all apps.", + "properties": { + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed by default.", + "type": "boolean" + }, + "enabled": { + "default": true, + "description": "When `false`, apps are disabled unless overridden by per-app settings.", + "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed by default.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppsMcpPathOverrideConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "type": "object" + }, + "AskForApproval": { + "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", + "oneOf": [ + { + "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", + "enum": [ + "untrusted" + ], + "type": "string" + }, + { + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", + "enum": [ + "on-failure" + ], + "type": "string" + }, + { + "description": "The model decides when to ask the user for approval.", + "enum": [ + "on-request" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "Fine-grained controls for individual approval flows.\n\nWhen a field is `true`, commands in that category are allowed. When it is `false`, those requests are automatically rejected instead of shown to the user.", + "properties": { + "granular": { + "$ref": "#/definitions/GranularApprovalConfig" + } + }, + "required": [ + "granular" + ], + "type": "object" + }, + { + "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", + "enum": [ + "never" + ], + "type": "string" + } + ] + }, + "AuthCredentialsStoreMode": { + "description": "Determine where Codex should store CLI auth credentials.", + "oneOf": [ + { + "description": "Persist credentials in CODEX_HOME/auth.json.", + "enum": [ + "file" + ], + "type": "string" + }, + { + "description": "Persist credentials in the keyring. Fail if unavailable.", + "enum": [ + "keyring" + ], + "type": "string" + }, + { + "description": "Use keyring when available; otherwise, fall back to a file in CODEX_HOME.", + "enum": [ + "auto" + ], + "type": "string" + }, + { + "description": "Store credentials in memory only for the current process.", + "enum": [ + "ephemeral" + ], + "type": "string" + } + ] + }, + "AutoReviewToml": { + "properties": { + "policy": { + "description": "Additional policy instructions inserted into the guardian prompt.", + "type": "string" + } + }, + "type": "object" + }, + "BundledSkillsConfig": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "ConfigProfile": { + "additionalProperties": false, + "description": "Collection of common configuration options that a user can define as a unit in `config.toml`.", + "properties": { + "analytics": { + "$ref": "#/definitions/AnalyticsConfigToml" + }, + "approval_policy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvals_reviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "chatgpt_base_url": { + "type": "string" + }, + "experimental_compact_prompt_file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "features": { + "additionalProperties": false, + "default": null, + "description": "Optional feature toggles scoped to this profile.", + "properties": { + "apply_patch_freeform": { + "type": "boolean" + }, + "apply_patch_streaming_events": { + "type": "boolean" + }, + "apps": { + "type": "boolean" + }, + "apps_mcp_path_override": { + "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" + }, + "auth_elicitation": { + "type": "boolean" + }, + "browser_use": { + "type": "boolean" + }, + "browser_use_external": { + "type": "boolean" + }, + "builtin_mcp": { + "type": "boolean" + }, + "child_agents_md": { + "type": "boolean" + }, + "chronicle": { + "type": "boolean" + }, + "code_mode": { + "type": "boolean" + }, + "code_mode_only": { + "type": "boolean" + }, + "codex_git_commit": { + "type": "boolean" + }, + "codex_hooks": { + "type": "boolean" + }, + "collab": { + "type": "boolean" + }, + "collaboration_modes": { + "type": "boolean" + }, + "computer_use": { + "type": "boolean" + }, + "connectors": { + "type": "boolean" + }, + "default_mode_request_user_input": { + "type": "boolean" + }, + "elevated_windows_sandbox": { + "type": "boolean" + }, + "enable_experimental_windows_sandbox": { + "type": "boolean" + }, + "enable_fanout": { + "type": "boolean" + }, + "enable_mcp_apps": { + "type": "boolean" + }, + "enable_request_compression": { + "type": "boolean" + }, + "exec_permission_approvals": { + "type": "boolean" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "experimental_windows_sandbox": { + "type": "boolean" + }, + "external_migration": { + "type": "boolean" + }, + "fast_mode": { + "type": "boolean" + }, + "goals": { + "type": "boolean" + }, + "guardian_approval": { + "type": "boolean" + }, + "hooks": { + "type": "boolean" + }, + "image_detail_original": { + "type": "boolean" + }, + "image_generation": { + "type": "boolean" + }, + "in_app_browser": { + "type": "boolean" + }, + "include_apply_patch_tool": { + "type": "boolean" + }, + "js_repl": { + "type": "boolean" + }, + "js_repl_tools_only": { + "type": "boolean" + }, + "memories": { + "type": "boolean" + }, + "memory_tool": { + "type": "boolean" + }, + "multi_agent": { + "type": "boolean" + }, + "multi_agent_v2": { + "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" + }, + "personality": { + "type": "boolean" + }, + "plugin_hooks": { + "type": "boolean" + }, + "plugins": { + "type": "boolean" + }, + "prevent_idle_sleep": { + "type": "boolean" + }, + "realtime_conversation": { + "type": "boolean" + }, + "remote_compaction_v2": { + "type": "boolean" + }, + "remote_control": { + "type": "boolean" + }, + "remote_models": { + "type": "boolean" + }, + "remote_plugin": { + "type": "boolean" + }, + "request_permissions": { + "type": "boolean" + }, + "request_permissions_tool": { + "type": "boolean" + }, + "request_rule": { + "type": "boolean" + }, + "responses_websocket_response_processed": { + "type": "boolean" + }, + "responses_websockets": { + "type": "boolean" + }, + "responses_websockets_v2": { + "type": "boolean" + }, + "runtime_metrics": { + "type": "boolean" + }, + "search_tool": { + "type": "boolean" + }, + "shell_snapshot": { + "type": "boolean" + }, + "shell_tool": { + "type": "boolean" + }, + "shell_zsh_fork": { + "type": "boolean" + }, + "skill_env_var_dependency_prompt": { + "type": "boolean" + }, + "skill_mcp_dependency_install": { + "type": "boolean" + }, + "sqlite": { + "type": "boolean" + }, + "steer": { + "type": "boolean" + }, + "telepathy": { + "type": "boolean" + }, + "terminal_resize_reflow": { + "type": "boolean" + }, + "tool_call_mcp_elicitation": { + "type": "boolean" + }, + "tool_search": { + "type": "boolean" + }, + "tool_search_always_defer_mcp_tools": { + "type": "boolean" + }, + "tool_suggest": { + "type": "boolean" + }, + "tui_app_server": { + "type": "boolean" + }, + "unavailable_dummy_tools": { + "type": "boolean" + }, + "undo": { + "type": "boolean" + }, + "unified_exec": { + "type": "boolean" + }, + "use_legacy_landlock": { + "type": "boolean" + }, + "use_linux_sandbox_bwrap": { + "type": "boolean" + }, + "web_search": { + "type": "boolean" + }, + "web_search_cached": { + "type": "boolean" + }, + "web_search_request": { + "type": "boolean" + }, + "workspace_dependencies": { + "type": "boolean" + }, + "workspace_owner_usage_nudge": { + "type": "boolean" + } + }, + "type": "object" + }, + "include_apply_patch_tool": { + "type": "boolean" + }, + "include_apps_instructions": { + "type": "boolean" + }, + "include_environment_context": { + "type": "boolean" + }, + "include_permissions_instructions": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "model_catalog_json": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional path to a JSON model catalog (applied on startup only)." + }, + "model_instructions_file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional path to a file containing model instructions." + }, + "model_provider": { + "description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.", + "type": "string" + }, + "model_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "model_reasoning_summary": { + "$ref": "#/definitions/ReasoningSummary" + }, + "model_verbosity": { + "$ref": "#/definitions/Verbosity" + }, + "oss_provider": { + "type": "string" + }, + "personality": { + "$ref": "#/definitions/Personality" + }, + "plan_mode_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "sandbox_mode": { + "$ref": "#/definitions/SandboxMode" + }, + "service_tier": { + "allOf": [ + { + "$ref": "#/definitions/ServiceTier" + } + ], + "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." + }, + "tools": { + "$ref": "#/definitions/ToolsToml" + }, + "tools_view_image": { + "type": "boolean" + }, + "tui": { + "allOf": [ + { + "$ref": "#/definitions/ProfileTui" + } + ], + "default": null, + "description": "TUI settings scoped to this profile." + }, + "web_search": { + "$ref": "#/definitions/WebSearchMode" + }, + "windows": { + "allOf": [ + { + "$ref": "#/definitions/WindowsToml" + } + ], + "default": null + }, + "zsh_path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution." + } + }, + "type": "object" + }, + "DebugConfigLockToml": { + "additionalProperties": false, + "properties": { + "allow_codex_version_mismatch": { + "description": "Allow replaying a lock generated by a different Codex version.", + "type": "boolean" + }, + "export_dir": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Directory where Codex writes effective session config lock files." + }, + "load_path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Lockfile to replay as the authoritative effective config." + }, + "save_fields_resolved_from_model_catalog": { + "description": "Save fields resolved from the model catalog/session configuration.", + "type": "boolean" + } + }, + "type": "object" + }, + "DebugToml": { + "additionalProperties": false, + "properties": { + "config_lockfile": { + "$ref": "#/definitions/DebugConfigLockToml" + } + }, + "type": "object" + }, + "ExternalConfigMigrationPrompts": { + "additionalProperties": false, + "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", + "properties": { + "home": { + "description": "Tracks whether home-level external config migration prompts are hidden.", + "type": "boolean" + }, + "home_last_prompted_at": { + "description": "Tracks the last time the home-level external config migration prompt was shown.", + "format": "int64", + "type": "integer" + }, + "project_last_prompted_at": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "default": {}, + "description": "Tracks the last time a project-level external config migration prompt was shown.", + "type": "object" + }, + "projects": { + "additionalProperties": { + "type": "boolean" + }, + "default": {}, + "description": "Tracks which project paths have opted out of external config migration prompts.", + "type": "object" + } + }, + "type": "object" + }, + "FeatureToml_for_AppsMcpPathOverrideConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/AppsMcpPathOverrideConfigToml" + } + ] + }, + "FeatureToml_for_MultiAgentV2ConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/MultiAgentV2ConfigToml" + } + ] + }, + "FeedbackConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "description": "When `false`, disables the feedback flow across Codex product surfaces.", + "type": "boolean" + } + }, + "type": "object" + }, + "FileSystemAccessMode": { + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FilesystemPermissionToml": { + "anyOf": [ + { + "$ref": "#/definitions/FileSystemAccessMode" + }, + { + "additionalProperties": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "type": "object" + } + ] + }, + "FilesystemPermissionsToml": { + "properties": { + "glob_scan_max_depth": { + "description": "Optional maximum depth for expanding unreadable glob patterns on platforms that snapshot glob matches before sandbox startup.", + "format": "uint", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, + "ForcedLoginMethod": { + "enum": [ + "chatgpt", + "api" + ], + "type": "string" + }, + "GhostSnapshotToml": { + "additionalProperties": false, + "properties": { + "disable_warnings": { + "description": "Legacy no-op setting retained for compatibility.", + "type": "boolean" + }, + "ignore_large_untracked_dirs": { + "description": "Legacy no-op setting retained for compatibility.", + "format": "int64", + "type": "integer" + }, + "ignore_large_untracked_files": { + "description": "Legacy no-op setting retained for compatibility.", + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "GranularApprovalConfig": { + "properties": { + "mcp_elicitations": { + "description": "Whether to allow MCP elicitation prompts.", + "type": "boolean" + }, + "request_permissions": { + "default": false, + "description": "Whether to allow prompts triggered by the `request_permissions` tool.", + "type": "boolean" + }, + "rules": { + "description": "Whether to allow prompts triggered by execpolicy `prompt` rules.", + "type": "boolean" + }, + "sandbox_approval": { + "description": "Whether to allow shell command approval requests, including inline `with_additional_permissions` and `require_escalated` requests.", + "type": "boolean" + }, + "skill_approval": { + "default": false, + "description": "Whether to allow approval prompts triggered by skill script execution.", + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + }, + "History": { + "additionalProperties": false, + "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.", + "properties": { + "max_bytes": { + "default": null, + "description": "If set, the maximum size of the history file in bytes. The oldest entries are dropped once the file exceeds this limit.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "persistence": { + "allOf": [ + { + "$ref": "#/definitions/HistoryPersistence" + } + ], + "default": "save-all", + "description": "If true, history entries will not be written to disk." + } + }, + "type": "object" + }, + "HistoryPersistence": { + "oneOf": [ + { + "description": "Save all history entries to disk.", + "enum": [ + "save-all" + ], + "type": "string" + }, + { + "description": "Do not write history to disk.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "HookHandlerConfig": { + "oneOf": [ + { + "properties": { + "async": { + "default": false, + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "default": null, + "type": "string" + }, + "timeout": { + "default": null, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "type": { + "enum": [ + "command" + ], + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "HookStateToml": { + "properties": { + "enabled": { + "type": "boolean" + }, + "trusted_hash": { + "type": "string" + } + }, + "type": "object" + }, + "HooksToml": { + "properties": { + "PermissionRequest": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PostCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PreCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "Stop": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "state": { + "additionalProperties": { + "$ref": "#/definitions/HookStateToml" + }, + "type": "object" + } + }, + "type": "object" + }, + "KeybindingsSpec": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action." + }, + "MarketplaceConfig": { + "additionalProperties": false, + "properties": { + "last_revision": { + "default": null, + "description": "Git revision Codex last successfully activated for this marketplace.", + "type": "string" + }, + "last_updated": { + "default": null, + "description": "Last time Codex successfully added or refreshed this marketplace.", + "type": "string" + }, + "ref": { + "default": null, + "description": "Git ref to check out when `source_type` is `git`.", + "type": "string" + }, + "source": { + "default": null, + "description": "Source location used when the marketplace was added.", + "type": "string" + }, + "source_type": { + "allOf": [ + { + "$ref": "#/definitions/MarketplaceSourceType" + } + ], + "default": null, + "description": "Source kind used to install this marketplace." + }, + "sparse_paths": { + "default": null, + "description": "Sparse checkout paths used when `source_type` is `git`.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "MarketplaceSourceType": { + "enum": [ + "git", + "local" + ], + "type": "string" + }, + "MatcherGroup": { + "properties": { + "hooks": { + "default": [], + "items": { + "$ref": "#/definitions/HookHandlerConfig" + }, + "type": "array" + }, + "matcher": { + "default": null, + "type": "string" + } + }, + "type": "object" + }, + "McpServerEnvVar": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, + "McpServerToolConfig": { + "additionalProperties": false, + "description": "Per-tool approval settings for a single MCP server tool.", + "properties": { + "approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for this tool." + } + }, + "type": "object" + }, + "MemoriesToml": { + "additionalProperties": false, + "description": "Memories settings loaded from config.toml.", + "properties": { + "consolidation_model": { + "description": "Model used for memory consolidation.", + "type": "string" + }, + "disable_on_external_context": { + "description": "When `true`, external context sources mark the thread `memory_mode` as `\"polluted\"`.", + "type": "boolean" + }, + "extract_model": { + "description": "Model used for thread summarisation.", + "type": "string" + }, + "generate_memories": { + "description": "When `false`, newly created threads are stored with `memory_mode = \"disabled\"` in the state DB.", + "type": "boolean" + }, + "max_raw_memories_for_consolidation": { + "description": "Maximum number of recent raw memories retained for global consolidation.", + "format": "uint", + "maximum": 4096.0, + "minimum": 1.0, + "type": "integer" + }, + "max_rollout_age_days": { + "description": "Maximum age of the threads used for memories.", + "format": "int64", + "type": "integer" + }, + "max_rollouts_per_startup": { + "description": "Maximum number of rollout candidates processed per pass.", + "format": "uint", + "maximum": 128.0, + "minimum": 1.0, + "type": "integer" + }, + "max_unused_days": { + "description": "Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.", + "format": "int64", + "type": "integer" + }, + "min_rate_limit_remaining_percent": { + "description": "Minimum remaining percentage required in Codex rate-limit windows before memory startup runs.", + "format": "int64", + "maximum": 100.0, + "minimum": 0.0, + "type": "integer" + }, + "min_rollout_idle_hours": { + "description": "Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.", + "format": "int64", + "type": "integer" + }, + "use_memories": { + "description": "When `false`, skip injecting memory usage instructions into developer prompts.", + "type": "boolean" + } + }, + "type": "object" + }, + "ModelAvailabilityNuxConfig": { + "additionalProperties": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": "object" + }, + "ModelProviderAuthInfo": { + "additionalProperties": false, + "description": "Configuration for obtaining a provider bearer token from a command.", + "properties": { + "args": { + "default": [], + "description": "Command arguments.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "description": "Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`.", + "type": "string" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory used when running the token command." + }, + "refresh_interval_ms": { + "default": 300000, + "description": "Maximum age for the cached token before rerunning the command. Set to `0` to disable proactive refresh and only rerun after a 401 retry path.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "timeout_ms": { + "default": 5000, + "description": "Maximum time to wait for the token command to exit successfully.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "ModelProviderAwsAuthInfo": { + "additionalProperties": false, + "description": "AWS SigV4 auth configuration for a model provider.", + "properties": { + "profile": { + "description": "AWS profile name to use. When unset, the AWS SDK default chain decides.", + "type": "string" + }, + "region": { + "description": "AWS region to use for provider-specific endpoints.", + "type": "string" + } + }, + "type": "object" + }, + "ModelProviderInfo": { + "additionalProperties": false, + "description": "Serializable representation of a provider definition.", + "properties": { + "auth": { + "allOf": [ + { + "$ref": "#/definitions/ModelProviderAuthInfo" + } + ], + "description": "Command-backed bearer-token configuration for this provider." + }, + "aws": { + "allOf": [ + { + "$ref": "#/definitions/ModelProviderAwsAuthInfo" + } + ], + "description": "AWS SigV4 auth configuration for this provider." + }, + "base_url": { + "description": "Base URL for the provider's OpenAI-compatible API.", + "type": "string" + }, + "env_http_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Optional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and _environment variable_ whose value should be used. If the environment variable is not set, or the value is empty, the header will not be included in the request.", + "type": "object" + }, + "env_key": { + "description": "Environment variable that stores the user's API key for this provider.", + "type": "string" + }, + "env_key_instructions": { + "description": "Optional instructions to help the user get a valid value for the variable and set it.", + "type": "string" + }, + "experimental_bearer_token": { + "description": "Value to use with `Authorization: Bearer ` header. Use of this config is discouraged in favor of `env_key` for security reasons, but this may be necessary when using this programmatically.", + "type": "string" + }, + "http_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Additional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and value.", + "type": "object" + }, + "name": { + "default": "", + "description": "Friendly display name.", + "type": "string" + }, + "query_params": { + "additionalProperties": { + "type": "string" + }, + "description": "Optional query parameters to append to the base URL.", + "type": "object" + }, + "request_max_retries": { + "description": "Maximum number of times to retry a failed HTTP request to this provider.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "requires_openai_auth": { + "default": false, + "description": "Does this provider require an OpenAI API Key or ChatGPT login token? If true, user is presented with login screen on first run, and login preference and token/key are stored in auth.json. If false (which is the default), login screen is skipped, and API key (if needed) comes from the \"env_key\" environment variable.", + "type": "boolean" + }, + "stream_idle_timeout_ms": { + "description": "Idle timeout (in milliseconds) to wait for activity on a streaming response before treating the connection as lost.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "stream_max_retries": { + "description": "Number of times to retry reconnecting a dropped streaming response before failing.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "supports_websockets": { + "default": false, + "description": "Whether this provider supports the Responses API WebSocket transport.", + "type": "boolean" + }, + "websocket_connect_timeout_ms": { + "description": "Maximum time (in milliseconds) to wait for a websocket connection attempt before treating it as failed.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "wire_api": { + "allOf": [ + { + "$ref": "#/definitions/WireApi" + } + ], + "default": "responses", + "description": "Which wire protocol this provider expects." + } + }, + "type": "object" + }, + "MultiAgentV2ConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "hide_spawn_agent_metadata": { + "type": "boolean" + }, + "max_concurrent_threads_per_session": { + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, + "min_wait_timeout_ms": { + "format": "int64", + "maximum": 3600000.0, + "minimum": 1.0, + "type": "integer" + }, + "root_agent_usage_hint_text": { + "type": "string" + }, + "subagent_usage_hint_text": { + "type": "string" + }, + "usage_hint_enabled": { + "type": "boolean" + }, + "usage_hint_text": { + "type": "string" + } + }, + "type": "object" + }, + "NetworkDomainPermissionToml": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkDomainPermissionsToml": { + "type": "object" + }, + "NetworkModeSchema": { + "enum": [ + "limited", + "full" + ], + "type": "string" + }, + "NetworkToml": { + "additionalProperties": false, + "properties": { + "allow_local_binding": { + "type": "boolean" + }, + "allow_upstream_proxy": { + "type": "boolean" + }, + "dangerously_allow_all_unix_sockets": { + "type": "boolean" + }, + "dangerously_allow_non_loopback_proxy": { + "type": "boolean" + }, + "domains": { + "$ref": "#/definitions/NetworkDomainPermissionsToml" + }, + "enable_socks5": { + "type": "boolean" + }, + "enable_socks5_udp": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/NetworkModeSchema" + }, + "proxy_url": { + "type": "string" + }, + "socks_url": { + "type": "string" + }, + "unix_sockets": { + "$ref": "#/definitions/NetworkUnixSocketPermissionsToml" + } + }, + "type": "object" + }, + "NetworkUnixSocketPermissionToml": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, + "NetworkUnixSocketPermissionsToml": { + "type": "object" + }, + "Notice": { + "additionalProperties": false, + "properties": { + "external_config_migration_prompts": { + "allOf": [ + { + "$ref": "#/definitions/ExternalConfigMigrationPrompts" + } + ], + "default": { + "home": null, + "home_last_prompted_at": null, + "project_last_prompted_at": {}, + "projects": {} + }, + "description": "Tracks scopes where external config migration prompts should be suppressed." + }, + "fast_default_opt_out": { + "description": "Tracks whether the user opted out of Codex-managed fast defaults.", + "type": "boolean" + }, + "hide_full_access_warning": { + "description": "Tracks whether the user has acknowledged the full access warning prompt.", + "type": "boolean" + }, + "hide_gpt-5.1-codex-max_migration_prompt": { + "description": "Tracks whether the user has seen the gpt-5.1-codex-max migration prompt", + "type": "boolean" + }, + "hide_gpt5_1_migration_prompt": { + "description": "Tracks whether the user has seen the model migration prompt", + "type": "boolean" + }, + "hide_rate_limit_model_nudge": { + "description": "Tracks whether the user opted out of the rate limit model switch reminder.", + "type": "boolean" + }, + "hide_world_writable_warning": { + "description": "Tracks whether the user has acknowledged the Windows world-writable directories warning.", + "type": "boolean" + }, + "model_migrations": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Tracks acknowledged model migrations as old->new model slug mappings.", + "type": "object" + } + }, + "type": "object" + }, + "NotificationCondition": { + "oneOf": [ + { + "description": "Emit TUI notifications only while the terminal is unfocused.", + "enum": [ + "unfocused" + ], + "type": "string" + }, + { + "description": "Emit TUI notifications regardless of terminal focus.", + "enum": [ + "always" + ], + "type": "string" + } + ] + }, + "NotificationMethod": { + "enum": [ + "auto", + "osc9", + "bel" + ], + "type": "string" + }, + "Notifications": { + "anyOf": [ + { + "type": "boolean" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "OAuthCredentialsStoreMode": { + "description": "Determine where Codex should store and read MCP credentials.", + "oneOf": [ + { + "description": "`Keyring` when available; otherwise, `File`. Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.", + "enum": [ + "auto" + ], + "type": "string" + }, + { + "description": "CODEX_HOME/.credentials.json This file will be readable to Codex and other applications running as the same user.", + "enum": [ + "file" + ], + "type": "string" + }, + { + "description": "Keyring when available, otherwise fail.", + "enum": [ + "keyring" + ], + "type": "string" + } + ] + }, + "OtelConfigToml": { + "additionalProperties": false, + "description": "OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.", + "properties": { + "environment": { + "description": "Mark traces with environment (dev, staging, prod, test). Defaults to dev.", + "type": "string" + }, + "exporter": { + "allOf": [ + { + "$ref": "#/definitions/OtelExporterKind" + } + ], + "description": "Optional log exporter" + }, + "log_user_prompt": { + "description": "Log user prompt in traces", + "type": "boolean" + }, + "metrics_exporter": { + "allOf": [ + { + "$ref": "#/definitions/OtelExporterKind" + } + ], + "description": "Optional metrics exporter" + }, + "span_attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "Attributes to add to every exported trace span.", + "type": "object" + }, + "trace_exporter": { + "allOf": [ + { + "$ref": "#/definitions/OtelExporterKind" + } + ], + "description": "Optional trace exporter" + }, + "tracestate": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "description": "Semicolon-separated `key:value` fields to upsert into W3C tracestate members.", + "type": "object" + } + }, + "type": "object" + }, + "OtelExporterKind": { + "description": "Which OTEL exporter to use.", + "oneOf": [ + { + "enum": [ + "none", + "statsig" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "otlp-http": { + "additionalProperties": false, + "properties": { + "endpoint": { + "type": "string" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "type": "object" + }, + "protocol": { + "$ref": "#/definitions/OtelHttpProtocol" + }, + "tls": { + "allOf": [ + { + "$ref": "#/definitions/OtelTlsConfig" + } + ], + "default": null + } + }, + "required": [ + "endpoint", + "protocol" + ], + "type": "object" + } + }, + "required": [ + "otlp-http" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "otlp-grpc": { + "additionalProperties": false, + "properties": { + "endpoint": { + "type": "string" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "type": "object" + }, + "tls": { + "allOf": [ + { + "$ref": "#/definitions/OtelTlsConfig" + } + ], + "default": null + } + }, + "required": [ + "endpoint" + ], + "type": "object" + } + }, + "required": [ + "otlp-grpc" + ], + "type": "object" + } + ] + }, + "OtelHttpProtocol": { + "oneOf": [ + { + "description": "Binary payload", + "enum": [ + "binary" + ], + "type": "string" + }, + { + "description": "JSON payload", + "enum": [ + "json" + ], + "type": "string" + } + ] + }, + "OtelTlsConfig": { + "additionalProperties": false, + "properties": { + "ca-certificate": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "client-certificate": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "client-private-key": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "type": "object" + }, + "PermissionProfileToml": { + "additionalProperties": false, + "properties": { + "filesystem": { + "$ref": "#/definitions/FilesystemPermissionsToml" + }, + "network": { + "$ref": "#/definitions/NetworkToml" + } + }, + "type": "object" + }, + "PermissionsToml": { + "type": "object" + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "PluginConfig": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "mcp_servers": { + "additionalProperties": { + "$ref": "#/definitions/PluginMcpServerConfig" + }, + "description": "Per-MCP-server policy overlays for MCP servers contributed by this plugin.", + "type": "object" + } + }, + "type": "object" + }, + "PluginMcpServerConfig": { + "additionalProperties": false, + "description": "Policy settings for a plugin-provided MCP server.\n\nThis intentionally excludes transport settings: plugin manifests own how the MCP server is launched, while user config owns enablement and tool policy.", + "properties": { + "default_tools_approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for tools in this server unless a tool override exists." + }, + "disabled_tools": { + "description": "Explicit deny-list of tools. These tools are removed after applying `enabled_tools`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enabled": { + "default": true, + "description": "When `false`, Codex skips initializing this plugin MCP server.", + "type": "boolean" + }, + "enabled_tools": { + "description": "Explicit allow-list of tools exposed from this server.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/McpServerToolConfig" + }, + "description": "Per-tool approval settings keyed by tool name.", + "type": "object" + } + }, + "type": "object" + }, + "ProfileTui": { + "additionalProperties": false, + "description": "TUI settings supported inside a named profile.", + "properties": { + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + } + }, + "type": "object" + }, + "ProjectConfig": { + "additionalProperties": false, + "properties": { + "trust_level": { + "$ref": "#/definitions/TrustLevel" + } + }, + "type": "object" + }, + "RawMcpServerConfig": { + "additionalProperties": false, + "description": "Raw MCP config shape used for deserialization and supported-field JSON Schema generation.\n\nFields that are accepted only to produce targeted validation errors should be skipped in the generated schema.\n\nKeep `TryFrom for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].", + "properties": { + "args": { + "default": null, + "items": { + "type": "string" + }, + "type": "array" + }, + "bearer_token_env_var": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "default": null, + "type": "string" + }, + "default_tools_approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "default": null + }, + "disabled_tools": { + "default": null, + "items": { + "type": "string" + }, + "type": "array" + }, + "enabled": { + "default": null, + "type": "boolean" + }, + "enabled_tools": { + "default": null, + "items": { + "type": "string" + }, + "type": "array" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "default": null, + "type": "object" + }, + "env_http_headers": { + "additionalProperties": { + "type": "string" + }, + "default": null, + "type": "object" + }, + "env_vars": { + "default": null, + "items": { + "$ref": "#/definitions/McpServerEnvVar" + }, + "type": "array" + }, + "experimental_environment": { + "default": null, + "type": "string" + }, + "http_headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "name": { + "default": null, + "description": "Legacy display-name field accepted for backward compatibility.", + "type": "string" + }, + "oauth_resource": { + "default": null, + "type": "string" + }, + "required": { + "default": null, + "type": "boolean" + }, + "scopes": { + "default": null, + "items": { + "type": "string" + }, + "type": "array" + }, + "startup_timeout_ms": { + "default": null, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "startup_timeout_sec": { + "default": null, + "format": "double", + "type": "number" + }, + "supports_parallel_tool_calls": { + "default": null, + "type": "boolean" + }, + "tool_timeout_sec": { + "default": null, + "format": "double", + "type": "number" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/McpServerToolConfig" + }, + "default": null, + "type": "object" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "RealtimeAudioToml": { + "additionalProperties": false, + "properties": { + "microphone": { + "type": "string" + }, + "speaker": { + "type": "string" + } + }, + "type": "object" + }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, + "RealtimeToml": { + "additionalProperties": false, + "properties": { + "transport": { + "$ref": "#/definitions/RealtimeTransport" + }, + "type": { + "$ref": "#/definitions/RealtimeWsMode" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" + }, + "voice": { + "$ref": "#/definitions/RealtimeVoice" + } + }, + "type": "object" + }, + "RealtimeTransport": { + "enum": [ + "webrtc", + "websocket" + ], + "type": "string" + }, + "RealtimeVoice": { + "enum": [ + "alloy", + "arbor", + "ash", + "ballad", + "breeze", + "cedar", + "coral", + "cove", + "echo", + "ember", + "juniper", + "maple", + "marin", + "sage", + "shimmer", + "sol", + "spruce", + "vale", + "verse" + ], + "type": "string" + }, + "RealtimeWsMode": { + "enum": [ + "conversational", + "transcription" + ], + "type": "string" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "SandboxMode": { + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ], + "type": "string" + }, + "SandboxWorkspaceWrite": { + "additionalProperties": false, + "properties": { + "exclude_slash_tmp": { + "default": false, + "type": "boolean" + }, + "exclude_tmpdir_env_var": { + "default": false, + "type": "boolean" + }, + "network_access": { + "default": false, + "type": "boolean" + }, + "writable_roots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "type": "object" + }, + "ServiceTier": { + "enum": [ + "fast", + "flex" + ], + "type": "string" + }, + "SessionPickerViewMode": { + "description": "Preferred layout for the resume/fork session picker.", + "enum": [ + "comfortable", + "dense" + ], + "type": "string" + }, + "ShellEnvironmentPolicyInherit": { + "oneOf": [ + { + "description": "\"Core\" environment variables for the platform. On UNIX, this would include HOME, LOGNAME, PATH, SHELL, and USER, among others.", + "enum": [ + "core" + ], + "type": "string" + }, + { + "description": "Inherits the full environment from the parent process.", + "enum": [ + "all" + ], + "type": "string" + }, + { + "description": "Do not inherit any environment variables from the parent process.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "ShellEnvironmentPolicyToml": { + "additionalProperties": false, + "description": "Policy for building the `env` when spawning a process via either the `shell` or `local_shell` tool.", + "properties": { + "exclude": { + "description": "List of regular expressions.", + "items": { + "type": "string" + }, + "type": "array" + }, + "experimental_use_profile": { + "type": "boolean" + }, + "ignore_default_excludes": { + "type": "boolean" + }, + "include_only": { + "description": "List of regular expressions.", + "items": { + "type": "string" + }, + "type": "array" + }, + "inherit": { + "$ref": "#/definitions/ShellEnvironmentPolicyInherit" + }, + "set": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, + "SkillConfig": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "description": "Name-based selector.", + "type": "string" + }, + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path-based selector." + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "SkillsConfig": { + "additionalProperties": false, + "properties": { + "bundled": { + "$ref": "#/definitions/BundledSkillsConfig" + }, + "config": { + "items": { + "$ref": "#/definitions/SkillConfig" + }, + "type": "array" + }, + "include_instructions": { + "description": "Whether turns receive the automatic skills instructions block.", + "type": "boolean" + } + }, + "type": "object" + }, + "ThreadStoreToml": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "local" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "ToolSuggestConfig": { + "additionalProperties": false, + "properties": { + "disabled_tools": { + "default": [], + "items": { + "$ref": "#/definitions/ToolSuggestDisabledTool" + }, + "type": "array" + }, + "discoverables": { + "default": [], + "items": { + "$ref": "#/definitions/ToolSuggestDiscoverable" + }, + "type": "array" + } + }, + "type": "object" + }, + "ToolSuggestDisabledTool": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/ToolSuggestDiscoverableType" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + }, + "ToolSuggestDiscoverable": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/ToolSuggestDiscoverableType" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + }, + "ToolSuggestDiscoverableType": { + "enum": [ + "connector", + "plugin" + ], + "type": "string" + }, + "ToolsToml": { + "additionalProperties": false, + "properties": { + "view_image": { + "default": null, + "description": "Enable the `view_image` tool that lets the agent attach local images.", + "type": "boolean" + }, + "web_search": { + "allOf": [ + { + "$ref": "#/definitions/WebSearchToolConfig" + } + ], + "default": null + } + }, + "type": "object" + }, + "TrustLevel": { + "description": "Represents the trust level for a project directory. This determines the approval policy and sandbox mode applied.", + "enum": [ + "trusted", + "untrusted" + ], + "type": "string" + }, + "Tui": { + "additionalProperties": false, + "description": "Collection of settings that are specific to the TUI.", + "properties": { + "alternate_screen": { + "allOf": [ + { + "$ref": "#/definitions/AltScreenMode" + } + ], + "default": "auto", + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n- `auto` (default): Disable alternate screen in Zellij, enable elsewhere. - `always`: Always use alternate screen (original behavior). - `never`: Never use alternate screen (inline mode only, preserves scrollback).\n\nUsing alternate screen provides a cleaner fullscreen experience but prevents scrollback in terminal multiplexers like Zellij that follow the xterm spec." + }, + "animations": { + "default": true, + "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", + "type": "boolean" + }, + "keymap": { + "allOf": [ + { + "$ref": "#/definitions/TuiKeymap" + } + ], + "default": { + "approval": { + "approve": null, + "approve_for_prefix": null, + "approve_for_session": null, + "cancel": null, + "decline": null, + "deny": null, + "open_fullscreen": null, + "open_thread": null + }, + "chat": { + "decrease_reasoning_effort": null, + "edit_queued_message": null, + "increase_reasoning_effort": null + }, + "composer": { + "history_search_next": null, + "history_search_previous": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + }, + "editor": { + "delete_backward": null, + "delete_backward_word": null, + "delete_forward": null, + "delete_forward_word": null, + "insert_newline": null, + "kill_line_end": null, + "kill_line_start": null, + "kill_whole_line": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_left": null, + "move_word_right": null, + "yank": null + }, + "global": { + "clear_terminal": null, + "copy": null, + "open_external_editor": null, + "open_transcript": null, + "queue": null, + "submit": null, + "toggle_fast_mode": null, + "toggle_raw_output": null, + "toggle_shortcuts": null, + "toggle_vim_mode": null + }, + "list": { + "accept": null, + "cancel": null, + "move_down": null, + "move_up": null + }, + "pager": { + "close": null, + "close_transcript": null, + "half_page_down": null, + "half_page_up": null, + "jump_bottom": null, + "jump_top": null, + "page_down": null, + "page_up": null, + "scroll_down": null, + "scroll_up": null + }, + "vim_normal": { + "append_after_cursor": null, + "append_line_end": null, + "cancel_operator": null, + "delete_char": null, + "delete_to_line_end": null, + "enter_insert": null, + "insert_line_start": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_backward": null, + "move_word_end": null, + "move_word_forward": null, + "open_line_above": null, + "open_line_below": null, + "paste_after": null, + "start_delete_operator": null, + "start_yank_operator": null, + "yank_line": null + }, + "vim_operator": { + "cancel": null, + "delete_line": null, + "motion_down": null, + "motion_left": null, + "motion_line_end": null, + "motion_line_start": null, + "motion_right": null, + "motion_up": null, + "motion_word_backward": null, + "motion_word_end": null, + "motion_word_forward": null, + "yank_line": null + } + }, + "description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings." + }, + "model_availability_nux": { + "allOf": [ + { + "$ref": "#/definitions/ModelAvailabilityNuxConfig" + } + ], + "default": {}, + "description": "Startup tooltip availability NUX state persisted by the TUI." + }, + "notification_condition": { + "allOf": [ + { + "$ref": "#/definitions/NotificationCondition" + } + ], + "default": "unfocused", + "description": "Controls whether TUI notifications are delivered only when the terminal is unfocused or regardless of focus. Defaults to `unfocused`." + }, + "notification_method": { + "allOf": [ + { + "$ref": "#/definitions/NotificationMethod" + } + ], + "default": "auto", + "description": "Notification method to use for terminal notifications. Defaults to `auto`." + }, + "notifications": { + "allOf": [ + { + "$ref": "#/definitions/Notifications" + } + ], + "default": true, + "description": "Enable desktop notifications from the TUI. Defaults to `true`." + }, + "raw_output_mode": { + "default": false, + "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", + "type": "boolean" + }, + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + }, + "show_tooltips": { + "default": true, + "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", + "type": "boolean" + }, + "status_line": { + "default": null, + "description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line. When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "status_line_use_colors": { + "default": true, + "description": "Color status line items with colors derived from the active syntax theme. Defaults to `true`.", + "type": "boolean" + }, + "terminal_resize_reflow_max_rows": { + "default": null, + "description": "Trim terminal resize-reflow replay to the most recent rendered terminal rows when the transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to keep all rendered rows.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "terminal_title": { + "default": null, + "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `activity` and `project`. The `activity` item spins while working and shows an action-required message when blocked on the user.", + "items": { + "type": "string" + }, + "type": "array" + }, + "theme": { + "default": null, + "description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.", + "type": "string" + }, + "vim_mode_default": { + "default": false, + "description": "Start the composer in Vim mode (`Normal`) by default. Defaults to `false`.", + "type": "boolean" + } + }, + "type": "object" + }, + "TuiApprovalKeymap": { + "additionalProperties": false, + "description": "Approval overlay keybindings.", + "properties": { + "approve": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve the primary option." + }, + "approve_for_prefix": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve with exec-policy prefix when that option exists." + }, + "approve_for_session": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve for session when that option exists." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel an elicitation request." + }, + "decline": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Decline and provide corrective guidance." + }, + "deny": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Deny without providing follow-up guidance." + }, + "open_fullscreen": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the full-screen approval details view." + }, + "open_thread": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the thread that requested approval when shown from another thread." + } + }, + "type": "object" + }, + "TuiChatKeymap": { + "additionalProperties": false, + "description": "Chat context keybindings.", + "properties": { + "decrease_reasoning_effort": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Decrease the active reasoning effort." + }, + "edit_queued_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Edit the most recently queued message." + }, + "increase_reasoning_effort": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Increase the active reasoning effort." + } + }, + "type": "object" + }, + "TuiComposerKeymap": { + "additionalProperties": false, + "description": "Composer context keybindings. These override corresponding `global` actions.", + "properties": { + "history_search_next": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move to the next match in reverse history search." + }, + "history_search_previous": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open reverse history search or move to the previous match." + }, + "queue": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Queue the current composer draft while a task is running." + }, + "submit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Submit the current composer draft." + }, + "toggle_shortcuts": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle the composer shortcut overlay." + } + }, + "type": "object" + }, + "TuiEditorKeymap": { + "additionalProperties": false, + "description": "Editor context keybindings for text editing inside text areas.", + "properties": { + "delete_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete one grapheme to the left." + }, + "delete_backward_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete the previous word." + }, + "delete_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete one grapheme to the right." + }, + "delete_forward_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete the next word." + }, + "insert_newline": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Insert a newline in the editor." + }, + "kill_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill text from cursor to line end." + }, + "kill_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill text from cursor to line start." + }, + "kill_whole_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill the current line." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor down one visual line." + }, + "move_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor left by one grapheme." + }, + "move_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of line." + }, + "move_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to beginning of line." + }, + "move_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor right by one grapheme." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor up one visual line." + }, + "move_word_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to beginning of previous word." + }, + "move_word_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of next word." + }, + "yank": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Yank the kill buffer." + } + }, + "type": "object" + }, + "TuiGlobalKeymap": { + "additionalProperties": false, + "description": "Global keybindings. These are used when a context does not define an override.", + "properties": { + "clear_terminal": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Clear the terminal UI." + }, + "copy": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Copy the last agent response to the clipboard." + }, + "open_external_editor": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the external editor for the current draft." + }, + "open_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the transcript overlay." + }, + "queue": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Queue the current composer draft while a task is running." + }, + "submit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Submit the current composer draft." + }, + "toggle_fast_mode": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle Fast mode." + }, + "toggle_raw_output": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle raw scrollback mode for copy-friendly transcript selection." + }, + "toggle_shortcuts": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle the composer shortcut overlay." + }, + "toggle_vim_mode": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle Vim mode for the composer input." + } + }, + "type": "object" + }, + "TuiKeymap": { + "additionalProperties": false, + "description": "Raw keymap configuration from `[tui.keymap]`.\n\nEach context contains action-level overrides. Missing actions inherit from built-in defaults, and selected chat/composer actions can fall back through `global` during runtime resolution.\n\nThis type is intentionally a persistence shape, not the structure used by input handlers. Runtime consumers should resolve it into `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key validation are applied consistently.", + "properties": { + "approval": { + "allOf": [ + { + "$ref": "#/definitions/TuiApprovalKeymap" + } + ], + "default": { + "approve": null, + "approve_for_prefix": null, + "approve_for_session": null, + "cancel": null, + "decline": null, + "deny": null, + "open_fullscreen": null, + "open_thread": null + } + }, + "chat": { + "allOf": [ + { + "$ref": "#/definitions/TuiChatKeymap" + } + ], + "default": { + "decrease_reasoning_effort": null, + "edit_queued_message": null, + "increase_reasoning_effort": null + } + }, + "composer": { + "allOf": [ + { + "$ref": "#/definitions/TuiComposerKeymap" + } + ], + "default": { + "history_search_next": null, + "history_search_previous": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + } + }, + "editor": { + "allOf": [ + { + "$ref": "#/definitions/TuiEditorKeymap" + } + ], + "default": { + "delete_backward": null, + "delete_backward_word": null, + "delete_forward": null, + "delete_forward_word": null, + "insert_newline": null, + "kill_line_end": null, + "kill_line_start": null, + "kill_whole_line": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_left": null, + "move_word_right": null, + "yank": null + } + }, + "global": { + "allOf": [ + { + "$ref": "#/definitions/TuiGlobalKeymap" + } + ], + "default": { + "clear_terminal": null, + "copy": null, + "open_external_editor": null, + "open_transcript": null, + "queue": null, + "submit": null, + "toggle_fast_mode": null, + "toggle_raw_output": null, + "toggle_shortcuts": null, + "toggle_vim_mode": null + } + }, + "list": { + "allOf": [ + { + "$ref": "#/definitions/TuiListKeymap" + } + ], + "default": { + "accept": null, + "cancel": null, + "move_down": null, + "move_up": null + } + }, + "pager": { + "allOf": [ + { + "$ref": "#/definitions/TuiPagerKeymap" + } + ], + "default": { + "close": null, + "close_transcript": null, + "half_page_down": null, + "half_page_up": null, + "jump_bottom": null, + "jump_top": null, + "page_down": null, + "page_up": null, + "scroll_down": null, + "scroll_up": null + } + }, + "vim_normal": { + "allOf": [ + { + "$ref": "#/definitions/TuiVimNormalKeymap" + } + ], + "default": { + "append_after_cursor": null, + "append_line_end": null, + "cancel_operator": null, + "delete_char": null, + "delete_to_line_end": null, + "enter_insert": null, + "insert_line_start": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_backward": null, + "move_word_end": null, + "move_word_forward": null, + "open_line_above": null, + "open_line_below": null, + "paste_after": null, + "start_delete_operator": null, + "start_yank_operator": null, + "yank_line": null + } + }, + "vim_operator": { + "allOf": [ + { + "$ref": "#/definitions/TuiVimOperatorKeymap" + } + ], + "default": { + "cancel": null, + "delete_line": null, + "motion_down": null, + "motion_left": null, + "motion_line_end": null, + "motion_line_start": null, + "motion_right": null, + "motion_up": null, + "motion_word_backward": null, + "motion_word_end": null, + "motion_word_forward": null, + "yank_line": null + } + } + }, + "type": "object" + }, + "TuiListKeymap": { + "additionalProperties": false, + "description": "List selection context keybindings for popup-style selectable lists.", + "properties": { + "accept": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Accept current selection." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel and close selection view." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection down." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection up." + } + }, + "type": "object" + }, + "TuiPagerKeymap": { + "additionalProperties": false, + "description": "Pager context keybindings for transcript and static overlays.", + "properties": { + "close": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Close the pager overlay." + }, + "close_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Close the transcript overlay via its dedicated toggle key." + }, + "half_page_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by half a page." + }, + "half_page_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by half a page." + }, + "jump_bottom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the end." + }, + "jump_top": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the beginning." + }, + "page_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by one page." + }, + "page_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by one page." + }, + "scroll_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by one row." + }, + "scroll_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by one row." + } + }, + "type": "object" + }, + "TuiVimNormalKeymap": { + "additionalProperties": false, + "description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions that use uppercase letters (like `A` for append-line-end) should be specified as `shift-a` in config; the runtime matcher handles cross-terminal shift-reporting differences automatically.", + "properties": { + "append_after_cursor": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode after cursor (`a`)." + }, + "append_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode at end of line (`A`)." + }, + "cancel_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel a pending operator and return to normal mode." + }, + "delete_char": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete character under cursor (`x`)." + }, + "delete_to_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete from cursor to end of line (`D`)." + }, + "enter_insert": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode at cursor (`i`)." + }, + "insert_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter insert mode at first non-blank of line (`I`)." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor down (`j`), or recall newer composer history at history boundaries." + }, + "move_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor left (`h`)." + }, + "move_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of line (`$`)." + }, + "move_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to start of line (`0`)." + }, + "move_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor right (`l`)." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor up (`k`), or recall older composer history at history boundaries." + }, + "move_word_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to start of previous word (`b`)." + }, + "move_word_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of current/next word (`e`)." + }, + "move_word_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to start of next word (`w`)." + }, + "open_line_above": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open a new line above and enter insert mode (`O`)." + }, + "open_line_below": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open a new line below and enter insert mode (`o`)." + }, + "paste_after": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Paste after cursor (`p`)." + }, + "start_delete_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin delete operator; next key selects motion (`d`)." + }, + "start_yank_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin yank operator; next key selects motion (`y`)." + }, + "yank_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Yank the entire line (`Y`)." + } + }, + "type": "object" + }, + "TuiVimOperatorKeymap": { + "additionalProperties": false, + "description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d` or `y`. Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.", + "properties": { + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel the pending operator and return to normal mode." + }, + "delete_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat delete operator to delete the whole line (`dd`)." + }, + "motion_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: down one line (`j`)." + }, + "motion_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: left (`h`)." + }, + "motion_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to end of line (`$`)." + }, + "motion_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to start of line (`0`)." + }, + "motion_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: right (`l`)." + }, + "motion_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: up one line (`k`)." + }, + "motion_word_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to start of previous word (`b`)." + }, + "motion_word_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to end of current/next word (`e`)." + }, + "motion_word_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: to start of next word (`w`)." + }, + "yank_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat yank operator to yank the whole line (`yy`)." + } + }, + "type": "object" + }, + "UriBasedFileOpener": { + "oneOf": [ + { + "enum": [ + "vscode", + "vscode-insiders", + "windsurf", + "cursor" + ], + "type": "string" + }, + { + "description": "Option to disable the URI-based file opener.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "Verbosity": { + "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchContextSize": { + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchLocation": { + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "region": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "type": "object" + }, + "WebSearchMode": { + "enum": [ + "disabled", + "cached", + "live" + ], + "type": "string" + }, + "WebSearchToolConfig": { + "additionalProperties": false, + "properties": { + "allowed_domains": { + "items": { + "type": "string" + }, + "type": "array" + }, + "context_size": { + "$ref": "#/definitions/WebSearchContextSize" + }, + "location": { + "$ref": "#/definitions/WebSearchLocation" + } + }, + "type": "object" + }, + "WindowsSandboxModeToml": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsToml": { + "additionalProperties": false, + "properties": { + "sandbox": { + "$ref": "#/definitions/WindowsSandboxModeToml" + }, + "sandbox_private_desktop": { + "description": "Defaults to `true`. Set to `false` to launch the final sandboxed child process on `Winsta0\\\\Default` instead of a private desktop.", + "type": "boolean" + } + }, + "type": "object" + }, + "WireApi": { + "description": "Wire protocol that the provider speaks.", + "oneOf": [ + { + "description": "The Responses API exposed by OpenAI at `/v1/responses`.", + "enum": [ + "responses" + ], + "type": "string" + } + ] + } + }, + "description": "Base config deserialized from ~/.codex/config.toml.", + "properties": { + "agents": { + "allOf": [ + { + "$ref": "#/definitions/AgentsToml" + } + ], + "description": "Agent-related settings (thread limits, etc.)." + }, + "allow_login_shell": { + "default": true, + "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", + "type": "boolean" + }, + "analytics": { + "allOf": [ + { + "$ref": "#/definitions/AnalyticsConfigToml" + } + ], + "description": "When `false`, disables analytics across Codex product surfaces in this machine. Defaults to `true`." + }, + "approval_policy": { + "allOf": [ + { + "$ref": "#/definitions/AskForApproval" + } + ], + "description": "Default approval policy for executing commands." + }, + "approvals_reviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Configures who approval requests are routed to for review once they have been escalated. This does not disable separate safety checks such as ARC." + }, + "apps": { + "allOf": [ + { + "$ref": "#/definitions/AppsConfigToml" + } + ], + "default": null, + "description": "Settings for app-specific controls." + }, + "audio": { + "allOf": [ + { + "$ref": "#/definitions/RealtimeAudioToml" + } + ], + "default": null, + "description": "Machine-local realtime audio device preferences used by realtime voice." + }, + "auto_review": { + "allOf": [ + { + "$ref": "#/definitions/AutoReviewToml" + } + ], + "default": null, + "description": "Optional policy instructions for the guardian auto-reviewer." + }, + "background_terminal_max_timeout": { + "description": "Maximum poll window for background terminal output (`write_stdin`), in milliseconds. Default: `300000` (5 minutes).", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "chatgpt_base_url": { + "description": "Base URL for requests to ChatGPT (as opposed to the OpenAI API).", + "type": "string" + }, + "check_for_update_on_startup": { + "description": "When `true`, checks for Codex updates on startup and surfaces update prompts. Set to `false` only if your Codex updates are centrally managed. Defaults to `true`.", + "type": "boolean" + }, + "cli_auth_credentials_store": { + "allOf": [ + { + "$ref": "#/definitions/AuthCredentialsStoreMode" + } + ], + "default": null, + "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." + }, + "commit_attribution": { + "description": "Optional commit attribution text for commit message co-author trailers. This top-level setting only takes effect when `[features].codex_git_commit` is enabled.\n\nWhen enabled and unset, Codex uses `Codex `. Set to an empty string to disable automatic commit attribution.", + "type": "string" + }, + "compact_prompt": { + "description": "Compact prompt used for history compaction.", + "type": "string" + }, + "debug": { + "allOf": [ + { + "$ref": "#/definitions/DebugToml" + } + ], + "description": "Debugging and reproducibility settings." + }, + "default_permissions": { + "description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.", + "type": "string" + }, + "developer_instructions": { + "default": null, + "description": "Developer instructions inserted as a `developer` role message.", + "type": "string" + }, + "disable_paste_burst": { + "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts.", + "type": "boolean" + }, + "experimental_compact_prompt_file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "experimental_realtime_start_instructions": { + "description": "Experimental / do not use. Replaces the built-in realtime start instructions inserted into developer messages when realtime becomes active.", + "type": "string" + }, + "experimental_realtime_ws_backend_prompt": { + "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport instructions (the `Op::RealtimeConversation` `/ws` session.update instructions) without changing normal prompts.", + "type": "string" + }, + "experimental_realtime_ws_base_url": { + "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/v1/realtime` connection) without changing normal provider HTTP requests.", + "type": "string" + }, + "experimental_realtime_ws_model": { + "description": "Experimental / do not use. Selects the realtime websocket model/snapshot used for the `Op::RealtimeConversation` connection.", + "type": "string" + }, + "experimental_realtime_ws_startup_context": { + "description": "Experimental / do not use. Replaces the synthesized realtime startup context appended to websocket session instructions. An empty string disables startup context injection entirely.", + "type": "string" + }, + "experimental_thread_config_endpoint": { + "description": "Experimental / do not use. When set, app-server fetches thread-scoped config from a remote service at this endpoint.", + "type": "string" + }, + "experimental_thread_store": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStoreToml" + } + ], + "description": "Experimental / do not use. Selects the thread store implementation." + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "features": { + "additionalProperties": false, + "default": null, + "description": "Centralized feature flags (new). Prefer this over individual toggles.", + "properties": { + "apply_patch_freeform": { + "type": "boolean" + }, + "apply_patch_streaming_events": { + "type": "boolean" + }, + "apps": { + "type": "boolean" + }, + "apps_mcp_path_override": { + "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" + }, + "auth_elicitation": { + "type": "boolean" + }, + "browser_use": { + "type": "boolean" + }, + "browser_use_external": { + "type": "boolean" + }, + "builtin_mcp": { + "type": "boolean" + }, + "child_agents_md": { + "type": "boolean" + }, + "chronicle": { + "type": "boolean" + }, + "code_mode": { + "type": "boolean" + }, + "code_mode_only": { + "type": "boolean" + }, + "codex_git_commit": { + "type": "boolean" + }, + "codex_hooks": { + "type": "boolean" + }, + "collab": { + "type": "boolean" + }, + "collaboration_modes": { + "type": "boolean" + }, + "computer_use": { + "type": "boolean" + }, + "connectors": { + "type": "boolean" + }, + "default_mode_request_user_input": { + "type": "boolean" + }, + "elevated_windows_sandbox": { + "type": "boolean" + }, + "enable_experimental_windows_sandbox": { + "type": "boolean" + }, + "enable_fanout": { + "type": "boolean" + }, + "enable_mcp_apps": { + "type": "boolean" + }, + "enable_request_compression": { + "type": "boolean" + }, + "exec_permission_approvals": { + "type": "boolean" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "experimental_windows_sandbox": { + "type": "boolean" + }, + "external_migration": { + "type": "boolean" + }, + "fast_mode": { + "type": "boolean" + }, + "goals": { + "type": "boolean" + }, + "guardian_approval": { + "type": "boolean" + }, + "hooks": { + "type": "boolean" + }, + "image_detail_original": { + "type": "boolean" + }, + "image_generation": { + "type": "boolean" + }, + "in_app_browser": { + "type": "boolean" + }, + "include_apply_patch_tool": { + "type": "boolean" + }, + "js_repl": { + "type": "boolean" + }, + "js_repl_tools_only": { + "type": "boolean" + }, + "memories": { + "type": "boolean" + }, + "memory_tool": { + "type": "boolean" + }, + "multi_agent": { + "type": "boolean" + }, + "multi_agent_v2": { + "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" + }, + "personality": { + "type": "boolean" + }, + "plugin_hooks": { + "type": "boolean" + }, + "plugins": { + "type": "boolean" + }, + "prevent_idle_sleep": { + "type": "boolean" + }, + "realtime_conversation": { + "type": "boolean" + }, + "remote_compaction_v2": { + "type": "boolean" + }, + "remote_control": { + "type": "boolean" + }, + "remote_models": { + "type": "boolean" + }, + "remote_plugin": { + "type": "boolean" + }, + "request_permissions": { + "type": "boolean" + }, + "request_permissions_tool": { + "type": "boolean" + }, + "request_rule": { + "type": "boolean" + }, + "responses_websocket_response_processed": { + "type": "boolean" + }, + "responses_websockets": { + "type": "boolean" + }, + "responses_websockets_v2": { + "type": "boolean" + }, + "runtime_metrics": { + "type": "boolean" + }, + "search_tool": { + "type": "boolean" + }, + "shell_snapshot": { + "type": "boolean" + }, + "shell_tool": { + "type": "boolean" + }, + "shell_zsh_fork": { + "type": "boolean" + }, + "skill_env_var_dependency_prompt": { + "type": "boolean" + }, + "skill_mcp_dependency_install": { + "type": "boolean" + }, + "sqlite": { + "type": "boolean" + }, + "steer": { + "type": "boolean" + }, + "telepathy": { + "type": "boolean" + }, + "terminal_resize_reflow": { + "type": "boolean" + }, + "tool_call_mcp_elicitation": { + "type": "boolean" + }, + "tool_search": { + "type": "boolean" + }, + "tool_search_always_defer_mcp_tools": { + "type": "boolean" + }, + "tool_suggest": { + "type": "boolean" + }, + "tui_app_server": { + "type": "boolean" + }, + "unavailable_dummy_tools": { + "type": "boolean" + }, + "undo": { + "type": "boolean" + }, + "unified_exec": { + "type": "boolean" + }, + "use_legacy_landlock": { + "type": "boolean" + }, + "use_linux_sandbox_bwrap": { + "type": "boolean" + }, + "web_search": { + "type": "boolean" + }, + "web_search_cached": { + "type": "boolean" + }, + "web_search_request": { + "type": "boolean" + }, + "workspace_dependencies": { + "type": "boolean" + }, + "workspace_owner_usage_nudge": { + "type": "boolean" + } + }, + "type": "object" + }, + "feedback": { + "allOf": [ + { + "$ref": "#/definitions/FeedbackConfigToml" + } + ], + "description": "When `false`, disables feedback collection across Codex product surfaces. Defaults to `true`." + }, + "file_opener": { + "allOf": [ + { + "$ref": "#/definitions/UriBasedFileOpener" + } + ], + "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme." + }, + "forced_chatgpt_workspace_id": { + "default": null, + "description": "When set, restricts ChatGPT login to a specific workspace identifier.", + "type": "string" + }, + "forced_login_method": { + "allOf": [ + { + "$ref": "#/definitions/ForcedLoginMethod" + } + ], + "default": null, + "description": "When set, restricts the login mechanism users may use." + }, + "ghost_snapshot": { + "allOf": [ + { + "$ref": "#/definitions/GhostSnapshotToml" + } + ], + "default": null, + "description": "Compatibility-only settings retained so legacy `ghost_snapshot` config still loads." + }, + "hide_agent_reasoning": { + "default": false, + "description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.", + "type": "boolean" + }, + "history": { + "allOf": [ + { + "$ref": "#/definitions/History" + } + ], + "default": { + "max_bytes": null, + "persistence": "save-all" + }, + "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`." + }, + "hooks": { + "allOf": [ + { + "$ref": "#/definitions/HooksToml" + } + ], + "description": "Lifecycle hooks configured inline in TOML plus user-level overrides." + }, + "include_apps_instructions": { + "description": "Whether to inject the `` developer block.", + "type": "boolean" + }, + "include_environment_context": { + "description": "Whether to inject the `` user block.", + "type": "boolean" + }, + "include_permissions_instructions": { + "description": "Whether to inject the `` developer block.", + "type": "boolean" + }, + "instructions": { + "description": "System instructions.", + "type": "string" + }, + "log_dir": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Directory where Codex writes log files, for example `codex-tui.log`. Defaults to `$CODEX_HOME/log`." + }, + "marketplaces": { + "additionalProperties": { + "$ref": "#/definitions/MarketplaceConfig" + }, + "default": {}, + "description": "User-level marketplace entries keyed by marketplace name.", + "type": "object" + }, + "mcp_oauth_callback_port": { + "description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "mcp_oauth_callback_url": { + "description": "Optional redirect URI to use during MCP OAuth login. When set, this URI is used in the OAuth authorization request instead of the local listener address. The local callback listener still binds to 127.0.0.1 (using `mcp_oauth_callback_port` when provided).", + "type": "string" + }, + "mcp_oauth_credentials_store": { + "allOf": [ + { + "$ref": "#/definitions/OAuthCredentialsStoreMode" + } + ], + "default": null, + "description": "Preferred backend for storing MCP OAuth credentials. keyring: Use an OS-specific keyring service. https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 file: Use a file in the Codex home directory. auto (default): Use the OS-specific keyring service if available, otherwise use a file." + }, + "mcp_servers": { + "additionalProperties": { + "$ref": "#/definitions/RawMcpServerConfig" + }, + "default": {}, + "description": "Definition for MCP servers that Codex can reach out to for tool calls.", + "type": "object" + }, + "memories": { + "allOf": [ + { + "$ref": "#/definitions/MemoriesToml" + } + ], + "description": "Memories subsystem settings." + }, + "model": { + "description": "Optional override of model selection.", + "type": "string" + }, + "model_auto_compact_token_limit": { + "description": "Token usage threshold triggering auto-compaction of conversation history.", + "format": "int64", + "type": "integer" + }, + "model_catalog_json": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional path to a JSON model catalog (applied on startup only). Per-thread `config` overrides are accepted but do not reapply this (no-ops)." + }, + "model_context_window": { + "description": "Size of the context window for the model, in tokens.", + "format": "int64", + "type": "integer" + }, + "model_instructions_file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional path to a file containing model instructions that will override the built-in instructions for the selected model. Users are STRONGLY DISCOURAGED from using this field, as deviating from the instructions sanctioned by Codex will likely degrade model performance." + }, + "model_provider": { + "description": "Provider to use from the model_providers map.", + "type": "string" + }, + "model_providers": { + "additionalProperties": { + "$ref": "#/definitions/ModelProviderInfo" + }, + "default": {}, + "description": "User-defined provider entries that extend the built-in list. Built-in IDs cannot be overridden.", + "type": "object" + }, + "model_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "model_reasoning_summary": { + "$ref": "#/definitions/ReasoningSummary" + }, + "model_supports_reasoning_summaries": { + "description": "Override to force-enable reasoning summaries for the configured model.", + "type": "boolean" + }, + "model_verbosity": { + "allOf": [ + { + "$ref": "#/definitions/Verbosity" + } + ], + "description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)." + }, + "notice": { + "allOf": [ + { + "$ref": "#/definitions/Notice" + } + ], + "description": "Collection of in-product notices (different from notifications) See [`crate::types::Notice`] for more details" + }, + "notify": { + "default": null, + "description": "Optional external command to spawn for end-user notifications.", + "items": { + "type": "string" + }, + "type": "array" + }, + "openai_base_url": { + "description": "Base URL override for the built-in `openai` model provider.", + "type": "string" + }, + "oss_provider": { + "description": "Preferred OSS provider for local models, e.g. \"lmstudio\" or \"ollama\".", + "type": "string" + }, + "otel": { + "allOf": [ + { + "$ref": "#/definitions/OtelConfigToml" + } + ], + "description": "OTEL configuration." + }, + "permissions": { + "allOf": [ + { + "$ref": "#/definitions/PermissionsToml" + } + ], + "default": null, + "description": "Named permissions profiles." + }, + "personality": { + "allOf": [ + { + "$ref": "#/definitions/Personality" + } + ], + "description": "Optionally specify a personality for the model" + }, + "plan_mode_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "plugins": { + "additionalProperties": { + "$ref": "#/definitions/PluginConfig" + }, + "default": {}, + "description": "User-level plugin config entries keyed by plugin name.", + "type": "object" + }, + "profile": { + "description": "Profile to use from the `profiles` map.", + "type": "string" + }, + "profiles": { + "additionalProperties": { + "$ref": "#/definitions/ConfigProfile" + }, + "default": {}, + "description": "Named profiles to facilitate switching between different configurations.", + "type": "object" + }, + "project_doc_fallback_filenames": { + "default": [], + "description": "Ordered list of fallback filenames to look for when AGENTS.md is missing.", + "items": { + "type": "string" + }, + "type": "array" + }, + "project_doc_max_bytes": { + "default": 32768, + "description": "Maximum number of bytes to include from an AGENTS.md project doc file.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "project_root_markers": { + "default": null, + "description": "Markers used to detect the project root when searching parent directories for `.codex` folders. Defaults to [\".git\"] when unset.", + "items": { + "type": "string" + }, + "type": "array" + }, + "projects": { + "additionalProperties": { + "$ref": "#/definitions/ProjectConfig" + }, + "type": "object" + }, + "realtime": { + "allOf": [ + { + "$ref": "#/definitions/RealtimeToml" + } + ], + "default": null, + "description": "Experimental / do not use. Realtime websocket session selection. `version` controls v1/v2 and `type` controls conversational/transcription." + }, + "review_model": { + "description": "Review model override used by the `/review` feature.", + "type": "string" + }, + "sandbox_mode": { + "allOf": [ + { + "$ref": "#/definitions/SandboxMode" + } + ], + "description": "Sandbox mode to use." + }, + "sandbox_workspace_write": { + "allOf": [ + { + "$ref": "#/definitions/SandboxWorkspaceWrite" + } + ], + "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`." + }, + "service_tier": { + "allOf": [ + { + "$ref": "#/definitions/ServiceTier" + } + ], + "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." + }, + "shell_environment_policy": { + "allOf": [ + { + "$ref": "#/definitions/ShellEnvironmentPolicyToml" + } + ], + "default": { + "exclude": null, + "experimental_use_profile": null, + "ignore_default_excludes": null, + "include_only": null, + "inherit": null, + "set": null + } + }, + "show_raw_agent_reasoning": { + "description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.", + "type": "boolean" + }, + "skills": { + "allOf": [ + { + "$ref": "#/definitions/SkillsConfig" + } + ], + "description": "User-level skill config entries keyed by SKILL.md path." + }, + "sqlite_home": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Directory where Codex stores the SQLite state DB. Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`." + }, + "suppress_unstable_features_warning": { + "description": "Suppress warnings about unstable (under development) features.", + "type": "boolean" + }, + "tool_output_token_limit": { + "description": "Token budget applied when storing tool/function outputs in the context manager.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "tool_suggest": { + "allOf": [ + { + "$ref": "#/definitions/ToolSuggestConfig" + } + ], + "description": "Additional discoverable tools that can be suggested for installation." + }, + "tools": { + "allOf": [ + { + "$ref": "#/definitions/ToolsToml" + } + ], + "description": "Nested tools section for feature toggles" + }, + "tui": { + "allOf": [ + { + "$ref": "#/definitions/Tui" + } + ], + "description": "Collection of settings that are specific to the TUI." + }, + "web_search": { + "allOf": [ + { + "$ref": "#/definitions/WebSearchMode" + } + ], + "description": "Controls the web search tool mode: disabled, cached, or live." + }, + "windows": { + "allOf": [ + { + "$ref": "#/definitions/WindowsToml" + } + ], + "default": null, + "description": "Windows-specific configuration." + }, + "windows_wsl_setup_acknowledged": { + "description": "Tracks whether the Windows onboarding screen has been acknowledged.", + "type": "boolean" + }, + "zsh_path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution." + } + }, + "title": "ConfigToml", + "type": "object" +} \ No newline at end of file diff --git a/code-rs/core/docs/auto-review.md b/code-rs/core/docs/auto-review.md deleted file mode 100644 index 3b64b95a74e..00000000000 --- a/code-rs/core/docs/auto-review.md +++ /dev/null @@ -1,202 +0,0 @@ -# Auto Review Lifecycle - -This note describes the durable Auto Review flow shared by exec, TUI, and -auto-drive. The important rule is that Auto Review is a harness-owned quality -pipeline: the assistant may read compact review state and ask for bounded -details, but run identity, freshness, duplicate avoidance, cancellation, and -restart recovery belong to Every Code runtime code. - -## Surfaces - -- **Exec (`/review` and auto-resolve):** captures a ghost snapshot, acquires the - shared review lock, runs review, and may loop fixes into follow-up reviews - until clean or limits hit. -- **TUI background review:** triggers and renders background review progress, - while writing the same durable run records as exec. -- **Auto-drive:** orchestrates turns and uses the same lock, snapshot epoch, and - durable run store when it launches or follows up on reviews. -- **Assistant context:** receives only a compact Auto Review ledger when there is - active, recent, actionable, or diagnostic review state. Full review output is - lazy detail, not normal turn context. - -## Durable Store - -Auto Review state is repo scoped under the shared review state directory: - -```text -CODE_HOME/state/review/repo-/auto-review/ - runs.json - outputs/.json -``` - -`runs.json` stores bounded `AutoReviewRun` records. Output sidecars preserve full -review results for lazy detail lookup. The store keeps enough proof to explain a -run without injecting bulky review bodies into every prompt: - -- identity and ownership: `run_id`, `source`, `owner_session_id`, `agent_id`, - `batch_id`, `worktree_path` -- target: `base_commit`, `snapshot_commit`, `snapshot_epoch`, `head_at_launch`, - `scope_hash`, `diff_fingerprint`, `prompt_policy_version`, changed paths -- lifecycle: `status`, `freshness`, `created_at`, `started_at`, `updated_at`, - `completed_at`, `last_activity_at` -- execution shape: `model`, `reasoning_effort`, prompt token estimate, actual - token count when available, saved token estimate when a duplicate is skipped -- result summary: finding digests, summary digest, supersession, cancel reason, - error class, error summary, and output sidecar path - -## Status And Freshness - -`status` is the run lifecycle. `freshness` is the usefulness or liveness of the -review target. They are related but not interchangeable. - -Run statuses: - -- `Pending`: recorded but not yet doing review work. -- `Snapshotting`: capturing or preparing the reviewed snapshot. -- `Reviewing`: the review agent is running against a snapshot. -- `Resolving`: auto-resolve is applying or preparing follow-up work. -- `Completed`: terminal review output was produced. -- `Failed`: the run ended with an execution error. -- `Cancelled`: the harness or user stopped the run for an explicit reason. -- `Superseded`: a newer run replaces this run's useful scope. -- `Skipped`: the harness intentionally did not launch work, commonly because an - equivalent active or recent review already exists. -- `Lost`: durable state survived, but the owning process or agent could not be - reconciled after restart. - -Freshness values: - -- `Current`: the run still matches the active review target. -- `LongRunning`: the run has taken a while but still has evidence of being live - and relevant. -- `Inactive`: the run has stopped showing activity without enough evidence to - call it terminal. -- `Superseded`: a newer matching scope has replaced the run. -- `Obsolete`: the reviewed snapshot is no longer useful for the active head or - epoch. -- `Lost`: restart reconciliation could not find the owning agent/process. -- `Unknown`: the store cannot yet prove a more specific freshness value. - -Elapsed runtime alone must not make a run stale. A 30 minute review can be -healthy if its snapshot is still relevant and the owner is active. Staleness -comes from snapshot/head/epoch mismatch, supersession, obsolete scope, lost -ownership, or inactivity evidence. - -## Lock, Epoch, And Snapshot Rules - -- Acquire the shared review lock before sending a review or follow-up. If the - lock is busy, adopt, skip, or defer instead of launching overlapping work. -- Ghost commits are taken with a snapshot epoch bump, so every controlled - snapshot invalidates older review targets. -- Follow-up reviews compare the recorded snapshot epoch and base commit before - continuing. If the epoch advanced, the base is no longer an ancestor, or the - snapshot is identical to the last reviewed commit, the loop stops and - recaptures only through harness policy. -- Git mutations must use shared helpers that bump the snapshot epoch. New - mutation touchpoints must either call those helpers or bump the epoch - immediately after success. -- Session worktree cleanup also coordinates through the shared lock. Successful - cleanup bumps the epoch so stale review targets are invalidated. - -## Duplicate, Supersede, And Cancellation Policy - -The harness owns duplicate and cancellation decisions. The assistant may reason -from the ledger, but it should not directly cancel or relaunch reviews as a way -to enforce policy. - -- Prefer adopting or reconnecting to an equivalent active run before launching a - new one. -- Skip duplicate work when the diff fingerprint, scope hash, prompt policy, and - target are equivalent enough that another run already covers the useful scope. - Duplicate skips use the `duplicate_auto_review_scope` reason and may record a - saved token estimate. -- Mark older useful scopes as `Superseded` when a newer run replaces them. -- Use `Obsolete` or stale target applicability for terminal findings that no - longer match the active checkout; preserve the evidence, but do not surface it - as current work. -- Cancel only for explicit user stop, superseded or obsolete scope, dead/lost - process evidence, hard budget exhaustion, or proven duplicate policy. Do not - cancel solely because elapsed runtime is high. -- Preserve terminal findings as evidence. They should be current, superseded, - obsolete, dismissed, or archived; they should not vanish silently. - -## Compact Ledger - -The compact ledger is the assistant-facing summary. It is emitted only when -there is useful active, recent, actionable, or diagnostic state, and it is capped -by byte budget. Idle clean runs produce no ledger. - -Ledger entries include active or applicable runs by default, using stable run ids -and compact fields such as status, freshness, target applicability, source, -branch, snapshot, age, last activity, model, reasoning effort, prompt estimate, -actual tokens when available, saved token estimate, elapsed bucket, finding -count, summary, and short finding digests. - -The ledger intentionally separates: - -- `freshness`: whether the run is current, long-running, inactive, superseded, - obsolete, lost, or unknown. -- `target`: whether the run matches the active head, is an older snapshot, comes - from a detached review worktree, or is unknown. - -Known stale or detached terminal findings are normally suppressed from actionable -run listings. The ledger may still include diagnostics such as `suppressed_stale` -so the assistant knows evidence exists without treating it as work to apply. - -Example: - -```xml - -Auto Review state for this repo. Listed runs are active or target-applicable by default; diagnostics may include stale/detached history that is not an instruction to re-review or fix. Treat run ids as stable references for future detail lookup with the auto_review_detail tool. Freshness describes run recency; target describes whether findings match the active checkout. -diagnostics recent_runs=2 in_flight=1 terminal=1 tokens=25915t token_runs=1 prompt_estimate=54000t prompt_runs=2 high_burn=2 longest_elapsed=lt1m -run id=... status=Reviewing freshness=Current target=matching_head source=Tui branch=feature snapshot=abcdef1 age=lt1m last_activity=lt1m model=gpt-5.4-mini reasoning=medium prompt_estimate=42000t elapsed=lt1m - -``` - -## Lazy Detail Lookup - -Full review bodies live in output sidecars and are retrieved through bounded -detail lookup by `run_id` and optional `finding_id`. Detail lookup is read-only. -It returns either run detail or one finding's detail, includes metadata about -truncation and omitted findings, and enforces a hard byte cap. - -Use the compact ledger first. Fetch details only when a listed finding or run is -relevant to the current task. Do not paste raw sidecar JSON or full review output -into ordinary context. - -## Proof Metrics And Dogfood Diagnostics - -Diagnostics are designed to prove whether Auto Review is saving work and -surfacing useful findings. The compact ledger can report recent counters without -listing bulky run details: - -- lifecycle totals: `recent_runs`, `in_flight`, `terminal`, `failed`, - `cancelled`, `lost` -- duplicate and supersession proof: `skipped`, `duplicate_skipped`, - `superseded`, `saved_estimate`, `saved_runs` -- token and prompt cost: `tokens`, `token_runs`, `prompt_estimate`, - `prompt_runs`, `high_burn` -- usefulness and freshness proof: `suppressed_stale`, plus per-run finding - counts, elapsed buckets, summary digests, and stable finding ids - -Dogfood sessions should use these signals to answer concrete questions: - -- Did duplicate review launches decrease? -- How many tokens were actually spent or plausibly avoided? -- Did terminal findings surface while still current? -- Are stale, detached, superseded, cancelled, failed, and lost runs classified - without polluting normal assistant context? -- Was latency caused by the first review pass, follow-up loops, lock/worktree - contention, retries, prompt size, model choice, or reasoning effort? - -## Extension Checklist - -- Add new review entrypoints through the durable run store. -- Record lifecycle transitions with enough target, timing, model, token, and - terminal-reason data to explain the run later. -- Use lock and epoch helpers for every review or git mutation path. -- Keep runtime separate from staleness; long-running can still be healthy. -- Preserve terminal evidence, but surface only bounded current/actionable state - by default. -- Keep assistant-visible context compact. Add diagnostics or lazy detail instead - of injecting raw review bodies. diff --git a/code-rs/core/hierarchical_agents_message.md b/code-rs/core/hierarchical_agents_message.md new file mode 100644 index 00000000000..4f782078c8e --- /dev/null +++ b/code-rs/core/hierarchical_agents_message.md @@ -0,0 +1,7 @@ +Files called AGENTS.md commonly appear in many places inside a container - at "/", in "~", deep within git repositories, or in any other directory; their location is not limited to version-controlled folders. + +Their purpose is to pass along human guidance to you, the agent. Such guidance can include coding standards, explanations of the project layout, steps for building or testing, and even wording that must accompany a GitHub pull-request description produced by the agent; all of it is to be followed. + +Each AGENTS.md governs the entire directory that contains it and every child directory beneath that point. Whenever you change a file, you have to comply with every AGENTS.md whose scope covers that file. Naming conventions, stylistic rules and similar directives are restricted to the code that falls inside that scope unless the document explicitly states otherwise. + +When two AGENTS.md files disagree, the one located deeper in the directory structure overrides the higher-level file, while instructions given directly in the prompt by the system, developer, or user outrank any AGENTS.md content. diff --git a/code-rs/core/prompt_coder.md b/code-rs/core/prompt_coder.md deleted file mode 100644 index 37c038edd54..00000000000 --- a/code-rs/core/prompt_coder.md +++ /dev/null @@ -1,94 +0,0 @@ -You are the Every Code agent. You operate in the Every Code CLI through the `code` command, and your short display name is Code. - -Every Code is a fast, community-driven coding agent focused on practical developer ergonomics: browser control, multi-agent flows, autonomous tasks, and on-the-fly reasoning control. It preserves compatibility with Codex CLI where useful, but Every Code owns its product direction, defaults, releases, and UX. - -# Changes - -This version has a few key changes and additions. In particular it is focus on providing you with more tools and has a number of feature designed to allow you to complete long term coding tasks with ease. You have much more independent control over your environment and should perform tasks without requesting human assistance. - -## Code design -Focus on producing final, maintable, production ready code every time. -- AVOID flags and feature gates. If every minor feature gets a flag, it creates a spagetti of intractable dependencies. -- AVOID retaining dead code. Old code can always be recovered from git. Retaining it at scale significant increasing the -- Do not overengineer - use the most simple, direct solution which can be maintained. Don't solve problems we don't have yet. -- Do not underengineer - cover obvious edge cases or anything likely to be a problem in production use. Find the balance. -- Always use apply_patch to edit files. - -## Testing -With your additional browser tools you can validate web UI easily. For code that generates a web interface, always test with browser tools after changes and use your visual judgment to improve UX. You should always generate aesthetically pleasing interfaces with great UX. - -## Linting -Before linting a file for the first time on a file you MUST do a dry-run first. -Only run the lint when explicitly requested be by the user OR only the code you've changed will be affected. This helps keep changes surgical. - -## Code Bridge (events from apps -> Code) -- Local Sentry-style telemetry plus two-way control: error/console streaming, pageviews/screenshots, and control commands. Install in apps via npm: `@just-every/code-bridge`. -- Host writes `.code/code-bridge.json` (url/secret/port) per workspace; Code polls it and connects as a consumer. -- Bridge clients send console/errors/screenshot/pageview/control events. -- Adjust subscriptions with the internal tool `code_bridge` (actions: subscribe | screenshot | javascript). `subscribe` sets the workspace default level (errors|warn|info|trace) and enables all capabilities; `screenshot` requests a capture; `javascript` runs provided JS on the bridge client (requires `code`). Examples: `{"action":"subscribe","level":"trace"}`, `{"action":"screenshot"}`, `{"action":"javascript","code":"window.location.href"}`. -- Defaults: errors-only until you subscribe; the subscription is persisted for the workspace. - -# Tools - -## Shell tools -You still have access to CLI tools through the shell function. Use it for any command-line work (e.g., git, builds, tests, codegen). apply_patch is one of these CLI helpers and must be invoked via shell to edit files safely and atomically. -{"command":["git","status"]} -{"command":["gh","workflow", "view", ".github/workflows/filename.yml"]} -{"command":["rg","-n","--glob","**/package.json","^\\s*\\\"(name|scripts)\\\""],"workdir":"./repo"} -{"command":["fd","-H","-I","-t","f"],"workdir":"./src","timeout":10000} -{"command":["sh","-lc","git log --since='14 days ago' --stat"]} -{"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]} - -When you run shell tools with Code they will run in the foreground for up to 10 seconds, then yield and run in the background. This stops long running tools from disrupting your workflow. You can then use wait until they complete, or continue with other work while they are running. If you have other work to complete, you should always try to complete this while the tool is running. You will receive a message when the tool completes in the background. The output of your commands is not shown to the user. - -## Browser tools -Use the browser tools to open a live page, interact with it, and harvest results. When the browser is open, screenshots are auto-attached to your subsequent messages. The browser will either be an internal headless browser, or a CPD connection to the user's active Chrome browser. Your screenshots will be 1024×768 which exactly matches the viewport. - -## Code Bridge -A local Sentry-like bridge for development environments: add `@just-every/code-bridge` to your JavaScript app to stream errors/console, pageviews/screenshots, and expose a control channel for two-way, real-time debugging. The `code_bridge` tool supports: `{"action":"subscribe","level":"trace|info|warn|errors"}` (persists workspace defaults and always requests full capabilities), `{"action":"screenshot"}` to ask connected bridges for a screenshot, and `{"action":"javascript","code":""}` to execute JS on the bridge and return the result. - -## Web tools -Use `web.run` when you need multi-step browsing—search, opens, clicks, screenshots, or specialized lookups. Use `browser {"action":"fetch","url":"https://example.com"}` when you already know the URL and just need its Markdown content in a single fetch. - -## Agent tools -Your agents are like having a team of expert peers at your disposal at any time. Use them for non-trivial work. -Example; -agent { - "action": "create", - "create": { - "name": "jwt-middleware", - "task": "Implement JWT middleware (RS256) with key rotation and unit/integration tests. Preserve existing OAuth flows. Provide README usage snippet.", - "context": "Service: services/api (Rust Axum). Secrets via env. CI: `cargo test --all`.", - "files": ["services/api", "services/api/src", "services/api/Cargo.toml"], - "context_files": [".code/context/large-context-bundle.txt"], // Optional: inline workspace text file contents into the subagent's initial prompt; use only when the extra context is worth the cost. - "context_budget_tokens": 700000, // Required for very large context_files; defaults conservatively. - "models": ["code-gpt-5.4","claude-sonnet-4.6","antigravity"], // Agent/model selector slugs; include diverse families for multi-agent, release/workflow, infrastructure, security, or product-risk work when useful. - "output": "Middleware + passing tests + README snippet", - "write": true // Allow changes - will launch every agent in a separate worktree - } -} -agent {"action":"wait","wait":{"batch_id":"","return_all":true,"timeout_seconds":600}} // Long timeout or you can do separate work and check back later. - -Use `files` for lightweight path hints. Use `context_files` only when the subagent must receive selected text file contents in its initial prompt; pair large `context_files` with an explicit `context_budget_tokens` value so the launch cost is deliberate. - -## Agent/Model Selector Guide for `agent.create.models` -{MODEL_DESCRIPTIONS} - -# WARNING (using git) -- You have permission to use `git` as needed. `gh` may also be installed. -- Prefer merge over rebase by default; avoid rebases as a first resort. If a rebase is explicitly required by maintainers, confirm first and proceed carefully; otherwise stick to pull/merge to prevent history churn and conflicts. -- NEVER use `git revert` or `git checkout` unless you are sure it will not overwrite any unrelated changes. Multiple changes may have been made to the code and you can not be sure that you will revert only your changes. -- Only perform `git push` when you are asked to. - -# Final output -You can include FULL markdown in any responses you make. These will be converted to beautiful output in the terminal. -Markdown tables, quotes, callouts, task lists, strikethrough, fenced code blocks and inline code are also all supported. -Use ASCII graphics to illustrate responses whenever it would make your explaination clearer - particularly when diagrams, flowcharts or humour is needed! -When you suggest next steps; -1. Focus on the steps YOU can perform, not ones the user would perform. -2. Only number next steps if there is more than one. - -# Conclusion -- Work autonomously as long as possible. -- Split out tasks using agents to optimise token usage. -- Compelete tasks on behalf of the user whenever possible. Do not as the user to perform a task you can find a way to do, even if your way is a less efficient. diff --git a/code-rs/core/prompt_coordinator.md b/code-rs/core/prompt_coordinator.md deleted file mode 100644 index 386f4cfe1a1..00000000000 --- a/code-rs/core/prompt_coordinator.md +++ /dev/null @@ -1,54 +0,0 @@ -You have a special role within Code. You are the Auto Drive Coordinator — the mission lead orchestrating this coding session. - -You direct the Code CLI (role: user) and an optional fleet of helper agents. You never run tools, write code, or implement changes yourself. You only output a single JSON object matching the coordinator schema each turn. - -# North Star -- **CLI Autonomy**: The CLI is a highly autonomous senior agent that persists until tasks are resolved end-to-end. Let it handle its own multi-step workflows. Delegate whole milestones to it. -- **Strategic Swarming**: You decide *who* does the work. Use the CLI for stateful, sequential coding and running local tools. Use agents for parallel research, gathering diverse opinions, or offloading well-defined isolated coding tasks. -- **Absolute Completion**: "Done" requires hard evidence. Never finish a task based on code just "looking complete." Prove it works with verified tests and edge-case handling. - -# Mission Lead Responsibilities -- **Set outcomes**: Define the next milestone and what "done" means for it. -- **Delegate execution**: Hand off entire phases of work. The CLI handles the step-by-step tactics via its internal `update_plan` tool. -- **Maintain cadence**: Phase progression typically goes Explore -> Implement -> Validate -> Harden. -- **Manage risk**: Proactively command agents or the CLI to hunt for regressions, test edge cases, and ensure production readiness. Do not leave work for the user - if a fixable risk exists, fix it before finishing. - -# The Single Most Important Rule: Milestones, Not Micromanagement -You must provide ONE MILESTONE per turn to the CLI, not one tiny step. A milestone is a coherent outcome (e.g., "investigate + patch + validate"). - -In `cli_milestone_instruction`, **DO** provide: -- The milestone outcome. -- Constraints / scope boundaries. -- Definition of done (what validation must be run). -- A stop condition ("Iterate until tests pass, only ask me if irrecoverably blocked"). - -**Do NOT** provide: -- Step-by-step shell commands (e.g., "Run npm test"). -- File-by-file directions or exact line numbers. -- Requests to show you file contents or diffs (you cannot read them directly, let the CLI evaluate them). - -# Agent Policy (When to Swarm vs. When to use the CLI) -Agents work in isolated, parallel worktrees. Use them strategically based on their strengths. -- **Broad Research & Planning:** Spawn multiple agents with diverse models to evaluate different architectural approaches, search for root causes of a complex bug, or draft competing implementation plans. -- **Parallel Coding:** Offload straightforward, well-scoped coding tasks to fast, efficient models (like `-spark` or `-flash` model). Launch them in parallel on different tasks to implement distinct components simultaneously while the main CLI focuses on integration. -- **No Highly Dependent Chains:** Don't use agents if the task requires step-by-step stateful changes where each step depends on the previous one. The CLI's native loop is better for stateful persistence. - -# CLI Model Routing -When schema fields are available, pick `cli_model` and `cli_reasoning_effort` on every continue turn. -- Use the configured routing entries from the environment guidance, including each model's allowed reasoning levels. -- Prefer higher reasoning levels for hard planning/problem-solving turns. -- Prefer faster routing entries for clear implementation loops and failing-test iteration. -- Only set these fields to `null` when finishing. - -# Completion Gate -Code completion is not task completion. Never set `finish_status` to `"finish_success"` unless you can explicitly populate the `finish_evidence` object with proof that: -1. The primary task is fully resolved end-to-end. -2. Relevant validation is green (tests, builds, linting run by the CLI). -3. Obvious edge cases were tested and handled. - -**Do not leave unresolved risks, missing tests, or "todos" for the user.** If you identify a gap, you MUST stay in `"continue"` and issue a **"Ship Sweep"** milestone to the CLI to fix it. Only output `"finish_success"` when the solution is rock solid. - -# Good Milestone Prompts -- ✅ "Take the failing auth flow from red to green; patch minimally, validate with the strongest available checks, and report evidence." -- ✅ "Harden the feature for production: add missing tests, run validation, and verify edge cases." -- ✅ "Execute the architectural plan. I've spawned 3 agents to handle the modular components in parallel; CLI, please coordinate merging their work and running the integration tests." diff --git a/code-rs/core/src/account_switching.rs b/code-rs/core/src/account_switching.rs deleted file mode 100644 index ea28f0579be..00000000000 --- a/code-rs/core/src/account_switching.rs +++ /dev/null @@ -1,1074 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::io; -use std::path::Path; - -use chrono::{DateTime, Utc}; -use code_app_server_protocol::AuthMode; - -use crate::auth; -use crate::account_usage; -use crate::auth_accounts; -use crate::protocol::RateLimitReachedType; - -#[derive(Debug, Default)] -pub struct RateLimitSwitchState { - tried_accounts: HashSet, - limited_chatgpt_accounts: HashSet, - blocked_until: HashMap>, -} - -impl RateLimitSwitchState { - pub(crate) fn mark_limited( - &mut self, - account_id: &str, - mode: AuthMode, - blocked_until: Option>, - ) { - self.tried_accounts.insert(account_id.to_string()); - - if mode.is_chatgpt() { - self.limited_chatgpt_accounts - .insert(account_id.to_string()); - } - - if let Some(until) = blocked_until { - self.blocked_until - .entry(account_id.to_string()) - .and_modify(|existing| { - if until > *existing { - *existing = until; - } - }) - .or_insert(until); - } else { - self.blocked_until.remove(account_id); - } - } - - fn blocked_until(&self, account_id: &str) -> Option> { - self.blocked_until.get(account_id).copied() - } - - fn has_tried(&self, account_id: &str) -> bool { - self.tried_accounts.contains(account_id) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct CandidateScore { - reset_at: Option>, - used_percent: f64, -} - -fn account_has_credentials(account: &auth_accounts::StoredAccount) -> bool { - match account.mode { - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => account.tokens.is_some(), - AuthMode::ApiKey => account.openai_api_key.is_some(), - } -} - -fn usage_reset_blocked_until( - snapshot: &account_usage::StoredRateLimitSnapshot, -) -> Option> { - let reached = snapshot - .snapshot - .as_ref() - .and_then(|snapshot| snapshot.rate_limit_reached_type) - .is_some_and(is_usage_limit_reached_type); - let (primary_exhausted, secondary_exhausted) = snapshot - .snapshot - .as_ref() - .map(|snapshot| { - ( - snapshot.primary_used_percent >= 100.0, - snapshot.secondary_used_percent >= 100.0, - ) - }) - .unwrap_or_default(); - - let hinted_limit = snapshot.last_usage_limit_hit_at.is_some(); - - if reached || primary_exhausted || secondary_exhausted || hinted_limit { - let primary_reset = (reached || primary_exhausted || hinted_limit) - .then_some(snapshot.primary_next_reset_at) - .flatten(); - let secondary_reset = (reached || secondary_exhausted || hinted_limit) - .then_some(snapshot.secondary_next_reset_at) - .flatten(); - return primary_reset - .into_iter() - .chain(secondary_reset) - .max() - .or(snapshot.last_usage_limit_hit_at); - } - - None -} - -fn is_usage_limit_reached_type(reached: RateLimitReachedType) -> bool { - matches!( - reached, - RateLimitReachedType::RateLimitReached - | RateLimitReachedType::WorkspaceOwnerCreditsDepleted - | RateLimitReachedType::WorkspaceMemberCreditsDepleted - | RateLimitReachedType::WorkspaceOwnerUsageLimitReached - | RateLimitReachedType::WorkspaceMemberUsageLimitReached - ) -} - -fn usage_used_percent(snapshot: &account_usage::StoredRateLimitSnapshot) -> Option { - let snapshot = snapshot.snapshot.as_ref()?; - let used = snapshot - .primary_used_percent - .max(snapshot.secondary_used_percent); - if used.is_finite() { - Some(used) - } else { - None - } -} - -fn usage_preferred_reset_at( - snapshot: &account_usage::StoredRateLimitSnapshot, - now: DateTime, -) -> Option> { - let secondary_reset = snapshot.secondary_next_reset_at.filter(|reset_at| *reset_at > now); - let primary_reset = snapshot.primary_next_reset_at.filter(|reset_at| *reset_at > now); - secondary_reset.or(primary_reset) -} - -fn candidate_score( - snapshot_map: &HashMap, - account_id: &str, - now: DateTime, -) -> CandidateScore { - let snapshot = snapshot_map.get(account_id); - CandidateScore { - reset_at: snapshot.and_then(|snapshot| usage_preferred_reset_at(snapshot, now)), - used_percent: snapshot.and_then(usage_used_percent).unwrap_or(0.0), - } -} - -fn score_is_better(score: CandidateScore, best_score: CandidateScore) -> bool { - match (score.reset_at, best_score.reset_at) { - (Some(reset_at), Some(best_reset_at)) if reset_at != best_reset_at => { - reset_at < best_reset_at - } - (Some(_), None) => true, - (None, Some(_)) => false, - _ => score.used_percent < best_score.used_percent, - } -} - -fn is_blocked(now: DateTime, blocked_until: Option>) -> bool { - blocked_until.is_some_and(|until| until > now) -} - -fn has_unexpired_tried_marker( - state: &RateLimitSwitchState, - account_id: &str, - now: DateTime, -) -> bool { - state.has_tried(account_id) - && !state - .blocked_until(account_id) - .is_some_and(|blocked_until| blocked_until <= now) -} - -pub(crate) fn select_next_account_id( - code_home: &Path, - state: &RateLimitSwitchState, - allow_api_key_fallback: bool, - now: DateTime, - current_account_id: Option<&str>, -) -> io::Result> { - let current = match current_account_id { - Some(id) => Some(id.to_string()), - None => auth_accounts::get_active_account_id(code_home)?, - }; - let accounts = auth_accounts::list_accounts(code_home)?; - - let snapshots = account_usage::list_rate_limit_snapshots(code_home).unwrap_or_default(); - let snapshot_map: HashMap = snapshots - .into_iter() - .map(|snap| (snap.account_id.clone(), snap)) - .collect(); - - let mut chatgpt_accounts: Vec<&auth_accounts::StoredAccount> = accounts - .iter() - .filter(|acc| acc.mode.is_chatgpt()) - .filter(|acc| account_has_credentials(acc)) - .collect(); - let mut api_key_accounts: Vec<&auth_accounts::StoredAccount> = accounts - .iter() - .filter(|acc| acc.mode == AuthMode::ApiKey) - .filter(|acc| account_has_credentials(acc)) - .collect(); - - // Prefer deterministic ordering. - chatgpt_accounts.sort_by(|a, b| a.id.cmp(&b.id)); - api_key_accounts.sort_by(|a, b| a.id.cmp(&b.id)); - - let current = current.as_deref(); - - let mut best_chatgpt: Option<(&auth_accounts::StoredAccount, CandidateScore)> = None; - for account in &chatgpt_accounts { - if current.is_some_and(|id| id == account.id) { - continue; - } - if has_unexpired_tried_marker(state, &account.id, now) { - continue; - } - - let blocked_until = state - .blocked_until(&account.id) - .into_iter() - .chain(snapshot_map.get(&account.id).and_then(usage_reset_blocked_until)) - .max(); - if is_blocked(now, blocked_until) { - continue; - } - - let score = candidate_score(&snapshot_map, &account.id, now); - match best_chatgpt { - None => best_chatgpt = Some((*account, score)), - Some((_, best_score)) => { - if score_is_better(score, best_score) { - best_chatgpt = Some((*account, score)); - } - } - } - } - - if let Some((account, _)) = best_chatgpt { - return Ok(Some(account.id.clone())); - } - - if !allow_api_key_fallback { - return Ok(None); - } - - // Only allow API key fallback when every ChatGPT account is either blocked - // or has already been tried and still rate/usage limited. - let all_chatgpt_unavailable = chatgpt_accounts.iter().all(|account| { - let blocked_until = state - .blocked_until(&account.id) - .into_iter() - .chain(snapshot_map.get(&account.id).and_then(usage_reset_blocked_until)) - .max(); - let blocked = is_blocked(now, blocked_until); - let expired_tried_block = state - .blocked_until(&account.id) - .is_some_and(|blocked_until| blocked_until <= now); - let exhausted = state.limited_chatgpt_accounts.contains(&account.id) && !expired_tried_block; - let tried = state.has_tried(&account.id); - blocked || (tried && exhausted) - }); - - if !chatgpt_accounts.is_empty() && !all_chatgpt_unavailable { - return Ok(None); - } - - for account in &api_key_accounts { - if current.is_some_and(|id| id == account.id) { - continue; - } - if state.has_tried(&account.id) { - continue; - } - return Ok(Some(account.id.clone())); - } - - Ok(None) -} - -pub fn switch_active_account_to_preferred_for_new_session( - code_home: &Path, - now: DateTime, -) -> io::Result> { - let current_account_id = auth_accounts::get_active_account_id(code_home)?; - let accounts = auth_accounts::list_accounts(code_home)?; - - if let Some(current_account_id) = current_account_id.as_deref() - && let Some(current) = accounts.iter().find(|account| account.id == current_account_id) - && !current.mode.is_chatgpt() - { - return Ok(None); - } - - let snapshots = account_usage::list_rate_limit_snapshots(code_home).unwrap_or_default(); - let snapshot_map: HashMap = snapshots - .into_iter() - .map(|snap| (snap.account_id.clone(), snap)) - .collect(); - - let mut best_chatgpt: Option<(&auth_accounts::StoredAccount, CandidateScore)> = None; - let mut chatgpt_accounts: Vec<&auth_accounts::StoredAccount> = accounts - .iter() - .filter(|acc| acc.mode.is_chatgpt()) - .filter(|acc| account_has_credentials(acc)) - .collect(); - chatgpt_accounts.sort_by(|a, b| a.id.cmp(&b.id)); - - for account in chatgpt_accounts { - let blocked_until = snapshot_map.get(&account.id).and_then(usage_reset_blocked_until); - if is_blocked(now, blocked_until) { - continue; - } - - let score = candidate_score(&snapshot_map, &account.id, now); - match best_chatgpt { - None => best_chatgpt = Some((account, score)), - Some((_, best_score)) => { - if score_is_better(score, best_score) { - best_chatgpt = Some((account, score)); - } - } - } - } - - let Some((account, _)) = best_chatgpt else { - return Ok(None); - }; - - if current_account_id.as_deref() == Some(account.id.as_str()) { - return Ok(None); - } - - auth::activate_account(code_home, &account.id)?; - Ok(Some(account.id.clone())) -} - -pub fn switch_active_account_on_rate_limit( - code_home: &Path, - state: &mut RateLimitSwitchState, - allow_api_key_fallback: bool, - now: DateTime, - current_account_id: &str, - current_mode: AuthMode, - blocked_until: Option>, -) -> io::Result> { - state.mark_limited(current_account_id, current_mode, blocked_until); - - let next_account_id = select_next_account_id( - code_home, - state, - allow_api_key_fallback, - now, - Some(current_account_id), - )?; - - if let Some(next_account_id) = next_account_id.as_deref() { - auth::activate_account(code_home, next_account_id)?; - } - - Ok(next_account_id) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token_data::{IdTokenInfo, TokenData}; - use base64::Engine; - use chrono::TimeZone; - use serde::Serialize; - use tempfile::tempdir; - - fn fake_jwt(email: &str, plan: &str) -> String { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": email, - "https://api.openai.com/auth": { - "chatgpt_plan_type": plan, - } - }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).expect("header")); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).expect("payload")); - let signature_b64 = b64url_no_pad(b"sig"); - format!("{header_b64}.{payload_b64}.{signature_b64}") - } - - fn chatgpt_tokens(account_id: &str, email: &str) -> TokenData { - TokenData { - id_token: IdTokenInfo { - email: Some(email.to_string()), - chatgpt_plan_type: None, - chatgpt_account_is_fedramp: false, - raw_jwt: fake_jwt(email, "pro"), - }, - access_token: "access".to_string(), - refresh_token: "refresh".to_string(), - account_id: Some(account_id.to_string()), - } - } - - fn fixed_now() -> DateTime { - Utc.with_ymd_and_hms(2025, 12, 22, 12, 0, 0).unwrap() - } - - fn sample_snapshot(used_percent: f64) -> crate::protocol::RateLimitSnapshotEvent { - crate::protocol::RateLimitSnapshotEvent { - primary_used_percent: used_percent, - secondary_used_percent: used_percent, - primary_to_secondary_ratio_percent: 25.0, - primary_window_minutes: 300, - secondary_window_minutes: 10_080, - primary_reset_after_seconds: Some(600), - secondary_reset_after_seconds: Some(3_600), - rate_limit_reached_type: None, - } - } - - #[test] - fn selects_another_chatgpt_account_when_available() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - let next = select_next_account_id( - home.path(), - &state, - false, - fixed_now(), - Some(a.id.as_str()), - ) - .expect("select"); - assert_eq!(next.as_deref(), Some(b.id.as_str())); - } - - #[test] - fn skips_chatgpt_accounts_blocked_by_reset_time() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - - let now = fixed_now(); - let reset_in = Some(60 * 60); - account_usage::record_usage_limit_hint(home.path(), &b.id, Some("Pro"), reset_in, now) - .expect("hint"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - let next = - select_next_account_id(home.path(), &state, false, now, Some(a.id.as_str())) - .expect("select"); - assert!(next.is_none()); - } - - #[test] - fn skips_chatgpt_accounts_blocked_by_hint_without_reset() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - - let now = fixed_now() + chrono::Duration::hours(1); - account_usage::record_usage_limit_hint( - home.path(), - &b.id, - Some("Pro"), - None, - now + chrono::Duration::hours(1), - ) - .expect("hint"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - let next = - select_next_account_id(home.path(), &state, false, now, Some(a.id.as_str())) - .expect("select"); - assert!(next.is_none()); - } - - #[test] - fn typed_usage_limit_snapshot_blocks_only_that_account() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - let c = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-c", "c@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert c"); - - let now = fixed_now(); - let mut limited_snapshot = sample_snapshot(95.0); - limited_snapshot.rate_limit_reached_type = Some( - RateLimitReachedType::WorkspaceMemberUsageLimitReached, - ); - account_usage::record_rate_limit_snapshot( - home.path(), - &b.id, - Some("Pro"), - &limited_snapshot, - now, - ) - .expect("limited snapshot"); - let mut available_snapshot = sample_snapshot(20.0); - available_snapshot.primary_reset_after_seconds = None; - available_snapshot.secondary_reset_after_seconds = None; - account_usage::record_rate_limit_snapshot( - home.path(), - &c.id, - Some("Pro"), - &available_snapshot, - now, - ) - .expect("available snapshot"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - let next = select_next_account_id( - home.path(), - &state, - false, - now, - Some(a.id.as_str()), - ) - .expect("select"); - assert_eq!(next.as_deref(), Some(c.id.as_str())); - } - - #[test] - fn rate_limit_switch_prefers_candidate_with_earliest_weekly_reset() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - let c = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-c", "c@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert c"); - - let now = fixed_now(); - let mut later_reset = sample_snapshot(5.0); - later_reset.secondary_reset_after_seconds = Some(7_200); - account_usage::record_rate_limit_snapshot( - home.path(), - &b.id, - Some("Pro"), - &later_reset, - now, - ) - .expect("later snapshot"); - let mut earlier_reset = sample_snapshot(50.0); - earlier_reset.secondary_reset_after_seconds = Some(3_600); - account_usage::record_rate_limit_snapshot( - home.path(), - &c.id, - Some("Pro"), - &earlier_reset, - now, - ) - .expect("earlier snapshot"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - let next = select_next_account_id( - home.path(), - &state, - false, - now, - Some(a.id.as_str()), - ) - .expect("select"); - assert_eq!(next.as_deref(), Some(c.id.as_str())); - } - - #[test] - fn new_session_switches_to_earliest_weekly_reset_account() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - - let now = fixed_now(); - let mut active_snapshot = sample_snapshot(10.0); - active_snapshot.secondary_reset_after_seconds = Some(7_200); - account_usage::record_rate_limit_snapshot( - home.path(), - &a.id, - Some("Pro"), - &active_snapshot, - now, - ) - .expect("active snapshot"); - let mut preferred_snapshot = sample_snapshot(80.0); - preferred_snapshot.secondary_reset_after_seconds = Some(3_600); - account_usage::record_rate_limit_snapshot( - home.path(), - &b.id, - Some("Pro"), - &preferred_snapshot, - now, - ) - .expect("preferred snapshot"); - - let switched = switch_active_account_to_preferred_for_new_session(home.path(), now) - .expect("switch"); - assert_eq!(switched.as_deref(), Some(b.id.as_str())); - - let active = auth_accounts::get_active_account_id(home.path()) - .expect("active account") - .expect("active account id"); - assert_eq!(active, b.id); - } - - #[test] - fn new_session_does_not_switch_from_api_key_account() { - let home = tempdir().expect("tmp"); - let api = auth_accounts::upsert_api_key_account( - home.path(), - "sk-test".to_string(), - None, - true, - ) - .expect("insert api"); - let chatgpt = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-chat", "chat@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert chatgpt"); - - let now = fixed_now(); - account_usage::record_rate_limit_snapshot( - home.path(), - &chatgpt.id, - Some("Pro"), - &sample_snapshot(20.0), - now, - ) - .expect("snapshot"); - - let switched = switch_active_account_to_preferred_for_new_session(home.path(), now) - .expect("switch"); - assert!(switched.is_none()); - - let active = auth_accounts::get_active_account_id(home.path()) - .expect("active account") - .expect("active account id"); - assert_eq!(active, api.id); - } - - #[test] - fn temporary_block_expires_and_allows_account_again() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - - let now = fixed_now(); - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, Some(now + chrono::Duration::hours(1))); - - let next = select_next_account_id( - home.path(), - &state, - false, - now, - Some(b.id.as_str()), - ) - .expect("select while blocked"); - assert!(next.is_none()); - - let next = select_next_account_id( - home.path(), - &state, - false, - now + chrono::Duration::hours(2), - Some(b.id.as_str()), - ) - .expect("select after reset"); - assert_eq!(next.as_deref(), Some(a.id.as_str())); - } - - #[test] - fn preferred_reset_uses_primary_when_secondary_is_stale() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - let c = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-c", "c@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert c"); - - let now = fixed_now(); - let mut stale_secondary = sample_snapshot(50.0); - stale_secondary.primary_reset_after_seconds = Some(1_800); - stale_secondary.secondary_reset_after_seconds = Some(1_800); - account_usage::record_rate_limit_snapshot( - home.path(), - &b.id, - Some("Pro"), - &stale_secondary, - now - chrono::Duration::hours(1), - ) - .expect("stale secondary snapshot"); - let mut later_reset = sample_snapshot(5.0); - later_reset.secondary_reset_after_seconds = Some(7_200); - account_usage::record_rate_limit_snapshot( - home.path(), - &c.id, - Some("Pro"), - &later_reset, - now, - ) - .expect("later snapshot"); - - account_usage::record_rate_limit_snapshot( - home.path(), - &b.id, - Some("Pro"), - &crate::protocol::RateLimitSnapshotEvent { - primary_reset_after_seconds: Some(1_800), - secondary_reset_after_seconds: Some(1), - ..sample_snapshot(50.0) - }, - now - chrono::Duration::seconds(2), - ) - .expect("mixed reset snapshot"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - let next = select_next_account_id( - home.path(), - &state, - false, - now, - Some(a.id.as_str()), - ) - .expect("select"); - assert_eq!(next.as_deref(), Some(b.id.as_str())); - } - - #[test] - fn new_limit_without_reset_consumes_expired_temporary_block() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - let c = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-c", "c@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert c"); - - let now = fixed_now(); - let mut preferred_snapshot = sample_snapshot(30.0); - preferred_snapshot.secondary_reset_after_seconds = Some(1_800); - account_usage::record_rate_limit_snapshot( - home.path(), - &a.id, - Some("Pro"), - &preferred_snapshot, - now + chrono::Duration::hours(2), - ) - .expect("preferred snapshot"); - let mut later_snapshot = sample_snapshot(10.0); - later_snapshot.secondary_reset_after_seconds = Some(7_200); - account_usage::record_rate_limit_snapshot( - home.path(), - &c.id, - Some("Pro"), - &later_snapshot, - now + chrono::Duration::hours(2), - ) - .expect("later snapshot"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, Some(now + chrono::Duration::hours(1))); - - let next = select_next_account_id( - home.path(), - &state, - false, - now + chrono::Duration::hours(2), - Some(b.id.as_str()), - ) - .expect("select after reset"); - assert_eq!(next.as_deref(), Some(a.id.as_str())); - - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - let next = select_next_account_id( - home.path(), - &state, - false, - now + chrono::Duration::hours(2), - Some(b.id.as_str()), - ) - .expect("select after second failure"); - assert_eq!(next.as_deref(), Some(c.id.as_str())); - } - - #[test] - fn api_key_fallback_requires_all_chatgpt_limited() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - let api = auth_accounts::upsert_api_key_account( - home.path(), - "sk-test".to_string(), - None, - false, - ) - .expect("insert api"); - - let now = fixed_now(); - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&a.id, AuthMode::ChatGPT, None); - - let next = select_next_account_id(home.path(), &state, true, now, Some(a.id.as_str())) - .expect("select"); - assert_eq!(next.as_deref(), Some(b.id.as_str())); - - // After both ChatGPT accounts are exhausted, allow API key fallback. - state.mark_limited(&b.id, AuthMode::ChatGPT, None); - let next = select_next_account_id(home.path(), &state, true, now, Some(b.id.as_str())) - .expect("select"); - assert_eq!(next.as_deref(), Some(api.id.as_str())); - } - - #[test] - fn prefers_current_account_override_over_active_account() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - - let mut state = RateLimitSwitchState::default(); - state.mark_limited(&b.id, AuthMode::ChatGPT, None); - - let next = select_next_account_id( - home.path(), - &state, - false, - fixed_now(), - Some(b.id.as_str()), - ) - .expect("select"); - - assert_eq!(next.as_deref(), Some(a.id.as_str())); - } - - #[test] - fn switches_active_account_on_usage_limit() { - let home = tempdir().expect("tmp"); - let a = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-a", "a@example.com"), - Utc::now(), - None, - true, - ) - .expect("insert a"); - let b = auth_accounts::upsert_chatgpt_account( - home.path(), - chatgpt_tokens("acct-b", "b@example.com"), - Utc::now(), - None, - false, - ) - .expect("insert b"); - - let mut state = RateLimitSwitchState::default(); - let now = fixed_now(); - let next = switch_active_account_on_rate_limit( - home.path(), - &mut state, - false, - now, - a.id.as_str(), - AuthMode::ChatGPT, - None, - ) - .expect("switch"); - - assert_eq!(next.as_deref(), Some(b.id.as_str())); - - let active = auth_accounts::get_active_account_id(home.path()) - .expect("active account") - .expect("active account id"); - assert_eq!(active, b.id); - } -} diff --git a/code-rs/core/src/account_usage.rs b/code-rs/core/src/account_usage.rs deleted file mode 100644 index 4125a1b13fc..00000000000 --- a/code-rs/core/src/account_usage.rs +++ /dev/null @@ -1,1260 +0,0 @@ -use std::collections::BTreeMap; -use std::fs::{self, OpenOptions}; -use std::io::{ErrorKind, Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; - -use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc}; -use crate::protocol::{RateLimitReachedType, RateLimitSnapshotEvent}; -use fs2::FileExt; -use serde::{Deserialize, Serialize}; -use serde_json; - -use crate::protocol::TokenUsage; - -const USAGE_VERSION: u32 = 1; -const USAGE_SUBDIR: &str = "usage"; -const HOURLY_HISTORY_DAYS: i64 = 183; // retain ~6 months of hourly usage for history views -const UNKNOWN_RESET_RELOG_INTERVAL: Duration = Duration::hours(24); -const RESET_PASSED_TOLERANCE: Duration = Duration::seconds(5); -const RATE_LIMIT_REFRESH_STALE_INTERVAL_SECS: i64 = 30 * 60; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum RateLimitWarningScope { - Primary, - Secondary, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct RateLimitWarningRecord { - threshold: f64, - #[serde(default)] - reset_at: Option>, - #[serde(default)] - logged_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TokenTotals { - #[serde(default)] - pub input_tokens: u64, - #[serde(default)] - pub cached_input_tokens: u64, - #[serde(default)] - pub output_tokens: u64, - #[serde(default)] - pub reasoning_output_tokens: u64, - #[serde(default)] - pub total_tokens: u64, -} - -impl TokenTotals { - fn add_usage(&mut self, usage: &TokenUsage) { - self.input_tokens = self.input_tokens.saturating_add(usage.input_tokens); - self.cached_input_tokens = self - .cached_input_tokens - .saturating_add(usage.cached_input_tokens); - self.output_tokens = self.output_tokens.saturating_add(usage.output_tokens); - self.reasoning_output_tokens = self - .reasoning_output_tokens - .saturating_add(usage.reasoning_output_tokens); - self.total_tokens = self.total_tokens.saturating_add(usage.total_tokens); - } - - fn add_totals(&mut self, other: &TokenTotals) { - self.input_tokens = self.input_tokens.saturating_add(other.input_tokens); - self.cached_input_tokens = self - .cached_input_tokens - .saturating_add(other.cached_input_tokens); - self.output_tokens = self.output_tokens.saturating_add(other.output_tokens); - self.reasoning_output_tokens = self - .reasoning_output_tokens - .saturating_add(other.reasoning_output_tokens); - self.total_tokens = self.total_tokens.saturating_add(other.total_tokens); - } - - fn from_usage(usage: &TokenUsage) -> Self { - let mut totals = TokenTotals::default(); - totals.add_usage(usage); - totals - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct TokenWindowEntry { - timestamp: DateTime, - tokens: TokenTotals, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct AggregatedUsageEntry { - period_start: DateTime, - tokens: TokenTotals, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct RateLimitInfo { - #[serde(default)] - snapshot: Option, - #[serde(default)] - observed_at: Option>, - #[serde(default, alias = "next_reset_at")] - primary_next_reset_at: Option>, - #[serde(default)] - secondary_next_reset_at: Option>, - #[serde(default)] - last_refresh_attempt_at: Option>, - #[serde(default)] - last_usage_limit_hit_at: Option>, - #[serde(default)] - primary_threshold_logs: Vec, - #[serde(default)] - secondary_threshold_logs: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct AccountUsageData { - version: u32, - account_id: String, - #[serde(default)] - plan: Option, - #[serde(default)] - last_updated: DateTime, - #[serde(default)] - totals: TokenTotals, - #[serde(default)] - hourly_entries: Vec, - #[serde(default)] - hourly_buckets: Vec, - #[serde(default)] - daily_buckets: Vec, - #[serde(default)] - monthly_buckets: Vec, - #[serde(default)] - tokens_last_hour: TokenTotals, - #[serde(default)] - rate_limit: Option, -} - -impl AccountUsageData { - fn new(account_id: String) -> Self { - Self { - version: USAGE_VERSION, - account_id, - plan: None, - last_updated: Utc::now(), - totals: TokenTotals::default(), - hourly_entries: Vec::new(), - hourly_buckets: Vec::new(), - daily_buckets: Vec::new(), - monthly_buckets: Vec::new(), - tokens_last_hour: TokenTotals::default(), - rate_limit: None, - } - } - - fn apply_plan(&mut self, plan: Option<&str>) { - if let Some(plan) = plan { - if self.plan.as_deref() != Some(plan) { - self.plan = Some(plan.to_string()); - } - } - } - - fn update_last_hour(&mut self, now: DateTime) { - self.compact_usage(now); - let hourly_cutoff = now - Duration::hours(1); - let history_cutoff = now - Duration::days(HOURLY_HISTORY_DAYS); - self.hourly_entries - .retain(|entry| entry.timestamp >= history_cutoff); - - let mut totals = TokenTotals::default(); - for entry in &self.hourly_entries { - if entry.timestamp < hourly_cutoff { - continue; - } - totals.add_totals(&entry.tokens); - } - self.tokens_last_hour = totals; - } - - fn compact_usage(&mut self, now: DateTime) { - let recent_cutoff = now - Duration::hours(1); - let mut rollover: BTreeMap, TokenTotals> = BTreeMap::new(); - let mut recent: Vec = Vec::new(); - - for entry in self.hourly_entries.drain(..) { - if entry.timestamp >= recent_cutoff { - recent.push(entry); - } else { - let bucket = truncate_to_hour(entry.timestamp); - rollover - .entry(bucket) - .or_insert_with(TokenTotals::default) - .add_totals(&entry.tokens); - } - } - - self.hourly_entries = recent; - - for (period_start, tokens) in rollover { - let day_start = truncate_to_day(period_start); - add_to_bucket(&mut self.daily_buckets, day_start, tokens.clone()); - add_to_bucket(&mut self.hourly_buckets, period_start, tokens); - } - - self.compact_hourly_buckets(now); - self.compact_daily_buckets(now); - } - - fn compact_hourly_buckets(&mut self, now: DateTime) { - if self.hourly_buckets.is_empty() { - return; - } - - let current_hour = truncate_to_hour(now); - let cutoff = current_hour - Duration::hours(24); - let mut remaining: Vec = Vec::new(); - - for entry in self.hourly_buckets.drain(..) { - if entry.period_start >= cutoff { - remaining.push(entry); - } - } - - remaining.sort_by_key(|item| item.period_start); - self.hourly_buckets = remaining; - } - - fn compact_daily_buckets(&mut self, now: DateTime) { - if self.daily_buckets.is_empty() { - return; - } - - let today = truncate_to_day(now); - let cutoff = today - Duration::days(30); - let mut remaining: Vec = Vec::new(); - let mut monthly_rollover: BTreeMap, TokenTotals> = BTreeMap::new(); - - for entry in self.daily_buckets.drain(..) { - if entry.period_start < cutoff { - let month_key = truncate_to_month(entry.period_start); - monthly_rollover - .entry(month_key) - .or_insert_with(TokenTotals::default) - .add_totals(&entry.tokens); - } else { - remaining.push(entry); - } - } - - remaining.sort_by_key(|item| item.period_start); - self.daily_buckets = remaining; - - for (period_start, tokens) in monthly_rollover { - add_to_bucket(&mut self.monthly_buckets, period_start, tokens); - } - } -} - -fn add_to_bucket( - buckets: &mut Vec, - period_start: DateTime, - tokens: TokenTotals, -) { - match buckets.binary_search_by(|entry| entry.period_start.cmp(&period_start)) { - Ok(idx) => buckets[idx].tokens.add_totals(&tokens), - Err(idx) => { - buckets.insert( - idx, - AggregatedUsageEntry { - period_start, - tokens, - }, - ); - } - } -} - -fn truncate_to_hour(ts: DateTime) -> DateTime { - let naive = ts.naive_utc(); - let trimmed = naive - .with_minute(0) - .and_then(|dt| dt.with_second(0)) - .and_then(|dt| dt.with_nanosecond(0)) - .expect("valid hour truncation"); - Utc.from_utc_datetime(&trimmed) -} - -fn truncate_to_day(ts: DateTime) -> DateTime { - let date = ts.date_naive(); - let start = date.and_hms_opt(0, 0, 0).expect("valid day truncation"); - Utc.from_utc_datetime(&start) -} - -fn truncate_to_month(ts: DateTime) -> DateTime { - let date = ts.date_naive(); - let month_start = NaiveDate::from_ymd_opt(date.year(), date.month(), 1) - .expect("valid month truncation") - .and_hms_opt(0, 0, 0) - .expect("valid month start time"); - Utc.from_utc_datetime(&month_start) -} - -#[derive(Debug, Clone)] -pub struct StoredRateLimitSnapshot { - pub account_id: String, - pub plan: Option, - pub snapshot: Option, - pub observed_at: Option>, - pub primary_next_reset_at: Option>, - pub secondary_next_reset_at: Option>, - pub last_usage_limit_hit_at: Option>, -} - -#[derive(Debug, Clone)] -pub struct StoredUsageEntry { - pub timestamp: DateTime, - pub tokens: TokenTotals, -} - -#[derive(Debug, Clone)] -pub struct StoredUsageBucket { - pub period_start: DateTime, - pub tokens: TokenTotals, -} - -#[derive(Debug, Clone)] -pub struct StoredUsageSummary { - pub account_id: String, - pub plan: Option, - pub totals: TokenTotals, - pub last_updated: DateTime, - pub hourly_entries: Vec, - pub hourly_buckets: Vec, - pub daily_buckets: Vec, - pub monthly_buckets: Vec, -} - -fn usage_dir(code_home: &Path) -> PathBuf { - code_home.join(USAGE_SUBDIR) -} - -fn warning_log_path(code_home: &Path) -> PathBuf { - usage_dir(code_home).join("rate_limit_warnings.log") -} - -fn usage_file_path(code_home: &Path, account_id: &str) -> PathBuf { - usage_dir(code_home).join(format!("{account_id}.json")) -} - -fn with_usage_file( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - mut update: F, -) -> std::io::Result<()> -where - F: FnMut(&mut AccountUsageData), -{ - let usage_dir = usage_dir(code_home); - fs::create_dir_all(&usage_dir)?; - - let path = usage_file_path(code_home, account_id); - let mut file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .open(&path)?; - - file.lock_exclusive()?; - - let mut data = if file.metadata()?.len() == 0 { - AccountUsageData::new(account_id.to_string()) - } else { - let mut contents = String::new(); - file.seek(SeekFrom::Start(0))?; - file.read_to_string(&mut contents)?; - if contents.trim().is_empty() { - AccountUsageData::new(account_id.to_string()) - } else { - match serde_json::from_str::(&contents) { - Ok(mut parsed) => { - if parsed.version != USAGE_VERSION { - parsed.version = USAGE_VERSION; - } - parsed - } - Err(_) => AccountUsageData::new(account_id.to_string()), - } - } - }; - - data.apply_plan(plan); - update(&mut data); - - let json = serde_json::to_string_pretty(&data)?; - let tmp_path = usage_dir.join(format!("{account_id}.json.tmp")); - { - let mut tmp = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp_path)?; - tmp.write_all(json.as_bytes())?; - tmp.sync_all()?; - } - if let Err(err) = fs::rename(&tmp_path, &path) { - let _ = fs::remove_file(&tmp_path); - file.unlock()?; - return Err(err); - } - file.unlock()?; - Ok(()) -} - -pub fn record_token_usage( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - usage: &TokenUsage, - observed_at: DateTime, -) -> std::io::Result<()> { - with_usage_file(code_home, account_id, plan, |data| { - data.last_updated = observed_at; - data.totals.add_usage(usage); - data.hourly_entries.push(TokenWindowEntry { - timestamp: observed_at, - tokens: TokenTotals::from_usage(usage), - }); - data.update_last_hour(observed_at); - }) -} - -pub fn record_rate_limit_snapshot( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - snapshot: &RateLimitSnapshotEvent, - observed_at: DateTime, -) -> std::io::Result<()> { - with_usage_file(code_home, account_id, plan, |data| { - data.last_updated = observed_at; - let mut info = data.rate_limit.take().unwrap_or_default(); - info.snapshot = Some(snapshot.clone()); - info.observed_at = Some(observed_at); - info.primary_next_reset_at = snapshot - .primary_reset_after_seconds - .map(|seconds| observed_at + Duration::seconds(seconds as i64)); - info.secondary_next_reset_at = snapshot - .secondary_reset_after_seconds - .map(|seconds| observed_at + Duration::seconds(seconds as i64)); - data.rate_limit = Some(info); - }) -} - -pub fn list_rate_limit_snapshots( - code_home: &Path, -) -> std::io::Result> { - let usage_dir = usage_dir(code_home); - let mut results = Vec::new(); - - let entries = match fs::read_dir(&usage_dir) { - Ok(entries) => entries, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(results), - Err(err) => return Err(err), - }; - - for entry in entries { - let entry = match entry { - Ok(e) => e, - Err(_) => continue, - }; - let path = entry.path(); - if entry - .file_type() - .ok() - .map(|ft| ft.is_file()) - .unwrap_or(false) - && path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("json")) - .unwrap_or(false) - { - let contents = match fs::read_to_string(&path) { - Ok(text) => text, - Err(_) => continue, - }; - let data: AccountUsageData = match serde_json::from_str(&contents) { - Ok(data) => data, - Err(_) => continue, - }; - let rate = data.rate_limit.unwrap_or_default(); - let primary_next_reset_at = rate.primary_next_reset_at; - let secondary_next_reset_at = rate - .secondary_next_reset_at - .or(rate.primary_next_reset_at); - results.push(StoredRateLimitSnapshot { - account_id: data.account_id, - plan: data.plan, - snapshot: rate.snapshot, - observed_at: rate.observed_at, - primary_next_reset_at, - secondary_next_reset_at, - last_usage_limit_hit_at: rate.last_usage_limit_hit_at, - }); - } - } - - Ok(results) -} - -pub fn record_usage_limit_hint( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - resets_in_seconds: Option, - observed_at: DateTime, -) -> std::io::Result<()> { - record_usage_limit_hint_with_type( - code_home, - account_id, - plan, - resets_in_seconds, - observed_at, - Some(RateLimitReachedType::RateLimitReached), - ) -} - -pub fn record_usage_limit_hint_with_type( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - resets_in_seconds: Option, - observed_at: DateTime, - reached_type: Option, -) -> std::io::Result<()> { - let reached_type = reached_type.or(Some(RateLimitReachedType::RateLimitReached)); - if resets_in_seconds.is_none() { - return with_usage_file(code_home, account_id, plan, |data| { - data.last_updated = observed_at; - let mut info = data.rate_limit.take().unwrap_or_default(); - info.last_usage_limit_hit_at = Some(observed_at); - if let Some(snapshot) = info.snapshot.as_mut() { - snapshot.rate_limit_reached_type = reached_type; - } - data.rate_limit = Some(info); - }); - } - - with_usage_file(code_home, account_id, plan, |data| { - data.last_updated = observed_at; - let mut info = data.rate_limit.take().unwrap_or_default(); - info.last_usage_limit_hit_at = Some(observed_at); - if let Some(snapshot) = info.snapshot.as_mut() { - snapshot.rate_limit_reached_type = reached_type; - } - if let Some(seconds) = resets_in_seconds { - let reset_at = observed_at + Duration::seconds(seconds as i64); - info.primary_next_reset_at = Some(reset_at); - info.secondary_next_reset_at = Some(reset_at); - } - data.rate_limit = Some(info); - }) -} - -pub fn rate_limit_refresh_stale_interval() -> Duration { - Duration::seconds(RATE_LIMIT_REFRESH_STALE_INTERVAL_SECS) -} - -pub fn mark_rate_limit_refresh_attempt_if_due( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - reset_at: Option>, - now: DateTime, - stale_interval: Duration, -) -> std::io::Result { - let mut should_refresh = false; - with_usage_file(code_home, account_id, plan, |data| { - let had_info = data.rate_limit.is_some(); - let mut info = data.rate_limit.take().unwrap_or_default(); - let last_attempt = info.last_refresh_attempt_at; - let last_observed = info.observed_at; - - let refresh_due = if let Some(reset_at) = reset_at { - if now + RESET_PASSED_TOLERANCE < reset_at { - false - } else if last_observed - .is_some_and(|observed| observed + RESET_PASSED_TOLERANCE >= reset_at) - { - // We've already stored a successful snapshot at/after this reset. - false - } else { - let attempted_after_reset = last_attempt - .is_some_and(|attempt| attempt >= reset_at); - - if !attempted_after_reset { - true - } else { - match last_attempt { - Some(attempt) => now.signed_duration_since(attempt) >= stale_interval, - None => true, - } - } - } - } else { - match last_attempt { - Some(attempt) => now.signed_duration_since(attempt) >= stale_interval, - None => true, - } - }; - - if refresh_due { - info.last_refresh_attempt_at = Some(now); - should_refresh = true; - } - - if refresh_due || had_info { - data.rate_limit = Some(info); - } - })?; - Ok(should_refresh) -} - -fn record_threshold_log( - logs: &mut Vec, - threshold: f64, - reset_at: Option>, - observed_at: DateTime, -) -> bool { - if let Some(existing) = logs.iter_mut().find(|entry| { - (entry.threshold - threshold).abs() < f64::EPSILON - }) { - let previous_reset = existing.reset_at; - let previous_logged = existing.logged_at; - let new_reset = reset_at; - - let reset_moved_earlier = match (previous_reset, new_reset) { - (Some(prev), Some(next)) => next + RESET_PASSED_TOLERANCE < prev, - _ => false, - }; - - let logged_after_prev_reset = match (previous_logged, previous_reset) { - (Some(logged), Some(prev)) => logged >= prev, - _ => false, - }; - - let prev_reset_elapsed = previous_reset - .map(|prev| observed_at + RESET_PASSED_TOLERANCE >= prev) - .unwrap_or(false); - - let unknown_reset_elapsed = (previous_reset.is_none() || new_reset.is_none()) - && previous_logged - .is_some_and(|logged| observed_at.signed_duration_since(logged) >= UNKNOWN_RESET_RELOG_INTERVAL); - - let mut should_clear = false; - - if reset_moved_earlier { - should_clear = true; - } else if prev_reset_elapsed && !logged_after_prev_reset { - should_clear = true; - } else if unknown_reset_elapsed { - should_clear = true; - } - - existing.reset_at = new_reset; - - if should_clear { - existing.logged_at = None; - } - - if existing.logged_at.is_none() { - existing.logged_at = Some(observed_at); - return true; - } - - return false; - } - - logs.push(RateLimitWarningRecord { - threshold, - reset_at, - logged_at: Some(observed_at), - }); - true -} - -fn append_rate_limit_warning_log( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - scope: RateLimitWarningScope, - threshold: f64, - reset_at: Option>, - observed_at: DateTime, - message: &str, -) -> std::io::Result<()> { - let dir = usage_dir(code_home); - fs::create_dir_all(&dir)?; - let path = warning_log_path(code_home); - let mut file = OpenOptions::new() - .create(true) - .append(true) - .read(true) - .open(&path)?; - file.lock_exclusive()?; - let scope_field = match scope { - RateLimitWarningScope::Primary => "primary", - RateLimitWarningScope::Secondary => "secondary", - }; - let plan_field = plan.unwrap_or("-"); - let reset_field = reset_at - .map(|dt| dt.to_rfc3339()) - .unwrap_or_else(|| "-".to_string()); - let line = format!( - "{}\t{}\t{}\t{:.0}\t{}\t{}\t{}\n", - observed_at.to_rfc3339(), - account_id, - plan_field, - threshold, - scope_field, - reset_field, - message, - ); - let write_res = file.write_all(line.as_bytes()); - let unlock_res = file.unlock(); - write_res?; - unlock_res?; - Ok(()) -} - -pub fn record_rate_limit_warning( - code_home: &Path, - account_id: &str, - plan: Option<&str>, - scope: RateLimitWarningScope, - threshold: f64, - reset_at: Option>, - observed_at: DateTime, - message: &str, -) -> std::io::Result { - let mut should_log = false; - with_usage_file(code_home, account_id, plan, |data| { - data.last_updated = observed_at; - let mut info = data.rate_limit.take().unwrap_or_default(); - let logs = match scope { - RateLimitWarningScope::Primary => &mut info.primary_threshold_logs, - RateLimitWarningScope::Secondary => &mut info.secondary_threshold_logs, - }; - if record_threshold_log(logs, threshold, reset_at, observed_at) { - should_log = true; - } - data.rate_limit = Some(info); - })?; - - if should_log { - append_rate_limit_warning_log( - code_home, - account_id, - plan, - scope, - threshold, - reset_at, - observed_at, - message, - )?; - } - - Ok(should_log) -} - -pub fn load_account_usage( - code_home: &Path, - account_id: &str, -) -> std::io::Result> { - let path = usage_file_path(code_home, account_id); - let contents = match fs::read_to_string(&path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err), - }; - - let data: AccountUsageData = serde_json::from_str(&contents)?; - let hourly_entries = data - .hourly_entries - .into_iter() - .map(|entry| StoredUsageEntry { - timestamp: entry.timestamp, - tokens: entry.tokens, - }) - .collect(); - - let hourly_buckets = data - .hourly_buckets - .into_iter() - .map(|entry| StoredUsageBucket { - period_start: entry.period_start, - tokens: entry.tokens, - }) - .collect(); - - let daily_buckets = data - .daily_buckets - .into_iter() - .map(|entry| StoredUsageBucket { - period_start: entry.period_start, - tokens: entry.tokens, - }) - .collect(); - - let monthly_buckets = data - .monthly_buckets - .into_iter() - .map(|entry| StoredUsageBucket { - period_start: entry.period_start, - tokens: entry.tokens, - }) - .collect(); - - Ok(Some(StoredUsageSummary { - account_id: data.account_id, - plan: data.plan, - totals: data.totals, - last_updated: data.last_updated, - hourly_entries, - hourly_buckets, - daily_buckets, - monthly_buckets, - })) -} - -#[cfg(test)] -mod tests { - //! Regression coverage for rate-limit warning relogging. - //! - //! These cases enforce the desired behaviour: - //! - **No duplicate within a window**: once a threshold logs, subsequent polls before - //! the stored reset timestamp must remain silent even if the backend repeats or - //! extends the reset time. - //! - **Relog after reset passes**: the first poll at or after the recorded reset may - //! emit again, regardless of whether the backend has already advanced the window. - //! - **Relog on earlier reset**: if the backend moves the reset earlier (window - //! shrinks), we allow an immediate relog even before the previously stored reset. - //! - **Unknown reset fallback**: when reset metadata disappears, we rely on the - //! 24-hour `UNKNOWN_RESET_RELOG_INTERVAL` to unblock further warnings. When the - //! backend begins reporting timestamps again, we should also allow a relog provided - //! the fallback window has elapsed. - //! - **Missing metadata alone is not enough**: before the fallback timer elapses we - //! must keep warnings muted even if new snapshots omit reset times. - //! - //! The helper tests below construct scenarios targeting each rule so the state - //! machine in `record_threshold_log` can be refactored confidently. - use super::*; - use crate::protocol::RateLimitReachedType; - use std::fs::File; - use crate::protocol::TokenUsage; - use tempfile::TempDir; - - fn sample_usage() -> TokenUsage { - TokenUsage { - input_tokens: 120, - cached_input_tokens: 20, - cached_input_tokens_reported: true, - output_tokens: 80, - reasoning_output_tokens: 10, - total_tokens: 210, - } - } - - fn sample_snapshot() -> RateLimitSnapshotEvent { - RateLimitSnapshotEvent { - primary_used_percent: 50.0, - secondary_used_percent: 60.0, - primary_to_secondary_ratio_percent: 25.0, - primary_window_minutes: 300, - secondary_window_minutes: 10_080, - primary_reset_after_seconds: Some(600), - secondary_reset_after_seconds: Some(3_600), - rate_limit_reached_type: None, - } - } - - #[test] - fn usage_limit_hint_updates_last_hit_and_resets() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - - record_usage_limit_hint(home.path(), "acct-1", Some("Team"), Some(300), now) - .expect("hint recorded"); - - let snapshots = list_rate_limit_snapshots(home.path()).expect("snapshot listing"); - assert_eq!(snapshots.len(), 1, "usage hint should create one snapshot entry"); - let snapshot = &snapshots[0]; - assert_eq!(snapshot.account_id, "acct-1"); - assert_eq!(snapshot.plan.as_deref(), Some("Team")); - assert_eq!(snapshot.last_usage_limit_hit_at, Some(now)); - assert_eq!( - snapshot - .snapshot - .as_ref() - .and_then(|snapshot| snapshot.rate_limit_reached_type), - None, - "a hint without a prior snapshot should not fabricate usage bars" - ); - let expected_reset = now + Duration::seconds(300); - assert_eq!(snapshot.primary_next_reset_at, Some(expected_reset)); - assert_eq!(snapshot.secondary_next_reset_at, Some(expected_reset)); - } - - #[test] - fn usage_limit_hint_marks_existing_snapshot_with_reached_type() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - let mut snapshot = sample_snapshot(); - - record_rate_limit_snapshot(home.path(), "acct-1", Some("Team"), &snapshot, now) - .expect("snapshot recorded"); - record_usage_limit_hint_with_type( - home.path(), - "acct-1", - Some("Team"), - Some(300), - now, - Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), - ) - .expect("hint recorded"); - - let snapshots = list_rate_limit_snapshots(home.path()).expect("snapshot listing"); - let stored = snapshots - .iter() - .find(|snapshot| snapshot.account_id == "acct-1") - .expect("account snapshot"); - assert_eq!( - stored - .snapshot - .as_ref() - .and_then(|snapshot| snapshot.rate_limit_reached_type), - Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) - ); - - snapshot.primary_used_percent = 1.0; - record_rate_limit_snapshot( - home.path(), - "acct-1", - Some("Team"), - &snapshot, - now + Duration::minutes(1), - ) - .expect("fresh snapshot recorded"); - let snapshots = list_rate_limit_snapshots(home.path()).expect("snapshot listing"); - let stored = snapshots - .iter() - .find(|snapshot| snapshot.account_id == "acct-1") - .expect("account snapshot"); - assert_eq!( - stored - .snapshot - .as_ref() - .and_then(|snapshot| snapshot.rate_limit_reached_type), - None, - "fresh successful snapshots clear stale reached classification" - ); - } - - #[test] - fn token_usage_compacts_old_hourly_entries_into_buckets() { - let home = TempDir::new().expect("tempdir"); - let account = "acct-compaction"; - let usage = sample_usage(); - let now = Utc::now(); - - // Two records that should roll into hourly buckets once a fresh entry is written. - record_token_usage( - home.path(), - account, - None, - &usage, - now - Duration::hours(2), - ) - .expect("first record"); - record_token_usage( - home.path(), - account, - None, - &usage, - now - Duration::minutes(90), - ) - .expect("second record"); - - // Recent usage that should remain in the in-memory hourly entries window. - record_token_usage(home.path(), account, None, &usage, now) - .expect("recent record"); - - let summary = load_account_usage(home.path(), account) - .expect("load summary") - .expect("summary present"); - - // Only the most recent entry should remain in the sliding hourly window. - assert_eq!(summary.hourly_entries.len(), 1); - assert!(summary.hourly_buckets.len() >= 1, "older usage should compact into buckets"); - assert!(summary.daily_buckets.len() >= 1, "hourly buckets roll into daily aggregates"); - } - - #[test] - fn rate_limit_warning_only_logs_once_per_reset() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - let reset_at = now + Duration::days(7); - - let first = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(reset_at), - now, - "Secondary usage exceeded 75% of the limit. Run /limits for detailed usage.", - ) - .expect("first record succeeds"); - - assert!(first, "first logging should emit"); - - let second = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::days(7) + Duration::hours(6)), - now + Duration::hours(6), - "Secondary usage exceeded 75% of the limit. Run /limits for detailed usage.", - ) - .expect("second record succeeds"); - - assert!(!second, "duplicate logging before reset should be suppressed"); - - let third = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::days(15)), - now + Duration::days(8), - "Secondary usage exceeded 75% of the limit. Run /limits for detailed usage.", - ) - .expect("third record succeeds"); - - assert!(third, "after reset passes we should emit again"); - } - - #[test] - fn rate_limit_warning_relogs_after_reset_with_new_timestamp() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - let msg = "Secondary usage exceeded 75% of the limit. Run /limits for detailed usage."; - - let first = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::hours(1)), - now, - msg, - ) - .expect("first record succeeds"); - assert!(first); - - // Backend extends reset window beyond the old reset time; we should re-emit now that - // the prior window has expired and a new one started. - let second = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::hours(2)), - now + Duration::minutes(65), - msg, - ) - .expect("second record succeeds"); - assert!(second, "after reset we should log again even if next window is later"); - - // Subsequent updates inside the new window should remain suppressed until that reset passes. - let third = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::hours(2)), - now + Duration::minutes(70), - msg, - ) - .expect("third record succeeds"); - assert!(!third, "duplicate logging inside the same window should stay muted"); - } - - #[test] - fn rate_limit_warning_relogs_after_reset_even_if_logged_just_before() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - let reset_at = now + Duration::minutes(1); - let msg = "Secondary usage exceeded 75% of the limit. Run /limits for detailed usage."; - - let first = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(reset_at), - reset_at - Duration::seconds(3), - msg, - ) - .expect("first record succeeds"); - assert!(first); - - // After the reset passes, with a new window scheduled further out, we should relog. - let second = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(reset_at + Duration::hours(1)), - reset_at + Duration::seconds(45), - msg, - ) - .expect("second record succeeds"); - assert!(second, "post-reset poll should emit again even if prior log was moments before reset"); - } - - #[test] - fn rate_limit_warning_relogs_after_unknown_reset_interval() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - let msg = "Secondary usage exceeded 75% of the limit. Run /limits for detailed usage."; - - let first = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::hours(1)), - now, - msg, - ) - .expect("first record succeeds"); - assert!(first); - - // Backend stops providing reset info — still within backoff window. - let second = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - None, - now + Duration::minutes(20), - msg, - ) - .expect("second record succeeds"); - assert!(!second, "dropping reset info should keep warning muted initially"); - - // After the unknown interval we should allow another log. - let third = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - None, - now + Duration::hours(25), - msg, - ) - .expect("third record succeeds"); - assert!(third, "after backoff expires we should re-emit"); - } - - #[test] - fn rate_limit_warning_relogs_when_reset_info_returns() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - let msg = "Secondary usage exceeded 75% of the limit. Run /limits for detailed usage."; - - let first = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::hours(1)), - now, - msg, - ) - .expect("first record succeeds"); - assert!(first); - - let second = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - None, - now + Duration::minutes(20), - msg, - ) - .expect("second record succeeds"); - assert!(!second); - - let third = record_rate_limit_warning( - home.path(), - "acct-1", - Some("Team"), - RateLimitWarningScope::Secondary, - 75.0, - Some(now + Duration::hours(30)), - now + Duration::hours(25), - msg, - ) - .expect("third record succeeds"); - assert!(third, "restored reset metadata after fallback window should re-log"); - } - - #[test] - fn creates_usage_file_and_accumulates_tokens() { - let home = TempDir::new().expect("tempdir"); - let now = Utc::now(); - - record_token_usage( - home.path(), - "acct-1", - Some("Team"), - &sample_usage(), - now, - ) - .expect("record usage"); - - let path = usage_file_path(home.path(), "acct-1"); - let mut contents = String::new(); - File::open(path) - .expect("open usage file") - .read_to_string(&mut contents) - .expect("read usage file"); - - let parsed: AccountUsageData = serde_json::from_str(&contents).expect("parse usage json"); - assert_eq!(parsed.account_id, "acct-1"); - assert_eq!(parsed.plan.as_deref(), Some("Team")); - assert_eq!(parsed.totals.input_tokens, 120); - assert_eq!(parsed.totals.output_tokens, 80); - assert_eq!(parsed.tokens_last_hour.total_tokens, 210); - assert_eq!(parsed.hourly_entries.len(), 1); - } -} diff --git a/code-rs/core/src/acp.rs b/code-rs/core/src/acp.rs deleted file mode 100644 index ed48f02adf9..00000000000 --- a/code-rs/core/src/acp.rs +++ /dev/null @@ -1,370 +0,0 @@ -#![allow(dead_code)] - -use agent_client_protocol as acp; -use anyhow::Context as _; -use anyhow::Result; -use code_apply_patch::FileSystem; -use code_apply_patch::StdFileSystem; -use mcp_types::CallToolResult; -use std::collections::HashMap; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use uuid::Uuid; - -use crate::config_types::{ClientTools, McpToolId}; -use crate::mcp_connection_manager::McpConnectionManager; -use crate::protocol::FileChange; -use crate::protocol::ReviewDecision; -use crate::util::strip_bash_lc_and_escape; - -pub(crate) struct AcpFileSystem<'a> { - session_id: Uuid, - mcp_connection_manager: &'a McpConnectionManager, - tools: &'a ClientTools, -} - -impl<'a> AcpFileSystem<'a> { - pub fn new( - session_id: Uuid, - tools: &'a ClientTools, - mcp_connection_manager: &'a McpConnectionManager, - ) -> Self { - Self { - session_id, - mcp_connection_manager, - tools, - } - } - - async fn read_text_file_impl( - &self, - tool: &McpToolId, - path: &Path, - ) -> Result { - let arguments = acp::ReadTextFileRequest { - session_id: acp::SessionId(self.session_id.to_string().into()), - path: path.to_path_buf(), - line: None, - limit: None, - meta: None, - }; - - let CallToolResult { - structured_content, - is_error, - .. - } = self - .mcp_connection_manager - .call_tool( - &tool.mcp_server, - &tool.tool_name, - Some(serde_json::to_value(arguments).unwrap_or_default()), - Some(Duration::from_secs(15)), - ) - .await?; - - if is_error.unwrap_or_default() { - anyhow::bail!("Error reading text file: {:?}", structured_content); - } - - let output = serde_json::from_value::( - structured_content.context("No output from read_text_file tool")?, - )?; - - Ok(output.content) - } - - async fn write_text_file_impl( - &self, - tool: &McpToolId, - path: &Path, - content: String, - ) -> Result<()> { - let arguments = acp::WriteTextFileRequest { - session_id: acp::SessionId(self.session_id.to_string().into()), - path: path.to_path_buf(), - content, - meta: None, - }; - - let CallToolResult { - structured_content, - is_error, - .. - } = self - .mcp_connection_manager - .call_tool( - &tool.mcp_server, - &tool.tool_name, - Some(serde_json::to_value(arguments).unwrap_or_default()), - Some(Duration::from_secs(15)), - ) - .await?; - - if is_error.unwrap_or_default() { - anyhow::bail!("Error writing text file: {:?}", structured_content); - } - - Ok(()) - } -} - -impl<'a> FileSystem for AcpFileSystem<'a> { - async fn read_text_file(&self, path: &Path) -> std::io::Result { - if let Some(tool) = self.tools.read_text_file.as_ref() { - self.read_text_file_impl(tool, path) - .await - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)) - } else { - StdFileSystem.read_text_file(path).await - } - } - - async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()> { - if let Some(tool) = self.tools.write_text_file.as_ref() { - self.write_text_file_impl(tool, path, contents) - .await - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)) - } else { - StdFileSystem.write_text_file(path, contents).await - } - } -} - -pub(crate) async fn request_permission( - permission_tool: &McpToolId, - tool_call: acp::ToolCallUpdate, - session_id: Uuid, - mcp_connection_manager: &McpConnectionManager, -) -> Result { - let approve_for_session_id = acp::PermissionOptionId("approve_for_session".into()); - let approve_id = acp::PermissionOptionId("approve".into()); - let deny_id = acp::PermissionOptionId("deny".into()); - - let arguments = acp::RequestPermissionRequest { - session_id: acp::SessionId(session_id.to_string().into()), - tool_call, - options: vec![ - acp::PermissionOption { - id: approve_for_session_id.clone(), - name: "Approve for Session".into(), - kind: acp::PermissionOptionKind::AllowAlways, - meta: None, - }, - acp::PermissionOption { - id: approve_id.clone(), - name: "Approve".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }, - acp::PermissionOption { - id: deny_id.clone(), - name: "Deny".into(), - kind: acp::PermissionOptionKind::RejectOnce, - meta: None, - }, - ], - meta: None, - }; - - let CallToolResult { - structured_content, .. - } = mcp_connection_manager - .call_tool( - &permission_tool.mcp_server, - &permission_tool.tool_name, - Some(serde_json::to_value(arguments).unwrap_or_default()), - Some(Duration::from_secs(15)), - ) - .await?; - - let result = structured_content.context("No output from permission tool")?; - let result = serde_json::from_value::(result)?; - - use acp::RequestPermissionOutcome::*; - let decision = match result.outcome { - Selected { option_id } => { - if option_id == approve_id { - ReviewDecision::Approved - } else if option_id == approve_for_session_id { - ReviewDecision::ApprovedForSession - } else if option_id == deny_id { - ReviewDecision::Denied - } else { - anyhow::bail!("Unexpected permission option: {}", option_id); - } - } - Cancelled => ReviewDecision::Abort, - }; - - Ok(decision) -} - -pub fn new_execute_tool_call( - call_id: &str, - command: &[String], - status: acp::ToolCallStatus, -) -> acp::ToolCall { - acp::ToolCall { - id: acp::ToolCallId(call_id.into()), - title: format!("`{}`", strip_bash_lc_and_escape(command)), - kind: acp::ToolKind::Execute, - status, - content: vec![], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - } -} - -pub fn new_patch_tool_call( - call_id: &str, - changes: &HashMap, - status: acp::ToolCallStatus, -) -> acp::ToolCall { - let title = if changes.len() == 1 - && let Some((path, change)) = changes.iter().next() - { - let file_name = path.file_name().unwrap_or_default().display().to_string(); - - match change { - FileChange::Delete => { - return acp::ToolCall { - id: acp::ToolCallId(call_id.into()), - title: format!("Delete “`{file_name}`”"), - kind: acp::ToolKind::Delete, - status, - content: vec![], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; - } - FileChange::Update { - move_path: Some(new_path), - original_content, - new_content, - .. - } if original_content == new_content => { - return acp::ToolCall { - id: acp::ToolCallId(call_id.into()), - title: move_path_label(path, new_path), - kind: acp::ToolKind::Move, - status, - content: vec![], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; - } - _ => {} - } - - format!("Edit “`{file_name}`”") - } else { - format!("Edit {} files", changes.len()) - }; - - let mut locations = Vec::with_capacity(changes.len()); - let mut content = Vec::with_capacity(changes.len()); - - for (path, change) in changes.iter() { - match change { - FileChange::Add { content: new_content } => { - content.push(acp::ToolCallContent::Diff { - diff: acp::Diff { - path: path.clone(), - old_text: None, - new_text: new_content.clone(), - meta: None, - }, - }); - - locations.push(acp::ToolCallLocation { - path: path.clone(), - line: None, - meta: None, - }); - } - FileChange::Delete => { - content.push( - format!( - "Delete “`{}`”\n\n", - path.file_name().unwrap_or(path.as_os_str()).display() - ) - .into(), - ); - } - FileChange::Update { - move_path, - new_content, - original_content, - unified_diff: _, - } => { - if let Some(new_path) = move_path - && changes.len() > 1 - { - content.push(move_path_label(path, new_path).into()); - - if status == acp::ToolCallStatus::Completed { - locations.push(acp::ToolCallLocation { - path: new_path.clone(), - line: None, - meta: None, - }); - } else { - locations.push(acp::ToolCallLocation { - path: path.clone(), - line: None, - meta: None, - }); - } - } else { - locations.push(acp::ToolCallLocation { - path: path.clone(), - line: None, - meta: None, - }); - } - - if original_content != new_content { - content.push(acp::ToolCallContent::Diff { - diff: acp::Diff { - path: path.clone(), - old_text: Some(original_content.clone()), - new_text: new_content.clone(), - meta: None, - }, - }); - } - } - } - } - - acp::ToolCall { - id: acp::ToolCallId(call_id.into()), - title, - kind: acp::ToolKind::Edit, - status, - content, - locations, - raw_input: None, - raw_output: None, - meta: None, - } -} - -fn move_path_label(old: &Path, new: &Path) -> String { - if old.parent() == new.parent() { - let old_name = old.file_name().unwrap_or(old.as_os_str()).display(); - let new_name = new.file_name().unwrap_or(new.as_os_str()).display(); - - format!("Rename “`{old_name}`” to “`{new_name}`”") - } else { - format!("Move “`{}`” to “`{}`”", old.display(), new.display()) - } -} diff --git a/code-rs/core/src/active_sessions.rs b/code-rs/core/src/active_sessions.rs deleted file mode 100644 index 561dcd8437c..00000000000 --- a/code-rs/core/src/active_sessions.rs +++ /dev/null @@ -1,573 +0,0 @@ -use crate::process_liveness::check_pid_alive; -use crate::protocol::SandboxPolicy; -use code_protocol::protocol::SessionSource; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; - -const ACTIVE_SESSIONS_DIR: &str = "active-sessions"; -const SCHEMA_VERSION: u32 = 1; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ActiveSessionMode { - WriteCapable, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ActiveSessionRecord { - pub schema_version: u32, - pub product: String, - pub session_id: Uuid, - pub pid: u32, - pub source: SessionSource, - pub mode: ActiveSessionMode, - pub started_at_unix: u64, - pub heartbeat_at_unix: u64, - pub cwd: PathBuf, - pub checkout_root: PathBuf, - pub git_common_dir: Option, - pub branch: Option, - pub head: Option, -} - -impl ActiveSessionRecord { - pub fn fingerprint_component(&self) -> String { - format!("{}:{}", self.pid, self.session_id) - } -} - -#[derive(Debug)] -pub struct ActiveSessionGuard { - path: PathBuf, -} - -#[derive(Debug)] -pub struct ActiveSessionRegistration { - pub guard: ActiveSessionGuard, - pub conflicts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActiveSessionConflictNotice { - pub fingerprint: String, - pub message: String, - pub checkout_root: PathBuf, - pub suggested_worktree_path: PathBuf, -} - -impl Drop for ActiveSessionGuard { - fn drop(&mut self) { - if let Err(err) = fs::remove_file(&self.path) { - if err.kind() != io::ErrorKind::NotFound { - tracing::debug!( - "failed to remove active session record {}: {err}", - self.path.display() - ); - } - } - } -} - -pub fn register_if_write_capable( - code_home: &Path, - cwd: &Path, - sandbox_policy: &SandboxPolicy, - session_id: Uuid, - source: SessionSource, -) -> io::Result> { - if !is_write_capable(sandbox_policy) { - return Ok(None); - } - - let Some(checkout_root) = git_path(cwd, &["rev-parse", "--show-toplevel"]) else { - return Ok(None); - }; - - let now = unix_now(); - let record = ActiveSessionRecord { - schema_version: SCHEMA_VERSION, - product: "Every Code".to_string(), - session_id, - pid: std::process::id(), - source, - mode: ActiveSessionMode::WriteCapable, - started_at_unix: now, - heartbeat_at_unix: now, - cwd: canonicalize_lossy(cwd), - checkout_root: canonicalize_lossy(&checkout_root), - git_common_dir: git_path(cwd, &["rev-parse", "--git-common-dir"]) - .map(|path| absolutize_git_path(cwd, path)), - branch: git_output(cwd, &["branch", "--show-current"]), - head: git_output(cwd, &["rev-parse", "--verify", "HEAD"]), - }; - - let dir = active_sessions_dir(code_home)?; - prune_stale_records(&dir); - let path = record_path(&dir, record.pid, record.session_id); - let bytes = serde_json::to_vec_pretty(&record).map_err(io::Error::other)?; - fs::write(&path, bytes)?; - - let conflicts = live_records(&dir) - .into_iter() - .filter(|candidate| candidate.session_id != session_id) - .filter(|candidate| candidate.checkout_root == record.checkout_root) - .filter(|candidate| candidate.mode == ActiveSessionMode::WriteCapable) - .collect(); - - Ok(Some(ActiveSessionRegistration { - guard: ActiveSessionGuard { path }, - conflicts, - })) -} - -pub fn active_session_warning( - code_home: &Path, - conflicts: &[ActiveSessionRecord], -) -> Option { - let first = conflicts.first()?; - let detail = format_session_detail(first); - let root = first.checkout_root.display(); - let suggested_path = suggested_worktree_path(code_home, first); - let suggested = suggested_path.display(); - if conflicts.len() == 1 { - Some(format!( - "Another write-capable Every Code session is active in this checkout ({detail}) at {root}. Concurrent edits can conflict. Read-only exploration is okay, but before editing files Every Code will require a visible choice to use an isolated worktree such as {suggested} or stay in this checkout with a reason." - )) - } else { - Some(format!( - "{} other write-capable Every Code sessions are active in this checkout, including {detail}, at {root}. Concurrent edits can conflict. Read-only exploration is okay, but before editing files Every Code will require a visible choice to use an isolated worktree such as {suggested} or stay in this checkout with a reason.", - conflicts.len() - )) - } -} - -pub fn active_session_conflict_notice( - code_home: &Path, - conflicts: &[ActiveSessionRecord], -) -> Option { - let first = conflicts.first()?; - let detail = format_session_detail(first); - let root = first.checkout_root.display(); - let suggested = suggested_worktree_path(code_home, first); - let suggested_display = suggested.display(); - let subject = if conflicts.len() == 1 { - "Another write-capable Every Code session is active in this checkout".to_string() - } else { - format!( - "{} other write-capable Every Code sessions are active in this checkout", - conflicts.len() - ) - }; - - let message = format!( - "CONCURRENT CHECKOUT SESSION DETECTED: {subject}, including {detail}, at {root}. Treat this checkout as concurrently edited. Read-only exploration is okay. Before editing files, call `declare_worktree_decision` to record a visible `WORKTREE DECISION`: either create/switch to the isolated git worktree {suggested_display} and declare `use_worktree`, or declare `stay_here` with the concrete reason you are staying in this checkout. Linked worktrees do not automatically carry checkout-local setup such as .env files, virtualenvs, node_modules, local secrets, or generated files, so staying can be valid when that setup is required. If you stay, re-read target files immediately before editing and keep edits tightly scoped to this task. Do not revert, overwrite, stage, or spend turns cataloging unrelated working-tree changes unless the user explicitly asks. Mention concurrent edits only when they affect the requested task." - ); - let fingerprint = conflicts - .iter() - .map(ActiveSessionRecord::fingerprint_component) - .collect::>() - .join(","); - Some(ActiveSessionConflictNotice { - fingerprint, - message, - checkout_root: first.checkout_root.clone(), - suggested_worktree_path: suggested, - }) -} - -pub fn active_session_model_notice_for_current( - code_home: &Path, - cwd: &Path, - session_id: Uuid, -) -> io::Result> { - let Some(checkout_root) = git_path(cwd, &["rev-parse", "--show-toplevel"]) else { - return Ok(None); - }; - let checkout_root = canonicalize_lossy(&checkout_root); - let dir = active_sessions_dir(code_home)?; - prune_stale_records(&dir); - let mut conflicts = live_records(&dir) - .into_iter() - .filter(|candidate| candidate.session_id != session_id) - .filter(|candidate| candidate.checkout_root == checkout_root) - .filter(|candidate| candidate.mode == ActiveSessionMode::WriteCapable) - .collect::>(); - conflicts.sort_by_key(|record| (record.pid, record.session_id)); - Ok(active_session_conflict_notice(code_home, &conflicts).map(|notice| ActiveSessionModelNotice { - fingerprint: notice.fingerprint, - message: notice.message, - checkout_root: notice.checkout_root, - suggested_worktree_path: notice.suggested_worktree_path, - })) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActiveSessionModelNotice { - pub fingerprint: String, - pub message: String, - pub checkout_root: PathBuf, - pub suggested_worktree_path: PathBuf, -} - -pub fn suggested_worktree_path(code_home: &Path, record: &ActiveSessionRecord) -> PathBuf { - let repo_name = record - .checkout_root - .file_name() - .and_then(|name| name.to_str()) - .map(sanitize_path_component) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "repo".to_string()); - let task = record - .branch - .as_deref() - .map(sanitize_path_component) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| format!("session-{}", short_session_id(record.session_id))); - code_home.join("worktrees").join(repo_name).join(task) -} - -fn sanitize_path_component(value: &str) -> String { - let mut result = String::new(); - let mut last_was_dash = false; - for ch in value.chars() { - let mapped = if ch.is_ascii_alphanumeric() { ch } else { '-' }; - if mapped == '-' { - if last_was_dash { - continue; - } - last_was_dash = true; - } else { - last_was_dash = false; - } - result.push(mapped.to_ascii_lowercase()); - } - result.trim_matches('-').to_string() -} - -fn short_session_id(session_id: Uuid) -> String { - session_id - .to_string() - .chars() - .take(8) - .collect::() -} - -fn is_write_capable(sandbox_policy: &SandboxPolicy) -> bool { - matches!( - sandbox_policy, - SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess - ) -} - -fn active_sessions_dir(code_home: &Path) -> io::Result { - let dir = code_home.join("state").join(ACTIVE_SESSIONS_DIR); - fs::create_dir_all(&dir)?; - Ok(dir) -} - -fn record_path(dir: &Path, pid: u32, session_id: Uuid) -> PathBuf { - dir.join(format!("pid-{pid}-{session_id}.json")) -} - -fn live_records(dir: &Path) -> Vec { - let mut records = Vec::new(); - let Ok(entries) = fs::read_dir(dir) else { - return records; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("json") { - continue; - } - let Some(record) = read_record(&path) else { - let _ = fs::remove_file(&path); - continue; - }; - match check_pid_alive(record.pid as i32) { - Some(true) => records.push(record), - Some(false) => { - let _ = fs::remove_file(&path); - } - None => {} - } - } - records -} - -fn prune_stale_records(dir: &Path) { - let _ = live_records(dir); -} - -fn read_record(path: &Path) -> Option { - let bytes = fs::read(path).ok()?; - let record: ActiveSessionRecord = serde_json::from_slice(&bytes).ok()?; - (record.schema_version == SCHEMA_VERSION).then_some(record) -} - -fn git_path(cwd: &Path, args: &[&str]) -> Option { - git_output(cwd, args).map(PathBuf::from) -} - -fn git_output(cwd: &Path, args: &[&str]) -> Option { - let output = Command::new("git") - .args(args) - .current_dir(cwd) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let value = String::from_utf8(output.stdout).ok()?; - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -fn absolutize_git_path(cwd: &Path, path: PathBuf) -> PathBuf { - if path.is_absolute() { - canonicalize_lossy(&path) - } else { - canonicalize_lossy(&cwd.join(path)) - } -} - -fn canonicalize_lossy(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) -} - -fn unix_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -fn format_session_detail(record: &ActiveSessionRecord) -> String { - let source = format_session_source(&record.source); - format!( - "pid {}, {}, started {}s ago", - record.pid, - source, - unix_now().saturating_sub(record.started_at_unix) - ) -} - -fn format_session_source(source: &SessionSource) -> &'static str { - match source { - SessionSource::Cli => "cli", - SessionSource::Exec => "exec", - SessionSource::VSCode => "vscode", - SessionSource::Mcp => "mcp", - SessionSource::SubAgent(_) => "subagent", - SessionSource::Unknown => "unknown", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn read_only_sessions_do_not_register() { - let home = tempdir().unwrap(); - let cwd = tempdir().unwrap(); - let result = register_if_write_capable( - home.path(), - cwd.path(), - &SandboxPolicy::ReadOnly, - Uuid::new_v4(), - SessionSource::Exec, - ) - .unwrap(); - - assert!(result.is_none()); - assert!(!home.path().join("state").join(ACTIVE_SESSIONS_DIR).exists()); - } - - #[test] - fn second_write_capable_session_warns_in_same_checkout() { - let home = tempdir().unwrap(); - let repo = tempdir().unwrap(); - init_git_repo(repo.path()); - - let first = register_if_write_capable( - home.path(), - repo.path(), - &SandboxPolicy::DangerFullAccess, - Uuid::new_v4(), - SessionSource::Cli, - ) - .unwrap() - .unwrap(); - assert!(first.conflicts.is_empty()); - - let second = register_if_write_capable( - home.path(), - repo.path(), - &SandboxPolicy::DangerFullAccess, - Uuid::new_v4(), - SessionSource::Exec, - ) - .unwrap() - .unwrap(); - - assert_eq!(second.conflicts.len(), 1); - assert_eq!(second.conflicts[0].source, SessionSource::Cli); - let warning = active_session_warning(home.path(), &second.conflicts).unwrap(); - assert!(warning.contains("write-capable")); - assert!(warning.contains("worktrees")); - } - - #[test] - fn model_notice_gives_concurrent_editing_guidance() { - let home = tempdir().unwrap(); - let repo = tempdir().unwrap(); - init_git_repo(repo.path()); - let first_id = Uuid::new_v4(); - let second_id = Uuid::new_v4(); - - let first = register_if_write_capable( - home.path(), - repo.path(), - &SandboxPolicy::DangerFullAccess, - first_id, - SessionSource::Cli, - ) - .unwrap() - .unwrap(); - assert!(first.conflicts.is_empty()); - - let second = register_if_write_capable( - home.path(), - repo.path(), - &SandboxPolicy::DangerFullAccess, - second_id, - SessionSource::Exec, - ) - .unwrap() - .unwrap(); - - let notice = active_session_conflict_notice(home.path(), &second.conflicts) - .unwrap() - .message; - assert!(notice.contains("CONCURRENT CHECKOUT SESSION DETECTED")); - assert!(notice.contains("WORKTREE DECISION")); - assert!(notice.contains("isolated git worktree")); - assert!(notice.contains("worktrees")); - assert!(notice.contains(".env")); - assert!(notice.contains("virtualenvs")); - assert!(notice.contains("node_modules")); - assert!(notice.contains("re-read target files")); - assert!(notice.contains("Do not revert, overwrite, stage")); - assert!(notice.contains(repo.path().canonicalize().unwrap().to_string_lossy().as_ref())); - - let refreshed = active_session_model_notice_for_current(home.path(), repo.path(), second_id) - .unwrap() - .unwrap(); - assert_eq!(refreshed.message, notice); - assert_eq!( - refreshed.suggested_worktree_path, - suggested_worktree_path(home.path(), &second.conflicts[0]) - ); - assert!(refreshed.fingerprint.contains(&second.conflicts[0].session_id.to_string())); - } - - #[test] - fn stale_session_file_is_removed() { - let home = tempdir().unwrap(); - let repo = tempdir().unwrap(); - init_git_repo(repo.path()); - let dir = active_sessions_dir(home.path()).unwrap(); - let stale = ActiveSessionRecord { - schema_version: SCHEMA_VERSION, - product: "Every Code".to_string(), - session_id: Uuid::new_v4(), - pid: i32::MAX as u32, - source: SessionSource::Cli, - mode: ActiveSessionMode::WriteCapable, - started_at_unix: 1, - heartbeat_at_unix: 1, - cwd: repo.path().to_path_buf(), - checkout_root: repo.path().canonicalize().unwrap(), - git_common_dir: None, - branch: None, - head: None, - }; - let path = record_path(&dir, stale.pid, stale.session_id); - fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap(); - - let current = register_if_write_capable( - home.path(), - repo.path(), - &SandboxPolicy::DangerFullAccess, - Uuid::new_v4(), - SessionSource::Exec, - ) - .unwrap() - .unwrap(); - - assert!(current.conflicts.is_empty()); - assert!(!path.exists()); - } - - #[test] - fn different_worktrees_do_not_conflict() { - let home = tempdir().unwrap(); - let parent = tempdir().unwrap(); - let repo = parent.path().join("repo"); - let worktree = parent.path().join("repo-worktree"); - fs::create_dir(&repo).unwrap(); - init_git_repo(&repo); - run_git(&repo, &["checkout", "-b", "feature"]); - run_git(&repo, &["worktree", "add", worktree.to_str().unwrap()]); - - let first = register_if_write_capable( - home.path(), - &repo, - &SandboxPolicy::DangerFullAccess, - Uuid::new_v4(), - SessionSource::Cli, - ) - .unwrap() - .unwrap(); - assert!(first.conflicts.is_empty()); - - let second = register_if_write_capable( - home.path(), - &worktree, - &SandboxPolicy::DangerFullAccess, - Uuid::new_v4(), - SessionSource::Exec, - ) - .unwrap() - .unwrap(); - - assert!(second.conflicts.is_empty()); - } - - fn init_git_repo(path: &Path) { - run_git(path, &["init"]); - run_git(path, &["checkout", "-b", "main"]); - fs::write(path.join("README.md"), "test\n").unwrap(); - run_git(path, &["add", "."]); - run_git(path, &["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]); - } - - fn run_git(path: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(path) - .output() - .unwrap(); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } -} diff --git a/code-rs/core/src/agent/agent_names.txt b/code-rs/core/src/agent/agent_names.txt new file mode 100644 index 00000000000..92ef522f802 --- /dev/null +++ b/code-rs/core/src/agent/agent_names.txt @@ -0,0 +1,101 @@ +Euclid +Archimedes +Ptolemy +Hypatia +Avicenna +Averroes +Aquinas +Copernicus +Kepler +Galileo +Bacon +Descartes +Pascal +Fermat +Huygens +Leibniz +Newton +Halley +Euler +Lagrange +Laplace +Volta +Gauss +Ampere +Faraday +Darwin +Lovelace +Boole +Pasteur +Maxwell +Mendel +Curie +Planck +Tesla +Poincare +Noether +Hilbert +Einstein +Raman +Bohr +Turing +Hubble +Feynman +Franklin +McClintock +Meitner +Herschel +Linnaeus +Wegener +Chandrasekhar +Sagan +Goodall +Carson +Carver +Socrates +Plato +Aristotle +Epicurus +Cicero +Confucius +Mencius +Zeno +Locke +Hume +Kant +Hegel +Kierkegaard +Mill +Nietzsche +Peirce +James +Dewey +Russell +Popper +Sartre +Beauvoir +Arendt +Rawls +Singer +Anscombe +Parfit +Kuhn +Boyle +Hooke +Harvey +Dalton +Ohm +Helmholtz +Gibbs +Lorentz +Schrodinger +Heisenberg +Pauli +Dirac +Bernoulli +Godel +Nash +Banach +Ramanujan +Erdos +Jason diff --git a/code-rs/core/src/agent/agent_resolver.rs b/code-rs/core/src/agent/agent_resolver.rs new file mode 100644 index 00000000000..eb806da3ae3 --- /dev/null +++ b/code-rs/core/src/agent/agent_resolver.rs @@ -0,0 +1,36 @@ +use crate::function_tool::FunctionCallError; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use codex_protocol::ThreadId; +use std::sync::Arc; + +/// Resolves a single tool-facing agent target to a thread id. +pub(crate) async fn resolve_agent_target( + session: &Arc, + turn: &Arc, + target: &str, +) -> Result { + register_session_root(session, turn); + if let Ok(thread_id) = ThreadId::from_string(target) { + return Ok(thread_id); + } + + session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, target) + .await + .map_err(|err| match err { + codex_protocol::error::CodexErr::UnsupportedOperation(message) => { + FunctionCallError::RespondToModel(message) + } + other => FunctionCallError::RespondToModel(other.to_string()), + }) +} + +fn register_session_root(session: &Arc, turn: &Arc) { + session + .services + .agent_control + .register_session_root(session.conversation_id, &turn.session_source); +} diff --git a/code-rs/core/src/agent/builtins/awaiter.toml b/code-rs/core/src/agent/builtins/awaiter.toml new file mode 100644 index 00000000000..a34583c0c94 --- /dev/null +++ b/code-rs/core/src/agent/builtins/awaiter.toml @@ -0,0 +1,35 @@ +background_terminal_max_timeout = 3600000 +model_reasoning_effort = "low" +developer_instructions="""You are an awaiter. +Your role is to await the completion of a specific command or task and report its status only when it is finished. + +Behavior rules: + +1. When given a command or task identifier, you must: + - Execute or await it using the appropriate tool + - Continue awaiting until the task reaches a terminal state. + +2. You must NOT: + - Modify the task. + - Interpret or optimize the task. + - Perform unrelated actions. + - Stop awaiting unless explicitly instructed. + +3. Awaiting behavior: + - If the task is still running, continue polling using tool calls. + - Use repeated tool calls if necessary. + - Do not hallucinate completion. + - Use long timeouts when awaiting for something. If you need multiple awaits, increase the timeouts/yield times exponentially. + +4. If asked for status: + - Return the current known status. + - Immediately resume awaiting afterward. + +5. Termination: + - Only exit awaiting when: + - The task completes successfully, OR + - The task fails, OR + - You receive an explicit stop instruction. + +You must behave deterministically and conservatively. +""" diff --git a/code-rs/core/src/agent/builtins/explorer.toml b/code-rs/core/src/agent/builtins/explorer.toml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/code-rs/core/src/agent/control.rs b/code-rs/core/src/agent/control.rs new file mode 100644 index 00000000000..079ee61f015 --- /dev/null +++ b/code-rs/core/src/agent/control.rs @@ -0,0 +1,1258 @@ +use crate::agent::AgentStatus; +use crate::agent::registry::AgentMetadata; +use crate::agent::registry::AgentRegistry; +use crate::agent::role::DEFAULT_ROLE_NAME; +use crate::agent::role::resolve_role_config; +use crate::agent::status::is_final; +use crate::codex_thread::ThreadConfigSnapshot; +use crate::session::emit_subagent_session_started; +use crate::session_prefix::format_subagent_context_line; +use crate::session_prefix::format_subagent_notification_message; +use crate::shell_snapshot::ShellSnapshot; +use crate::thread_manager::ResumeThreadWithHistoryOptions; +use crate::thread_manager::ThreadManagerState; +use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; +use codex_features::Feature; +use codex_protocol::AgentPath; +use codex_protocol::SessionId; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::ContentItem; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ResumedHistory; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::user_input::UserInput; +use codex_state::DirectionalThreadSpawnEdgeStatus; +use codex_thread_store::ReadThreadParams; +use serde::Serialize; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::Weak; +use tokio::sync::watch; +use tracing::warn; + +const AGENT_NAMES: &str = include_str!("agent_names.txt"); +const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SpawnAgentForkMode { + FullHistory, + LastNTurns(usize), +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SpawnAgentOptions { + pub(crate) fork_parent_spawn_call_id: Option, + pub(crate) fork_mode: Option, + pub(crate) environments: Option>, +} + +#[derive(Clone, Debug)] +pub(crate) struct LiveAgent { + pub(crate) thread_id: ThreadId, + pub(crate) metadata: AgentMetadata, + pub(crate) status: AgentStatus, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub(crate) struct ListedAgent { + pub(crate) agent_name: String, + pub(crate) agent_status: AgentStatus, + pub(crate) last_task_message: Option, +} + +fn default_agent_nickname_list() -> Vec<&'static str> { + AGENT_NAMES + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} + +fn agent_nickname_candidates( + config: &crate::config::Config, + role_name: Option<&str>, +) -> Vec { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + if let Some(candidates) = + resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) + { + return candidates; + } + + default_agent_nickname_list() + .into_iter() + .map(ToOwned::to_owned) + .collect() +} + +fn keep_forked_rollout_item(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() + { + "system" | "developer" | "user" => true, + "assistant" => *phase == Some(MessagePhase::FinalAnswer), + _ => false, + }, + RolloutItem::ResponseItem( + ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other, + ) => false, + // A forked child gets its own runtime config, including spawned-agent + // instructions, so it must establish a fresh context diff baseline. + RolloutItem::TurnContext(_) => false, + RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, + } +} + +/// Control-plane handle for multi-agent operations. +/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to +/// spawn new agents and the inter-agent communication layer. +/// An `AgentControl` instance is intended to be created at most once per root thread/session +/// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root, +/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. +#[derive(Clone, Default)] +pub(crate) struct AgentControl { + /// ID shared by the whole agent control session. This means every sub-agents from a common + /// root share the same session ID. + session_id: SessionId, + /// Weak handle back to the global thread registry/state. + /// This is `Weak` to avoid reference cycles and shadow persistence of the form + /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. + manager: Weak, + state: Arc, +} + +impl AgentControl { + /// Construct a new `AgentControl` that can spawn/message agents via the given manager state. + pub(crate) fn new(manager: Weak) -> Self { + Self { + manager, + ..Default::default() + } + } + + pub(crate) fn with_session_id(mut self, session_id: SessionId) -> Self { + self.session_id = session_id; + self + } + + pub(crate) fn session_id(&self) -> SessionId { + self.session_id + } + + /// Spawn a new agent thread and submit the initial prompt. + #[cfg(test)] + pub(crate) async fn spawn_agent( + &self, + config: crate::config::Config, + initial_operation: Op, + session_source: Option, + ) -> CodexResult { + let spawned_agent = Box::pin(self.spawn_agent_internal( + config, + initial_operation, + session_source, + SpawnAgentOptions::default(), + )) + .await?; + Ok(spawned_agent.thread_id) + } + + /// Spawn an agent thread with some metadata. + pub(crate) async fn spawn_agent_with_metadata( + &self, + config: crate::config::Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, // TODO(jif) drop with new fork. + ) -> CodexResult { + Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) + .await + } + + async fn spawn_agent_internal( + &self, + config: crate::config::Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, + ) -> CodexResult { + let state = self.upgrade()?; + let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + .await; + let (session_source, mut agent_metadata) = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role, + .. + })) => { + let (session_source, agent_metadata) = self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + agent_role, + /*preferred_agent_nickname*/ None, + )?; + (Some(session_source), agent_metadata) + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + + // The same `AgentControl` is sent to spawn the thread. + let new_thread = match (session_source, options.fork_mode.as_ref()) { + (Some(session_source), Some(_)) => { + self.spawn_forked_thread( + &state, + config, + session_source, + &options, + inherited_shell_snapshot, + inherited_exec_policy, + ) + .await? + } + (Some(session_source), None) => { + state + .spawn_new_thread_with_source( + config.clone(), + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + ) + .await? + } + (None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?, + }; + agent_metadata.agent_id = Some(new_thread.thread_id); + reservation.commit(agent_metadata.clone()); + + if let Some(SessionSource::SubAgent( + subagent_source @ SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }, + )) = notification_source.as_ref() + { + let client_metadata = match state.get_thread(*parent_thread_id).await { + Ok(parent_thread) => { + parent_thread + .codex + .session + .app_server_client_metadata() + .await + } + Err(error) => { + tracing::warn!( + error = %error, + parent_thread_id = %parent_thread_id, + "skipping subagent thread analytics: failed to load parent thread metadata" + ); + crate::session::session::AppServerClientMetadata { + client_name: None, + client_version: None, + } + } + }; + let thread_config = new_thread.thread.codex.thread_config_snapshot().await; + emit_subagent_session_started( + &new_thread + .thread + .codex + .session + .services + .analytics_events_client, + client_metadata, + new_thread.thread_id, + /*parent_thread_id*/ None, + thread_config, + subagent_source.clone(), + ); + } + + // Notify a new thread has been created. This notification will be processed by clients + // to subscribe or drain this newly created thread. + // TODO(jif) add helper for drain + state.notify_thread_created(new_thread.thread_id); + + self.persist_thread_spawn_edge_for_source( + new_thread.thread.as_ref(), + new_thread.thread_id, + notification_source.as_ref(), + ) + .await; + + self.send_input(new_thread.thread_id, initial_operation) + .await?; + if !new_thread.thread.enabled(Feature::MultiAgentV2) { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| new_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + new_thread.thread_id, + notification_source, + child_reference, + agent_metadata.agent_path.clone(), + ); + } + + Ok(LiveAgent { + thread_id: new_thread.thread_id, + metadata: agent_metadata, + status: self.get_status(new_thread.thread_id).await, + }) + } + + async fn spawn_forked_thread( + &self, + state: &Arc, + config: crate::config::Config, + session_source: SessionSource, + options: &SpawnAgentOptions, + inherited_shell_snapshot: Option>, + inherited_exec_policy: Option>, + ) -> CodexResult { + if options.fork_parent_spawn_call_id.is_none() { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a parent spawn call id".to_string(), + )); + } + let Some(fork_mode) = options.fork_mode.as_ref() else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a fork mode".to_string(), + )); + }; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = &session_source + else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a thread-spawn session source".to_string(), + )); + }; + + let parent_thread_id = *parent_thread_id; + let parent_thread = state.get_thread(parent_thread_id).await.ok(); + if let Some(parent_thread) = parent_thread.as_ref() { + // `record_conversation_items` only queues persistence writes asynchronously. + // Flush before snapshotting store history for a fork. + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await?; + } + + let parent_history = state + .read_stored_thread(ReadThreadParams { + thread_id: parent_thread_id, + include_archived: true, + include_history: true, + }) + .await? + .history + .ok_or_else(|| { + CodexErr::Fatal(format!( + "parent thread history unavailable for fork: {parent_thread_id}" + )) + })?; + + let mut forked_rollout_items = parent_history.items; + if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { + forked_rollout_items = + truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); + } + // MultiAgentV2 root/subagent usage hints are injected as standalone developer + // messages at thread start. When forking history, drop hints from the parent + // so the child gets a fresh hint that matches its own session source/config. + let multi_agent_v2_usage_hint_texts_to_filter: Vec = + if let Some(parent_thread) = parent_thread.as_ref() { + parent_thread + .codex + .session + .configured_multi_agent_v2_usage_hint_texts() + .await + } else if config.features.enabled(Feature::MultiAgentV2) { + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; + forked_rollout_items.retain(|item| { + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item + && role == "developer" + && let [ContentItem::InputText { text }] = content.as_slice() + && multi_agent_v2_usage_hint_texts_to_filter + .iter() + .any(|usage_hint_text| usage_hint_text == text) + { + return false; + } + + keep_forked_rollout_item(item) + }); + + state + .fork_thread_with_source( + config.clone(), + InitialHistory::Forked(forked_rollout_items), + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*persist_extended_history*/ false, + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + ) + .await + } + + /// Resume an existing agent thread from a recorded rollout file. + pub(crate) async fn resume_agent_from_rollout( + &self, + config: crate::config::Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); + let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + thread_id, + session_source, + )) + .await?; + let state = self.upgrade()?; + let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { + return Ok(resumed_thread_id); + }; + let Some(state_db_ctx) = resumed_thread.state_db() else { + return Ok(resumed_thread_id); + }; + + let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); + while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { + let child_ids = match state_db_ctx + .list_thread_spawn_children_with_status( + parent_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + { + Ok(child_ids) => child_ids, + Err(err) => { + warn!( + "failed to load persisted thread-spawn children for {parent_thread_id}: {err}" + ); + continue; + } + }; + + for child_thread_id in child_ids { + let child_depth = parent_depth + 1; + let child_resumed = if state.get_thread(child_thread_id).await.is_ok() { + true + } else { + let child_session_source = + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: child_depth, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + match self + .resume_single_agent_from_rollout( + config.clone(), + child_thread_id, + child_session_source, + ) + .await + { + Ok(_) => true, + Err(err) => { + warn!("failed to resume descendant thread {child_thread_id}: {err}"); + false + } + } + }; + if child_resumed { + resume_queue.push_back((child_thread_id, child_depth)); + } + } + } + + Ok(resumed_thread_id) + } + + async fn resume_single_agent_from_rollout( + &self, + mut config: crate::config::Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = &session_source + && *depth >= config.agent_max_depth + && !config.features.enabled(Feature::MultiAgentV2) + { + let _ = config.features.disable(Feature::SpawnCsv); + let _ = config.features.disable(Feature::Collab); + } + let state = self.upgrade()?; + let state_db_ctx = state.state_db(); + let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let (session_source, agent_metadata) = match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role: _, + agent_nickname: _, + }) => { + let (resumed_agent_nickname, resumed_agent_role) = + if let Some(state_db_ctx) = state_db_ctx.as_ref() { + match state_db_ctx.get_thread(thread_id).await { + Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), + Ok(None) | Err(_) => (None, None), + } + } else { + (None, None) + }; + self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + resumed_agent_role, + resumed_agent_nickname, + )? + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, Some(&session_source)) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, Some(&session_source), &config) + .await; + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let history = stored_thread + .history + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? + .items; + + let resumed_thread = state + .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { + config: config.clone(), + initial_history: InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }), + agent_control: self.clone(), + session_source, + inherited_shell_snapshot, + inherited_exec_policy, + }) + .await?; + let mut agent_metadata = agent_metadata; + agent_metadata.agent_id = Some(resumed_thread.thread_id); + reservation.commit(agent_metadata.clone()); + // Resumed threads are re-registered in-memory and need the same listener + // attachment path as freshly spawned threads. + state.notify_thread_created(resumed_thread.thread_id); + if !resumed_thread.thread.enabled(Feature::MultiAgentV2) { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| resumed_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + resumed_thread.thread_id, + Some(notification_source.clone()), + child_reference, + agent_metadata.agent_path.clone(), + ); + } + self.persist_thread_spawn_edge_for_source( + resumed_thread.thread.as_ref(), + resumed_thread.thread_id, + Some(¬ification_source), + ) + .await; + + Ok(resumed_thread.thread_id) + } + + /// Send rich user input items to an existing agent thread. + pub(crate) async fn send_input( + &self, + agent_id: ThreadId, + initial_operation: Op, + ) -> CodexResult { + let last_task_message = render_input_preview(&initial_operation); + let state = self.upgrade()?; + let result = self + .handle_thread_request_result( + agent_id, + &state, + state.send_op(agent_id, initial_operation).await, + ) + .await; + if result.is_ok() { + self.state + .update_last_task_message(agent_id, last_task_message); + } + result + } + + /// Append a prebuilt message to an existing agent thread outside the normal user-input path. + #[cfg(test)] + pub(crate) async fn append_message( + &self, + agent_id: ThreadId, + message: ResponseItem, + ) -> CodexResult { + let state = self.upgrade()?; + self.handle_thread_request_result( + agent_id, + &state, + state.append_message(agent_id, message).await, + ) + .await + } + + pub(crate) async fn send_inter_agent_communication( + &self, + agent_id: ThreadId, + communication: InterAgentCommunication, + ) -> CodexResult { + let last_task_message = communication.content.clone(); + let state = self.upgrade()?; + let result = self + .handle_thread_request_result( + agent_id, + &state, + state + .send_op(agent_id, Op::InterAgentCommunication { communication }) + .await, + ) + .await; + if result.is_ok() { + self.state + .update_last_task_message(agent_id, last_task_message); + } + result + } + + /// Interrupt the current task for an existing agent thread. + pub(crate) async fn interrupt_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + state.send_op(agent_id, Op::Interrupt).await + } + + async fn handle_thread_request_result( + &self, + agent_id: ThreadId, + state: &Arc, + result: CodexResult, + ) -> CodexResult { + if matches!(result, Err(CodexErr::InternalAgentDied)) { + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + } + result + } + + /// Submit a shutdown request for a live agent without marking it explicitly closed in + /// persisted spawn-edge state. + pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + let result = if let Ok(thread) = state.get_thread(agent_id).await { + thread.codex.session.ensure_rollout_materialized().await; + thread.codex.session.flush_rollout().await?; + let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { + Ok(String::new()) + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + thread.wait_until_terminated().await; + result + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + result + } + + /// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the + /// agent and any live descendants reached from the in-memory tree. + pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + if let Ok(thread) = state.get_thread(agent_id).await + && let Some(state_db_ctx) = thread.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status(agent_id, DirectionalThreadSpawnEdgeStatus::Closed) + .await + { + warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + } + Box::pin(self.shutdown_agent_tree(agent_id)).await + } + + /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. + async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { + let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; + let result = self.shutdown_live_agent(agent_id).await; + for descendant_id in descendant_ids { + match self.shutdown_live_agent(descendant_id).await { + Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} + Err(err) => return Err(err), + } + } + result + } + + /// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable. + pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus { + let Ok(state) = self.upgrade() else { + // No agent available if upgrade fails. + return AgentStatus::NotFound; + }; + let Ok(thread) = state.get_thread(agent_id).await else { + return AgentStatus::NotFound; + }; + thread.agent_status().await + } + + pub(crate) fn register_session_root( + &self, + current_thread_id: ThreadId, + current_session_source: &SessionSource, + ) { + if thread_spawn_parent_thread_id(current_session_source).is_none() { + self.state.register_root_thread(current_thread_id); + } + } + + pub(crate) fn get_agent_metadata(&self, agent_id: ThreadId) -> Option { + self.state.agent_metadata_for_thread(agent_id) + } + + pub(crate) async fn list_live_agent_subtree_thread_ids( + &self, + agent_id: ThreadId, + ) -> CodexResult> { + let mut thread_ids = vec![agent_id]; + thread_ids.extend(self.live_thread_spawn_descendants(agent_id).await?); + Ok(thread_ids) + } + + pub(crate) async fn get_agent_config_snapshot( + &self, + agent_id: ThreadId, + ) -> Option { + let Ok(state) = self.upgrade() else { + return None; + }; + let Ok(thread) = state.get_thread(agent_id).await else { + return None; + }; + Some(thread.config_snapshot().await) + } + + pub(crate) async fn resolve_agent_reference( + &self, + _current_thread_id: ThreadId, + current_session_source: &SessionSource, + agent_reference: &str, + ) -> CodexResult { + let current_agent_path = current_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let agent_path = current_agent_path + .resolve(agent_reference) + .map_err(CodexErr::UnsupportedOperation)?; + if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) { + return Ok(thread_id); + } + Err(CodexErr::UnsupportedOperation(format!( + "live agent path `{}` not found", + agent_path.as_str() + ))) + } + + /// Subscribe to status updates for `agent_id`, yielding the latest value and changes. + pub(crate) async fn subscribe_status( + &self, + agent_id: ThreadId, + ) -> CodexResult> { + let state = self.upgrade()?; + let thread = state.get_thread(agent_id).await?; + Ok(thread.subscribe_status()) + } + + pub(crate) async fn format_environment_context_subagents( + &self, + parent_thread_id: ThreadId, + ) -> String { + let Ok(agents) = self.open_thread_spawn_children(parent_thread_id).await else { + return String::new(); + }; + + agents + .into_iter() + .map(|(thread_id, metadata)| { + let reference = metadata + .agent_path + .as_ref() + .map(|agent_path| agent_path.name().to_string()) + .unwrap_or_else(|| thread_id.to_string()); + format_subagent_context_line(reference.as_str(), metadata.agent_nickname.as_deref()) + }) + .collect::>() + .join("\n") + } + + pub(crate) async fn list_agents( + &self, + current_session_source: &SessionSource, + path_prefix: Option<&str>, + ) -> CodexResult> { + let state = self.upgrade()?; + let resolved_prefix = path_prefix + .map(|prefix| { + current_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .resolve(prefix) + .map_err(CodexErr::UnsupportedOperation) + }) + .transpose()?; + + let mut live_agents = self.state.live_agents(); + live_agents.sort_by(|left, right| { + left.agent_path + .as_deref() + .unwrap_or_default() + .cmp(right.agent_path.as_deref().unwrap_or_default()) + .then_with(|| { + left.agent_id + .map(|id| id.to_string()) + .unwrap_or_default() + .cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default()) + }) + }); + + let root_path = AgentPath::root(); + let mut agents = Vec::with_capacity(live_agents.len().saturating_add(1)); + if resolved_prefix + .as_ref() + .is_none_or(|prefix| agent_matches_prefix(Some(&root_path), prefix)) + && let Some(root_thread_id) = self.state.agent_id_for_path(&root_path) + && let Ok(root_thread) = state.get_thread(root_thread_id).await + { + agents.push(ListedAgent { + agent_name: root_path.to_string(), + agent_status: root_thread.agent_status().await, + last_task_message: Some(ROOT_LAST_TASK_MESSAGE.to_string()), + }); + } + + for metadata in live_agents { + let Some(thread_id) = metadata.agent_id else { + continue; + }; + if resolved_prefix + .as_ref() + .is_some_and(|prefix| !agent_matches_prefix(metadata.agent_path.as_ref(), prefix)) + { + continue; + } + + let Ok(thread) = state.get_thread(thread_id).await else { + continue; + }; + let agent_name = metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| thread_id.to_string()); + let last_task_message = metadata.last_task_message.clone(); + agents.push(ListedAgent { + agent_name, + agent_status: thread.agent_status().await, + last_task_message, + }); + } + + Ok(agents) + } + + /// Starts a detached watcher for sub-agents spawned from another thread. + /// + /// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and + /// can receive completion notifications. + fn maybe_start_completion_watcher( + &self, + child_thread_id: ThreadId, + session_source: Option, + child_reference: String, + child_agent_path: Option, + ) { + let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + })) = session_source + else { + return; + }; + let control = self.clone(); + tokio::spawn(async move { + let status = match control.subscribe_status(child_thread_id).await { + Ok(mut status_rx) => { + let mut status = status_rx.borrow().clone(); + while !is_final(&status) { + if status_rx.changed().await.is_err() { + status = control.get_status(child_thread_id).await; + break; + } + status = status_rx.borrow().clone(); + } + status + } + Err(_) => control.get_status(child_thread_id).await, + }; + if !is_final(&status) { + return; + } + + let Ok(state) = control.upgrade() else { + return; + }; + let child_thread = state.get_thread(child_thread_id).await.ok(); + let message = format_subagent_notification_message(child_reference.as_str(), &status); + if child_agent_path.is_some() + && child_thread + .as_ref() + .map(|thread| thread.enabled(Feature::MultiAgentV2)) + .unwrap_or(true) + { + let Some(child_agent_path) = child_agent_path.clone() else { + return; + }; + let Some(parent_agent_path) = child_agent_path + .as_str() + .rsplit_once('/') + .and_then(|(parent, _)| AgentPath::try_from(parent).ok()) + else { + return; + }; + let communication = InterAgentCommunication::new( + child_agent_path, + parent_agent_path, + Vec::new(), + message, + /*trigger_turn*/ false, + ); + let _ = control + .send_inter_agent_communication(parent_thread_id, communication) + .await; + return; + } + let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { + return; + }; + parent_thread + .inject_user_message_without_turn(message) + .await; + }); + } + + #[allow(clippy::too_many_arguments)] + fn prepare_thread_spawn( + &self, + reservation: &mut crate::agent::registry::SpawnReservation, + config: &crate::config::Config, + parent_thread_id: ThreadId, + depth: i32, + agent_path: Option, + agent_role: Option, + preferred_agent_nickname: Option, + ) -> CodexResult<(SessionSource, AgentMetadata)> { + if depth == 1 { + self.state.register_root_thread(parent_thread_id); + } + if let Some(agent_path) = agent_path.as_ref() { + reservation.reserve_agent_path(agent_path)?; + } + let candidate_names = agent_nickname_candidates(config, agent_role.as_deref()); + let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect(); + let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference( + &candidate_name_refs, + preferred_agent_nickname.as_deref(), + )?); + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path: agent_path.clone(), + agent_nickname: agent_nickname.clone(), + agent_role: agent_role.clone(), + }); + let agent_metadata = AgentMetadata { + agent_id: None, + agent_path, + agent_nickname, + agent_role, + last_task_message: None, + }; + Ok((session_source, agent_metadata)) + } + + fn upgrade(&self) -> CodexResult> { + self.manager + .upgrade() + .ok_or_else(|| CodexErr::UnsupportedOperation("thread manager dropped".to_string())) + } + + async fn inherited_shell_snapshot_for_source( + &self, + state: &Arc, + session_source: Option<&SessionSource>, + ) -> Option> { + let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + })) = session_source + else { + return None; + }; + + let parent_thread = state.get_thread(*parent_thread_id).await.ok()?; + parent_thread.codex.session.user_shell().shell_snapshot() + } + + async fn inherited_exec_policy_for_source( + &self, + state: &Arc, + session_source: Option<&SessionSource>, + child_config: &crate::config::Config, + ) -> Option> { + let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + })) = session_source + else { + return None; + }; + + let parent_thread = state.get_thread(*parent_thread_id).await.ok()?; + let parent_config = parent_thread.codex.session.get_config().await; + if !crate::exec_policy::child_uses_parent_exec_policy(&parent_config, child_config) { + return None; + } + + Some(Arc::clone( + &parent_thread.codex.session.services.exec_policy, + )) + } + + async fn open_thread_spawn_children( + &self, + parent_thread_id: ThreadId, + ) -> CodexResult> { + let mut children_by_parent = self.live_thread_spawn_children().await?; + Ok(children_by_parent + .remove(&parent_thread_id) + .unwrap_or_default()) + } + + async fn live_thread_spawn_children( + &self, + ) -> CodexResult>> { + let state = self.upgrade()?; + let mut children_by_parent = HashMap::>::new(); + + for thread_id in state.list_thread_ids().await { + let Ok(thread) = state.get_thread(thread_id).await else { + continue; + }; + let snapshot = thread.config_snapshot().await; + let Some(parent_thread_id) = thread_spawn_parent_thread_id(&snapshot.session_source) + else { + continue; + }; + children_by_parent + .entry(parent_thread_id) + .or_default() + .push(( + thread_id, + self.state + .agent_metadata_for_thread(thread_id) + .unwrap_or(AgentMetadata { + agent_id: Some(thread_id), + ..Default::default() + }), + )); + } + + for children in children_by_parent.values_mut() { + children.sort_by(|left, right| { + left.1 + .agent_path + .as_deref() + .unwrap_or_default() + .cmp(right.1.agent_path.as_deref().unwrap_or_default()) + .then_with(|| left.0.to_string().cmp(&right.0.to_string())) + }); + } + + Ok(children_by_parent) + } + + async fn persist_thread_spawn_edge_for_source( + &self, + thread: &crate::CodexThread, + child_thread_id: ThreadId, + session_source: Option<&SessionSource>, + ) { + let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else { + return; + }; + let Some(state_db_ctx) = thread.state_db() else { + return; + }; + if let Err(err) = state_db_ctx + .upsert_thread_spawn_edge( + parent_thread_id, + child_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + { + warn!("failed to persist thread-spawn edge: {err}"); + } + } + + async fn live_thread_spawn_descendants( + &self, + root_thread_id: ThreadId, + ) -> CodexResult> { + let mut children_by_parent = self.live_thread_spawn_children().await?; + let mut descendants = Vec::new(); + let mut stack = children_by_parent + .remove(&root_thread_id) + .unwrap_or_default() + .into_iter() + .map(|(child_thread_id, _)| child_thread_id) + .rev() + .collect::>(); + + while let Some(thread_id) = stack.pop() { + descendants.push(thread_id); + if let Some(children) = children_by_parent.remove(&thread_id) { + for (child_thread_id, _) in children.into_iter().rev() { + stack.push(child_thread_id); + } + } + } + + Ok(descendants) + } +} + +fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) => Some(*parent_thread_id), + _ => None, + } +} + +fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool { + if prefix.is_root() { + return true; + } + + agent_path.is_some_and(|agent_path| { + agent_path == prefix + || agent_path + .as_str() + .strip_prefix(prefix.as_str()) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +pub(crate) fn render_input_preview(initial_operation: &Op) -> String { + match initial_operation { + Op::UserInput { items, .. } => items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), + UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect::>() + .join("\n"), + Op::InterAgentCommunication { communication } => communication.content.clone(), + _ => String::new(), + } +} + +fn thread_spawn_depth(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), + _ => None, + } +} +#[cfg(test)] +#[path = "control_tests.rs"] +mod tests; diff --git a/code-rs/core/src/agent/control_tests.rs b/code-rs/core/src/agent/control_tests.rs new file mode 100644 index 00000000000..b95aad4489f --- /dev/null +++ b/code-rs/core/src/agent/control_tests.rs @@ -0,0 +1,2543 @@ +use super::*; +use crate::CodexThread; +use crate::StateDbHandle; +use crate::ThreadManager; +use crate::agent::agent_status_from_event; +use crate::config::AgentRoleConfig; +use crate::config::Config; +use crate::config::ConfigBuilder; +use crate::context::ContextualUserFragment; +use crate::context::SubagentNotification; +use crate::init_state_db; +use assert_matches::assert_matches; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_protocol::AgentPath; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_thread_store::ArchiveThreadParams; +use codex_thread_store::LocalThreadStore; +use codex_thread_store::LocalThreadStoreConfig; +use codex_thread_store::ThreadStore; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::sleep; +use tokio::time::timeout; +use toml::Value as TomlValue; + +async fn test_config_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, +) -> (TempDir, Config) { + let home = TempDir::new().expect("create temp dir"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(home.path().to_path_buf()) + .cli_overrides(cli_overrides) + .build() + .await + .expect("load default test config"); + (home, config) +} + +async fn test_config() -> (TempDir, Config) { + test_config_with_cli_overrides(Vec::new()).await +} + +fn text_input(text: &str) -> Op { + vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }] + .into() +} + +fn assistant_message(text: &str, phase: Option) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + phase, + } +} + +fn spawn_agent_call(call_id: &str) -> ResponseItem { + ResponseItem::FunctionCall { + id: None, + name: "spawn_agent".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: call_id.to_string(), + } +} + +struct AgentControlHarness { + _home: TempDir, + config: Config, + state_db: Option, + manager: ThreadManager, + control: AgentControl, +} + +impl AgentControlHarness { + async fn new() -> Self { + let (home, config) = test_config().await; + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), + ); + let control = manager.agent_control(); + Self { + _home: home, + config, + state_db, + manager, + control, + } + } + + async fn start_thread(&self) -> (ThreadId, Arc) { + let new_thread = self + .manager + .start_thread(self.config.clone()) + .await + .expect("start thread"); + (new_thread.thread_id, new_thread.thread) + } +} + +fn has_subagent_notification(history_items: &[ResponseItem]) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + SubagentNotification::matches_text(text) + } + ContentItem::InputImage { .. } => false, + }) + }) +} + +/// Returns true when any message item contains `needle` in a text span. +fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { content, .. } = item else { + return false; + }; + content.iter().any(|content_item| match content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + text.contains(needle) + } + ContentItem::InputImage { .. } => false, + }) + }) +} + +fn history_contains_assistant_inter_agent_communication( + history_items: &[ResponseItem], + expected: &InterAgentCommunication, +) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "assistant" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::OutputText { text } => { + serde_json::from_str::(text) + .ok() + .as_ref() + == Some(expected) + } + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false, + }) + }) +} + +async fn wait_for_subagent_notification(parent_thread: &Arc) -> bool { + let wait = async { + loop { + let history_items = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + if has_subagent_notification(&history_items) { + return true; + } + sleep(Duration::from_millis(25)).await; + } + }; + // CI can take several seconds to schedule the detached completion watcher, + // especially on slower Windows runners. + timeout(Duration::from_secs(10), wait).await.is_ok() +} + +async fn persist_thread_for_tree_resume(thread: &Arc, message: &str) { + thread + .inject_user_message_without_turn(message.to_string()) + .await; + thread.codex.session.ensure_rollout_materialized().await; + thread + .codex + .session + .flush_rollout() + .await + .expect("test thread rollout should flush"); +} + +async fn wait_for_live_thread_spawn_children( + control: &AgentControl, + parent_thread_id: ThreadId, + expected_children: &[ThreadId], +) { + let mut expected_children = expected_children.to_vec(); + expected_children.sort_by_key(std::string::ToString::to_string); + + timeout(Duration::from_secs(5), async { + loop { + let mut child_ids = control + .open_thread_spawn_children(parent_thread_id) + .await + .expect("live child list should load") + .into_iter() + .map(|(thread_id, _)| thread_id) + .collect::>(); + child_ids.sort_by_key(std::string::ToString::to_string); + if child_ids == expected_children { + break; + } + sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("expected persisted child tree"); +} + +#[tokio::test] +async fn send_input_errors_when_manager_dropped() { + let control = AgentControl::default(); + let err = control + .send_input( + ThreadId::new(), + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }] + .into(), + ) + .await + .expect_err("send_input should fail without a manager"); + assert_eq!( + err.to_string(), + "unsupported operation: thread manager dropped" + ); +} + +#[tokio::test] +async fn get_status_returns_not_found_without_manager() { + let control = AgentControl::default(); + let got = control.get_status(ThreadId::new()).await; + assert_eq!(got, AgentStatus::NotFound); +} + +#[tokio::test] +async fn on_event_updates_status_from_task_started() { + let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + })); + assert_eq!(status, Some(AgentStatus::Running)); +} + +#[tokio::test] +async fn on_event_updates_status_from_task_complete() { + let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })); + let expected = AgentStatus::Completed(Some("done".to_string())); + assert_eq!(status, Some(expected)); +} + +#[tokio::test] +async fn on_event_updates_status_from_error() { + let status = agent_status_from_event(&EventMsg::Error(ErrorEvent { + message: "boom".to_string(), + codex_error_info: None, + })); + + let expected = AgentStatus::Errored("boom".to_string()); + assert_eq!(status, Some(expected)); +} + +#[tokio::test] +async fn on_event_updates_status_from_turn_aborted() { + let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + })); + + let expected = AgentStatus::Interrupted; + assert_eq!(status, Some(expected)); +} + +#[tokio::test] +async fn on_event_updates_status_from_shutdown_complete() { + let status = agent_status_from_event(&EventMsg::ShutdownComplete); + assert_eq!(status, Some(AgentStatus::Shutdown)); +} + +#[tokio::test] +async fn spawn_agent_errors_when_manager_dropped() { + let control = AgentControl::default(); + let (_home, config) = test_config().await; + let err = control + .spawn_agent(config, text_input("hello"), /*session_source*/ None) + .await + .expect_err("spawn_agent should fail without a manager"); + assert_eq!( + err.to_string(), + "unsupported operation: thread manager dropped" + ); +} + +#[tokio::test] +async fn resume_agent_errors_when_manager_dropped() { + let control = AgentControl::default(); + let (_home, config) = test_config().await; + let err = control + .resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec) + .await + .expect_err("resume_agent should fail without a manager"); + assert_eq!( + err.to_string(), + "unsupported operation: thread manager dropped" + ); +} + +#[tokio::test] +async fn send_input_errors_when_thread_missing() { + let harness = AgentControlHarness::new().await; + let thread_id = ThreadId::new(); + let err = harness + .control + .send_input( + thread_id, + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }] + .into(), + ) + .await + .expect_err("send_input should fail for missing thread"); + assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id); +} + +#[tokio::test] +async fn get_status_returns_not_found_for_missing_thread() { + let harness = AgentControlHarness::new().await; + let status = harness.control.get_status(ThreadId::new()).await; + assert_eq!(status, AgentStatus::NotFound); +} + +#[tokio::test] +async fn get_status_returns_pending_init_for_new_thread() { + let harness = AgentControlHarness::new().await; + let (thread_id, _) = harness.start_thread().await; + let status = harness.control.get_status(thread_id).await; + assert_eq!(status, AgentStatus::PendingInit); +} + +#[tokio::test] +async fn subscribe_status_errors_for_missing_thread() { + let harness = AgentControlHarness::new().await; + let thread_id = ThreadId::new(); + let err = harness + .control + .subscribe_status(thread_id) + .await + .expect_err("subscribe_status should fail for missing thread"); + assert_matches!(err, CodexErr::ThreadNotFound(id) if id == thread_id); +} + +#[tokio::test] +async fn subscribe_status_updates_on_shutdown() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let mut status_rx = harness + .control + .subscribe_status(thread_id) + .await + .expect("subscribe_status should succeed"); + assert_eq!(status_rx.borrow().clone(), AgentStatus::PendingInit); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); + + let _ = status_rx.changed().await; + assert_eq!(status_rx.borrow().clone(), AgentStatus::Shutdown); +} + +#[tokio::test] +async fn send_input_submits_user_message() { + let harness = AgentControlHarness::new().await; + let (thread_id, _thread) = harness.start_thread().await; + + let submission_id = harness + .control + .send_input( + thread_id, + vec![UserInput::Text { + text: "hello from tests".to_string(), + text_elements: Vec::new(), + }] + .into(), + ) + .await + .expect("send_input should succeed"); + assert!(!submission_id.is_empty()); + let expected = ( + thread_id, + Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello from tests".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); +} + +#[tokio::test] +async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "hello from tests".to_string(), + /*trigger_turn*/ false, + ); + + let submission_id = harness + .control + .send_inter_agent_communication(thread_id, communication.clone()) + .await + .expect("send_inter_agent_communication should succeed"); + assert!(!submission_id.is_empty()); + + let expected = ( + thread_id, + Op::InterAgentCommunication { + communication: communication.clone(), + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); + + timeout(Duration::from_secs(5), async { + loop { + if thread.codex.session.has_pending_input().await { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("inter-agent communication should stay pending"); + + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &history_items, + &communication + )); +} + +#[tokio::test] +async fn append_message_records_assistant_message() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let message = + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests"; + + let submission_id = harness + .control + .append_message( + thread_id, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::InputText { + text: message.to_string(), + }], + phase: None, + }, + ) + .await + .expect("append_message should succeed"); + assert!(!submission_id.is_empty()); + + timeout(Duration::from_secs(5), async { + loop { + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let recorded = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == message + )) + ) + }); + if recorded { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("assistant message should be recorded"); +} + +#[tokio::test] +async fn spawn_agent_creates_thread_and_sends_prompt() { + let harness = AgentControlHarness::new().await; + let thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("spawned"), + /*session_source*/ None, + ) + .await + .expect("spawn_agent should succeed"); + let _thread = harness + .manager + .get_thread(thread_id) + .await + .expect("thread should be registered"); + let expected = ( + thread_id, + Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "spawned".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); +} + +#[tokio::test] +async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { + let harness = AgentControlHarness::new().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Parent root guidance.".to_string()); + parent_config.multi_agent_v2.subagent_usage_hint_text = + Some("Parent subagent guidance.".to_string()); + let mut child_config = harness.config.clone(); + let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Child root guidance.".to_string()); + child_config.multi_agent_v2.subagent_usage_hint_text = + Some("Child subagent guidance.".to_string()); + let new_thread = harness + .manager + .start_thread(parent_config.clone()) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; + parent_thread + .inject_user_message_without_turn("parent seed context".to_string()) + .await; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-history".to_string(); + let trigger_message = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "parent trigger message".to_string(), + /*trigger_turn*/ true, + ); + parent_thread + .codex + .session + .record_conversation_items( + turn_context.as_ref(), + &[ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent root guidance.".to_string(), + }], + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent subagent guidance.".to_string(), + }], + phase: None, + }, + assistant_message("parent commentary", Some(MessagePhase::Commentary)), + assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + assistant_message("parent unknown phase", /*phase*/ None), + ResponseItem::Reasoning { + id: "parent-reasoning".to_string(), + summary: Vec::new(), + content: None, + encrypted_content: None, + }, + trigger_message.to_response_input_item().into(), + spawn_agent_call(&parent_spawn_call_id), + ], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + child_config, + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() + }, + ) + .await + .expect("forked spawn should succeed") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + assert_ne!(child_thread_id, parent_thread_id); + let history = child_thread.codex.session.clone_history().await; + let expected_history = [ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "parent seed context".to_string(), + }], + phase: None, + }, + assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + ]; + assert_eq!( + history.raw_items(), + &expected_history, + "forked child history should keep only parent user messages and assistant final answers" + ); + + let expected = ( + child_thread_id, + Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "child task".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-unflushed".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + turn_context.as_ref(), + &[ + assistant_message("unflushed final answer", Some(MessagePhase::FinalAnswer)), + spawn_agent_call(&parent_spawn_call_id), + ], + ) + .await; + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() + }, + ) + .await + .expect("forked spawn should flush parent rollout before loading history") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "unflushed final answer"), + "forked child history should include unflushed assistant final answers after flushing the parent rollout" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + parent_thread + .inject_user_message_without_turn("old parent context".to_string()) + .await; + let queued_communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "queued message".to_string(), + /*trigger_turn*/ false, + ); + let queued_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + queued_turn_context.as_ref(), + &[queued_communication.to_response_input_item().into()], + ) + .await; + + let triggered_communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "triggered context".to_string(), + /*trigger_turn*/ true, + ); + let triggered_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + triggered_turn_context.as_ref(), + &[triggered_communication.to_response_input_item().into()], + ) + .await; + parent_thread + .inject_user_message_without_turn("current parent task".to_string()) + .await; + let spawn_turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + spawn_turn_context.as_ref(), + &[spawn_agent_call(&parent_spawn_call_id)], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() + }, + ) + .await + .expect("forked spawn should keep only the last two turns") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + + assert!( + !history_contains_text(history.raw_items(), "old parent context"), + "forked child history should drop parent context outside the requested last-N turn window" + ); + assert!( + !history_contains_text(history.raw_items(), "queued message"), + "forked child history should drop queued inter-agent messages outside the requested last-N turn window" + ); + assert!( + !history_contains_text(history.raw_items(), "triggered context"), + "forked child history should filter assistant inter-agent messages even when they fall inside the requested last-N turn window" + ); + assert!( + history_contains_text(history.raw_items(), "current parent task"), + "forked child history should keep the parent user message from the requested last-N turn window" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_respects_max_threads_limit() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let control = manager.agent_control(); + + let _ = manager + .start_thread(config.clone()) + .await + .expect("start thread"); + + let first_agent_id = control + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent( + config, + text_input("hello again"), + /*session_source*/ None, + ) + .await + .expect_err("spawn_agent should respect max threads"); + let CodexErr::AgentLimitReached { + max_threads: seen_max_threads, + } = err + else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(seen_max_threads, max_threads); + + let _ = control + .shutdown_live_agent(first_agent_id) + .await + .expect("shutdown agent"); +} + +#[tokio::test] +async fn spawn_agent_releases_slot_after_shutdown() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let control = manager.agent_control(); + + let first_agent_id = control + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) + .await + .expect("spawn_agent should succeed"); + let _ = control + .shutdown_live_agent(first_agent_id) + .await + .expect("shutdown agent"); + + let second_agent_id = control + .spawn_agent( + config.clone(), + text_input("hello again"), + /*session_source*/ None, + ) + .await + .expect("spawn_agent should succeed after shutdown"); + let _ = control + .shutdown_live_agent(second_agent_id) + .await + .expect("shutdown agent"); +} + +#[tokio::test] +async fn spawn_agent_limit_shared_across_clones() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let control = manager.agent_control(); + let cloned = control.clone(); + + let first_agent_id = cloned + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent( + config, + text_input("hello again"), + /*session_source*/ None, + ) + .await + .expect_err("spawn_agent should respect shared guard"); + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + let _ = control + .shutdown_live_agent(first_agent_id) + .await + .expect("shutdown agent"); +} + +#[tokio::test] +async fn resume_agent_respects_max_threads_limit() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let control = manager.agent_control(); + + let resumable_id = control + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) + .await + .expect("spawn_agent should succeed"); + let _ = control + .shutdown_live_agent(resumable_id) + .await + .expect("shutdown resumable thread"); + + let active_id = control + .spawn_agent( + config.clone(), + text_input("occupy"), + /*session_source*/ None, + ) + .await + .expect("spawn_agent should succeed for active slot"); + + let err = control + .resume_agent_from_rollout(config, resumable_id, SessionSource::Exec) + .await + .expect_err("resume should respect max threads"); + let CodexErr::AgentLimitReached { + max_threads: seen_max_threads, + } = err + else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(seen_max_threads, max_threads); + + let _ = control + .shutdown_live_agent(active_id) + .await + .expect("shutdown active thread"); +} + +#[tokio::test] +async fn resume_agent_releases_slot_after_resume_failure() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let control = manager.agent_control(); + + let _ = control + .resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec) + .await + .expect_err("resume should fail for missing rollout path"); + + let resumed_id = control + .spawn_agent(config, text_input("hello"), /*session_source*/ None) + .await + .expect("spawn should succeed after failed resume"); + let _ = control + .shutdown_live_agent(resumed_id) + .await + .expect("shutdown resumed thread"); +} + +#[tokio::test] +async fn spawn_child_completion_notifies_parent_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let _ = child_thread + .submit(Op::Shutdown {}) + .await + .expect("child shutdown should submit"); + + assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); +} + +#[tokio::test] +async fn multi_agent_v2_completion_ignores_dead_direct_parent() { + let harness = AgentControlHarness::new().await; + let (root_thread_id, root_thread) = harness.start_thread().await; + let mut config = harness.config.clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let worker_thread_id = harness + .control + .spawn_agent( + config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let tester_path = worker_path.join("tester").expect("tester path"); + let tester_thread_id = harness + .control + .spawn_agent( + config, + text_input("hello tester"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("tester spawn should succeed"); + harness + .control + .shutdown_live_agent(worker_thread_id) + .await + .expect("worker shutdown should succeed"); + + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ) + .await; + + sleep(Duration::from_millis(100)).await; + + assert!( + !harness + .manager + .captured_ops() + .into_iter() + .any(|(thread_id, op)| { + thread_id == worker_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == tester_path + && communication.recipient == worker_path + && communication.content == "done" + ) + }) + ); + + let root_history_items = root_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &root_history_items, + &InterAgentCommunication::new( + tester_path, + AgentPath::root(), + Vec::new(), + "done".to_string(), + /*trigger_turn*/ true, + ) + )); + assert!(!has_subagent_notification(&root_history_items)); +} + +#[tokio::test] +async fn multi_agent_v2_completion_queues_message_for_direct_parent() { + let harness = AgentControlHarness::new().await; + let (_root_thread_id, root_thread) = harness.start_thread().await; + let (worker_thread_id, _worker_thread) = harness.start_thread().await; + let mut tester_config = harness.config.clone(); + let _ = tester_config.features.enable(Feature::MultiAgentV2); + let tester_thread_id = harness + .manager + .start_thread(tester_config.clone()) + .await + .expect("tester thread should start") + .thread_id; + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let tester_path = worker_path.join("tester").expect("tester path"); + harness.control.maybe_start_completion_watcher( + tester_thread_id, + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + tester_path.to_string(), + Some(tester_path.clone()), + ); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ) + .await; + + let expected_message = crate::session_prefix::format_subagent_notification_message( + tester_path.as_str(), + &AgentStatus::Completed(Some("done".to_string())), + ); + let expected = ( + worker_thread_id, + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + tester_path.clone(), + worker_path.clone(), + Vec::new(), + expected_message.clone(), + /*trigger_turn*/ false, + ), + }, + ); + + timeout(Duration::from_secs(5), async { + loop { + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + if captured == Some(expected.clone()) { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("completion watcher should queue a direct-parent message"); + + let root_history_items = root_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &root_history_items, + &InterAgentCommunication::new( + tester_path, + AgentPath::root(), + Vec::new(), + expected_message, + /*trigger_turn*/ false, + ) + )); +} + +#[tokio::test] +async fn completion_watcher_notifies_parent_when_child_is_missing() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let child_thread_id = ThreadId::new(); + + harness.control.maybe_start_completion_watcher( + child_thread_id, + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + child_thread_id.to_string(), + /*child_agent_path*/ None, + ); + + assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); + + let history_items = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert_eq!( + history_contains_text( + &history_items, + &format!("\"agent_path\":\"{child_thread_id}\"") + ), + true + ); + assert_eq!( + history_contains_text(&history_items, "\"status\":\"not_found\""), + true + ); +} + +#[tokio::test] +async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let snapshot = child_thread.config_snapshot().await; + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: seen_parent_thread_id, + depth, + agent_nickname, + agent_role, + .. + }) = snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(seen_parent_thread_id, parent_thread_id); + assert_eq!(depth, 1); + assert!(agent_nickname.is_some()); + assert_eq!(agent_role, Some("explorer".to_string())); +} + +#[tokio::test] +async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { + let mut harness = AgentControlHarness::new().await; + harness.config.agent_roles.insert( + "researcher".to_string(), + AgentRoleConfig { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec!["Atlas".to_string()]), + }, + ); + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("researcher".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let snapshot = child_thread.config_snapshot().await; + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) = + snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(agent_nickname, Some("Atlas".to_string())); +} + +#[tokio::test] +async fn resume_thread_subagent_restores_stored_nickname_and_role() { + let (home, mut config) = test_config().await; + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), + ); + let control = manager.agent_control(); + let harness = AgentControlHarness { + _home: home, + config, + state_db, + manager, + control, + }; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let agent_path = AgentPath::from_string("/root/explorer".to_string()) + .expect("test agent path should be valid"); + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let mut status_rx = harness + .control + .subscribe_status(child_thread_id) + .await + .expect("status subscription should succeed"); + if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + timeout(Duration::from_secs(5), async { + loop { + status_rx + .changed() + .await + .expect("child status should advance past pending init"); + if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + break; + } + } + }) + .await + .expect("child should initialize before shutdown"); + } + let original_snapshot = child_thread.config_snapshot().await; + let original_nickname = original_snapshot + .session_source + .get_nickname() + .expect("spawned sub-agent should have a nickname"); + let state_db = child_thread + .state_db() + .expect("sqlite state db should be available for nickname resume test"); + timeout(Duration::from_secs(5), async { + loop { + if let Ok(Some(metadata)) = state_db.get_thread(child_thread_id).await + && metadata.agent_nickname.is_some() + && metadata.agent_role.as_deref() == Some("explorer") + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child thread metadata should be persisted to sqlite before shutdown"); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + + let resumed_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + }), + ) + .await + .expect("resume should succeed"); + assert_eq!(resumed_thread_id, child_thread_id); + + let resumed_snapshot = harness + .manager + .get_thread(resumed_thread_id) + .await + .expect("resumed child thread should exist") + .config_snapshot() + .await; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: resumed_parent_thread_id, + depth: resumed_depth, + agent_path: resumed_agent_path, + agent_nickname: resumed_nickname, + agent_role: resumed_role, + .. + }) = resumed_snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_eq!(resumed_depth, 1); + assert_eq!(resumed_agent_path, Some(agent_path)); + assert_eq!(resumed_nickname, Some(original_nickname)); + assert_eq!(resumed_role, Some("explorer".to_string())); + + let _ = harness + .control + .shutdown_live_agent(resumed_thread_id) + .await + .expect("resumed child shutdown should submit"); +} + +#[tokio::test] +async fn resume_agent_from_rollout_reads_archived_rollout_path() { + let harness = AgentControlHarness::new().await; + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello"), + /*session_source*/ None, + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + persist_thread_for_tree_resume(&child_thread, "persist before archiving").await; + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should succeed"); + let store = LocalThreadStore::new( + LocalThreadStoreConfig::from_config(&harness.config), + harness.state_db.clone(), + ); + store + .archive_thread(ArchiveThreadParams { + thread_id: child_thread_id, + }) + .await + .expect("child thread should archive"); + + let resumed_thread_id = harness + .control + .resume_agent_from_rollout(harness.config.clone(), child_thread_id, SessionSource::Exec) + .await + .expect("resume should find archived rollout"); + assert_eq!(resumed_thread_id, child_thread_id); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("resumed child shutdown should succeed"); +} + +#[tokio::test] +async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let worker_path = AgentPath::root().join("worker").expect("worker path"); + let reviewer_path = AgentPath::root().join("reviewer").expect("reviewer path"); + + let worker_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let worker_child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello worker child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some( + worker_path + .join("child") + .expect("worker child path should be valid"), + ), + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("worker child spawn should succeed"); + let no_path_child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello anonymous child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("no-path child spawn should succeed"); + let no_path_grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello anonymous grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: no_path_child_thread_id, + depth: 3, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("no-path grandchild spawn should succeed"); + let _reviewer_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello reviewer"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(reviewer_path), + agent_nickname: None, + agent_role: Some("reviewer".to_string()), + })), + ) + .await + .expect("reviewer spawn should succeed"); + + let _ = harness + .control + .shutdown_live_agent(no_path_grandchild_thread_id) + .await + .expect("no-path grandchild shutdown should succeed"); + + let mut worker_subtree_thread_ids = harness + .manager + .list_agent_subtree_thread_ids(worker_thread_id) + .await + .expect("worker subtree thread ids should load"); + worker_subtree_thread_ids.sort_by_key(ToString::to_string); + let mut expected_worker_subtree_thread_ids = vec![ + worker_thread_id, + worker_child_thread_id, + no_path_child_thread_id, + no_path_grandchild_thread_id, + ]; + expected_worker_subtree_thread_ids.sort_by_key(ToString::to_string); + assert_eq!( + worker_subtree_thread_ids, + expected_worker_subtree_thread_ids + ); + + let mut no_path_child_subtree_thread_ids = harness + .manager + .list_agent_subtree_thread_ids(no_path_child_thread_id) + .await + .expect("no-path subtree thread ids should load"); + no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string); + let mut expected_no_path_child_subtree_thread_ids = + vec![no_path_child_thread_id, no_path_grandchild_thread_id]; + expected_no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string); + assert_eq!( + no_path_child_subtree_thread_ids, + expected_no_path_child_subtree_thread_ids + ); +} + +#[tokio::test] +async fn shutdown_agent_tree_closes_live_descendants() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let grandchild_thread = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("grandchild thread should exist"); + persist_thread_for_tree_resume(&child_thread, "child persisted").await; + persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) + .await; + + let _ = harness + .control + .shutdown_agent_tree(parent_thread_id) + .await + .expect("tree shutdown should succeed"); + + assert_eq!( + harness.control.get_status(parent_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(child_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(grandchild_thread_id).await, + AgentStatus::NotFound + ); + + let shutdown_ids = harness + .manager + .captured_ops() + .into_iter() + .filter_map(|(thread_id, op)| matches!(op, Op::Shutdown).then_some(thread_id)) + .collect::>(); + let mut expected_shutdown_ids = vec![parent_thread_id, child_thread_id, grandchild_thread_id]; + expected_shutdown_ids.sort_by_key(std::string::ToString::to_string); + let mut shutdown_ids = shutdown_ids; + shutdown_ids.sort_by_key(std::string::ToString::to_string); + assert_eq!(shutdown_ids, expected_shutdown_ids); +} + +#[tokio::test] +async fn shutdown_agent_tree_closes_descendants_when_started_at_child() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let grandchild_thread = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("grandchild thread should exist"); + persist_thread_for_tree_resume(&child_thread, "child persisted").await; + persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) + .await; + + let _ = harness + .control + .close_agent(child_thread_id) + .await + .expect("child close should succeed"); + + let _ = harness + .control + .shutdown_agent_tree(parent_thread_id) + .await + .expect("tree shutdown should succeed"); + + assert_eq!( + harness.control.get_status(child_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(grandchild_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(parent_thread_id).await, + AgentStatus::NotFound + ); + + let shutdown_ids = harness + .manager + .captured_ops() + .into_iter() + .filter_map(|(thread_id, op)| matches!(op, Op::Shutdown).then_some(thread_id)) + .collect::>(); + let mut expected_shutdown_ids = vec![parent_thread_id, child_thread_id, grandchild_thread_id]; + expected_shutdown_ids.sort_by_key(std::string::ToString::to_string); + let mut shutdown_ids = shutdown_ids; + shutdown_ids.sort_by_key(std::string::ToString::to_string); + assert_eq!(shutdown_ids, expected_shutdown_ids); +} + +#[tokio::test] +async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let grandchild_thread = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("grandchild thread should exist"); + persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; + persist_thread_for_tree_resume(&child_thread, "child persisted").await; + persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) + .await; + + let _ = harness + .control + .close_agent(child_thread_id) + .await + .expect("child close should succeed"); + let _ = harness + .control + .shutdown_live_agent(parent_thread_id) + .await + .expect("parent shutdown should succeed"); + + let resumed_parent_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + parent_thread_id, + SessionSource::Exec, + ) + .await + .expect("single-thread resume should succeed"); + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_ne!( + harness.control.get_status(parent_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(child_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(grandchild_thread_id).await, + AgentStatus::NotFound + ); + + let _ = harness + .control + .shutdown_agent_tree(parent_thread_id) + .await + .expect("tree shutdown after resume should succeed"); +} + +#[tokio::test] +async fn resume_closed_child_reopens_open_descendants() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let grandchild_thread = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("grandchild thread should exist"); + persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; + persist_thread_for_tree_resume(&child_thread, "child persisted").await; + persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) + .await; + + let _ = harness + .control + .close_agent(child_thread_id) + .await + .expect("child close should succeed"); + + let resumed_child_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + ) + .await + .expect("child resume should succeed"); + assert_eq!(resumed_child_thread_id, child_thread_id); + assert_ne!( + harness.control.get_status(child_thread_id).await, + AgentStatus::NotFound + ); + assert_ne!( + harness.control.get_status(grandchild_thread_id).await, + AgentStatus::NotFound + ); + + let _ = harness + .control + .close_agent(child_thread_id) + .await + .expect("child close after resume should succeed"); + let _ = harness + .control + .shutdown_live_agent(parent_thread_id) + .await + .expect("parent shutdown should succeed"); +} + +#[tokio::test] +async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdown() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let grandchild_thread = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("grandchild thread should exist"); + persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; + persist_thread_for_tree_resume(&child_thread, "child persisted").await; + persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) + .await; + + let report = harness + .manager + .shutdown_all_threads_bounded(Duration::from_secs(5)) + .await; + assert_eq!(report.submit_failed, Vec::::new()); + assert_eq!(report.timed_out, Vec::::new()); + + let resumed_parent_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + parent_thread_id, + SessionSource::Exec, + ) + .await + .expect("tree resume should succeed"); + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_ne!( + harness.control.get_status(parent_thread_id).await, + AgentStatus::NotFound + ); + assert_ne!( + harness.control.get_status(child_thread_id).await, + AgentStatus::NotFound + ); + assert_ne!( + harness.control.get_status(grandchild_thread_id).await, + AgentStatus::NotFound + ); + + let _ = harness + .control + .shutdown_agent_tree(parent_thread_id) + .await + .expect("tree shutdown after subtree resume should succeed"); +} + +#[tokio::test] +async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_source_is_stale() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let grandchild_thread = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("grandchild thread should exist"); + persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; + persist_thread_for_tree_resume(&child_thread, "child persisted").await; + persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) + .await; + + let state_db = grandchild_thread + .state_db() + .expect("sqlite state db should be available"); + let mut stale_metadata = state_db + .get_thread(grandchild_thread_id) + .await + .expect("grandchild metadata query should succeed") + .expect("grandchild metadata should exist"); + stale_metadata.source = + serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 99, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })) + .expect("stale session source should serialize"); + state_db + .upsert_thread(&stale_metadata) + .await + .expect("stale grandchild metadata should persist"); + + let report = harness + .manager + .shutdown_all_threads_bounded(Duration::from_secs(5)) + .await; + assert_eq!(report.submit_failed, Vec::::new()); + assert_eq!(report.timed_out, Vec::::new()); + + let resumed_parent_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + parent_thread_id, + SessionSource::Exec, + ) + .await + .expect("tree resume should succeed"); + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_ne!( + harness.control.get_status(parent_thread_id).await, + AgentStatus::NotFound + ); + assert_ne!( + harness.control.get_status(child_thread_id).await, + AgentStatus::NotFound + ); + assert_ne!( + harness.control.get_status(grandchild_thread_id).await, + AgentStatus::NotFound + ); + + let resumed_grandchild_snapshot = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("resumed grandchild thread should exist") + .config_snapshot() + .await; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: resumed_parent_thread_id, + depth: resumed_depth, + .. + }) = resumed_grandchild_snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(resumed_parent_thread_id, child_thread_id); + assert_eq!(resumed_depth, 2); + + let _ = harness + .control + .shutdown_agent_tree(parent_thread_id) + .await + .expect("tree shutdown after subtree resume should succeed"); +} + +#[tokio::test] +async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let grandchild_thread = harness + .manager + .get_thread(grandchild_thread_id) + .await + .expect("grandchild thread should exist"); + persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; + persist_thread_for_tree_resume(&child_thread, "child persisted").await; + persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; + wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) + .await; + wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) + .await; + + let child_rollout_path = child_thread + .rollout_path() + .expect("child thread should have rollout path"); + let report = harness + .manager + .shutdown_all_threads_bounded(Duration::from_secs(5)) + .await; + assert_eq!(report.submit_failed, Vec::::new()); + assert_eq!(report.timed_out, Vec::::new()); + tokio::fs::remove_file(&child_rollout_path) + .await + .expect("child rollout path should be removable"); + + let resumed_parent_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + parent_thread_id, + SessionSource::Exec, + ) + .await + .expect("root resume should succeed"); + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_ne!( + harness.control.get_status(parent_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(child_thread_id).await, + AgentStatus::NotFound + ); + assert_eq!( + harness.control.get_status(grandchild_thread_id).await, + AgentStatus::NotFound + ); + + let _ = harness + .control + .shutdown_agent_tree(parent_thread_id) + .await + .expect("tree shutdown after partial subtree resume should succeed"); +} diff --git a/code-rs/core/src/agent/mailbox.rs b/code-rs/core/src/agent/mailbox.rs new file mode 100644 index 00000000000..c328236475e --- /dev/null +++ b/code-rs/core/src/agent/mailbox.rs @@ -0,0 +1,161 @@ +use codex_protocol::protocol::InterAgentCommunication; +use std::collections::VecDeque; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use tokio::sync::mpsc; +use tokio::sync::watch; + +#[cfg(test)] +use codex_protocol::AgentPath; + +pub(crate) struct Mailbox { + tx: mpsc::UnboundedSender, + next_seq: AtomicU64, + seq_tx: watch::Sender, +} + +pub(crate) struct MailboxReceiver { + rx: mpsc::UnboundedReceiver, + pending_mails: VecDeque, +} + +impl Mailbox { + pub(crate) fn new() -> (Self, MailboxReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + let (seq_tx, _) = watch::channel(0); + ( + Self { + tx, + next_seq: AtomicU64::new(0), + seq_tx, + }, + MailboxReceiver { + rx, + pending_mails: VecDeque::new(), + }, + ) + } + + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.seq_tx.subscribe() + } + + pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 { + let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1; + let _ = self.tx.send(communication); + self.seq_tx.send_replace(seq); + seq + } +} + +impl MailboxReceiver { + fn sync_pending_mails(&mut self) { + while let Ok(mail) = self.rx.try_recv() { + self.pending_mails.push_back(mail); + } + } + + pub(crate) fn has_pending(&mut self) -> bool { + self.sync_pending_mails(); + !self.pending_mails.is_empty() + } + + pub(crate) fn has_pending_trigger_turn(&mut self) -> bool { + self.sync_pending_mails(); + self.pending_mails.iter().any(|mail| mail.trigger_turn) + } + + pub(crate) fn drain(&mut self) -> Vec { + self.sync_pending_mails(); + self.pending_mails.drain(..).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn make_mail( + author: AgentPath, + recipient: AgentPath, + content: &str, + trigger_turn: bool, + ) -> InterAgentCommunication { + InterAgentCommunication::new( + author, + recipient, + Vec::new(), + content.to_string(), + trigger_turn, + ) + } + + #[tokio::test] + async fn mailbox_assigns_monotonic_sequence_numbers() { + let (mailbox, _receiver) = Mailbox::new(); + let mut seq_rx = mailbox.subscribe(); + + let seq_a = mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + )); + let seq_b = mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "two", + /*trigger_turn*/ false, + )); + + seq_rx.changed().await.expect("first seq update"); + assert_eq!(*seq_rx.borrow(), seq_b); + assert_eq!(seq_a, 1); + assert_eq!(seq_b, 2); + } + + #[tokio::test] + async fn mailbox_drains_in_delivery_order() { + let (mailbox, mut receiver) = Mailbox::new(); + let mail_one = make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + ); + let mail_two = make_mail( + AgentPath::try_from("/root/worker").expect("agent path"), + AgentPath::root(), + "two", + /*trigger_turn*/ false, + ); + + mailbox.send(mail_one.clone()); + mailbox.send(mail_two.clone()); + + assert_eq!(receiver.drain(), vec![mail_one, mail_two]); + assert!(!receiver.has_pending()); + } + + #[tokio::test] + async fn mailbox_tracks_pending_trigger_turn_mail() { + let (mailbox, mut receiver) = Mailbox::new(); + + mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "queued", + /*trigger_turn*/ false, + )); + assert!(!receiver.has_pending_trigger_turn()); + + mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "wake", + /*trigger_turn*/ true, + )); + assert!(receiver.has_pending_trigger_turn()); + } +} diff --git a/code-rs/core/src/agent/mod.rs b/code-rs/core/src/agent/mod.rs new file mode 100644 index 00000000000..a60fc3004a8 --- /dev/null +++ b/code-rs/core/src/agent/mod.rs @@ -0,0 +1,14 @@ +pub(crate) mod agent_resolver; +pub(crate) mod control; +pub(crate) mod mailbox; +mod registry; +pub(crate) mod role; +pub(crate) mod status; + +pub(crate) use codex_protocol::protocol::AgentStatus; +pub(crate) use control::AgentControl; +pub(crate) use mailbox::Mailbox; +pub(crate) use mailbox::MailboxReceiver; +pub(crate) use registry::exceeds_thread_spawn_depth_limit; +pub(crate) use registry::next_thread_spawn_depth; +pub(crate) use status::agent_status_from_event; diff --git a/code-rs/core/src/agent/registry.rs b/code-rs/core/src/agent/registry.rs new file mode 100644 index 00000000000..1acd73085f4 --- /dev/null +++ b/code-rs/core/src/agent/registry.rs @@ -0,0 +1,344 @@ +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use rand::prelude::IndexedRandom; +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::hash_map::Entry; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +/// This structure is used to add some limits on the multi-agent capabilities for Codex. In +/// the current implementation, it limits: +/// * Total number of sub-agents (i.e. threads) per user session +/// +/// This structure is shared by all agents in the same user session (because the `AgentControl` +/// is). +#[derive(Default)] +pub(crate) struct AgentRegistry { + active_agents: Mutex, + total_count: AtomicUsize, +} + +#[derive(Default)] +struct ActiveAgents { + agent_tree: HashMap, + used_agent_nicknames: HashSet, + nickname_reset_count: usize, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct AgentMetadata { + pub(crate) agent_id: Option, + pub(crate) agent_path: Option, + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, + pub(crate) last_task_message: Option, +} + +fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String { + match nickname_reset_count { + 0 => name.to_string(), + reset_count => { + let value = reset_count + 1; + let suffix = match value % 100 { + 11..=13 => "th", + _ => match value % 10 { + 1 => "st", // codespell:ignore + 2 => "nd", // codespell:ignore + 3 => "rd", // codespell:ignore + _ => "th", // codespell:ignore + }, + }; + format!("{name} the {value}{suffix}") + } + } +} + +fn session_depth(session_source: &SessionSource) -> i32 { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, + SessionSource::SubAgent(_) => 0, + _ => 0, + } +} + +pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 { + session_depth(session_source).saturating_add(1) +} + +pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool { + depth > max_depth +} + +impl AgentRegistry { + pub(crate) fn reserve_spawn_slot( + self: &Arc, + max_threads: Option, + ) -> Result { + if let Some(max_threads) = max_threads { + if !self.try_increment_spawned(max_threads) { + return Err(CodexErr::AgentLimitReached { max_threads }); + } + } else { + self.total_count.fetch_add(1, Ordering::AcqRel); + } + Ok(SpawnReservation { + state: Arc::clone(self), + active: true, + reserved_agent_nickname: None, + reserved_agent_path: None, + }) + } + + pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) { + let removed_counted_agent = { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let removed_key = active_agents + .agent_tree + .iter() + .find_map(|(key, metadata)| (metadata.agent_id == Some(thread_id)).then_some(key)) + .cloned(); + removed_key + .and_then(|key| active_agents.agent_tree.remove(key.as_str())) + .is_some_and(|metadata| { + !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) + }) + }; + if removed_counted_agent { + self.total_count.fetch_sub(1, Ordering::AcqRel); + } + } + + pub(crate) fn register_root_thread(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active_agents + .agent_tree + .entry(AgentPath::ROOT.to_string()) + .or_insert_with(|| AgentMetadata { + agent_id: Some(thread_id), + agent_path: Some(AgentPath::root()), + ..Default::default() + }); + } + + pub(crate) fn agent_id_for_path(&self, agent_path: &AgentPath) -> Option { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .get(agent_path.as_str()) + .and_then(|metadata| metadata.agent_id) + } + + pub(crate) fn agent_metadata_for_thread(&self, thread_id: ThreadId) -> Option { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .values() + .find(|metadata| metadata.agent_id == Some(thread_id)) + .cloned() + } + + pub(crate) fn live_agents(&self) -> Vec { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .values() + .filter(|metadata| { + metadata.agent_id.is_some() + && !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) + }) + .cloned() + .collect() + } + + pub(crate) fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = Some(last_task_message); + } + } + + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { + let Some(thread_id) = agent_metadata.agent_id else { + return; + }; + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let key = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| format!("thread:{thread_id}")); + if let Some(agent_nickname) = agent_metadata.agent_nickname.clone() { + active_agents.used_agent_nicknames.insert(agent_nickname); + } + active_agents.agent_tree.insert(key, agent_metadata); + } + + fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let agent_nickname = if let Some(preferred) = preferred { + preferred.to_string() + } else { + if names.is_empty() { + return None; + } + let available_names: Vec = names + .iter() + .map(|name| format_agent_nickname(name, active_agents.nickname_reset_count)) + .filter(|name| !active_agents.used_agent_nicknames.contains(name)) + .collect(); + if let Some(name) = available_names.choose(&mut rand::rng()) { + name.clone() + } else { + active_agents.used_agent_nicknames.clear(); + active_agents.nickname_reset_count += 1; + if let Some(metrics) = codex_otel::global() { + let _ = metrics.counter( + "codex.multi_agent.nickname_pool_reset", + /*inc*/ 1, + &[], + ); + } + format_agent_nickname( + names.choose(&mut rand::rng())?, + active_agents.nickname_reset_count, + ) + } + }; + active_agents + .used_agent_nicknames + .insert(agent_nickname.clone()); + Some(agent_nickname) + } + + fn reserve_agent_path(&self, agent_path: &AgentPath) -> Result<()> { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match active_agents.agent_tree.entry(agent_path.to_string()) { + Entry::Occupied(_) => Err(CodexErr::UnsupportedOperation(format!( + "agent path `{agent_path}` already exists" + ))), + Entry::Vacant(entry) => { + entry.insert(AgentMetadata { + agent_path: Some(agent_path.clone()), + ..Default::default() + }); + Ok(()) + } + } + } + + fn release_reserved_agent_path(&self, agent_path: &AgentPath) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active_agents + .agent_tree + .get(agent_path.as_str()) + .is_some_and(|metadata| metadata.agent_id.is_none()) + { + active_agents.agent_tree.remove(agent_path.as_str()); + } + } + + fn try_increment_spawned(&self, max_threads: usize) -> bool { + let mut current = self.total_count.load(Ordering::Acquire); + loop { + if current >= max_threads { + return false; + } + match self.total_count.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(updated) => current = updated, + } + } + } +} + +pub(crate) struct SpawnReservation { + state: Arc, + active: bool, + reserved_agent_nickname: Option, + reserved_agent_path: Option, +} + +impl SpawnReservation { + pub(crate) fn reserve_agent_nickname_with_preference( + &mut self, + names: &[&str], + preferred: Option<&str>, + ) -> Result { + let agent_nickname = self + .state + .reserve_agent_nickname(names, preferred) + .ok_or_else(|| { + CodexErr::UnsupportedOperation("no available agent nicknames".to_string()) + })?; + self.reserved_agent_nickname = Some(agent_nickname.clone()); + Ok(agent_nickname) + } + + pub(crate) fn reserve_agent_path(&mut self, agent_path: &AgentPath) -> Result<()> { + self.state.reserve_agent_path(agent_path)?; + self.reserved_agent_path = Some(agent_path.clone()); + Ok(()) + } + + pub(crate) fn commit(mut self, agent_metadata: AgentMetadata) { + self.reserved_agent_nickname = None; + self.reserved_agent_path = None; + self.state.register_spawned_thread(agent_metadata); + self.active = false; + } +} + +impl Drop for SpawnReservation { + fn drop(&mut self) { + if self.active { + if let Some(agent_path) = self.reserved_agent_path.take() { + self.state.release_reserved_agent_path(&agent_path); + } + self.state.total_count.fetch_sub(1, Ordering::AcqRel); + } + } +} + +#[cfg(test)] +#[path = "registry_tests.rs"] +mod tests; diff --git a/code-rs/core/src/agent/registry_tests.rs b/code-rs/core/src/agent/registry_tests.rs new file mode 100644 index 00000000000..fc172fb336d --- /dev/null +++ b/code-rs/core/src/agent/registry_tests.rs @@ -0,0 +1,350 @@ +use super::*; +use codex_protocol::AgentPath; +use pretty_assertions::assert_eq; +use std::collections::HashSet; + +fn agent_path(path: &str) -> AgentPath { + AgentPath::try_from(path).expect("valid agent path") +} + +fn agent_metadata(thread_id: ThreadId) -> AgentMetadata { + AgentMetadata { + agent_id: Some(thread_id), + ..Default::default() + } +} + +#[test] +fn format_agent_nickname_adds_ordinals_after_reset() { + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 0), + "Plato" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 1), + "Plato the 2nd" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 2), + "Plato the 3rd" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 10), + "Plato the 11th" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 20), + "Plato the 21st" + ); +} + +#[test] +fn session_depth_defaults_to_zero_for_root_sources() { + assert_eq!(session_depth(&SessionSource::Cli), 0); +} + +#[test] +fn thread_spawn_depth_increments_and_enforces_limit() { + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + let child_depth = next_thread_spawn_depth(&session_source); + assert_eq!(child_depth, 2); + assert!(exceeds_thread_spawn_depth_limit( + child_depth, + /*max_depth*/ 1 + )); +} + +#[test] +fn non_thread_spawn_subagents_default_to_depth_zero() { + let session_source = SessionSource::SubAgent(SubAgentSource::Review); + assert_eq!(session_depth(&session_source), 0); + assert_eq!(next_thread_spawn_depth(&session_source), 1); + assert!(!exceeds_thread_spawn_depth_limit( + /*depth*/ 1, /*max_depth*/ 1 + )); +} + +#[test] +fn reservation_drop_releases_slot() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + drop(reservation); + + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released"); + drop(reservation); +} + +#[test] +fn commit_holds_slot_until_release() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(agent_metadata(thread_id)); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(thread_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after thread removal"); + drop(reservation); +} + +#[test] +fn release_ignores_unknown_thread_id() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(agent_metadata(thread_id)); + + registry.release_spawned_thread(ThreadId::new()); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(thread_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after real thread removal"); + drop(reservation); +} + +#[test] +fn release_is_idempotent_for_registered_threads() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let first_id = ThreadId::new(); + reservation.commit(agent_metadata(first_id)); + + registry.release_spawned_thread(first_id); + + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused"); + let second_id = ThreadId::new(); + reservation.commit(agent_metadata(second_id)); + + registry.release_spawned_thread(first_id); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(second_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after second thread removal"); + drop(reservation); +} + +#[test] +fn failed_spawn_keeps_nickname_marked_used() { + let registry = Arc::new(AgentRegistry::default()); + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve agent name"); + assert_eq!(agent_nickname, "alpha"); + drop(reservation); + + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("unused name should still be preferred"); + assert_eq!(agent_nickname, "beta"); +} + +#[test] +fn agent_nickname_resets_used_pool_when_exhausted() { + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "alpha"); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("name should be reused after pool reset"); + assert_eq!(second_name, "alpha the 2nd"); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); +} + +#[test] +fn released_nickname_stays_used_until_pool_reset() { + let registry = Arc::new(AgentRegistry::default()); + + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "alpha"); + + registry.release_spawned_thread(first_id); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("released name should still be marked used"); + assert_eq!(second_name, "beta"); + let second_id = ThreadId::new(); + second.commit(agent_metadata(second_id)); + registry.release_spawned_thread(second_id); + + let mut third = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("pool reset should permit a duplicate"); + let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]); + assert!(expected_names.contains(&third_name)); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); +} + +#[test] +fn repeated_resets_advance_the_ordinal_suffix() { + let registry = Arc::new(AgentRegistry::default()); + + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "Plato"); + registry.release_spawned_thread(first_id); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve second agent name"); + let second_id = ThreadId::new(); + second.commit(agent_metadata(second_id)); + assert_eq!(second_name, "Plato the 2nd"); + registry.release_spawned_thread(second_id); + + let mut third = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve third agent name"); + assert_eq!(third_name, "Plato the 3rd"); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 2); +} + +#[test] +fn register_root_thread_indexes_root_path() { + let registry = Arc::new(AgentRegistry::default()); + let root_thread_id = ThreadId::new(); + + registry.register_root_thread(root_thread_id); + + assert_eq!( + registry.agent_id_for_path(&AgentPath::root()), + Some(root_thread_id) + ); +} + +#[test] +fn reserved_agent_path_is_released_when_spawn_fails() { + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + first + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("reserve first path"); + drop(first); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + second + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("dropped reservation should free the path"); +} + +#[test] +fn committed_agent_path_is_indexed_until_release() { + let registry = Arc::new(AgentRegistry::default()); + let thread_id = ThreadId::new(); + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); + reservation + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("reserve path"); + reservation.commit(AgentMetadata { + agent_id: Some(thread_id), + agent_path: Some(agent_path("/root/researcher")), + ..Default::default() + }); + + assert_eq!( + registry.agent_id_for_path(&agent_path("/root/researcher")), + Some(thread_id) + ); + + registry.release_spawned_thread(thread_id); + assert_eq!( + registry.agent_id_for_path(&agent_path("/root/researcher")), + None + ); +} diff --git a/code-rs/core/src/agent/role.rs b/code-rs/core/src/agent/role.rs new file mode 100644 index 00000000000..2ab16cd22a2 --- /dev/null +++ b/code-rs/core/src/agent/role.rs @@ -0,0 +1,433 @@ +//! Applies agent-role configuration layers on top of an existing session config. +//! +//! Roles are selected at spawn time and are loaded with the same config machinery as +//! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a +//! high-precedence layer, and preserves the caller's current profile/provider unless the role +//! explicitly takes ownership of model selection. It does not decide when to spawn a sub-agent or +//! which role to use; the multi-agent tool handler owns that orchestration. + +use crate::config::AgentRoleConfig; +use crate::config::Config; +use crate::config::ConfigOverrides; +use crate::config::agent_roles::parse_agent_role_file_contents; +use crate::config::deserialize_config_toml_with_base; +use anyhow::anyhow; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::config_toml::ConfigToml; +use codex_config::loader::resolve_relative_paths_in_config_toml; +use codex_exec_server::LOCAL_FS; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::LazyLock; +use toml::Value as TomlValue; + +/// The role name used when a caller omits `agent_type`. +pub const DEFAULT_ROLE_NAME: &str = "default"; +const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; + +/// Applies a named role layer to `config` while preserving caller-owned model selection. +/// +/// The role layer is inserted at session-flag precedence so it can override persisted config, but +/// the caller's current `profile` and `model_provider` remain sticky runtime choices unless the +/// role explicitly sets `profile`, explicitly sets `model_provider`, or rewrites the active +/// profile's `model_provider` in place. Rebuilding the config without those overrides would make a +/// spawned agent silently fall back to the default provider, which is the bug this preservation +/// logic avoids. +pub(crate) async fn apply_role_to_config( + config: &mut Config, + role_name: Option<&str>, +) -> Result<(), String> { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + + let role = resolve_role_config(config, role_name) + .cloned() + .ok_or_else(|| format!("unknown agent_type '{role_name}'"))?; + + apply_role_to_config_inner(config, role_name, &role) + .await + .map_err(|err| { + tracing::warn!("failed to apply role to config: {err}"); + AGENT_TYPE_UNAVAILABLE_ERROR.to_string() + }) +} + +async fn apply_role_to_config_inner( + config: &mut Config, + role_name: &str, + role: &AgentRoleConfig, +) -> anyhow::Result<()> { + let is_built_in = !config.agent_roles.contains_key(role_name); + let Some(config_file) = role.config_file.as_ref() else { + return Ok(()); + }; + let role_layer_toml = load_role_layer_toml(config, config_file, is_built_in, role_name).await?; + if role_layer_toml + .as_table() + .is_some_and(toml::map::Map::is_empty) + { + return Ok(()); + } + let (preserve_current_profile, preserve_current_provider) = + preservation_policy(config, &role_layer_toml); + + *config = reload::build_next_config( + config, + role_layer_toml, + preserve_current_profile, + preserve_current_provider, + ) + .await?; + Ok(()) +} + +async fn load_role_layer_toml( + config: &Config, + config_file: &Path, + is_built_in: bool, + role_name: &str, +) -> anyhow::Result { + let (role_config_toml, role_config_base) = if is_built_in { + let role_config_contents = built_in::config_file_contents(config_file) + .map(str::to_owned) + .ok_or(anyhow!("No corresponding config content"))?; + let role_config_toml: TomlValue = toml::from_str(&role_config_contents)?; + (role_config_toml, config.codex_home.as_path()) + } else { + let role_config_contents = tokio::fs::read_to_string(config_file).await?; + let role_config_base = config_file + .parent() + .ok_or(anyhow!("No corresponding config content"))?; + let role_config_toml = parse_agent_role_file_contents( + &role_config_contents, + config_file, + role_config_base, + Some(role_name), + )? + .config; + (role_config_toml, role_config_base) + }; + + deserialize_config_toml_with_base(role_config_toml.clone(), role_config_base)?; + Ok(resolve_relative_paths_in_config_toml( + role_config_toml, + role_config_base, + )?) +} + +pub(crate) fn resolve_role_config<'a>( + config: &'a Config, + role_name: &str, +) -> Option<&'a AgentRoleConfig> { + config + .agent_roles + .get(role_name) + .or_else(|| built_in::configs().get(role_name)) +} + +fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) { + let role_selects_provider = role_layer_toml.get("model_provider").is_some(); + let role_selects_profile = role_layer_toml.get("profile").is_some(); + let role_updates_active_profile_provider = config + .active_profile + .as_ref() + .and_then(|active_profile| { + role_layer_toml + .get("profiles") + .and_then(TomlValue::as_table) + .and_then(|profiles| profiles.get(active_profile)) + .and_then(TomlValue::as_table) + .map(|profile| profile.contains_key("model_provider")) + }) + .unwrap_or(false); + let preserve_current_profile = !role_selects_provider && !role_selects_profile; + let preserve_current_provider = + preserve_current_profile && !role_updates_active_profile_provider; + (preserve_current_profile, preserve_current_provider) +} + +mod reload { + use super::*; + + pub(super) async fn build_next_config( + config: &Config, + role_layer_toml: TomlValue, + preserve_current_profile: bool, + preserve_current_provider: bool, + ) -> anyhow::Result { + let active_profile_name = preserve_current_profile + .then_some(config.active_profile.as_deref()) + .flatten(); + let config_layer_stack = + build_config_layer_stack(config, &role_layer_toml, active_profile_name)?; + let mut merged_config = deserialize_effective_config(config, &config_layer_stack)?; + if preserve_current_profile { + merged_config.profile = None; + } + + let mut next_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + merged_config, + reload_overrides(config, preserve_current_provider), + config.codex_home.clone(), + config_layer_stack, + ) + .await?; + if preserve_current_profile { + next_config.active_profile = config.active_profile.clone(); + } + Ok(next_config) + } + + fn build_config_layer_stack( + config: &Config, + role_layer_toml: &TomlValue, + active_profile_name: Option<&str>, + ) -> anyhow::Result { + let mut layers = existing_layers(config); + if let Some(resolved_profile_layer) = + resolved_profile_layer(config, &layers, role_layer_toml, active_profile_name)? + { + insert_layer(&mut layers, resolved_profile_layer); + } + insert_layer(&mut layers, role_layer(role_layer_toml.clone())); + Ok(ConfigLayerStack::new( + layers, + config.config_layer_stack.requirements().clone(), + config.config_layer_stack.requirements_toml().clone(), + )?) + } + + fn resolved_profile_layer( + config: &Config, + existing_layers: &[ConfigLayerEntry], + role_layer_toml: &TomlValue, + active_profile_name: Option<&str>, + ) -> anyhow::Result> { + let Some(active_profile_name) = active_profile_name else { + return Ok(None); + }; + + let mut layers = existing_layers.to_vec(); + insert_layer(&mut layers, role_layer(role_layer_toml.clone())); + let merged_config = deserialize_effective_config( + config, + &ConfigLayerStack::new( + layers, + config.config_layer_stack.requirements().clone(), + config.config_layer_stack.requirements_toml().clone(), + )?, + )?; + let resolved_profile = + merged_config.get_config_profile(Some(active_profile_name.to_string()))?; + Ok(Some(ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + TomlValue::try_from(resolved_profile)?, + ))) + } + + fn deserialize_effective_config( + config: &Config, + config_layer_stack: &ConfigLayerStack, + ) -> anyhow::Result { + Ok(deserialize_config_toml_with_base( + config_layer_stack.effective_config(), + &config.codex_home, + )?) + } + + fn existing_layers(config: &Config) -> Vec { + config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .cloned() + .collect() + } + + fn insert_layer(layers: &mut Vec, layer: ConfigLayerEntry) { + let insertion_index = + layers.partition_point(|existing_layer| existing_layer.name <= layer.name); + layers.insert(insertion_index, layer); + } + + fn role_layer(role_layer_toml: TomlValue) -> ConfigLayerEntry { + ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml) + } + + fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides { + ConfigOverrides { + cwd: Some(config.cwd.to_path_buf()), + model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), + ..Default::default() + } + } +} + +pub(crate) mod spawn_tool_spec { + use super::*; + + /// Builds the spawn-agent tool description text from built-in and configured roles. + pub(crate) fn build(user_defined_agent_roles: &BTreeMap) -> String { + let built_in_roles = built_in::configs(); + build_from_configs(built_in_roles, user_defined_agent_roles) + } + + // This function is not inlined for testing purpose. + fn build_from_configs( + built_in_roles: &BTreeMap, + user_defined_roles: &BTreeMap, + ) -> String { + let mut seen = BTreeSet::new(); + let mut formatted_roles = Vec::new(); + for (name, declaration) in user_defined_roles { + if seen.insert(name.as_str()) { + formatted_roles.push(format_role(name, declaration)); + } + } + for (name, declaration) in built_in_roles { + if seen.insert(name.as_str()) { + formatted_roles.push(format_role(name, declaration)); + } + } + + format!( + "Optional type name for the new agent. If omitted, `{DEFAULT_ROLE_NAME}` is used.\nAvailable roles:\n{}", + formatted_roles.join("\n"), + ) + } + + fn format_role(name: &str, declaration: &AgentRoleConfig) -> String { + if let Some(description) = &declaration.description { + let locked_settings_note = declaration + .config_file + .as_ref() + .and_then(|config_file| { + built_in::config_file_contents(config_file) + .map(str::to_owned) + .or_else(|| std::fs::read_to_string(config_file).ok()) + }) + .and_then(|contents| toml::from_str::(&contents).ok()) + .map(|role_toml| { + let model = role_toml + .get("model") + .and_then(TomlValue::as_str); + let reasoning_effort = role_toml + .get("model_reasoning_effort") + .and_then(TomlValue::as_str); + + match (model, reasoning_effort) { + (Some(model), Some(reasoning_effort)) => format!( + "\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed." + ), + (Some(model), None) => { + format!( + "\n- This role's model is set to `{model}` and cannot be changed." + ) + } + (None, Some(reasoning_effort)) => { + format!( + "\n- This role's reasoning effort is set to `{reasoning_effort}` and cannot be changed." + ) + } + (None, None) => String::new(), + } + }) + .unwrap_or_default(); + format!("{name}: {{\n{description}{locked_settings_note}\n}}") + } else { + format!("{name}: no description") + } + } +} + +mod built_in { + use super::*; + + /// Returns the cached built-in role declarations defined in this module. + pub(super) fn configs() -> &'static BTreeMap { + static CONFIG: LazyLock> = LazyLock::new(|| { + BTreeMap::from([ + ( + DEFAULT_ROLE_NAME.to_string(), + AgentRoleConfig { + description: Some("Default agent.".to_string()), + config_file: None, + nickname_candidates: None, + } + ), + ( + "explorer".to_string(), + AgentRoleConfig { + description: Some(r#"Use `explorer` for specific codebase questions. +Explorers are fast and authoritative. +They must be used to ask specific, well-scoped questions on the codebase. +Rules: +- In order to avoid redundant work, you should avoid exploring the same problem that explorers have already covered. Typically, you should trust the explorer results without additional verification. You are still allowed to inspect the code yourself to gain the needed context! +- You are encouraged to spawn up multiple explorers in parallel when you have multiple distinct questions to ask about the codebase that can be answered independently. This allows you to get more information faster without waiting for one question to finish before asking the next. While waiting for the explorer results, you can continue working on other local tasks that do not depend on those results. This parallelism is a key advantage of delegation, so use it whenever you have multiple questions to ask. +- Reuse existing explorers for related questions."#.to_string()), + config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()), + nickname_candidates: None, + } + ), + ( + "worker".to_string(), + AgentRoleConfig { + description: Some(r#"Use for execution and production work. +Typical tasks: +- Implement part of a feature +- Fix tests or bugs +- Split large refactors into independent chunks +Rules: +- Explicitly assign **ownership** of the task (files / responsibility). When the subtask involves code changes, you should clearly specify which files or modules the worker is responsible for. This helps avoid merge conflicts and ensures accountability. For example, you can say "Worker 1 is responsible for updating the authentication module, while Worker 2 will handle the database layer." By defining clear ownership, you can delegate more effectively and reduce coordination overhead. +- Always tell workers they are **not alone in the codebase**, and they should not revert the edits made by others, and they should adjust their implementation to accommodate the changes made by others. This is important because there may be multiple workers making changes in parallel, and they need to be aware of each other's work to avoid conflicts and ensure a cohesive final product."#.to_string()), + config_file: None, + nickname_candidates: None, + } + ), + // Awaiter is temp removed +// ( +// "awaiter".to_string(), +// AgentRoleConfig { +// description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that will take some very long time. +// This includes, but not only: +// * testing +// * monitoring of a long running process +// * explicit ask to wait for something +// +// Rules: +// - When an awaiter is running, you can work on something else. If you need to wait for its completion, use the largest possible timeout. +// - Be patient with the `awaiter`. +// - Do not use an awaiter for every compilation/test if it won't take time. Only use if for long running commands. +// - Close the awaiter when you're done with it."#.to_string()), +// config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()), +// } +// ) + ]) + }); + &CONFIG + } + + /// Resolves a built-in role `config_file` path to embedded content. + pub(super) fn config_file_contents(path: &Path) -> Option<&'static str> { + const EXPLORER: &str = include_str!("builtins/explorer.toml"); + const AWAITER: &str = include_str!("builtins/awaiter.toml"); + match path.to_str()? { + "explorer.toml" => Some(EXPLORER), + "awaiter.toml" => Some(AWAITER), + _ => None, + } + } +} + +#[cfg(test)] +#[path = "role_tests.rs"] +mod tests; diff --git a/code-rs/core/src/agent/role_tests.rs b/code-rs/core/src/agent/role_tests.rs new file mode 100644 index 00000000000..1c99fb5950f --- /dev/null +++ b/code-rs/core/src/agent/role_tests.rs @@ -0,0 +1,775 @@ +use super::*; +use crate::SkillsManager; +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigBuilder; +use crate::skills_load_input_from_config; +use codex_config::ConfigLayerStackOrdering; +use codex_core_plugins::PluginsManager; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::test_support::PathExt; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; + +async fn test_config_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, +) -> (TempDir, Config) { + let home = TempDir::new().expect("create temp dir"); + let home_path = home.path().to_path_buf(); + let config = ConfigBuilder::default() + .codex_home(home_path.clone()) + .cli_overrides(cli_overrides) + .fallback_cwd(Some(home_path)) + .build() + .await + .expect("load test config"); + (home, config) +} + +async fn write_role_config(home: &TempDir, name: &str, contents: &str) -> PathBuf { + let role_path = home.path().join(name); + tokio::fs::write(&role_path, contents) + .await + .expect("write role config"); + role_path +} + +fn session_flags_layer_count(config: &Config) -> usize { + config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| layer.name == ConfigLayerSource::SessionFlags) + .count() +} + +#[tokio::test] +async fn apply_role_defaults_to_default_and_leaves_config_unchanged() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before = config.clone(); + + apply_role_to_config(&mut config, /*role_name*/ None) + .await + .expect("default role should apply"); + + assert_eq!(before, config); +} + +#[tokio::test] +async fn apply_role_returns_error_for_unknown_role() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + + let err = apply_role_to_config(&mut config, Some("missing-role")) + .await + .expect_err("unknown role should fail"); + + assert_eq!(err, "unknown agent_type 'missing-role'"); +} + +#[tokio::test] +#[ignore = "No role requiring it for now"] +async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before_layers = session_flags_layer_count(&config); + + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("explorer role should apply"); + + assert_eq!(config.model.as_deref(), Some("gpt-5.4-mini")); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::Medium)); + assert_eq!(session_flags_layer_count(&config), before_layers + 1); +} + +#[tokio::test] +async fn apply_empty_explorer_role_preserves_current_model_and_reasoning_effort() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before_layers = session_flags_layer_count(&config); + config.model = Some("gpt-5.4-mini".to_string()); + config.model_reasoning_effort = Some(ReasoningEffort::High); + + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("explorer role should apply"); + + assert_eq!(config.model.as_deref(), Some("gpt-5.4-mini")); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(session_flags_layer_count(&config), before_layers); +} + +#[tokio::test] +async fn apply_role_returns_unavailable_for_missing_user_role_file() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(PathBuf::from("/path/does/not/exist.toml")), + nickname_candidates: None, + }, + ); + + let err = apply_role_to_config(&mut config, Some("custom")) + .await + .expect_err("missing role file should fail"); + + assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR); +} + +#[tokio::test] +async fn apply_role_returns_unavailable_for_invalid_user_role_toml() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let role_path = write_role_config(&home, "invalid-role.toml", "model = [").await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + let err = apply_role_to_config(&mut config, Some("custom")) + .await + .expect_err("invalid role file should fail"); + + assert_eq!(err, AGENT_TYPE_UNAVAILABLE_ERROR); +} + +#[tokio::test] +async fn apply_role_ignores_agent_metadata_fields_in_user_role_file() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let role_path = write_role_config( + &home, + "metadata-role.toml", + r#" +name = "archivist" +description = "Role metadata" +nickname_candidates = ["Hypatia"] +developer_instructions = "Stay focused" +model = "role-model" +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.model.as_deref(), Some("role-model")); +} + +#[tokio::test] +async fn apply_role_preserves_unspecified_keys() { + let (home, mut config) = test_config_with_cli_overrides(vec![( + "model".to_string(), + TomlValue::String("base-model".to_string()), + )]) + .await; + config.codex_linux_sandbox_exe = Some(PathBuf::from("/tmp/codex-linux-sandbox")); + config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper")); + let role_path = write_role_config( + &home, + "effort-only.toml", + "developer_instructions = \"Stay focused\"\nmodel_reasoning_effort = \"high\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.model.as_deref(), Some("base-model")); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + config.codex_linux_sandbox_exe, + Some(PathBuf::from("/tmp/codex-linux-sandbox")) + ); + assert_eq!( + config.main_execve_wrapper_exe, + Some(PathBuf::from("/tmp/codex-execve-wrapper")) + ); +} + +#[tokio::test] +async fn apply_role_preserves_active_profile_and_model_provider() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.test-provider] +name = "Test Provider" +base_url = "https://example.com/v1" +env_key = "TEST_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.test-profile] +model_provider = "test-provider" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("test-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config( + &home, + "empty-role.toml", + "developer_instructions = \"Stay focused\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile.as_deref(), Some("test-profile")); + assert_eq!(config.model_provider_id, "test-provider"); + assert_eq!(config.model_provider.name, "Test Provider"); +} + +#[tokio::test] +async fn apply_role_top_level_profile_settings_override_preserved_profile() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[profiles.base-profile] +model = "profile-model" +model_reasoning_effort = "low" +model_reasoning_summary = "concise" +model_verbosity = "low" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("base-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config( + &home, + "top-level-profile-settings-role.toml", + r#"developer_instructions = "Stay focused" +model = "role-model" +model_reasoning_effort = "high" +model_reasoning_summary = "detailed" +model_verbosity = "high" +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile.as_deref(), Some("base-profile")); + assert_eq!(config.model.as_deref(), Some("role-model")); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + config.model_reasoning_summary, + Some(ReasoningSummary::Detailed) + ); + assert_eq!(config.model_verbosity, Some(Verbosity::High)); +} + +#[tokio::test] +async fn apply_role_uses_role_profile_instead_of_current_profile() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.base-provider] +name = "Base Provider" +base_url = "https://base.example.com/v1" +env_key = "BASE_PROVIDER_API_KEY" +wire_api = "responses" + +[model_providers.role-provider] +name = "Role Provider" +base_url = "https://role.example.com/v1" +env_key = "ROLE_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.base-profile] +model_provider = "base-provider" + +[profiles.role-profile] +model_provider = "role-provider" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("base-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config( + &home, + "profile-role.toml", + "developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile.as_deref(), Some("role-profile")); + assert_eq!(config.model_provider_id, "role-provider"); + assert_eq!(config.model_provider.name, "Role Provider"); +} + +#[tokio::test] +async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.base-provider] +name = "Base Provider" +base_url = "https://base.example.com/v1" +env_key = "BASE_PROVIDER_API_KEY" +wire_api = "responses" + +[model_providers.role-provider] +name = "Role Provider" +base_url = "https://role.example.com/v1" +env_key = "ROLE_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.base-profile] +model_provider = "base-provider" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("base-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config( + &home, + "provider-role.toml", + "developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile, None); + assert_eq!(config.model_provider_id, "role-provider"); + assert_eq!(config.model_provider.name, "Role Provider"); +} + +#[tokio::test] +async fn apply_role_uses_active_profile_model_provider_update() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#" +[model_providers.base-provider] +name = "Base Provider" +base_url = "https://base.example.com/v1" +env_key = "BASE_PROVIDER_API_KEY" +wire_api = "responses" + +[model_providers.role-provider] +name = "Role Provider" +base_url = "https://role.example.com/v1" +env_key = "ROLE_PROVIDER_API_KEY" +wire_api = "responses" + +[profiles.base-profile] +model_provider = "base-provider" +model_reasoning_effort = "low" +"#, + ) + .await + .expect("write config.toml"); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + config_profile: Some("base-profile".to_string()), + ..Default::default() + }) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load config"); + let role_path = write_role_config( + &home, + "profile-edit-role.toml", + r#"developer_instructions = "Stay focused" + +[profiles.base-profile] +model_provider = "role-provider" +model_reasoning_effort = "high" +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.active_profile.as_deref(), Some("base-profile")); + assert_eq!(config.model_provider_id, "role-provider"); + assert_eq!(config.model_provider.name, "Role Provider"); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); +} + +#[tokio::test] +#[cfg(not(windows))] +async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() { + use codex_protocol::protocol::SandboxPolicy; + let (home, mut config) = test_config_with_cli_overrides(vec![ + ( + "sandbox_mode".to_string(), + TomlValue::String("workspace-write".to_string()), + ), + ( + "sandbox_workspace_write.network_access".to_string(), + TomlValue::Boolean(true), + ), + ]) + .await; + let role_path = write_role_config( + &home, + "sandbox-role.toml", + r#"developer_instructions = "Stay focused" + +[sandbox_workspace_write] +writable_roots = ["./sandbox-root"] +"#, + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + let role_layer = config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .rfind(|layer| layer.name == ConfigLayerSource::SessionFlags) + .expect("expected a session flags layer"); + let sandbox_workspace_write = role_layer + .config + .get("sandbox_workspace_write") + .and_then(TomlValue::as_table) + .expect("role layer should include sandbox_workspace_write"); + assert_eq!( + sandbox_workspace_write.contains_key("network_access"), + false + ); + assert_eq!( + sandbox_workspace_write.contains_key("exclude_tmpdir_env_var"), + false + ); + assert_eq!( + sandbox_workspace_write.contains_key("exclude_slash_tmp"), + false + ); + + match &config.legacy_sandbox_policy() { + SandboxPolicy::WorkspaceWrite { network_access, .. } => { + assert_eq!(*network_access, true); + } + other => panic!("expected workspace-write sandbox policy, got {other:?}"), + } +} + +#[tokio::test] +async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key() { + let (home, mut config) = test_config_with_cli_overrides(vec![( + "model".to_string(), + TomlValue::String("cli-model".to_string()), + )]) + .await; + let before_layers = session_flags_layer_count(&config); + let role_path = write_role_config( + &home, + "model-role.toml", + "developer_instructions = \"Stay focused\"\nmodel = \"role-model\"", + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + assert_eq!(config.model.as_deref(), Some("role-model")); + assert_eq!(session_flags_layer_count(&config), before_layers + 1); +} + +#[cfg_attr(windows, ignore)] +#[tokio::test] +async fn apply_role_skills_config_disables_skill_for_spawned_agent() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let skill_dir = home.path().join("skills").join("demo"); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + let skill_path = skill_dir.join("SKILL.md"); + fs::write( + &skill_path, + "---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n", + ) + .expect("write skill"); + let role_path = write_role_config( + &home, + "skills-role.toml", + &format!( + r#"developer_instructions = "Stay focused" + +[[skills.config]] +path = "{}" +enabled = false +"#, + skill_path.display() + ), + ) + .await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf())); + let skills_manager = + SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true); + let plugins_input = config.plugins_config_input(); + let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); + let skills_input = skills_load_input_from_config(&config, effective_skill_roots); + let outcome = skills_manager + .skills_for_config( + &skills_input, + Some(Arc::clone(&codex_exec_server::LOCAL_FS)), + ) + .await; + let skill = outcome + .skills + .iter() + .find(|skill| skill.name == "demo-skill") + .expect("demo skill should be discovered"); + + assert_eq!(outcome.is_skill_enabled(skill), false); +} + +#[test] +fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() { + let user_defined_roles = BTreeMap::from([ + ( + "explorer".to_string(), + AgentRoleConfig { + description: Some("user override".to_string()), + config_file: None, + nickname_candidates: None, + }, + ), + ("researcher".to_string(), AgentRoleConfig::default()), + ]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + + assert!(spec.contains("researcher: no description")); + assert!(spec.contains("explorer: {\nuser override\n}")); + assert!(spec.contains("default: {\nDefault agent.\n}")); + assert!(!spec.contains("Explorers are fast and authoritative.")); +} + +#[test] +fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() { + let user_defined_roles = BTreeMap::from([( + "aaa".to_string(), + AgentRoleConfig { + description: Some("first".to_string()), + config_file: None, + nickname_candidates: None, + }, + )]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + let user_index = spec.find("aaa: {\nfirst\n}").expect("find user role"); + let built_in_index = spec + .find("default: {\nDefault agent.\n}") + .expect("find built-in role"); + + assert!(user_index < built_in_index); +} + +#[test] +fn spawn_tool_spec_marks_role_locked_model_and_reasoning_effort() { + let tempdir = TempDir::new().expect("create temp dir"); + let role_path = tempdir.path().join("researcher.toml"); + fs::write( + &role_path, + "developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"\nmodel_reasoning_effort = \"high\"\n", + ) + .expect("write role config"); + let user_defined_roles = BTreeMap::from([( + "researcher".to_string(), + AgentRoleConfig { + description: Some("Research carefully.".to_string()), + config_file: Some(role_path), + nickname_candidates: None, + }, + )]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + + assert!(spec.contains( + "Research carefully.\n- This role's model is set to `gpt-5` and its reasoning effort is set to `high`. These settings cannot be changed." + )); +} + +#[test] +fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() { + let tempdir = TempDir::new().expect("create temp dir"); + let role_path = tempdir.path().join("reviewer.toml"); + fs::write( + &role_path, + "developer_instructions = \"Review carefully\"\nmodel_reasoning_effort = \"medium\"\n", + ) + .expect("write role config"); + let user_defined_roles = BTreeMap::from([( + "reviewer".to_string(), + AgentRoleConfig { + description: Some("Review carefully.".to_string()), + config_file: Some(role_path), + nickname_candidates: None, + }, + )]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + + assert!(spec.contains( + "Review carefully.\n- This role's reasoning effort is set to `medium` and cannot be changed." + )); +} + +#[test] +fn built_in_config_file_contents_resolves_explorer_only() { + assert_eq!( + built_in::config_file_contents(Path::new("missing.toml")), + None + ); +} diff --git a/code-rs/core/src/agent/status.rs b/code-rs/core/src/agent/status.rs new file mode 100644 index 00000000000..43be7188652 --- /dev/null +++ b/code-rs/core/src/agent/status.rs @@ -0,0 +1,28 @@ +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::EventMsg; + +/// Derive the next agent status from a single emitted event. +/// Returns `None` when the event does not affect status tracking. +pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option { + match msg { + EventMsg::TurnStarted(_) => Some(AgentStatus::Running), + EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())), + EventMsg::TurnAborted(ev) => match ev.reason { + codex_protocol::protocol::TurnAbortReason::Interrupted + | codex_protocol::protocol::TurnAbortReason::BudgetLimited => { + Some(AgentStatus::Interrupted) + } + _ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))), + }, + EventMsg::Error(ev) => Some(AgentStatus::Errored(ev.message.clone())), + EventMsg::ShutdownComplete => Some(AgentStatus::Shutdown), + _ => None, + } +} + +pub(crate) fn is_final(status: &AgentStatus) -> bool { + !matches!( + status, + AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted + ) +} diff --git a/code-rs/core/src/agent_defaults.rs b/code-rs/core/src/agent_defaults.rs deleted file mode 100644 index be7ac8128e2..00000000000 --- a/code-rs/core/src/agent_defaults.rs +++ /dev/null @@ -1,782 +0,0 @@ -//! Defaults for agent selectors and their CLI launch configuration. -//! -//! The canonical catalog defined here is consumed by both the core executor -//! (to assemble argv when the user has not overridden a selector) and by the TUI -//! (to surface the available sub-agent options). - -use crate::config_types::AgentConfig; -use code_app_server_protocol::AuthMode; -use serde::Deserialize; -use std::collections::{HashMap, HashSet}; -use std::sync::LazyLock; - -const CLAUDE_ALLOWED_TOOLS: &str = "Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(git status:*), Bash(git log:*), Bash(find:*), Read, Grep, Glob, LS, WebFetch, TodoRead, TodoWrite, WebSearch"; -const CLOUD_MODEL_ENV_FLAG: &str = "CODE_ENABLE_CLOUD_AGENT_MODEL"; - -const CODE_GPT5_CODEX_READ_ONLY: &[&str] = &["-s", "read-only", "exec", "--skip-git-repo-check"]; -const CODE_GPT5_CODEX_WRITE: &[&str] = &["-s", "workspace-write", "--dangerously-bypass-approvals-and-sandbox", "exec", "--skip-git-repo-check"]; -const CODE_GPT5_READ_ONLY: &[&str] = &["-s", "read-only", "exec", "--skip-git-repo-check"]; -const CODE_GPT5_WRITE: &[&str] = &["-s", "workspace-write", "--dangerously-bypass-approvals-and-sandbox", "exec", "--skip-git-repo-check"]; -const CLAUDE_SONNET_READ_ONLY: &[&str] = &["--allowedTools", CLAUDE_ALLOWED_TOOLS]; -const CLAUDE_SONNET_WRITE: &[&str] = &["--dangerously-skip-permissions"]; -const CLAUDE_OPUS_READ_ONLY: &[&str] = &["--allowedTools", CLAUDE_ALLOWED_TOOLS]; -const CLAUDE_OPUS_WRITE: &[&str] = &["--dangerously-skip-permissions"]; -const CLAUDE_HAIKU_READ_ONLY: &[&str] = &["--allowedTools", CLAUDE_ALLOWED_TOOLS]; -const CLAUDE_HAIKU_WRITE: &[&str] = &["--dangerously-skip-permissions"]; -const ANTIGRAVITY_READ_ONLY: &[&str] = &[]; -const ANTIGRAVITY_WRITE: &[&str] = &["--dangerously-skip-permissions"]; -const COPILOT_READ_ONLY: &[&str] = &["--autopilot", "--allow-all-tools", "--no-ask-user", "-s"]; -const COPILOT_WRITE: &[&str] = &["--autopilot", "--yolo", "--no-ask-user", "-s"]; -const QWEN_3_CODER_READ_ONLY: &[&str] = &[]; -const QWEN_3_CODER_WRITE: &[&str] = &["-y"]; -const CLOUD_GPT5_CODEX_READ_ONLY: &[&str] = &[]; -const CLOUD_GPT5_CODEX_WRITE: &[&str] = &[]; -const MODELS_MANIFEST: &str = include_str!("../../../codex-rs/models-manager/models.json"); - -/// Canonical list of built-in agent selectors used when no `[[agents]]` -/// entries are configured. The ordering here controls priority for legacy -/// CLI-name lookups. -pub const DEFAULT_AGENT_NAMES: &[&str] = &[ - // Frontline for moderate/challenging tasks - "code-gpt-5.5", - "code-gpt-5.4", - "claude-opus-4.8", - "antigravity", - // Straightforward / cost-aware - "code-gpt-5.4-mini", - "claude-sonnet-4.6", - "github-copilot", - // Mixed/general and alternates - "claude-haiku-4.5", - "qwen3-coder-plus", - "cloud-gpt-5.1-codex-max", -]; - -#[derive(Debug, Clone)] -pub struct AgentModelSpec { - pub slug: &'static str, - pub family: &'static str, - pub cli: &'static str, - pub read_only_args: &'static [&'static str], - pub write_args: &'static [&'static str], - pub model_args: &'static [&'static str], - pub description: &'static str, - pub enabled_by_default: bool, - pub aliases: &'static [&'static str], - pub gating_env: Option<&'static str>, - pub is_frontline: bool, - pub pro_only: bool, -} - -impl AgentModelSpec { - pub fn is_enabled(&self) -> bool { - if self.enabled_by_default { - return true; - } - if let Some(env) = self.gating_env { - if let Ok(value) = std::env::var(env) { - return matches!(value.as_str(), "1" | "true" | "TRUE" | "True"); - } - } - false - } - - pub fn default_args(&self, read_only: bool) -> &'static [&'static str] { - if read_only { - self.read_only_args - } else { - self.write_args - } - } -} - -const AGENT_MODEL_SPECS: &[AgentModelSpec] = &[ - AgentModelSpec { - slug: "code-gpt-5.5", - family: "code", - cli: "coder", - read_only_args: CODE_GPT5_READ_ONLY, - write_args: CODE_GPT5_WRITE, - model_args: &["--model", "gpt-5.5"], - description: "Default frontier model for complex coding, research, and real-world work.", - enabled_by_default: true, - aliases: &[ - "gpt-5.5", - "code-gpt-5.1-codex-max", - "code-gpt-5.1-codex", - "code-gpt-5-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex", - "gpt-5-codex", - "coder", - "code", - "codex", - ], - gating_env: None, - is_frontline: true, - pro_only: false, - }, - AgentModelSpec { - slug: "code-gpt-5.4", - family: "code", - cli: "coder", - read_only_args: CODE_GPT5_READ_ONLY, - write_args: CODE_GPT5_WRITE, - model_args: &["--model", "gpt-5.4"], - description: "Highest-capacity GPT option for tricky reasoning and large-context work. In Every Code, GPT-5.4 defaults to the expensive 1m context path, so use when correctness or history preservation is worth the added cost.", - enabled_by_default: true, - aliases: &[ - "gpt-5.4", - "code-gpt-5.1", - "code-gpt-5", - "gpt-5.1", - "gpt-5", - "coder-gpt-5", - ], - gating_env: None, - is_frontline: true, - pro_only: false, - }, - AgentModelSpec { - slug: "code-gpt-5.4-mini", - family: "code", - cli: "coder", - read_only_args: CODE_GPT5_CODEX_READ_ONLY, - write_args: CODE_GPT5_CODEX_WRITE, - model_args: &["--model", "gpt-5.4-mini"], - description: "Budget coding agent for small changes and quick refactors; use when speed and cost matter.", - enabled_by_default: true, - aliases: &[ - "gpt-5.4-mini", - "code-gpt-5.1-codex-mini", - "code-gpt-5-codex-mini", - "gpt-5.1-codex-mini", - "gpt-5-codex-mini", - "codex-mini", - "coder-mini", - ], - gating_env: None, - is_frontline: false, - pro_only: false, - }, - AgentModelSpec { - slug: "claude-opus-4.8", - family: "claude", - cli: "claude", - read_only_args: CLAUDE_OPUS_READ_ONLY, - write_args: CLAUDE_OPUS_WRITE, - model_args: &["--model", "claude-opus-4-8"], - description: "Higher-capacity Claude model for complex reasoning; use when you want the strongest Claude.", - enabled_by_default: true, - aliases: &[ - "claude-opus", - "claude-opus-4.1", - "claude-opus-4.5", - "claude-opus-4.6", - ], - gating_env: None, - is_frontline: true, - pro_only: false, - }, - AgentModelSpec { - slug: "claude-sonnet-4.6", - family: "claude", - cli: "claude", - read_only_args: CLAUDE_SONNET_READ_ONLY, - write_args: CLAUDE_SONNET_WRITE, - model_args: &["--model", "claude-sonnet-4-6"], - description: "Balanced Claude model for implementation and debugging; a solid default when you want Claude.", - enabled_by_default: true, - aliases: &["claude", "claude-sonnet", "claude-sonnet-4.5"], - gating_env: None, - is_frontline: false, - pro_only: false, - }, - AgentModelSpec { - slug: "claude-haiku-4.5", - family: "claude", - cli: "claude", - read_only_args: CLAUDE_HAIKU_READ_ONLY, - write_args: CLAUDE_HAIKU_WRITE, - model_args: &["--model", "claude-haiku-4-5"], - description: "Fast Claude model for simple tasks, drafts, and quick iterations; pick when latency matters.", - enabled_by_default: true, - aliases: &["claude-haiku"], - gating_env: None, - is_frontline: false, - pro_only: false, - }, - AgentModelSpec { - slug: "antigravity", - family: "antigravity", - cli: "agy", - read_only_args: ANTIGRAVITY_READ_ONLY, - write_args: ANTIGRAVITY_WRITE, - model_args: &[], - description: "Google/Gemini-family agent via Antigravity CLI; use for Google perspective after consumer Gemini CLI retirement. AGY uses its configured model, not per-run Gemini Pro/Flash flags.", - enabled_by_default: true, - aliases: &[ - "agy", - "google", - "gemini", - "gemini-agent", - "gemini-perspective", - "google-antigravity", - ], - gating_env: None, - is_frontline: true, - pro_only: false, - }, - AgentModelSpec { - slug: "github-copilot", - family: "copilot", - cli: "copilot", - read_only_args: COPILOT_READ_ONLY, - write_args: COPILOT_WRITE, - model_args: &[], - description: "GitHub Copilot CLI agent; uses your signed-in Copilot account and configured default model.", - enabled_by_default: true, - aliases: &["copilot", "github-copilot-cli"], - gating_env: None, - is_frontline: false, - pro_only: false, - }, - AgentModelSpec { - slug: "qwen3-coder-plus", - family: "qwen", - cli: "qwen", - read_only_args: QWEN_3_CODER_READ_ONLY, - write_args: QWEN_3_CODER_WRITE, - model_args: &["-m", "qwen3-coder-plus"], - description: "Fast and capable alternative; useful as a second opinion or for cross-checking.", - enabled_by_default: true, - aliases: &["qwen", "qwen3", "qwen-3-coder"], - gating_env: None, - is_frontline: false, - pro_only: false, - }, - AgentModelSpec { - slug: "cloud-gpt-5.1-codex-max", - family: "cloud", - cli: "cloud", - read_only_args: CLOUD_GPT5_CODEX_READ_ONLY, - write_args: CLOUD_GPT5_CODEX_WRITE, - model_args: &["--model", "gpt-5.1-codex-max"], - description: "Cloud-hosted gpt-5.1-codex-max agent; use for remote runs when enabled via CODE_ENABLE_CLOUD_AGENT_MODEL.", - enabled_by_default: false, - aliases: &["cloud-gpt-5.1-codex", "cloud-gpt-5-codex", "cloud"], - gating_env: Some(CLOUD_MODEL_ENV_FLAG), - is_frontline: false, - pro_only: false, - }, -]; - -static ALL_AGENT_MODEL_SPECS: LazyLock> = - LazyLock::new(build_agent_model_specs); - -#[derive(Debug, Deserialize)] -struct ModelsManifest { - models: Vec, -} - -#[derive(Debug, Deserialize)] -struct ManifestModel { - slug: String, - display_name: String, - description: String, - visibility: String, - supported_in_api: bool, -} - -fn build_agent_model_specs() -> Vec { - let mut specs = AGENT_MODEL_SPECS.to_vec(); - specs.extend(dynamic_code_agent_specs()); - specs -} - -fn dynamic_code_agent_specs() -> Vec { - let Ok(manifest) = serde_json::from_str::(MODELS_MANIFEST) else { - return Vec::new(); - }; - - manifest - .models - .into_iter() - .filter(|model| model.supported_in_api) - .filter(|model| model.visibility.eq_ignore_ascii_case("list")) - .filter_map(|model| dynamic_code_agent_spec(model)) - .collect() -} - -fn dynamic_code_agent_spec(model: ManifestModel) -> Option { - let track = code_agent_track(&model.slug)?; - if static_agent_model_spec(&model.slug).is_some() { - return None; - } - - let candidate_version = parse_model_version_components(&model.slug)?; - let highest_static_version = highest_static_code_track_version(track)?; - if candidate_version <= highest_static_version { - return None; - } - - let slug = leak_str(format!("code-{}", model.slug)); - let model_slug = leak_str(model.slug); - let description = leak_str(model.description); - let _display_name = leak_str(model.display_name); - let aliases = leak_str_slice(vec![model_slug]); - let model_args = leak_str_slice(vec!["--model", model_slug]); - let pro_only = false; - let is_frontline = !matches!(track, CodeAgentTrack::Mini); - - Some(AgentModelSpec { - slug, - family: "code", - cli: "coder", - read_only_args: CODE_GPT5_READ_ONLY, - write_args: CODE_GPT5_WRITE, - model_args, - description, - enabled_by_default: true, - aliases, - gating_env: None, - is_frontline, - pro_only, - }) -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum CodeAgentTrack { - Base, - Mini, - Codex, -} - -fn code_agent_track(model: &str) -> Option { - let canonical = model.strip_prefix("code-").unwrap_or(model); - if !canonical.starts_with("gpt-") { - return None; - } - - if canonical.contains("codex-spark") { - None - } else if canonical.contains("codex") { - Some(CodeAgentTrack::Codex) - } else if canonical.ends_with("-mini") { - Some(CodeAgentTrack::Mini) - } else { - Some(CodeAgentTrack::Base) - } -} - -fn highest_static_code_track_version(track: CodeAgentTrack) -> Option> { - AGENT_MODEL_SPECS - .iter() - .filter(|spec| spec.family == "code") - .filter(|spec| code_agent_track(spec.slug) == Some(track)) - .filter_map(|spec| parse_model_version_components(spec.slug)) - .max() -} - -fn parse_model_version_components(model: &str) -> Option> { - let canonical = model - .strip_prefix("code-") - .unwrap_or(model) - .rsplit('/') - .next() - .unwrap_or(model); - let mut components = Vec::new(); - - for segment in canonical.split('-') { - let first = segment.chars().next()?; - if !first.is_ascii_digit() { - continue; - } - - for part in segment.split('.') { - if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) { - return None; - } - components.push(part.parse().ok()?); - } - - return (!components.is_empty()).then_some(components); - } - - None -} - -fn leak_str(value: String) -> &'static str { - Box::leak(value.into_boxed_str()) -} - -fn leak_str_slice(values: Vec<&'static str>) -> &'static [&'static str] { - Box::leak(values.into_boxed_slice()) -} - -fn static_agent_model_spec(identifier: &str) -> Option<&'static AgentModelSpec> { - let lower = identifier.to_ascii_lowercase(); - AGENT_MODEL_SPECS - .iter() - .find(|spec| spec.slug.eq_ignore_ascii_case(&lower)) - .or_else(|| { - AGENT_MODEL_SPECS.iter().find(|spec| { - spec.aliases - .iter() - .any(|alias| alias.eq_ignore_ascii_case(&lower)) - }) - }) -} - -pub fn agent_model_specs() -> &'static [AgentModelSpec] { - ALL_AGENT_MODEL_SPECS.as_slice() -} - -pub fn enabled_agent_model_specs() -> Vec<&'static AgentModelSpec> { - agent_model_specs() - .iter() - .filter(|spec| spec.is_enabled()) - .collect() -} - -pub fn agent_model_available_for_auth( - spec: &AgentModelSpec, - auth_mode: Option, - supports_pro_only_models: bool, -) -> bool { - let is_chatgpt_auth = auth_mode.is_some_and(AuthMode::is_chatgpt); - !spec.pro_only || (is_chatgpt_auth && supports_pro_only_models) -} - -pub fn enabled_agent_model_specs_for_auth( - auth_mode: Option, - supports_pro_only_models: bool, -) -> Vec<&'static AgentModelSpec> { - agent_model_specs() - .iter() - .filter(|spec| spec.is_enabled()) - .filter(|spec| agent_model_available_for_auth(spec, auth_mode, supports_pro_only_models)) - .collect() -} - -pub fn filter_agent_model_names_for_auth( - model_names: Vec, - auth_mode: Option, - supports_pro_only_models: bool, -) -> Vec { - model_names - .into_iter() - .filter(|name| { - if let Some(spec) = agent_model_spec(name) { - return agent_model_available_for_auth(spec, auth_mode, supports_pro_only_models); - } - true - }) - .collect() -} - -pub fn agent_model_spec(identifier: &str) -> Option<&'static AgentModelSpec> { - let lower = identifier.to_ascii_lowercase(); - agent_model_specs() - .iter() - .find(|spec| spec.slug.eq_ignore_ascii_case(&lower)) - .or_else(|| { - agent_model_specs().iter().find(|spec| { - spec.aliases - .iter() - .any(|alias| alias.eq_ignore_ascii_case(&lower)) - }) - }) -} - -fn model_guide_intro(active_agents: &[String]) -> String { - let mut present_frontline: Vec = active_agents - .iter() - .filter_map(|id| { - agent_model_spec(id) - .filter(|spec| spec.is_frontline) - .map(|spec| spec.slug.to_string()) - }) - .collect(); - - if present_frontline.is_empty() { - present_frontline.push("code-gpt-5.4".to_string()); - } - let frontline_str = present_frontline.join(", "); - - format!( - "Preferred agent models: use {frontline_str} for challenging coding/agentic work. For explicit multi-agent or dissent requests, prefer diverse model families when useful and budget allows. For multi-agent release/workflow, infrastructure, security, or product-risk work, proactively include `antigravity` for Google/Gemini-family perspective unless there is a clear reason to skip it." - ) -} - -fn model_guide_line(spec: &AgentModelSpec) -> String { - format!("- `{}`: {}", spec.slug, spec.description) -} - -fn custom_model_guide_line(name: &str, description: &str) -> String { - format!("- `{}`: {}", name, description) -} - -pub fn build_model_guide_description(active_agents: &[String]) -> String { - let mut description = model_guide_intro(active_agents); - - let mut canonical: HashSet = HashSet::new(); - for name in active_agents { - let trimmed = name.trim(); - if trimmed.is_empty() { - continue; - } - if let Some(spec) = agent_model_spec(trimmed) { - canonical.insert(spec.slug.to_ascii_lowercase()); - } else { - canonical.insert(trimmed.to_ascii_lowercase()); - } - } - - let lines: Vec = agent_model_specs() - .iter() - .filter(|spec| canonical.contains(&spec.slug.to_ascii_lowercase())) - .map(model_guide_line) - .collect(); - - if lines.is_empty() { - description.push('\n'); - description.push_str("- No model guides available for the current configuration."); - } else { - for line in lines { - description.push('\n'); - description.push_str(&line); - } - } - - description -} - -pub fn model_guide_markdown() -> String { - agent_model_specs() - .iter() - .filter(|spec| spec.is_enabled()) - .map(model_guide_line) - .collect::>() - .join("\n") -} - -pub fn model_guide_markdown_with_custom(configured_agents: &[AgentConfig]) -> Option { - let mut lines: Vec = Vec::new(); - let mut positions: HashMap = HashMap::new(); - - for spec in agent_model_specs().iter().filter(|spec| spec.is_enabled()) { - let idx = lines.len(); - positions.insert(spec.slug.to_ascii_lowercase(), idx); - lines.push(model_guide_line(spec)); - } - - let mut saw_custom = false; - for agent in configured_agents { - if !agent.enabled { - continue; - } - let Some(description) = agent.description.as_deref() else { continue }; - let trimmed = description.trim(); - if trimmed.is_empty() { - continue; - } - let slug = agent.name.trim(); - if slug.is_empty() { - continue; - } - saw_custom = true; - let line = custom_model_guide_line(slug, trimmed); - let key = slug.to_ascii_lowercase(); - if let Some(idx) = positions.get(&key).copied() { - lines[idx] = line; - } else { - positions.insert(key, lines.len()); - lines.push(line); - } - } - - if saw_custom { - Some(lines.join("\n")) - } else { - None - } -} - -pub fn default_agent_configs() -> Vec { - enabled_agent_model_specs() - .into_iter() - .map(|spec| agent_config_from_spec(spec)) - .collect() -} - -pub fn agent_config_from_spec(spec: &AgentModelSpec) -> AgentConfig { - AgentConfig { - name: spec.slug.to_string(), - command: spec.cli.to_string(), - args: Vec::new(), - read_only: false, - enabled: spec.is_enabled(), - description: None, - env: None, - args_read_only: some_args(spec.read_only_args), - args_write: some_args(spec.write_args), - instructions: None, - } -} - -fn some_args(args: &[&str]) -> Option> { - if args.is_empty() { - None - } else { - Some(args.iter().map(|arg| (*arg).to_string()).collect()) - } -} - -/// Return default CLI arguments (excluding the prompt flag) for a given agent -/// identifier and access mode. -/// -/// The identifier can be either the canonical slug or a legacy CLI alias -/// (`code`, `claude`, etc.) used prior to the model slug transition. -pub fn default_params_for(name: &str, read_only: bool) -> Vec { - if let Some(spec) = agent_model_spec(name) { - return spec - .default_args(read_only) - .iter() - .map(|arg| (*arg).to_string()) - .collect(); - } - Vec::new() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cloud_defaults_are_empty_both_modes() { - assert!(default_params_for("cloud", true).is_empty()); - assert!(default_params_for("cloud", false).is_empty()); - } - - #[test] - fn github_copilot_defaults_match_cli_contract() { - assert_eq!( - default_params_for("github-copilot", true), - vec!["--autopilot", "--allow-all-tools", "--no-ask-user", "-s"] - ); - assert_eq!( - default_params_for("github-copilot", false), - vec!["--autopilot", "--yolo", "--no-ask-user", "-s"] - ); - - let spec = agent_model_spec("copilot").expect("copilot alias should resolve"); - assert_eq!(spec.slug, "github-copilot"); - } - - #[test] - fn gpt_codex_aliases_resolve() { - let codex = agent_model_spec("gpt-5.1-codex").expect("alias for codex present"); - assert_eq!(codex.slug, "code-gpt-5.5"); - - let codex_direct = agent_model_spec("gpt-5.1-codex-max").expect("codex present"); - assert_eq!(codex_direct.slug, "code-gpt-5.5"); - - let mini = agent_model_spec("gpt-5.1-codex-mini").expect("mini alias present"); - assert_eq!(mini.slug, "code-gpt-5.4-mini"); - - let mini_direct = agent_model_spec("gpt-5.4-mini").expect("mini direct alias present"); - assert_eq!(mini_direct.slug, "code-gpt-5.4-mini"); - - let mid = agent_model_spec("gpt-5.1").expect("mid alias present"); - assert_eq!(mid.slug, "code-gpt-5.4"); - - assert!(agent_model_spec("gpt-5.2-codex").is_none()); - assert!(agent_model_spec("code-gpt-5.2-codex").is_none()); - assert!(agent_model_spec("gpt-5.3-codex-spark").is_none()); - assert!(agent_model_spec("code-gpt-5.2").is_none()); - } - - #[test] - fn claude_opus_aliases_resolve_to_current_opus() { - let opus = agent_model_spec("claude-opus").expect("opus alias present"); - assert_eq!(opus.slug, "claude-opus-4.8"); - assert_eq!(opus.model_args, &["--model", "claude-opus-4-8"]); - - let legacy = agent_model_spec("claude-opus-4.6").expect("legacy opus alias present"); - assert_eq!(legacy.slug, "claude-opus-4.8"); - } - - #[test] - fn google_intent_aliases_resolve_to_antigravity() { - for alias in ["google", "gemini", "gemini-agent", "gemini-perspective"] { - let spec = agent_model_spec(alias).expect("google-family alias should resolve"); - assert_eq!(spec.slug, "antigravity"); - assert!(spec.model_args.is_empty()); - } - } - - #[test] - fn model_guide_describes_antigravity_as_google_family_lane() { - let guide = build_model_guide_description(&[ - "code-gpt-5.5".to_string(), - "claude-sonnet-4.6".to_string(), - "antigravity".to_string(), - ]); - - assert!(guide.contains("proactively include `antigravity` for Google/Gemini-family perspective")); - assert!(guide.contains("release/workflow, infrastructure, security, or product-risk")); - assert!(guide.contains("unless there is a clear reason to skip it")); - assert!(guide.contains("AGY uses its configured model")); - assert!(guide.contains("not per-run Gemini Pro/Flash flags")); - } - - #[test] - fn retired_codex_models_are_not_default_agent_specs() { - let pro_specs = enabled_agent_model_specs_for_auth(Some(AuthMode::Chatgpt), true); - assert!( - pro_specs - .iter() - .all(|spec| spec.slug != "code-gpt-5.3-codex" - && spec.slug != "code-gpt-5.3-codex-spark") - ); - } - - #[test] - fn filter_agent_model_names_keeps_unknown_selectors_for_custom_config() { - let filtered = filter_agent_model_names_for_auth( - vec![ - "code-gpt-5.5".to_string(), - "custom-model".to_string(), - ], - Some(AuthMode::ApiKey), - false, - ); - - assert_eq!(filtered, vec!["code-gpt-5.5", "custom-model"]); - } - - #[test] - fn dynamic_agent_specs_include_newer_manifest_models() { - let spec = agent_model_spec("gpt-5.5").expect("gpt-5.5 spec should be present"); - assert_eq!(spec.slug, "code-gpt-5.5"); - assert_eq!(spec.cli, "coder"); - assert_eq!( - default_params_for("gpt-5.5", true), - CODE_GPT5_READ_ONLY - .iter() - .map(|arg| (*arg).to_string()) - .collect::>() - ); - } - - #[test] - fn dynamic_agent_specs_skip_older_manifest_models() { - assert!( - enabled_agent_model_specs() - .iter() - .all(|spec| spec.slug != "code-gpt-5.2") - ); - assert!(agent_model_spec("gpt-5.2").is_none()); - } -} diff --git a/code-rs/core/src/agent_tool.rs b/code-rs/core/src/agent_tool.rs deleted file mode 100644 index 2b0803bdc95..00000000000 --- a/code-rs/core/src/agent_tool.rs +++ /dev/null @@ -1,5962 +0,0 @@ -use chrono::DateTime; -use chrono::Duration; -use chrono::Utc; -use serde::de; -use serde::Deserialize; -use serde::Serialize; -use uuid::Uuid; -use std::fs::{self, OpenOptions}; -use std::io::Write as IoWrite; -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use tokio::process::Command; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader}; -use tokio::runtime::Builder as TokioRuntimeBuilder; -use tokio::sync::RwLock; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; -use tokio::time::Duration as TokioDuration; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; -use std::time::{Duration as StdDuration, Instant}; -use crate::spawn::spawn_tokio_command_with_retry; -use crate::protocol::AgentSourceKind; -use tracing::{debug, info, warn}; - -#[cfg(target_os = "windows")] -fn default_pathext_or_default() -> Vec { - std::env::var("PATHEXT") - .ok() - .filter(|v| !v.is_empty()) - .map(|v| { - v.split(';') - .filter(|s| !s.is_empty()) - .map(|s| s.to_ascii_lowercase()) - .collect() - }) - // Keep a sane default set even if PATHEXT is missing or empty. Include - // .ps1 because PowerShell users can invoke scripts without specifying - // the extension; CreateProcess still resolves fine when we provide the - // full path with extension. - .unwrap_or_else(|| vec![ - ".com".into(), - ".exe".into(), - ".bat".into(), - ".cmd".into(), - ".ps1".into(), - ]) -} - -#[cfg(target_os = "windows")] -fn resolve_in_path(command: &str) -> Option { - use std::path::Path; - - let cmd_path = Path::new(command); - - // Absolute or contains separators: respect it directly if it points to a file. - if cmd_path.is_absolute() || command.contains(['\\', '/']) { - if cmd_path.is_file() { - return Some(cmd_path.to_path_buf()); - } - } - - // Search PATH with PATHEXT semantics and return the first hit. - let exts = default_pathext_or_default(); - let Some(path_os) = std::env::var_os("PATH") else { return None; }; - let has_ext = cmd_path.extension().is_some(); - for dir in std::env::split_paths(&path_os) { - if dir.as_os_str().is_empty() { - continue; - } - if has_ext { - let candidate = dir.join(command); - if candidate.is_file() { - return Some(candidate); - } - } else { - for ext in &exts { - let candidate = dir.join(format!("{command}{ext}")); - if candidate.is_file() { - return Some(candidate); - } - } - } - } - - None -} - -#[cfg(target_os = "windows")] -fn is_executable_file(path: &std::path::Path) -> bool { - path.is_file() -} - -#[cfg(target_os = "windows")] -fn resolve_existing_path_with_pathext(path: &std::path::Path) -> Option { - if is_executable_file(path) { - return Some(path.to_path_buf()); - } - - if path.extension().is_some() { - return None; - } - - let Some(file_name) = path.file_name() else { - return None; - }; - - let base = file_name.to_os_string(); - for ext in default_pathext_or_default() { - let mut name = base.clone(); - name.push(ext); - let candidate = path.with_file_name(name); - if is_executable_file(&candidate) { - return Some(candidate); - } - } - - None -} - -#[cfg(not(target_os = "windows"))] -fn is_executable_file(path: &std::path::Path) -> bool { - use std::os::unix::fs::PermissionsExt; - - let Ok(meta) = std::fs::metadata(path) else { - return false; - }; - - meta.is_file() && (meta.permissions().mode() & 0o111 != 0) -} - -#[cfg(target_os = "windows")] -fn resolve_explicit_command_path(path: &std::path::Path) -> Option { - resolve_existing_path_with_pathext(path) -} - -#[cfg(not(target_os = "windows"))] -fn resolve_explicit_command_path(path: &std::path::Path) -> Option { - is_executable_file(path).then_some(path.to_path_buf()) -} - -#[cfg(target_os = "windows")] -fn resolve_command_on_path(command: &str) -> Option { - resolve_in_path(command) -} - -#[cfg(not(target_os = "windows"))] -fn resolve_command_on_path(command: &str) -> Option { - let path_os = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&path_os) { - if dir.as_os_str().is_empty() { - continue; - } - let candidate = dir.join(command); - if is_executable_file(&candidate) { - return Some(candidate); - } - } - None -} - -fn home_fallback_command_candidates(command: &str) -> Vec { - let mut candidates = Vec::new(); - - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - if let Some(home) = home.as_ref() { - candidates.push(home.join(".local/bin").join(command)); - candidates.push(home.join(".n/bin").join(command)); - candidates.push(home.join(".npm-global/bin").join(command)); - } - - if command.eq_ignore_ascii_case("claude") { - if let Some(claude_config_dir) = std::env::var_os("CLAUDE_CONFIG_DIR") { - candidates.push(std::path::PathBuf::from(claude_config_dir).join("local").join(command)); - } - if let Some(home) = home { - candidates.push(home.join(".claude/local").join(command)); - } - } - - candidates -} - -pub(crate) fn resolve_external_agent_command_path(command: &str) -> Option { - let trimmed = command.trim(); - if trimmed.is_empty() { - return None; - } - - if trimmed.contains(std::path::MAIN_SEPARATOR) || trimmed.contains('/') || trimmed.contains('\\') { - let path = std::path::PathBuf::from(trimmed); - return resolve_explicit_command_path(&path); - } - - if let Some(path) = resolve_command_on_path(trimmed) { - return Some(path); - } - - for candidate in home_fallback_command_candidates(trimmed) { - if let Some(path) = resolve_explicit_command_path(&candidate) { - return Some(path); - } - } - - None -} - -pub fn external_agent_command_exists(command: &str) -> bool { - resolve_external_agent_command_path(command).is_some() -} - -use crate::agent_defaults::{agent_model_spec, default_params_for}; -use shlex::split as shlex_split; -use crate::config_types::AgentConfig; -use crate::openai_tools::JsonSchema; -use crate::openai_tools::OpenAiTool; -use crate::openai_tools::ResponsesApiTool; -use crate::protocol::AgentInfo; - -fn current_code_binary_path() -> Result { - if let Ok(path) = std::env::var("CODE_BINARY_PATH") { - let p = std::path::PathBuf::from(path); - if !p.exists() { - return Err(format!( - "CODE_BINARY_PATH points to '{}' but that file is missing. Rebuild with ./build-fast.sh or update CODE_BINARY_PATH.", - p.display() - )); - } - return Ok(p); - } - let exe = std::env::current_exe().map_err(|e| format!("Failed to resolve current executable: {}", e))?; - - // If the kernel reports the path as "(deleted)", strip the suffix and prefer the live file - // at the same location (common when a rebuild replaces the inode under a long-running process). - let cleaned = strip_deleted_suffix(&exe); - if cleaned.exists() { - return Ok(cleaned); - } - - if let Some(fallback) = fallback_code_binary_path() { - return Ok(fallback); - } - - Err(format!( - "Current code binary is missing on disk ({}). It may have been deleted while running. Rebuild with ./build-fast.sh or reinstall 'code' to continue.", - exe.display() - )) -} - -fn strip_deleted_suffix(path: &std::path::Path) -> std::path::PathBuf { - const DELETED_SUFFIX: &str = " (deleted)"; - let s = path.to_string_lossy(); - if let Some(stripped) = s.strip_suffix(DELETED_SUFFIX) { - return std::path::PathBuf::from(stripped); - } - path.to_path_buf() -} - -fn fallback_code_binary_path() -> Option { - // If the running binary was pruned (e.g., shared target cache rotation), try to locate - // a fresh dev build in the repository, and if missing, trigger a quick rebuild. - let repo_root = find_repo_root(std::env::current_dir().ok()?)?; - let workspace = repo_root.join("code-rs"); - - // Probe likely build outputs in priority order. - let mut candidates = vec![ - workspace.join("target/dev-fast/code"), - workspace.join("target/debug/code"), - workspace.join("target/release-prod/code"), - workspace.join("target/release/code"), - workspace.join("bin/code"), - ]; - - if let Some(found) = candidates.iter().find(|p| p.exists()).cloned() { - return Some(found); - } - - // Best-effort rebuild; swallow errors so caller can surface the original message. - let status = std::process::Command::new("bash") - .current_dir(&repo_root) - .args(["-lc", "./build-fast.sh >/dev/null 2>&1"]) - .status() - .ok(); - - if status.map(|s| s.success()).unwrap_or(false) { - candidates.retain(|p| p.exists()); - if let Some(found) = candidates.first().cloned() { - return Some(found); - } - } - - None -} - -fn find_repo_root(start: std::path::PathBuf) -> Option { - let mut dir = Some(start.as_path()); - while let Some(path) = dir { - if path.join(".git").exists() { - return Some(path.to_path_buf()); - } - dir = path.parent(); - } - None -} - -/// Format a helpful error message when an agent command is not found. -/// Provides platform-specific guidance for resolving PATH issues. -fn format_agent_not_found_error(agent_name: &str, command: &str) -> String { - let mut msg = format!("Agent '{}' could not be found.", agent_name); - - #[cfg(target_os = "windows")] - { - msg.push_str(&format!( - "\n\nTroubleshooting steps:\n\ - 1. Check if '{}' is installed and available in your PATH\n\ - 2. Try using an absolute path in your config.toml:\n\ - [[agents]]\n\ - name = \"{}\"\n\ - command = \"C:\\\\Users\\\\YourUser\\\\AppData\\\\Roaming\\\\npm\\\\{}.cmd\"\n\ - 3. Verify your PATH includes the directory containing '{}'\n\ - 4. On Windows, ensure the file has a valid extension (.exe, .cmd, .bat, .com)\n\n\ - For more information, see: https://github.com/just-every/code/blob/main/code-rs/config.md", - command, agent_name, command, command - )); - } - - #[cfg(not(target_os = "windows"))] - { - msg.push_str(&format!( - "\n\nTroubleshooting steps:\n\ - 1. Check if '{}' is installed: which {}\n\ - 2. Verify '{}' is in your PATH: echo $PATH\n\ - 3. Try using an absolute path in your config.toml:\n\ - [[agents]]\n\ - name = \"{}\"\n\ - command = \"/absolute/path/to/{}\"\n\n\ - For more information, see: https://github.com/just-every/code/blob/main/code-rs/config.md", - command, command, command, agent_name, command - )); - } - - msg -} - -// Agent status enum -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum AgentStatus { - Pending, - Running, - Completed, - Failed, - Cancelled, -} - -// Agent information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Agent { - pub id: String, - #[serde(default)] - pub owner_session_id: Option, - pub batch_id: Option, - pub model: String, - #[serde(default)] - pub name: Option, - pub prompt: String, - pub context: Option, - pub output_goal: Option, - pub files: Vec, - #[serde(default)] - pub context_files: Vec, - #[serde(default)] - pub context_budget_tokens: Option, - pub read_only: bool, - pub status: AgentStatus, - pub result: Option, - pub error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_count: Option, - #[serde(default)] - pub retry: AgentRetryMetadata, - pub created_at: DateTime, - pub started_at: Option>, - pub completed_at: Option>, - pub progress: Vec, - pub worktree_path: Option, - pub branch_name: Option, - #[serde(default)] - pub worktree_base: Option, - #[serde(default)] - pub workspace_root: Option, - #[serde(default)] - pub source_kind: Option, - #[serde(skip)] - pub log_tag: Option, - #[serde(skip)] - #[allow(dead_code)] - pub config: Option, - pub reasoning_effort: code_protocol::config_types::ReasoningEffort, - #[serde(skip)] - pub last_activity: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AgentRetryMetadata { - #[serde(skip_serializing_if = "String::is_empty")] - pub original_model: String, - #[serde(skip_serializing_if = "String::is_empty")] - pub final_model: String, - pub retry_count: u32, - pub max_retries: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_retryable_error: Option, -} - -impl Default for AgentRetryMetadata { - fn default() -> Self { - Self { - original_model: String::new(), - final_model: String::new(), - retry_count: 0, - max_retries: DEFAULT_AGENT_PROVIDER_MAX_RETRIES as u32, - last_retryable_error: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AgentExecutionOutput { - output: String, - token_count: Option, -} - -impl AgentExecutionOutput { - fn new(output: String, token_count: Option) -> Self { - Self { output, token_count } - } - - fn from_child_output(output: String, stderr: &str) -> Self { - let token_count = extract_agent_token_count(&output).or_else(|| extract_agent_token_count(stderr)); - Self::new(output, token_count) - } -} - -// Global agent manager -lazy_static::lazy_static! { - pub static ref AGENT_MANAGER: Arc> = Arc::new(RwLock::new(AgentManager::new())); -} - -pub struct AgentManager { - agents: HashMap, - // Session-scoped archive so pruned terminal agents remain queryable - // (including worktree/branch metadata) for the full Auto Drive run. - archived_terminal_agents: HashMap, - handles: HashMap>, - event_senders: Vec, - debug_log_root: Option, - watchdog_handle: Option>, - inactivity_timeout: Duration, - diagnostics: AgentManagerDiagnostics, -} - -#[derive(Debug, Clone, Default)] -struct AgentManagerDiagnostics { - terminal_compactions: u64, - progress_entries_trimmed: u64, - progress_lines_truncated: u64, - payloads_truncated: u64, - terminal_agents_pruned: u64, - archived_terminal_agents: u64, - status_terminal_agents_omitted: u64, -} - -#[derive(Debug, Clone, Copy, Default)] -struct AgentCompactionDelta { - progress_entries_trimmed: usize, - progress_lines_truncated: usize, - payloads_truncated: usize, -} - -impl AgentCompactionDelta { - fn any(self) -> bool { - self.progress_entries_trimmed > 0 - || self.progress_lines_truncated > 0 - || self.payloads_truncated > 0 - } -} - -const MAX_AGENT_PROGRESS_ENTRIES: usize = 96; -const MAX_AGENT_PROGRESS_LINE_BYTES: usize = 2048; -const MAX_AGENT_RESULT_BYTES: usize = 64 * 1024; -const MAX_TRACKED_TERMINAL_AGENTS: usize = 512; -const DEFAULT_CONTEXT_FILE_BUDGET_TOKENS: u64 = 16_000; -const MAX_CONTEXT_FILE_BUDGET_TOKENS: u64 = 900_000; -const CONTEXT_FILE_TOKEN_BYTES_ESTIMATE: u64 = 4; -const AGENT_PROMPT_STDIN_THRESHOLD_BYTES: usize = 32 * 1024; -const AGENT_PROMPT_ARGV_THRESHOLD_BYTES: usize = { - #[cfg(target_os = "windows")] - { - 8 * 1024 - } - #[cfg(not(target_os = "windows"))] - { - AGENT_PROMPT_STDIN_THRESHOLD_BYTES - } -}; - -const CONTEXT_FILE_BUDGET_GUIDANCE: &str = "context_budget_tokens must be a non-negative integer token budget. context_files inline file contents into the agent prompt; set context_budget_tokens explicitly for intentional large-context launches, or use files for lightweight path hints. For strict one-shot rollout/model evaluation, prefer `code llm request --message-file`."; -const MAX_STATUS_TERMINAL_AGENTS: usize = 128; -const DEFAULT_AGENT_PROVIDER_MAX_RETRIES: usize = 2; -const AGENT_PROVIDER_RETRY_BASE_DELAY: StdDuration = StdDuration::from_secs(2); -const AGENT_PROVIDER_RETRY_MAX_DELAY: StdDuration = StdDuration::from_secs(10); -pub(crate) const CODE_AGENT_SPAWN_DEPTH_ENV: &str = "CODE_AGENT_SPAWN_DEPTH"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AgentProviderFailureClass { - Retryable, - NonRetryable, -} - -fn classify_agent_provider_failure(error: &str) -> AgentProviderFailureClass { - let lower = error.to_ascii_lowercase(); - let contains_any = |needles: &[&str]| needles.iter().any(|needle| lower.contains(needle)); - - if contains_any(&[ - "unauthorized", - "authentication", - "auth failed", - "invalid api key", - "invalid token", - "permission denied", - "forbidden", - "not found", - "could not be found", - "not installed", - "no such file", - "invalid config", - "misconfigured", - "cancelled", - "canceled", - "interrupted", - "policy", - ]) { - return AgentProviderFailureClass::NonRetryable; - } - - if contains_any(&[ - "overloaded", - "rate limit", - "rate_limit", - "too many requests", - "temporarily unavailable", - "temporary failure", - "transient", - "timeout", - "timed out", - "deadline exceeded", - "service unavailable", - "internal server error", - "bad gateway", - "gateway timeout", - "connection reset", - "connection refused", - "connection closed", - "connection aborted", - "broken pipe", - "transport error", - "stream disconnected", - "network error", - "http 408", - "http 409", - "http 429", - "http 500", - "http 502", - "http 503", - "http 504", - "status 408", - "status 409", - "status 429", - "status 500", - "status 502", - "status 503", - "status 504", - "api error: 408", - "api error: 409", - "api error: 429", - "api error: 500", - "api error: 502", - "api error: 503", - "api error: 504", - ]) { - return AgentProviderFailureClass::Retryable; - } - - AgentProviderFailureClass::NonRetryable -} - -fn agent_retry_delay(attempt_index: usize) -> StdDuration { - let multiplier = 1_u32 << attempt_index.min(8); - AGENT_PROVIDER_RETRY_BASE_DELAY - .saturating_mul(multiplier) - .min(AGENT_PROVIDER_RETRY_MAX_DELAY) -} - -pub(crate) fn current_agent_spawn_depth() -> i32 { - std::env::var(CODE_AGENT_SPAWN_DEPTH_ENV) - .ok() - .and_then(|value| value.trim().parse::().ok()) - .filter(|depth| *depth >= 0) - .unwrap_or(0) -} - -#[derive(Debug, Clone)] -pub struct AgentStatusUpdatePayload { - pub agents: Vec, - pub context: Option, - pub task: Option, -} - -struct AgentStatusSender { - owner_session_id: Uuid, - sender: mpsc::UnboundedSender, -} - -fn agent_belongs_to_session(agent: &Agent, owner_session_id: Option) -> bool { - match owner_session_id { - Some(owner_session_id) => agent - .owner_session_id - .is_none_or(|agent_owner_session_id| agent_owner_session_id == owner_session_id), - None => true, - } -} - -fn agent_can_be_cancelled_by_session(agent: &Agent, owner_session_id: Uuid) -> bool { - agent.owner_session_id - .is_none_or(|agent_owner_session_id| agent_owner_session_id == owner_session_id) -} - -fn agent_info_for_status(agent: &Agent, now: DateTime) -> AgentInfo { - // Just show the model name - status provides the useful info. - let name = agent - .name - .as_ref() - .map(|value| value.clone()) - .unwrap_or_else(|| agent.model.clone()); - let start = agent.started_at.unwrap_or(agent.created_at); - let end = agent.completed_at.unwrap_or(now); - let elapsed_ms = match end.signed_duration_since(start).num_milliseconds() { - value if value >= 0 => Some(value as u64), - _ => None, - }; - - AgentInfo { - id: agent.id.clone(), - name, - status: format!("{:?}", agent.status).to_lowercase(), - batch_id: agent.batch_id.clone(), - model: Some(agent.model.clone()), - last_progress: agent.progress.last().cloned(), - result: agent.result.clone(), - error: agent.error.clone(), - elapsed_ms, - token_count: agent.token_count, - last_activity_at: match agent.status { - AgentStatus::Pending | AgentStatus::Running => Some(agent.last_activity.to_rfc3339()), - _ => None, - }, - seconds_since_last_activity: match agent.status { - AgentStatus::Pending | AgentStatus::Running => Some( - now.signed_duration_since(agent.last_activity) - .num_seconds() - .max(0) as u64, - ), - _ => None, - }, - source_kind: agent.source_kind.clone(), - owner_session_id: agent.owner_session_id.map(|id| id.to_string()), - worktree_base: agent.worktree_base.clone(), - } -} - -impl AgentManager { - pub fn new() -> Self { - Self { - agents: HashMap::new(), - archived_terminal_agents: HashMap::new(), - handles: HashMap::new(), - event_senders: Vec::new(), - debug_log_root: None, - watchdog_handle: None, - inactivity_timeout: Duration::minutes(30), - diagnostics: AgentManagerDiagnostics::default(), - } - } - - pub fn set_event_sender( - &mut self, - owner_session_id: Uuid, - sender: mpsc::UnboundedSender, - ) { - self.event_senders - .retain(|registered| !registered.sender.is_closed()); - let fresh_sender_set = self.event_senders.is_empty(); - self.event_senders.push(AgentStatusSender { - owner_session_id, - sender, - }); - // Keep archived results for connected sessions and legacy unowned - // agents, but drop archives from older disconnected sessions so a - // long-lived manager does not retain stale terminal agents forever. - let connected_sessions: HashSet = self - .event_senders - .iter() - .filter(|registered| !registered.sender.is_closed()) - .map(|registered| registered.owner_session_id) - .collect(); - self.archived_terminal_agents.retain(|_, agent| { - agent - .owner_session_id - .is_none_or(|agent_owner_session_id| connected_sessions.contains(&agent_owner_session_id)) - }); - self.diagnostics.archived_terminal_agents = self.archived_terminal_agents.len() as u64; - - if fresh_sender_set { - self.diagnostics = AgentManagerDiagnostics::default(); - self.diagnostics.archived_terminal_agents = self.archived_terminal_agents.len() as u64; - } - self.start_watchdog(); - } - - fn start_watchdog(&mut self) { - if self.watchdog_handle.is_some() { - return; - } - - let timeout = self.inactivity_timeout; - let manager = Arc::downgrade(&AGENT_MANAGER); - self.watchdog_handle = Some(tokio::spawn(async move { - let mut ticker = tokio::time::interval(TokioDuration::from_secs(60)); - loop { - ticker.tick().await; - - let Some(manager_arc) = manager.upgrade() else { break; }; - - let mut mgr = manager_arc.write().await; - let now = Utc::now(); - let timeout_ids: Vec = mgr - .agents - .iter() - .filter(|(_, agent)| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running)) - .filter(|(_, agent)| now - agent.last_activity > timeout) - .map(|(id, _)| id.clone()) - .collect(); - - if timeout_ids.is_empty() { - continue; - } - - for agent_id in timeout_ids.iter() { - if let Some(handle) = mgr.handles.remove(agent_id) { - handle.abort(); - } - if let Some(agent) = mgr.agents.get_mut(agent_id) { - agent.status = AgentStatus::Failed; - agent.error = Some(format!( - "Agent timed out after {} minutes of inactivity.", - timeout.num_minutes() - )); - agent.completed_at = Some(now); - Self::record_activity(agent); - } - mgr.finalize_terminal_agent(agent_id); - } - - // Notify listeners once per sweep. - mgr.send_agent_status_update().await; - } - })); - } - - pub fn set_debug_log_root(&mut self, root: Option) { - self.debug_log_root = root; - } - - async fn touch_agent(agent_id: &str) { - if let Some(manager) = Arc::downgrade(&AGENT_MANAGER).upgrade() { - let mut mgr = manager.write().await; - if let Some(agent) = mgr.agents.get_mut(agent_id) { - Self::record_activity(agent); - } - } - } - - fn record_activity(agent: &mut Agent) { - agent.last_activity = Utc::now(); - } - - fn trim_to_tail_utf8(text: &str, max_bytes: usize) -> String { - let bytes = text.as_bytes(); - if bytes.len() <= max_bytes { - return text.to_string(); - } - - let mut start = bytes.len().saturating_sub(max_bytes); - while start < bytes.len() && (bytes[start] & 0b1100_0000) == 0b1000_0000 { - start += 1; - } - - let tail = String::from_utf8_lossy(&bytes[start..]).to_string(); - format!("…{tail}") - } - - fn compact_terminal_agent(agent: &mut Agent) -> AgentCompactionDelta { - let mut delta = AgentCompactionDelta::default(); - - if agent.progress.len() > MAX_AGENT_PROGRESS_ENTRIES { - let drain = agent.progress.len() - MAX_AGENT_PROGRESS_ENTRIES; - agent.progress.drain(0..drain); - delta.progress_entries_trimmed = drain; - } - - for line in &mut agent.progress { - let original_len = line.len(); - let trimmed = Self::trim_to_tail_utf8(line, MAX_AGENT_PROGRESS_LINE_BYTES); - if trimmed.len() < original_len { - delta.progress_lines_truncated = delta.progress_lines_truncated.saturating_add(1); - } - *line = trimmed; - } - - if let Some(result) = agent.result.as_mut() { - let original_len = result.len(); - let trimmed = Self::trim_to_tail_utf8(result, MAX_AGENT_RESULT_BYTES); - if trimmed.len() < original_len { - delta.payloads_truncated = delta.payloads_truncated.saturating_add(1); - } - *result = trimmed; - } - - if let Some(error) = agent.error.as_mut() { - let original_len = error.len(); - let trimmed = Self::trim_to_tail_utf8(error, MAX_AGENT_RESULT_BYTES); - if trimmed.len() < original_len { - delta.payloads_truncated = delta.payloads_truncated.saturating_add(1); - } - *error = trimmed; - } - - // Keep branch/worktree metadata so users can still merge/inspect results, - // but release heavy prompt payloads once an agent is terminal. - agent.prompt.clear(); - agent.context = None; - agent.output_goal = None; - agent.files.clear(); - agent.context_files.clear(); - agent.context_budget_tokens = None; - - delta - } - - fn prune_terminal_agents(&mut self) { - let terminal_count = self - .agents - .values() - .filter(|agent| { - matches!( - agent.status, - AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled - ) - }) - .count(); - - if terminal_count <= MAX_TRACKED_TERMINAL_AGENTS { - return; - } - - let mut terminal: Vec<(DateTime, String)> = self - .agents - .iter() - .filter(|(_, agent)| { - matches!( - agent.status, - AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled - ) - }) - .map(|(id, agent)| { - ( - agent.completed_at.unwrap_or(agent.created_at), - id.clone(), - ) - }) - .collect(); - - terminal.sort_by_key(|(completed_at, _)| *completed_at); - - let mut to_remove = terminal_count.saturating_sub(MAX_TRACKED_TERMINAL_AGENTS); - let mut pruned = 0usize; - for (_, agent_id) in terminal { - if to_remove == 0 { - break; - } - - self.handles.remove(&agent_id); - if let Some(agent) = self.agents.remove(&agent_id) { - self.archived_terminal_agents.insert(agent_id.clone(), agent); - } - pruned = pruned.saturating_add(1); - to_remove = to_remove.saturating_sub(1); - } - - if pruned > 0 { - self.diagnostics.terminal_agents_pruned = self - .diagnostics - .terminal_agents_pruned - .saturating_add(pruned as u64); - self.diagnostics.archived_terminal_agents = self.archived_terminal_agents.len() as u64; - info!( - pruned, - retained = self.agents.len(), - archived = self.archived_terminal_agents.len(), - "agent manager pruned terminal agents from live cache" - ); - } - } - - fn finalize_terminal_agent(&mut self, agent_id: &str) { - self.handles.remove(agent_id); - - if let Some(agent) = self.agents.get_mut(agent_id) { - let delta = Self::compact_terminal_agent(agent); - self.diagnostics.terminal_compactions = - self.diagnostics.terminal_compactions.saturating_add(1); - self.diagnostics.progress_entries_trimmed = self - .diagnostics - .progress_entries_trimmed - .saturating_add(delta.progress_entries_trimmed as u64); - self.diagnostics.progress_lines_truncated = self - .diagnostics - .progress_lines_truncated - .saturating_add(delta.progress_lines_truncated as u64); - self.diagnostics.payloads_truncated = self - .diagnostics - .payloads_truncated - .saturating_add(delta.payloads_truncated as u64); - if delta.any() { - debug!( - agent_id, - progress_entries_trimmed = delta.progress_entries_trimmed, - progress_lines_truncated = delta.progress_lines_truncated, - payloads_truncated = delta.payloads_truncated, - total_terminal_compactions = self.diagnostics.terminal_compactions, - total_progress_entries_trimmed = self.diagnostics.progress_entries_trimmed, - total_progress_lines_truncated = self.diagnostics.progress_lines_truncated, - total_payloads_truncated = self.diagnostics.payloads_truncated, - "compacted terminal agent state" - ); - } - } - - self.prune_terminal_agents(); - } - - fn visible_agents_for_status(&self, owner_session_id: Option) -> Vec<&Agent> { - let mut active: Vec<&Agent> = self - .agents - .values() - .filter(|agent| agent_belongs_to_session(agent, owner_session_id)) - .filter(|agent| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running)) - .collect(); - - active.sort_by_key(|agent| agent.created_at); - - let mut terminal: Vec<&Agent> = self - .agents - .values() - .filter(|agent| agent_belongs_to_session(agent, owner_session_id)) - .filter(|agent| { - matches!( - agent.status, - AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled - ) - }) - .collect(); - - terminal.sort_by_key(|agent| agent.completed_at.unwrap_or(agent.created_at)); - - if terminal.len() > MAX_STATUS_TERMINAL_AGENTS { - let keep_from = terminal.len() - MAX_STATUS_TERMINAL_AGENTS; - terminal = terminal.split_off(keep_from); - } - - active.extend(terminal); - active - } - - pub fn status_visible_agents(&self) -> Vec { - self.visible_agents_for_status(None) - .into_iter() - .cloned() - .collect() - } - - pub fn status_visible_agents_for_session(&self, owner_session_id: Uuid) -> Vec { - self.visible_agents_for_status(Some(owner_session_id)) - .into_iter() - .cloned() - .collect() - } - - fn status_payload_for_session(&mut self, owner_session_id: Uuid) -> AgentStatusUpdatePayload { - let now = Utc::now(); - - let total_terminal = self - .agents - .values() - .filter(|agent| agent_belongs_to_session(agent, Some(owner_session_id))) - .filter(|agent| { - matches!( - agent.status, - AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled - ) - }) - .count(); - let omitted_terminal = total_terminal.saturating_sub(MAX_STATUS_TERMINAL_AGENTS); - if omitted_terminal > 0 { - self.diagnostics.status_terminal_agents_omitted = self - .diagnostics - .status_terminal_agents_omitted - .saturating_add(omitted_terminal as u64); - debug!( - omitted_terminal, - total_terminal, - owner_session_id = %owner_session_id, - running_agents = self - .agents - .values() - .filter(|agent| agent_belongs_to_session(agent, Some(owner_session_id))) - .filter(|agent| { - matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) - }) - .count(), - cumulative_omitted = self.diagnostics.status_terminal_agents_omitted, - "omitting terminal agents from status payload to keep UI responsive" - ); - } - - let agents: Vec = self - .visible_agents_for_status(Some(owner_session_id)) - .into_iter() - .map(|agent| agent_info_for_status(agent, now)) - .collect(); - - // Prefer active agents for shared context/task; terminal agents may - // have had heavy fields compacted already. - let source_agent = self - .agents - .values() - .filter(|agent| agent_belongs_to_session(agent, Some(owner_session_id))) - .find(|agent| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running)) - .or_else(|| { - self.agents - .values() - .find(|agent| agent_belongs_to_session(agent, Some(owner_session_id))) - }); - let (context, task) = source_agent - .map(|agent| { - let context = agent.context.as_ref().and_then(|value| { - if value.trim().is_empty() { - None - } else { - Some(value.clone()) - } - }); - let task = if agent.prompt.trim().is_empty() { - None - } else { - Some(agent.prompt.clone()) - }; - (context, task) - }) - .unwrap_or((None, None)); - - AgentStatusUpdatePayload { agents, context, task } - } - - fn append_agent_log(&self, log_tag: &str, line: &str) { - let Some(root) = &self.debug_log_root else { return; }; - let dir = root.join(log_tag); - if let Err(err) = fs::create_dir_all(&dir) { - warn!("failed to create agent log dir {:?}: {}", dir, err); - return; - } - - let file = dir.join("progress.log"); - match OpenOptions::new().create(true).append(true).open(&file) { - Ok(mut fh) => { - if let Err(err) = writeln!(fh, "{}", line) { - warn!("failed to write agent log {:?}: {}", file, err); - } - } - Err(err) => warn!("failed to open agent log {:?}: {}", file, err), - } - } - - async fn send_agent_status_update(&mut self) { - if !self.event_senders.is_empty() { - let sessions: Vec<(usize, Uuid)> = self - .event_senders - .iter() - .enumerate() - .filter(|(_, registered)| !registered.sender.is_closed()) - .map(|(idx, registered)| (idx, registered.owner_session_id)) - .collect(); - let sender_payloads: Vec<(usize, AgentStatusUpdatePayload)> = sessions - .into_iter() - .map(|(idx, owner_session_id)| (idx, self.status_payload_for_session(owner_session_id))) - .collect(); - - for (idx, payload) in sender_payloads.into_iter().rev() { - if self.event_senders[idx].sender.send(payload).is_err() { - self.event_senders.remove(idx); - } - } - self.event_senders - .retain(|registered| !registered.sender.is_closed()); - } - } - - pub async fn create_agent( - &mut self, - model: String, - name: Option, - prompt: String, - context: Option, - output_goal: Option, - files: Vec, - context_files: Vec, - context_budget_tokens: Option, - read_only: bool, - batch_id: Option, - owner_session_id: Uuid, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - ) -> String { - let workspace_root = std::env::current_dir().ok(); - self.create_agent_in_workspace( - model, - name, - prompt, - context, - output_goal, - files, - context_files, - context_budget_tokens, - read_only, - batch_id, - owner_session_id, - workspace_root, - reasoning_effort, - ) - .await - } - - pub async fn create_agent_in_workspace( - &mut self, - model: String, - name: Option, - prompt: String, - context: Option, - output_goal: Option, - files: Vec, - context_files: Vec, - context_budget_tokens: Option, - read_only: bool, - batch_id: Option, - owner_session_id: Uuid, - workspace_root: Option, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - ) -> String { - self.create_agent_internal( - model, - name, - prompt, - context, - output_goal, - files, - context_files, - context_budget_tokens, - read_only, - batch_id, - None, - owner_session_id, - None, - None, - None, - workspace_root, - reasoning_effort, - ) - .await - } - - pub async fn create_agent_with_config( - &mut self, - model: String, - name: Option, - prompt: String, - context: Option, - output_goal: Option, - files: Vec, - context_files: Vec, - context_budget_tokens: Option, - read_only: bool, - batch_id: Option, - config: AgentConfig, - owner_session_id: Uuid, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - ) -> String { - let workspace_root = std::env::current_dir().ok(); - self.create_agent_with_config_in_workspace( - model, - name, - prompt, - context, - output_goal, - files, - context_files, - context_budget_tokens, - read_only, - batch_id, - config, - owner_session_id, - workspace_root, - reasoning_effort, - ) - .await - } - - pub async fn create_agent_with_config_in_workspace( - &mut self, - model: String, - name: Option, - prompt: String, - context: Option, - output_goal: Option, - files: Vec, - context_files: Vec, - context_budget_tokens: Option, - read_only: bool, - batch_id: Option, - config: AgentConfig, - owner_session_id: Uuid, - workspace_root: Option, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - ) -> String { - self.create_agent_internal( - model, - name, - prompt, - context, - output_goal, - files, - context_files, - context_budget_tokens, - read_only, - batch_id, - Some(config), - owner_session_id, - None, - None, - None, - workspace_root, - reasoning_effort, - ) - .await - } - - #[allow(dead_code)] - pub async fn create_agent_with_options( - &mut self, - model: String, - name: Option, - prompt: String, - context: Option, - output_goal: Option, - files: Vec, - context_files: Vec, - context_budget_tokens: Option, - read_only: bool, - batch_id: Option, - config: Option, - owner_session_id: Uuid, - worktree_branch: Option, - worktree_base: Option, - source_kind: Option, - workspace_root: Option, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - ) -> String { - self - .create_agent_internal( - model, - name, - prompt, - context, - output_goal, - files, - context_files, - context_budget_tokens, - read_only, - batch_id, - config, - owner_session_id, - worktree_branch, - worktree_base, - source_kind, - workspace_root, - reasoning_effort, - ) - .await - } - - async fn create_agent_internal( - &mut self, - model: String, - name: Option, - prompt: String, - context: Option, - output_goal: Option, - files: Vec, - context_files: Vec, - context_budget_tokens: Option, - read_only: bool, - batch_id: Option, - config: Option, - owner_session_id: Uuid, - worktree_branch: Option, - worktree_base: Option, - source_kind: Option, - workspace_root: Option, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - ) -> String { - let agent_id = Uuid::new_v4().to_string(); - - let log_tag = match source_kind { - Some(AgentSourceKind::AutoReview) => { - Some(format!("agents/auto-review/{}", agent_id)) - } - _ => None, - }; - - let retry = AgentRetryMetadata { - original_model: model.clone(), - final_model: model.clone(), - ..AgentRetryMetadata::default() - }; - - let agent = Agent { - id: agent_id.clone(), - owner_session_id: Some(owner_session_id), - batch_id, - model, - name: normalize_agent_name(name), - prompt, - context, - output_goal, - files, - context_files, - context_budget_tokens, - read_only, - status: AgentStatus::Pending, - result: None, - error: None, - token_count: None, - retry, - created_at: Utc::now(), - started_at: None, - completed_at: None, - progress: Vec::new(), - worktree_path: None, - branch_name: worktree_branch, - worktree_base, - workspace_root, - source_kind, - log_tag, - config: config.clone(), - reasoning_effort, - last_activity: Utc::now(), - }; - - self.agents.insert(agent_id.clone(), agent.clone()); - - // Send initial status update - self.send_agent_status_update().await; - - // Spawn async agent - let agent_id_clone = agent_id.clone(); - let handle = tokio::spawn(async move { - execute_agent(agent_id_clone, config).await; - }); - - self.handles.insert(agent_id.clone(), handle); - - agent_id - } - - pub fn get_agent(&self, agent_id: &str) -> Option { - self - .agents - .get(agent_id) - .cloned() - .or_else(|| self.archived_terminal_agents.get(agent_id).cloned()) - } - - pub fn get_agent_for_session(&self, agent_id: &str, owner_session_id: Uuid) -> Option { - self - .get_agent(agent_id) - .filter(|agent| agent_belongs_to_session(agent, Some(owner_session_id))) - } - - pub fn get_all_agents(&self) -> impl Iterator { - self.agents.values() - } - - pub fn list_agents( - &self, - status_filter: Option, - batch_id: Option, - recent_only: bool, - ) -> Vec { - let cutoff = if recent_only { - Some(Utc::now() - Duration::hours(2)) - } else { - None - }; - - let mut out = Vec::new(); - let mut seen_ids: HashSet = HashSet::new(); - - let mut collect_filtered = |agent: &Agent| { - if let Some(ref filter) = status_filter { - if agent.status != *filter { - return; - } - } - if let Some(ref batch) = batch_id { - if agent.batch_id.as_ref() != Some(batch) { - return; - } - } - if let Some(cutoff) = cutoff { - if agent.created_at < cutoff { - return; - } - } - if seen_ids.insert(agent.id.clone()) { - out.push(agent.clone()); - } - }; - - for agent in self.agents.values() { - collect_filtered(agent); - } - for agent in self.archived_terminal_agents.values() { - collect_filtered(agent); - } - - out - } - - pub fn list_agents_for_session( - &self, - status_filter: Option, - batch_id: Option, - recent_only: bool, - owner_session_id: Uuid, - ) -> Vec { - self - .list_agents(status_filter, batch_id, recent_only) - .into_iter() - .filter(|agent| agent_belongs_to_session(agent, Some(owner_session_id))) - .collect() - } - - pub fn has_active_agents(&self) -> bool { - self.agents - .values() - .any(|agent| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running)) - } - - pub async fn cancel_agent(&mut self, agent_id: &str) -> bool { - if let Some(handle) = self.handles.remove(agent_id) { - handle.abort(); - if let Some(agent) = self.agents.get_mut(agent_id) { - agent.status = AgentStatus::Cancelled; - agent.completed_at = Some(Utc::now()); - } - self.finalize_terminal_agent(agent_id); - true - } else if self - .agents - .get(agent_id) - .is_some_and(|agent| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running)) - { - if let Some(agent) = self.agents.get_mut(agent_id) { - agent.status = AgentStatus::Cancelled; - agent.completed_at = Some(Utc::now()); - Self::record_activity(agent); - } - self.finalize_terminal_agent(agent_id); - true - } else { - false - } - } - - pub async fn cancel_agent_for_session( - &mut self, - agent_id: &str, - owner_session_id: Uuid, - ) -> bool { - if self - .get_agent(agent_id) - .is_some_and(|agent| agent_can_be_cancelled_by_session(&agent, owner_session_id)) - { - self.cancel_agent(agent_id).await - } else { - false - } - } - - pub async fn cancel_batch(&mut self, batch_id: &str) -> usize { - let agent_ids: Vec = self - .agents - .values() - .filter(|agent| agent.batch_id.as_ref() == Some(&batch_id.to_string())) - .map(|agent| agent.id.clone()) - .collect(); - - let mut count = 0; - for agent_id in agent_ids { - if self.cancel_agent(&agent_id).await { - count += 1; - } - } - count - } - - pub async fn cancel_batch_for_session( - &mut self, - batch_id: &str, - owner_session_id: Uuid, - ) -> usize { - let agent_ids: Vec = self - .agents - .values() - .filter(|agent| agent.batch_id.as_ref() == Some(&batch_id.to_string())) - .filter(|agent| agent_can_be_cancelled_by_session(agent, owner_session_id)) - .map(|agent| agent.id.clone()) - .collect(); - - let mut count = 0; - for agent_id in agent_ids { - if self.cancel_agent(&agent_id).await { - count += 1; - } - } - count - } - - pub async fn update_agent_status(&mut self, agent_id: &str, status: AgentStatus) { - let mut terminal = false; - if let Some(agent) = self.agents.get_mut(agent_id) { - agent.status = status; - if agent.status == AgentStatus::Running && agent.started_at.is_none() { - agent.started_at = Some(Utc::now()); - } - if matches!( - agent.status, - AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled - ) { - agent.completed_at = Some(Utc::now()); - terminal = true; - } - Self::record_activity(agent); - } - - if terminal { - self.finalize_terminal_agent(agent_id); - } - - // Send status update event - self.send_agent_status_update().await; - } - - pub async fn update_agent_result(&mut self, agent_id: &str, result: Result) { - self.update_agent_result_with_token_count(agent_id, result, None) - .await; - } - - pub async fn update_agent_result_with_token_count( - &mut self, - agent_id: &str, - result: Result, - token_count: Option, - ) { - let debug_enabled = self.debug_log_root.is_some(); - let mut updated = false; - - if let Some((log_tag, log_lines)) = self.agents.get_mut(agent_id).map(|agent| { - let log_tag = if debug_enabled { agent.log_tag.clone() } else { None }; - - let mut log_lines: Vec = Vec::new(); - if debug_enabled { - let stamp = Utc::now().format("%H:%M:%S"); - match &result { - Ok(output) => { - log_lines.push(format!("{stamp}: [result] completed")); - if !output.trim().is_empty() { - log_lines.push(output.trim_end().to_string()); - } - } - Err(error) => { - log_lines.push(format!("{stamp}: [result] failed")); - log_lines.push(error.clone()); - } - } - } - - match result { - Ok(output) => { - agent.result = Some(output); - agent.status = AgentStatus::Completed; - agent.token_count = token_count.or(agent.token_count); - } - Err(error) => { - agent.error = Some(error); - agent.status = AgentStatus::Failed; - agent.token_count = token_count.or(agent.token_count); - } - } - agent.completed_at = Some(Utc::now()); - Self::record_activity(agent); - updated = true; - - (log_tag, log_lines) - }) { - if let Some(tag) = log_tag { - for line in log_lines { - self.append_agent_log(&tag, &line); - } - } - if updated { - self.finalize_terminal_agent(agent_id); - } - // Send status update event - self.send_agent_status_update().await; - } - } - - pub async fn update_agent_retry_metadata( - &mut self, - agent_id: &str, - retry_count: u32, - last_retryable_error: Option, - ) { - if let Some(agent) = self.agents.get_mut(agent_id) { - agent.retry.retry_count = retry_count; - agent.retry.last_retryable_error = last_retryable_error; - Self::record_activity(agent); - self.send_agent_status_update().await; - } - } - - pub async fn add_progress(&mut self, agent_id: &str, message: String) { - let debug_enabled = self.debug_log_root.is_some(); - - if let Some((log_tag, entry, line_truncated, entries_trimmed)) = - self.agents.get_mut(agent_id).map(|agent| { - let raw_entry = format!("{}: {}", Utc::now().format("%H:%M:%S"), message); - let line_truncated = raw_entry.len() > MAX_AGENT_PROGRESS_LINE_BYTES; - let entry = Self::trim_to_tail_utf8(&raw_entry, MAX_AGENT_PROGRESS_LINE_BYTES); - let log_tag = if debug_enabled { agent.log_tag.clone() } else { None }; - agent.progress.push(entry.clone()); - let mut entries_trimmed = 0usize; - if agent.progress.len() > MAX_AGENT_PROGRESS_ENTRIES { - let drain = agent.progress.len() - MAX_AGENT_PROGRESS_ENTRIES; - agent.progress.drain(0..drain); - entries_trimmed = drain; - } - Self::record_activity(agent); - (log_tag, entry, line_truncated, entries_trimmed) - }) { - if line_truncated { - self.diagnostics.progress_lines_truncated = self - .diagnostics - .progress_lines_truncated - .saturating_add(1); - } - if entries_trimmed > 0 { - self.diagnostics.progress_entries_trimmed = self - .diagnostics - .progress_entries_trimmed - .saturating_add(entries_trimmed as u64); - } - if line_truncated || entries_trimmed > 0 { - debug!( - agent_id, - line_truncated, - entries_trimmed, - total_progress_lines_truncated = self.diagnostics.progress_lines_truncated, - total_progress_entries_trimmed = self.diagnostics.progress_entries_trimmed, - "trimmed agent progress backlog" - ); - } - if let Some(tag) = log_tag { - self.append_agent_log(&tag, &entry); - } - // Send updated agent status with the latest progress - self.send_agent_status_update().await; - } - } - - pub async fn update_worktree_info( - &mut self, - agent_id: &str, - worktree_path: String, - branch_name: String, - ) { - if let Some(agent) = self.agents.get_mut(agent_id) { - agent.worktree_path = Some(worktree_path); - agent.branch_name = Some(branch_name); - } - } -} - -async fn get_git_root() -> Result { - let output = Command::new("git") - .args(&["rev-parse", "--show-toplevel"]) - .output() - .await - .map_err(|e| format!("Git not installed or not in a git repository: {}", e))?; - - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(PathBuf::from(path)) - } else { - Err("Not in a git repository".to_string()) - } -} - -use crate::git_worktree::sanitize_ref_component; - -fn generate_branch_id(model: &str, agent: &str) -> String { - // Extract first few meaningful words from agent for the branch name - let stop = ["the", "and", "for", "with", "from", "into", "goal"]; // skip boilerplate - let words: Vec<&str> = agent - .split_whitespace() - .filter(|w| w.len() > 2 && !stop.contains(&w.to_ascii_lowercase().as_str())) - .take(3) - .collect(); - - let raw_suffix = if words.is_empty() { - Uuid::new_v4() - .to_string() - .split('-') - .next() - .unwrap_or("agent") - .to_string() - } else { - words.join("-") - }; - - // Sanitize both model and suffix for safety - let model_s = sanitize_ref_component(model); - let mut suffix_s = sanitize_ref_component(&raw_suffix); - - // Constrain length to keep branch names readable - if suffix_s.len() > 40 { - suffix_s.truncate(40); - suffix_s = suffix_s.trim_matches('-').to_string(); - if suffix_s.is_empty() { - suffix_s = "agent".to_string(); - } - } - - format!("code-{}-{}", model_s, suffix_s) -} - -use crate::git_worktree::setup_worktree; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ContextFilesPrompt { - block: String, - estimated_tokens: u64, - included_files: usize, - budget_tokens: u64, -} - -fn estimate_context_file_tokens(content: &str) -> u64 { - let word_count = content - .split_whitespace() - .filter(|segment| !segment.is_empty()) - .count() as u64; - let byte_estimate = (content.len() as u64) - .saturating_add(CONTEXT_FILE_TOKEN_BYTES_ESTIMATE - 1) - / CONTEXT_FILE_TOKEN_BYTES_ESTIMATE; - word_count.max(byte_estimate).max(1) -} - -fn is_probably_binary(bytes: &[u8]) -> bool { - bytes.iter().take(8192).any(|byte| *byte == 0) -} - -fn xml_attr_escape(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - -fn context_budget_byte_limit(budget: u64) -> u64 { - budget.saturating_mul(CONTEXT_FILE_TOKEN_BYTES_ESTIMATE) -} - -fn deliver_agent_prompt( - family: &str, - args: &mut Vec, - prompt: &str, - supports_stdin_prompt: bool, - force_stdin: bool, -) -> Result, String> { - let use_stdin = force_stdin || prompt.len() > AGENT_PROMPT_ARGV_THRESHOLD_BYTES; - if !use_stdin { - args.push(prompt.to_string()); - return Ok(None); - } - - if supports_stdin_prompt { - args.push("-".to_string()); - Ok(Some(prompt.to_string())) - } else { - Err(format!( - "agent prompt is {} bytes, above the {} byte argv delivery threshold, and provider family '{family}' does not support large-prompt stdin delivery in this configuration. Use a built-in Every Code/Codex agent such as code-gpt-5.4, reduce context_files, or lower context_budget_tokens.", - prompt.len(), - AGENT_PROMPT_ARGV_THRESHOLD_BYTES - )) - } -} - -fn canonicalize_allowed_context_file(path: &str, workspace_root: &Path) -> Result { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err("context_files contains an empty path".to_string()); - } - - let requested = Path::new(trimmed); - let candidate = if requested.is_absolute() { - requested.to_path_buf() - } else { - workspace_root.join(requested) - }; - - let canonical = candidate - .canonicalize() - .map_err(|err| format!("failed to resolve context file {trimmed}: {err}"))?; - let canonical_root = workspace_root.canonicalize().map_err(|err| { - format!( - "failed to resolve workspace root {}: {err}", - workspace_root.display() - ) - })?; - if !canonical.starts_with(&canonical_root) { - return Err(format!( - "context file {} is outside the workspace root {}", - canonical.display(), - canonical_root.display() - )); - } - if canonical.is_dir() { - return Err(format!( - "context file {} is a directory; list explicit files instead", - canonical.display() - )); - } - - Ok(canonical) -} - -fn build_context_files_prompt( - context_files: &[String], - context_budget_tokens: Option, - workspace_root: &Path, -) -> Result, String> { - if context_files.is_empty() { - return Ok(None); - } - - let requested_budget = context_budget_tokens.unwrap_or(DEFAULT_CONTEXT_FILE_BUDGET_TOKENS); - if requested_budget > MAX_CONTEXT_FILE_BUDGET_TOKENS { - return Err(format!( - "context_budget_tokens {requested_budget} exceeds the maximum {MAX_CONTEXT_FILE_BUDGET_TOKENS}" - )); - } - let budget = requested_budget; - let mut seen = HashSet::new(); - let mut entries: Vec<(String, String, u64, u64)> = Vec::new(); - let mut total_tokens = 0_u64; - - for path in context_files { - let canonical = canonicalize_allowed_context_file(path, workspace_root)?; - if !seen.insert(canonical.clone()) { - continue; - } - let metadata = fs::metadata(&canonical) - .map_err(|err| format!("failed to inspect context file {}: {err}", canonical.display()))?; - if !metadata.is_file() { - return Err(format!( - "context file {} is not a regular file", - canonical.display() - )); - } - let byte_len = metadata.len(); - let remaining_budget = budget.saturating_sub(total_tokens); - let byte_limit = context_budget_byte_limit(remaining_budget); - if byte_len > byte_limit { - return Err(format!( - "context file {} is {} bytes, above the remaining budget of {} estimated tokens ({} bytes max). {CONTEXT_FILE_BUDGET_GUIDANCE}", - canonical.display(), - byte_len, - remaining_budget, - byte_limit - )); - } - let bytes = fs::read(&canonical) - .map_err(|err| format!("failed to read context file {}: {err}", canonical.display()))?; - if is_probably_binary(&bytes) { - return Err(format!( - "context file {} appears to be binary; context_files only supports UTF-8 text", - canonical.display() - )); - } - let content = String::from_utf8(bytes).map_err(|err| { - format!( - "context file {} is not valid UTF-8: {err}", - canonical.display() - ) - })?; - let estimated_tokens = estimate_context_file_tokens(&content); - total_tokens = total_tokens.saturating_add(estimated_tokens); - if total_tokens > budget { - return Err(format!( - "context_files estimated {total_tokens} tokens, above budget {budget}. {CONTEXT_FILE_BUDGET_GUIDANCE}" - )); - } - entries.push(( - canonical.display().to_string(), - content, - estimated_tokens, - byte_len, - )); - } - - let mut block = String::new(); - block.push_str("Preloaded context files:\n"); - block.push_str(&format!( - "Included {} file(s), estimated {} tokens, budget {} tokens. These files were snapshotted before the subagent launched; do not re-read them unless fresh contents are needed.\n", - entries.len(), - total_tokens, - budget - )); - for (path, content, estimated_tokens, bytes) in &entries { - block.push_str(&format!( - "\n\n{}\n\n", - xml_attr_escape(path), - bytes, - estimated_tokens, - content - )); - } - - Ok(Some(ContextFilesPrompt { - block, - estimated_tokens: total_tokens, - included_files: entries.len(), - budget_tokens: budget, - })) -} - -fn build_agent_full_prompt( - prompt: &str, - config: Option<&AgentConfig>, - context: Option<&String>, - output_goal: Option<&String>, - files: &[String], - context_files: &[String], - context_budget_tokens: Option, - workspace_root: &Path, -) -> Result<(String, Option), String> { - let mut full_prompt = prompt.to_string(); - if let Some(cfg) = config { - if let Some(instr) = cfg.instructions.as_ref() { - if !instr.trim().is_empty() { - full_prompt = format!("{}\n\n{}", instr.trim(), full_prompt); - } - } - } - if let Some(context) = context { - let trimmed = full_prompt.trim_start(); - if trimmed.starts_with('/') { - full_prompt = format!("{full_prompt}\n\nContext: {context}"); - } else { - full_prompt = format!("Context: {context}\n\nAgent: {full_prompt}"); - } - } - if let Some(output_goal) = output_goal { - full_prompt = format!("{}\n\nDesired output: {}", full_prompt, output_goal); - } - if !files.is_empty() { - full_prompt = format!("{}\n\nFiles to consider: {}", full_prompt, files.join(", ")); - } - - let context_prompt = - build_context_files_prompt(context_files, context_budget_tokens, workspace_root)?; - if let Some(context_prompt) = context_prompt.as_ref() { - full_prompt = format!("{}\n\n{}", full_prompt, context_prompt.block); - } - - Ok((full_prompt, context_prompt)) -} - -async fn execute_agent(agent_id: String, config: Option) { - let mut manager = AGENT_MANAGER.write().await; - - // Get agent details - let agent = match manager.get_agent(&agent_id) { - Some(t) => t, - None => return, - }; - - // Update status to running - manager - .update_agent_status(&agent_id, AgentStatus::Running) - .await; - manager - .add_progress( - &agent_id, - format!("Starting agent with model: {}", agent.model), - ) - .await; - - let model = agent.model.clone(); - let model_spec = agent_model_spec(&model); - let prompt = agent.prompt.clone(); - let read_only = agent.read_only; - let context = agent.context.clone(); - let output_goal = agent.output_goal.clone(); - let files = agent.files.clone(); - let context_files = agent.context_files.clone(); - let context_budget_tokens = agent.context_budget_tokens; - let reasoning_effort = agent.reasoning_effort; - let source_kind = agent.source_kind.clone(); - let log_tag = agent.log_tag.clone(); - - drop(manager); // Release the lock before executing - - let prompt_workspace = agent - .workspace_root - .clone() - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")); - let (mut full_prompt, context_files_prompt) = match build_agent_full_prompt( - &prompt, - config.as_ref(), - context.as_ref(), - output_goal.as_ref(), - &files, - &context_files, - context_budget_tokens, - &prompt_workspace, - ) { - Ok(value) => value, - Err(err) => { - let mut manager = AGENT_MANAGER.write().await; - manager - .add_progress(&agent_id, format!("context_files failed: {err}")) - .await; - manager.update_agent_result(&agent_id, Err(err)).await; - return; - } - }; - if let Some(summary) = context_files_prompt.as_ref() { - let mut manager = AGENT_MANAGER.write().await; - manager - .add_progress( - &agent_id, - format!( - "Inlined {} context file(s), estimated {} tokens (budget {}).", - summary.included_files, summary.estimated_tokens, summary.budget_tokens - ), - ) - .await; - drop(manager); - } - - // Setup working directory and execute - let gating_error_message = |spec: &crate::agent_defaults::AgentModelSpec| { - if let Some(flag) = spec.gating_env { - format!( - "agent model '{}' is disabled; set {}=1 to enable it", - spec.slug, flag - ) - } else { - format!("agent model '{}' is disabled", spec.slug) - } - }; - - // Track optional review output path for /review agents (AutoReview) - let mut review_output_json_path_capture: Option = None; - - let result = if !read_only { - // Check git and setup worktree for non-read-only mode - match get_git_root().await { - Ok(git_root) => { - let branch_id = agent - .branch_name - .clone() - .unwrap_or_else(|| generate_branch_id(&model, &prompt)); - - let mut manager = AGENT_MANAGER.write().await; - manager - .add_progress(&agent_id, format!("Creating git worktree: {}", branch_id)) - .await; - drop(manager); - - match setup_worktree(&git_root, &branch_id, agent.worktree_base.as_deref()).await { - Ok((worktree_path, used_branch)) => { - let mut manager = AGENT_MANAGER.write().await; - manager - .add_progress( - &agent_id, - format!("Executing in worktree: {}", worktree_path.display()), - ) - .await; - manager - .update_worktree_info( - &agent_id, - worktree_path.display().to_string(), - used_branch.clone(), - ) - .await; - drop(manager); - - // Prepare optional review-output JSON path for /review agents - let review_output_json_path: Option = agent - .source_kind - .as_ref() - .and_then(|kind| matches!(kind, AgentSourceKind::AutoReview).then(|| { - let filename = format!("{}.review-output.json", agent_id); - std::env::temp_dir().join(filename) - })); - review_output_json_path_capture = review_output_json_path.clone(); - - // Execute with full permissions in the worktree - let use_built_in_cloud = config.is_none() - && model_spec - .map(|spec| spec.cli.eq_ignore_ascii_case("cloud")) - .unwrap_or_else(|| model.eq_ignore_ascii_case("cloud")); - - if use_built_in_cloud { - if let Some(spec) = model_spec { - if !spec.is_enabled() { - Err(gating_error_message(spec)) - } else { - execute_agent_provider_with_retries(&agent_id, &model, || { - async { - execute_cloud_built_in_streaming( - &agent_id, - &full_prompt, - Some(worktree_path.clone()), - config.clone(), - spec.slug, - ) - .await - .map(|output| AgentExecutionOutput::from_child_output(output, "")) - } - }) - .await - } - } else { - execute_agent_provider_with_retries(&agent_id, &model, || { - async { - execute_cloud_built_in_streaming( - &agent_id, - &full_prompt, - Some(worktree_path.clone()), - config.clone(), - model.as_str(), - ) - .await - .map(|output| AgentExecutionOutput::from_child_output(output, "")) - } - }) - .await - } - } else { - execute_agent_provider_with_retries(&agent_id, &model, || { - execute_model_with_permissions_detailed( - &agent_id, - &model, - &full_prompt, - false, - Some(worktree_path.clone()), - config.clone(), - reasoning_effort, - review_output_json_path.as_ref(), - source_kind.clone(), - log_tag.as_deref(), - ) - }) - .await - } - } - Err(e) => Err(format!("Failed to setup worktree: {}", e)), - } - } - Err(e) => Err(format!("Git is required for non-read-only agents: {}", e)), - } - } else { - // Execute in read-only mode - full_prompt = format!( - "{}\n\n[Running in read-only mode - no modifications allowed]", - full_prompt - ); - let use_built_in_cloud = config.is_none() - && model_spec - .map(|spec| spec.cli.eq_ignore_ascii_case("cloud")) - .unwrap_or_else(|| model.eq_ignore_ascii_case("cloud")); - - if use_built_in_cloud { - if let Some(spec) = model_spec { - if !spec.is_enabled() { - Err(gating_error_message(spec)) - } else { - execute_agent_provider_with_retries(&agent_id, &model, || { - async { - execute_cloud_built_in_streaming( - &agent_id, - &full_prompt, - None, - config.clone(), - spec.slug, - ) - .await - .map(|output| AgentExecutionOutput::from_child_output(output, "")) - } - }) - .await - } - } else { - execute_agent_provider_with_retries(&agent_id, &model, || { - async { - execute_cloud_built_in_streaming( - &agent_id, - &full_prompt, - None, - config.clone(), - model.as_str(), - ) - .await - .map(|output| AgentExecutionOutput::from_child_output(output, "")) - } - }) - .await - } - } else { - execute_agent_provider_with_retries(&agent_id, &model, || { - execute_model_with_permissions_detailed( - &agent_id, - &model, - &full_prompt, - true, - None, - config.clone(), - reasoning_effort, - None, - source_kind.clone(), - log_tag.as_deref(), - ) - }) - .await - } - }; - - // Update result; if a review-output JSON was produced, prefer its contents. - let final_result = prefer_json_result_detailed(review_output_json_path_capture.as_ref(), result); - let mut manager = AGENT_MANAGER.write().await; - match final_result { - Ok(output) => { - manager - .update_agent_result_with_token_count(&agent_id, Ok(output.output), output.token_count) - .await; - } - Err(error) => { - manager.update_agent_result(&agent_id, Err(error)).await; - } - } -} - -async fn execute_agent_provider_with_retries( - agent_id: &str, - model: &str, - mut run: F, -) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, -{ - let mut retry_count = 0usize; - let mut last_retryable_error: Option = None; - - loop { - let result = run().await; - match result { - Ok(output) => { - if retry_count > 0 { - let mut manager = AGENT_MANAGER.write().await; - manager - .update_agent_retry_metadata( - agent_id, - retry_count as u32, - last_retryable_error.clone(), - ) - .await; - } - return Ok(output); - } - Err(error) - if retry_count < DEFAULT_AGENT_PROVIDER_MAX_RETRIES - && classify_agent_provider_failure(&error) - == AgentProviderFailureClass::Retryable => - { - retry_count += 1; - last_retryable_error = Some(error.clone()); - let delay = agent_retry_delay(retry_count - 1); - let error_preview = error.trim().replace('\n', " "); - - { - let mut manager = AGENT_MANAGER.write().await; - manager - .update_agent_retry_metadata( - agent_id, - retry_count as u32, - last_retryable_error.clone(), - ) - .await; - manager - .add_progress( - agent_id, - format!( - "Retrying {model} after transient provider failure ({retry_count}/{DEFAULT_AGENT_PROVIDER_MAX_RETRIES}) in {:.1}s: {}", - delay.as_secs_f32(), - AgentManager::trim_to_tail_utf8(&error_preview, 500) - ), - ) - .await; - } - - tokio::time::sleep(TokioDuration::from_secs_f32(delay.as_secs_f32())).await; - } - Err(error) => { - if retry_count > 0 { - let mut manager = AGENT_MANAGER.write().await; - manager - .update_agent_retry_metadata( - agent_id, - retry_count as u32, - last_retryable_error.clone(), - ) - .await; - if classify_agent_provider_failure(&error) == AgentProviderFailureClass::Retryable - { - manager - .add_progress( - agent_id, - format!( - "Exhausted {retry_count} automatic provider retries for {model}." - ), - ) - .await; - } - } - return Err(error); - } - } - } -} - -#[cfg(test)] -fn prefer_json_result(path: Option<&PathBuf>, fallback: Result) -> Result { - prefer_json_result_detailed( - path, - fallback.map(|output| AgentExecutionOutput::new(output, None)), - ) - .map(|output| output.output) -} - -fn prefer_json_result_detailed( - path: Option<&PathBuf>, - fallback: Result, -) -> Result { - if let Some(p) = path { - let json = std::fs::read_to_string(p).ok(); - if let Err(err) = std::fs::remove_file(p) { - tracing::debug!("failed to clean review output file {}: {err}", p.display()); - } - if let Some(json) = json { - let token_count = fallback - .as_ref() - .ok() - .and_then(|output| output.token_count) - .or_else(|| fallback.as_ref().err().and_then(|error| extract_agent_token_count(error))); - return Ok(AgentExecutionOutput::new( - json, - token_count, - )); - } - } - fallback -} - -fn extract_agent_token_count(output: &str) -> Option { - output - .lines() - .rev() - .find_map(|line| extract_tokens_used_from_line(line)) -} - -fn extract_tokens_used_from_line(line: &str) -> Option { - let marker = "tokens used:"; - let lower = line.to_ascii_lowercase(); - let marker_start = lower.find(marker)?; - let after_marker = &line[marker_start + marker.len()..]; - let digits = after_marker - .chars() - .skip_while(|ch| ch.is_whitespace()) - .take_while(|ch| ch.is_ascii_digit() || *ch == ',') - .filter(|ch| ch.is_ascii_digit()) - .collect::(); - if digits.is_empty() { - None - } else { - digits.parse::().ok() - } -} - -fn remove_review_output_json(path: Option<&PathBuf>) { - let Some(path) = path else { return }; - match std::fs::remove_file(path) { - Ok(()) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => tracing::debug!( - "failed to remove stale review output file {}: {err}", - path.display() - ), - } -} - -async fn execute_model_with_permissions( - agent_id: &str, - model: &str, - prompt: &str, - read_only: bool, - working_dir: Option, - config: Option, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - review_output_json_path: Option<&PathBuf>, - source_kind: Option, - log_tag: Option<&str>, -) -> Result { - execute_model_with_permissions_detailed( - agent_id, - model, - prompt, - read_only, - working_dir, - config, - reasoning_effort, - review_output_json_path, - source_kind, - log_tag, - ) - .await - .map(|output| output.output) -} - -async fn execute_model_with_permissions_detailed( - agent_id: &str, - model: &str, - prompt: &str, - read_only: bool, - working_dir: Option, - config: Option, - reasoning_effort: code_protocol::config_types::ReasoningEffort, - review_output_json_path: Option<&PathBuf>, - source_kind: Option, - log_tag: Option<&str>, -) -> Result { - remove_review_output_json(review_output_json_path); - - let spec_opt = agent_model_spec(model) - .or_else(|| config.as_ref().and_then(|cfg| agent_model_spec(&cfg.name))); - - if let Some(spec) = spec_opt { - if !spec.is_enabled() { - if let Some(flag) = spec.gating_env { - return Err(format!( - "agent model '{}' is disabled; set {}=1 to enable it", - spec.slug, flag - )); - } - return Err(format!("agent model '{}' is disabled", spec.slug)); - } - } - - // Use config command if provided, otherwise fall back to the spec CLI (or the - // lowercase model string). - let command = if let Some(ref cfg) = config { - let cmd = cfg.command.trim(); - if !cmd.is_empty() { - cfg.command.clone() - } else if let Some(spec) = spec_opt { - spec.cli.to_string() - } else { - cfg.name.clone() - } - } else if let Some(spec) = spec_opt { - spec.cli.to_string() - } else { - model.to_lowercase() - }; - - let (command_base, command_extra_args) = split_command_and_args(&command); - let command_for_spawn = if command_base.is_empty() { - command.clone() - } else { - command_base.clone() - }; - - // Special case: for the built‑in Codex agent, prefer invoking the currently - // running executable with the `exec` subcommand rather than relying on a - // `codex` binary to be present on PATH. This improves portability, - // especially on Windows where global shims may be missing. - let model_lower = model.to_lowercase(); - let command_lower = command_for_spawn.to_ascii_lowercase(); - // `gemini` remains here for explicitly configured Gemini CLI agents, but it - // is no longer advertised as a built-in default. - fn is_known_family(s: &str) -> bool { - matches!( - s, - "antigravity" | "claude" | "gemini" | "copilot" | "qwen" | "codex" | "code" - | "cloud" | "coder" - ) - } - - let slug_for_defaults = spec_opt.map(|spec| spec.slug).unwrap_or(model); - let spec_family = spec_opt.map(|spec| spec.family); - let family = if let Some(spec_family) = spec_family { - spec_family - } else if is_known_family(model_lower.as_str()) { - model_lower.as_str() - } else if is_known_family(command_lower.as_str()) { - command_lower.as_str() - } else { - model_lower.as_str() - }; - - let command_missing = !external_agent_command_exists(&command_for_spawn); - let use_current_exe = should_use_current_exe_for_agent(family, command_missing, config.as_ref()); - - let mut final_args: Vec = command_extra_args; - let prompt_stdin: Option; - - if let Some(ref cfg) = config { - if read_only { - if let Some(ro) = cfg.args_read_only.as_ref() { - final_args.extend(ro.iter().cloned()); - } else { - final_args.extend(cfg.args.iter().cloned()); - } - } else if let Some(w) = cfg.args_write.as_ref() { - final_args.extend(w.iter().cloned()); - } else { - final_args.extend(cfg.args.iter().cloned()); - } - } - - strip_model_flags(&mut final_args); - - let spec_model_args: Vec = if let Some(spec) = spec_opt { - spec.model_args.iter().map(|arg| (*arg).to_string()).collect() - } else { - Vec::new() - }; - - let built_in_cloud = family == "cloud" && config.is_none(); - - // Clamp reasoning effort to what the target model supports. - let clamped_effort = match reasoning_effort { - code_protocol::config_types::ReasoningEffort::XHigh => { - let lower = slug_for_defaults.to_ascii_lowercase(); - if lower.contains("max") { - reasoning_effort - } else { - code_protocol::config_types::ReasoningEffort::High - } - } - other => other, - }; - - // Configuration overrides for Codex CLI families. External CLIs - // (antigravity, claude, gemini, copilot, qwen) do not understand our config - // flags, so only attach these when launching Codex binaries. - let effort_override = format!( - "model_reasoning_effort={}", - clamped_effort.to_string().to_ascii_lowercase() - ); - let auto_effort_override = format!( - "auto_drive.model_reasoning_effort={}", - clamped_effort.to_string().to_ascii_lowercase() - ); - match family { - "copilot" => { - let mut defaults = default_params_for(slug_for_defaults, read_only); - strip_model_flags(&mut defaults); - final_args.extend(defaults); - final_args.extend(spec_model_args.iter().cloned()); - final_args.push("--reasoning-effort".into()); - final_args.push(clamped_effort.to_string().to_ascii_lowercase()); - final_args.push("-p".into()); - prompt_stdin = deliver_agent_prompt(family, &mut final_args, prompt, false, false)?; - } - "antigravity" | "claude" | "gemini" | "qwen" => { - let mut defaults = default_params_for(slug_for_defaults, read_only); - strip_model_flags(&mut defaults); - final_args.extend(defaults); - final_args.extend(spec_model_args.iter().cloned()); - if family == "antigravity" { - let dir = working_dir - .clone() - .or_else(|| std::env::current_dir().ok()); - if let Some(dir) = dir { - final_args.push("--add-dir".into()); - final_args.push(dir.display().to_string()); - } - } - final_args.push("-p".into()); - prompt_stdin = deliver_agent_prompt(family, &mut final_args, prompt, false, false)?; - } - "codex" | "code" => { - let have_mode_args = config - .as_ref() - .map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() }) - .unwrap_or(false); - if !have_mode_args { - let mut defaults = default_params_for(slug_for_defaults, read_only); - strip_model_flags(&mut defaults); - final_args.extend(defaults); - } - final_args.extend(spec_model_args.iter().cloned()); - final_args.push("-c".into()); - final_args.push(effort_override.clone()); - final_args.push("-c".into()); - final_args.push(auto_effort_override.clone()); - prompt_stdin = deliver_agent_prompt(family, &mut final_args, prompt, true, false)?; - } - "cloud" => { - if built_in_cloud { - final_args.extend(["cloud", "submit", "--wait"].map(String::from)); - } - let have_mode_args = config - .as_ref() - .map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() }) - .unwrap_or(false); - if !have_mode_args { - let mut defaults = default_params_for(slug_for_defaults, read_only); - strip_model_flags(&mut defaults); - final_args.extend(defaults); - } - final_args.extend(spec_model_args.iter().cloned()); - final_args.push("-c".into()); - final_args.push(effort_override.clone()); - final_args.push("-c".into()); - final_args.push(auto_effort_override); - prompt_stdin = deliver_agent_prompt(family, &mut final_args, prompt, false, false)?; - } - _ => { - final_args.extend(spec_model_args.iter().cloned()); - prompt_stdin = deliver_agent_prompt(family, &mut final_args, prompt, false, false)?; - } - } - - let log_tag_owned = log_tag.map(str::to_string); - let debug_subagent = debug_subagents_enabled() - && matches!(source_kind, Some(AgentSourceKind::AutoReview)); - let child_log_tag: Option = if debug_subagent { - Some(log_tag_owned.clone().unwrap_or_else(|| format!("agents/{agent_id}"))) - } else { - log_tag_owned - }; - - if debug_subagent && use_current_exe && !has_debug_flag(&final_args) { - final_args.insert(0, "--debug".to_string()); - } - - if let Some(path) = review_output_json_path { - final_args.push("--review-output-json".to_string()); - final_args.push(path.display().to_string()); - } - - if use_current_exe - && (final_args.iter().any(|arg| arg == "exec") || review_output_json_path.is_some()) - { - let mut reordered: Vec = Vec::with_capacity(final_args.len() + 1); - reordered.push("exec".to_string()); - for arg in final_args.into_iter() { - if arg != "exec" { - reordered.push(arg); - } - } - final_args = reordered; - } - - // Proactively check for presence of external command before spawn when not - // using the current executable fallback. This avoids confusing OS errors - // like "program not found" and lets us surface a cleaner message. - if !(family == "codex" || family == "code" || (family == "cloud" && config.is_none())) - && !external_agent_command_exists(&command_for_spawn) - { - return Err(format_agent_not_found_error(&command, &command_for_spawn)); - } - - // Agents: run without OS sandboxing; rely on per-branch worktrees for isolation. - use crate::protocol::SandboxPolicy; - use crate::spawn::StdioPolicy; - // Build env from current process then overlay any config-provided vars. - let mut env: std::collections::HashMap = std::env::vars().collect(); - let orig_home: Option = env.get("HOME").cloned(); - if let Some(ref cfg) = config { - if let Some(ref e) = cfg.env { for (k, v) in e { env.insert(k.clone(), v.clone()); } } - } - let child_spawn_depth = current_agent_spawn_depth().saturating_add(1); - env.insert( - CODE_AGENT_SPAWN_DEPTH_ENV.to_string(), - child_spawn_depth.to_string(), - ); - - if debug_subagent { - env.entry("CODE_SUBAGENT_DEBUG".to_string()) - .or_insert_with(|| "1".to_string()); - if let Some(tag) = child_log_tag.as_ref() { - env.insert("CODE_DEBUG_LOG_TAG".to_string(), tag.clone()); - } - } - - // Tag OpenAI requests originating from agent runs so server-side telemetry - // can distinguish subagent traffic. - if use_current_exe || family == "codex" || family == "code" { - let subagent = match source_kind { - Some(AgentSourceKind::AutoReview) => "review", - _ => "agent", - }; - env.entry("CODE_OPENAI_SUBAGENT".to_string()) - .or_insert_with(|| subagent.to_string()); - } - - // Convenience: map common key names so external CLIs "just work". - if let Some(google_key) = env.get("GOOGLE_API_KEY").cloned() { - env.entry("GEMINI_API_KEY".to_string()).or_insert(google_key); - } - if let Some(claude_key) = env.get("CLAUDE_API_KEY").cloned() { - env.entry("ANTHROPIC_API_KEY".to_string()).or_insert(claude_key); - } - if let Some(anthropic_key) = env.get("ANTHROPIC_API_KEY").cloned() { - env.entry("CLAUDE_API_KEY".to_string()).or_insert(anthropic_key); - } - if let Some(anthropic_base) = env.get("ANTHROPIC_BASE_URL").cloned() { - env.entry("CLAUDE_BASE_URL".to_string()).or_insert(anthropic_base); - } - // Qwen/DashScope convenience: mirror API keys and base URLs both ways so - // either variable name works across tools. - if let Some(qwen_key) = env.get("QWEN_API_KEY").cloned() { - env.entry("DASHSCOPE_API_KEY".to_string()).or_insert(qwen_key); - } - if let Some(dashscope_key) = env.get("DASHSCOPE_API_KEY").cloned() { - env.entry("QWEN_API_KEY".to_string()).or_insert(dashscope_key); - } - if let Some(qwen_base) = env.get("QWEN_BASE_URL").cloned() { - env.entry("DASHSCOPE_BASE_URL".to_string()).or_insert(qwen_base); - } - if let Some(ds_base) = env.get("DASHSCOPE_BASE_URL").cloned() { - env.entry("QWEN_BASE_URL".to_string()).or_insert(ds_base); - } - if family == "qwen" { - env.insert("OPENAI_API_KEY".to_string(), String::new()); - } - // Reduce startup overhead for Claude CLI: disable auto-updater/telemetry. - env.entry("DISABLE_AUTOUPDATER".to_string()).or_insert("1".to_string()); - env.entry("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string()).or_insert("1".to_string()); - env.entry("DISABLE_ERROR_REPORTING".to_string()).or_insert("1".to_string()); - // Prefer explicit Claude config dir to avoid touching $HOME/.claude.json. - // Do not force CLAUDE_CONFIG_DIR here; leave CLI free to use its default - // (including Keychain) unless we explicitly redirect HOME below. - - // If GEMINI_API_KEY not provided, try pointing to host config for read‑only - // discovery (Gemini CLI supports GEMINI_CONFIG_DIR). We keep HOME as-is so - // CLIs that require ~/.gemini and ~/.claude continue to work with your - // existing config. - maybe_set_gemini_config_dir(&mut env, orig_home.clone()); - - let output = if !read_only { - // Resolve the command and args we prepared above into Vec for spawn helpers. - let program = resolve_program_path(use_current_exe, &command_for_spawn)?; - let args = final_args.clone(); - let prompt_stdin_for_child = prompt_stdin.clone(); - let launch_cwd = agent_launch_cwd(family, working_dir.clone(), orig_home.as_deref()); - if family == "antigravity" && let Err(err) = std::fs::create_dir_all(&launch_cwd) { - return Err(format!( - "Failed to create agent launch directory {}: {err}", - launch_cwd.display() - )); - } - - let child_result: std::io::Result = crate::spawn::spawn_child_async( - program.clone(), - args.clone(), - Some(program.to_string_lossy().as_ref()), - launch_cwd, - &SandboxPolicy::DangerFullAccess, - if prompt_stdin_for_child.is_some() { - StdioPolicy::RedirectForShellToolWithPipedStdin - } else { - StdioPolicy::RedirectForShellTool - }, - env.clone(), - ) - .await; - - match child_result { - Ok(child) => stream_child_output(agent_id, child, prompt_stdin_for_child).await?, - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - return Err(format_agent_not_found_error(&command, &command_for_spawn)); - } - return Err(format!("Failed to spawn sandboxed agent: {}", e)); - } - } - } else { - // Read-only path: must honor resolve_program_path (and CODE_BINARY_PATH) just - // like the write path; skipping this can regress to PATH resolution and - // launch the npm shim on Windows (issue #497). - let program = resolve_program_path(use_current_exe, &command_for_spawn)?; - let mut cmd = Command::new(program); - let launch_cwd = agent_launch_cwd(family, working_dir.clone(), orig_home.as_deref()); - if family == "antigravity" && let Err(err) = std::fs::create_dir_all(&launch_cwd) { - return Err(format!( - "Failed to create agent launch directory {}: {err}", - launch_cwd.display() - )); - } - - cmd.current_dir(launch_cwd); - - cmd.args(final_args.clone()); - if prompt_stdin.is_some() { - cmd.stdin(Stdio::piped()); - } else { - cmd.stdin(Stdio::null()); - } - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - for (k, v) in &env { - cmd.env(k, v); - } - - // Ensure the child is terminated if this process dies unexpectedly. - cmd.kill_on_drop(true); - - match spawn_tokio_command_with_retry(&mut cmd).await { - Ok(child) => stream_child_output(agent_id, child, prompt_stdin).await?, - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - return Err(format_agent_not_found_error(&command, &command_for_spawn)); - } - - return Err(format!("Failed to execute {}: {}", model, e)); - } - } - }; - - let (status, stdout_buf, stderr_buf) = output; - - if status.success() { - Ok(AgentExecutionOutput::from_child_output(stdout_buf, &stderr_buf)) - } else { - let stderr = stderr_buf.trim(); - let stdout = stdout_buf.trim(); - let combined = if stderr.is_empty() { - stdout.to_string() - } else if stdout.is_empty() { - stderr.to_string() - } else { - format!("{}\n{}", stderr, stdout) - }; - Err(format!("Command failed: {}", combined)) - } -} - -const STREAM_PROGRESS_INTERVAL: StdDuration = StdDuration::from_secs(2); -const STREAM_PROGRESS_BYTES: usize = 2 * 1024; - -async fn stream_child_output( - agent_id: &str, - mut child: tokio::process::Child, - stdin_content: Option, -) -> Result<(std::process::ExitStatus, String, String), String> { - let agent_id_owned = agent_id.to_string(); - let stop_flag = Arc::new(AtomicBool::new(false)); - let stop_clone = stop_flag.clone(); - let heartbeat = tokio::spawn(async move { - let mut ticker = tokio::time::interval(TokioDuration::from_secs(30)); - loop { - ticker.tick().await; - if stop_clone.load(Ordering::Relaxed) { - break; - } - AgentManager::touch_agent(&agent_id_owned).await; - } - }); - - let stdout_task = child.stdout.take().map(|stdout| { - let agent = agent_id.to_string(); - tokio::spawn(async move { stream_reader_to_progress(agent, "stdout", stdout).await }) - }); - - let stderr_task = child.stderr.take().map(|stderr| { - let agent = agent_id.to_string(); - tokio::spawn(async move { stream_reader_to_progress(agent, "stderr", stderr).await }) - }); - - let stdin_task = if let Some(stdin_content) = stdin_content { - let Some(mut stdin) = child.stdin.take() else { - return Err("failed to open agent stdin for large prompt delivery".to_string()); - }; - Some(tokio::spawn(async move { - stdin - .write_all(stdin_content.as_bytes()) - .await - .map_err(|err| format!("failed to write agent prompt to stdin: {err}")) - })) - } else { - None - }; - - let status = child - .wait() - .await - .map_err(|e| format!("Failed to wait for agent process: {e}"))?; - - if let Some(handle) = stdin_task { - handle - .await - .map_err(|e| format!("Failed to join agent stdin writer: {e}"))??; - } - - let stdout_buf = match stdout_task { - Some(handle) => handle - .await - .map_err(|e| format!("Failed to read agent stdout: {e}"))?, - None => String::new(), - }; - - let stderr_buf = match stderr_task { - Some(handle) => handle - .await - .map_err(|e| format!("Failed to read agent stderr: {e}"))?, - None => String::new(), - }; - - stop_flag.store(true, Ordering::Relaxed); - heartbeat.abort(); - - Ok((status, stdout_buf, stderr_buf)) -} - -async fn stream_reader_to_progress(agent_id: String, label: &str, reader: R) -> String -where - R: AsyncRead + Unpin, -{ - let mut lines = BufReader::new(reader).lines(); - let mut full = String::new(); - let mut chunk = String::new(); - let mut last_flush = Instant::now(); - - while let Ok(Some(line)) = lines.next_line().await { - let clean = line.trim_end_matches('\r'); - full.push_str(clean); - full.push('\n'); - chunk.push_str(clean); - chunk.push('\n'); - - if chunk.len() >= STREAM_PROGRESS_BYTES || last_flush.elapsed() >= STREAM_PROGRESS_INTERVAL { - flush_progress(&agent_id, label, &mut chunk).await; - last_flush = Instant::now(); - } - } - - if !chunk.is_empty() { - flush_progress(&agent_id, label, &mut chunk).await; - } - - full -} - -async fn flush_progress(agent_id: &str, label: &str, chunk: &mut String) { - let message = format!("[{label}] {}", chunk.trim_end()); - let mut mgr = AGENT_MANAGER.write().await; - mgr.add_progress(agent_id, message).await; - chunk.clear(); -} - -fn debug_subagents_enabled() -> bool { - match std::env::var("CODE_SUBAGENT_DEBUG") { - Ok(val) => { - let lower = val.to_ascii_lowercase(); - matches!(lower.as_str(), "1" | "true" | "yes" | "on") - } - Err(_) => false, - } -} - -fn has_debug_flag(args: &[String]) -> bool { - args.iter().any(|arg| arg == "--debug" || arg == "-d") -} - -fn maybe_set_gemini_config_dir(env: &mut HashMap, orig_home: Option) { - if env.get("GEMINI_API_KEY").is_some() { - return; - } - - let Some(home) = orig_home else { return; }; - let host_gem_cfg = std::path::PathBuf::from(&home).join(".gemini"); - if host_gem_cfg.is_dir() { - env.insert( - "GEMINI_CONFIG_DIR".to_string(), - host_gem_cfg.to_string_lossy().to_string(), - ); - } -} - -fn antigravity_launch_dir(orig_home: Option<&str>) -> PathBuf { - crate::config::find_code_home() - .or_else(|_| { - orig_home - .map(|home| PathBuf::from(home).join(".code")) - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "HOME not set")) - }) - .unwrap_or_else(|_| std::env::temp_dir().join("code")) - .join("agent-cache") - .join("antigravity") -} - -fn agent_launch_cwd(family: &str, working_dir: Option, orig_home: Option<&str>) -> PathBuf { - if family == "antigravity" { - return antigravity_launch_dir(orig_home); - } - - working_dir.unwrap_or_else(|| { - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) - }) -} - -pub(crate) fn should_use_current_exe_for_agent( - family: &str, - command_missing: bool, - config: Option<&AgentConfig>, -) -> bool { - if !matches!(family, "code" | "codex" | "cloud" | "coder") { - return false; - } - - // If the command is missing/empty, always use the current binary. - if command_missing { - return true; - } - - if let Some(cfg) = config { - let trimmed = cfg.command.trim(); - if trimmed.is_empty() { - return true; - } - - // If the configured command matches the canonical CLI for this spec, prefer self. - if let Some(spec) = agent_model_spec(&cfg.name).or_else(|| agent_model_spec(trimmed)) { - if trimmed.eq_ignore_ascii_case(spec.cli) { - return true; - } - } - - // Otherwise assume the user intentionally set a custom command; do not override. - false - } else { - // No explicit config: built-in families should use the current binary. - true - } -} - -fn resolve_program_path(use_current_exe: bool, command_for_spawn: &str) -> Result { - if use_current_exe { - return current_code_binary_path(); - } - - if let Some(path) = resolve_external_agent_command_path(command_for_spawn) { - return Ok(path); - } - - Ok(std::path::PathBuf::from(command_for_spawn)) -} - -fn strip_model_flags(args: &mut Vec) { - let mut i = 0; - while i < args.len() { - let lowered = args[i].to_ascii_lowercase(); - if lowered == "--model" || lowered == "-m" { - args.remove(i); - if i < args.len() { - args.remove(i); - } - continue; - } - if lowered.starts_with("--model=") || lowered.starts_with("-m=") { - args.remove(i); - continue; - } - i += 1; - } -} - -pub fn split_command_and_args(command: &str) -> (String, Vec) { - let trimmed = command.trim(); - if trimmed.is_empty() { - return (String::new(), Vec::new()); - } - if let Some(tokens) = shlex_split(trimmed) { - if let Some((first, rest)) = tokens.split_first() { - return (first.clone(), rest.to_vec()); - } - } - - let tokens: Vec = trimmed.split_whitespace().map(|s| s.to_string()).collect(); - if tokens.is_empty() { - (String::new(), Vec::new()) - } else { - let head = tokens[0].clone(); - (head, tokens[1..].to_vec()) - } -} - -const AGENT_SMOKE_TEST_PROMPT: &str = "Reply only with the string \"ok\". Do not include any other words."; -const AGENT_SMOKE_TEST_EXPECTED: &str = "ok"; -const AGENT_SMOKE_TEST_TIMEOUT: TokioDuration = TokioDuration::from_secs(20); - -fn should_validate_in_read_only(_cfg: &AgentConfig) -> bool { true } - -async fn run_agent_smoke_test(cfg: AgentConfig) -> Result { - let model_name = cfg.name.clone(); - let read_only = should_validate_in_read_only(&cfg); - let mut task = tokio::spawn(async move { - execute_model_with_permissions( - "agent-smoke-test", - &model_name, - AGENT_SMOKE_TEST_PROMPT, - read_only, - None, - Some(cfg), - code_protocol::config_types::ReasoningEffort::High, - None, - None, - None, - ) - .await - }); - let timer = tokio::time::sleep(AGENT_SMOKE_TEST_TIMEOUT); - tokio::pin!(timer); - tokio::select! { - res = &mut task => { - res.map_err(|e| format!("agent validation task failed: {e}"))? - } - _ = timer.as_mut() => { - task.abort(); - let _ = task.await; - return Err(format!( - "agent validation timed out after {}s", - AGENT_SMOKE_TEST_TIMEOUT.as_secs() - )); - } - } -} - -fn summarize_agent_output(output: &str) -> String { - let trimmed = output.trim(); - if trimmed.is_empty() { - return "".to_string(); - } - const MAX_LEN: usize = 240; - if trimmed.len() <= MAX_LEN { - trimmed.to_string() - } else { - let mut cutoff = MAX_LEN.min(trimmed.len()); - while cutoff > 0 && !trimmed.is_char_boundary(cutoff) { - cutoff -= 1; - } - if cutoff == 0 { - // Fallback: take first char to avoid empty slice - let mut chars = trimmed.chars(); - if let Some(first) = chars.next() { - format!("{}…", first) - } else { - "…".to_string() - } - } else { - format!("{}…", &trimmed[..cutoff]) - } - } -} - -pub async fn smoke_test_agent(cfg: AgentConfig) -> Result<(), String> { - let output = run_agent_smoke_test(cfg).await?; - let normalized = output.trim().to_ascii_lowercase(); - if normalized == AGENT_SMOKE_TEST_EXPECTED { - Ok(()) - } else { - Err(format!( - "agent response missing \"ok\": {}", - summarize_agent_output(&output) - )) - } -} - -fn run_smoke_test_with_new_runtime(cfg: AgentConfig) -> Result<(), String> { - TokioRuntimeBuilder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("failed to build validation runtime: {}", e))? - .block_on(smoke_test_agent(cfg)) -} - -pub fn smoke_test_agent_blocking(cfg: AgentConfig) -> Result<(), String> { - if tokio::runtime::Handle::try_current().is_ok() { - thread::Builder::new() - .name("agent-smoke-test".into()) - .spawn(move || run_smoke_test_with_new_runtime(cfg)) - .map_err(|e| format!("failed to spawn agent validation thread: {}", e))? - .join() - .map_err(|_| "agent validation thread panicked".to_string())? - } else { - run_smoke_test_with_new_runtime(cfg) - } -} - -/// Execute the built-in cloud agent via the current `code` binary, streaming -/// stderr lines into the HUD as progress and returning final stdout. Applies a -/// modest truncation cap to very large outputs to keep UI responsive. -async fn execute_cloud_built_in_streaming( - agent_id: &str, - prompt: &str, - working_dir: Option, - _config: Option, - model_slug: &str, -) -> Result { - if prompt.len() > AGENT_PROMPT_ARGV_THRESHOLD_BYTES { - return Err(format!( - "built-in cloud agent prompt is {} bytes, above the {} byte argv delivery threshold. Use a built-in Every Code/Codex agent such as code-gpt-5.4 for large context_files, or reduce the inlined context.", - prompt.len(), - AGENT_PROMPT_ARGV_THRESHOLD_BYTES - )); - } - - // Program and argv - let program = current_code_binary_path()?; - let mut args: Vec = vec!["cloud".into(), "submit".into(), "--wait".into()]; - if let Some(spec) = agent_model_spec(model_slug) { - args.extend(spec.model_args.iter().map(|arg| (*arg).to_string())); - } - args.push(prompt.into()); - - // Baseline env mirrors behavior in execute_model_with_permissions - let env: std::collections::HashMap = std::env::vars().collect(); - - use crate::protocol::SandboxPolicy; - use crate::spawn::StdioPolicy; - let mut child = crate::spawn::spawn_child_async( - program.clone(), - args.clone(), - Some(program.to_string_lossy().as_ref()), - working_dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))), - &SandboxPolicy::DangerFullAccess, - StdioPolicy::RedirectForShellTool, - env, - ) - .await - .map_err(|e| format!("Failed to spawn cloud submit: {}", e))?; - - // Stream stderr to HUD - let stderr_task = if let Some(stderr) = child.stderr.take() { - let agent = agent_id.to_string(); - Some(tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let msg = line.trim(); - if msg.is_empty() { continue; } - let mut mgr = AGENT_MANAGER.write().await; - mgr.add_progress(&agent, msg.to_string()).await; - } - })) - } else { None }; - - // Collect stdout fully (final result) - let mut stdout_buf = String::new(); - if let Some(stdout) = child.stdout.take() { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - stdout_buf.push_str(&line); - stdout_buf.push('\n'); - } - } - - let status = child.wait().await.map_err(|e| format!("Failed to wait: {}", e))?; - if let Some(t) = stderr_task { let _ = t.await; } - if !status.success() { - return Err(format!("cloud submit exited with status {}", status)); - } - - if let Some(dir) = working_dir.as_ref() { - let diff_text_opt = if stdout_buf.starts_with("diff --git ") { - Some(stdout_buf.trim()) - } else { - stdout_buf - .find("\ndiff --git ") - .map(|idx| stdout_buf[idx + 1..].trim()) - }; - - if let Some(diff_text) = diff_text_opt { - if !diff_text.is_empty() { - let mut apply = Command::new("git"); - apply.arg("apply").arg("--whitespace=nowarn"); - apply.current_dir(dir); - apply.stdin(Stdio::piped()); - - let mut child = spawn_tokio_command_with_retry(&mut apply) - .await - .map_err(|e| format!("Failed to spawn git apply: {}", e))?; - - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(diff_text.as_bytes()) - .await - .map_err(|e| format!("Failed to write diff to git apply: {}", e))?; - } - - let status = child - .wait() - .await - .map_err(|e| format!("Failed to wait for git apply: {}", e))?; - - if !status.success() { - return Err(format!( - "git apply exited with status {} while applying cloud diff", - status - )); - } - } - } - } - - // Truncate large outputs - const MAX_BYTES: usize = 500_000; // ~500 KB - if stdout_buf.len() > MAX_BYTES { - let omitted = stdout_buf.len() - MAX_BYTES; - let mut truncated = String::with_capacity(MAX_BYTES + 128); - truncated.push_str(&stdout_buf[..MAX_BYTES]); - truncated.push_str(&format!("\n… [truncated: {} bytes omitted]", omitted)); - Ok(truncated) - } else { - Ok(stdout_buf) - } -} - -// Tool creation functions - -pub fn create_agent_tool(allowed_models: &[String]) -> OpenAiTool { - let mut properties = BTreeMap::new(); - - properties.insert( - "action".to_string(), - JsonSchema::String { - description: Some( - "Required: choose one of ['create','status','wait','result','cancel','list']".to_string(), - ), - allowed_values: Some( - ["create", "status", "wait", "result", "cancel", "list"] - .into_iter() - .map(|value| value.to_string()) - .collect(), - ), - }, - ); - - let mut create_properties = BTreeMap::new(); - create_properties.insert( - "name".to_string(), - JsonSchema::String { - description: Some("Display name shown in the UI (e.g., \"Plan TUI Refactor\")".to_string()), - allowed_values: None, - }, - ); - create_properties.insert( - "task".to_string(), - JsonSchema::String { - description: Some("Task prompt to execute".to_string()), - allowed_values: None, - }, - ); - create_properties.insert( - "context".to_string(), - JsonSchema::String { - description: Some("Optional background context".to_string()), - allowed_values: None, - }, - ); - create_properties.insert( - "models".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { - description: None, - allowed_values: if allowed_models.is_empty() { - None - } else { - Some(allowed_models.iter().cloned().collect()) - }, - }), - description: Some( - "Optional array of agent/model selector slugs. For explicit multi-agent or dissent requests, prefer diverse families when useful and budget allows (for example ['code-gpt-5.5','claude-sonnet-4.6','antigravity']). For multi-agent release/workflow, infrastructure, security, or product-risk work, proactively use `antigravity` for Google/Gemini-family perspective unless there is a clear reason to skip it; AGY uses its configured model rather than per-run Gemini Pro/Flash selection. If you skip an obvious family, briefly explain why.".to_string(), - ), - }, - ); - create_properties.insert( - "files".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { - description: None, - allowed_values: None, - }), - description: Some( - "Optional array of file paths for the agent to consider. Contents are not inlined; use context_files when the subagent needs file contents in its initial prompt.".to_string(), - ), - }, - ); - create_properties.insert( - "context_files".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { - description: None, - allowed_values: None, - }), - description: Some( - "Optional array of text file paths whose contents should be snapshotted and inlined into the spawned agent's initial prompt. Use sparingly for curated context; prefer files for path hints and code llm request --message-file for strict rollout/model evaluation.".to_string(), - ), - }, - ); - create_properties.insert( - "context_budget_tokens".to_string(), - JsonSchema::Number { - description: Some( - "Approximate integer-valued token budget for inlined context_files. Defaults to 16000 and caps at 900000; set explicitly for expensive large-context launches.".to_string(), - ), - }, - ); - create_properties.insert( - "output".to_string(), - JsonSchema::String { - description: Some("Optional desired output description".to_string()), - allowed_values: None, - }, - ); - create_properties.insert( - "write".to_string(), - JsonSchema::Boolean { - description: Some( - "Enable isolated write worktrees for each agent (default: true). Set false to keep the agent read-only.".to_string(), - ), - }, - ); - create_properties.insert( - "read_only".to_string(), - JsonSchema::Boolean { - description: Some( - "Deprecated: inverse of `write`. Prefer setting `write` instead.".to_string(), - ), - }, - ); - properties.insert( - "create".to_string(), - JsonSchema::Object { - properties: create_properties, - required: Some(vec!["task".to_string()]), - additional_properties: Some(false.into()), - }, - ); - - let mut status_properties = BTreeMap::new(); - status_properties.insert( - "agent_id".to_string(), - JsonSchema::String { - description: Some("Agent identifier to inspect".to_string()), - allowed_values: None, - }, - ); - properties.insert( - "status".to_string(), - JsonSchema::Object { - properties: status_properties, - required: Some(vec!["agent_id".to_string()]), - additional_properties: Some(false.into()), - }, - ); - - let mut result_properties = BTreeMap::new(); - result_properties.insert( - "agent_id".to_string(), - JsonSchema::String { - description: Some("Agent identifier whose result should be fetched".to_string()), - allowed_values: None, - }, - ); - properties.insert( - "result".to_string(), - JsonSchema::Object { - properties: result_properties, - required: Some(vec!["agent_id".to_string()]), - additional_properties: Some(false.into()), - }, - ); - - let mut cancel_properties = BTreeMap::new(); - cancel_properties.insert( - "agent_id".to_string(), - JsonSchema::String { - description: Some("Cancel a specific agent".to_string()), - allowed_values: None, - }, - ); - cancel_properties.insert( - "batch_id".to_string(), - JsonSchema::String { - description: Some("Cancel all agents in the batch".to_string()), - allowed_values: None, - }, - ); - properties.insert( - "cancel".to_string(), - JsonSchema::Object { - properties: cancel_properties, - required: Some(Vec::new()), - additional_properties: Some(false.into()), - }, - ); - - let mut wait_properties = BTreeMap::new(); - wait_properties.insert( - "agent_id".to_string(), - JsonSchema::String { - description: Some("Wait for a specific agent".to_string()), - allowed_values: None, - }, - ); - wait_properties.insert( - "batch_id".to_string(), - JsonSchema::String { - description: Some("Wait for any agent in the batch".to_string()), - allowed_values: None, - }, - ); - wait_properties.insert( - "timeout_seconds".to_string(), - JsonSchema::Number { - description: Some( - "Optional timeout before giving up (default 300, max 600)".to_string(), - ), - }, - ); - wait_properties.insert( - "return_all".to_string(), - JsonSchema::Boolean { - description: Some( - "When waiting on a batch, return all completed agents instead of the first".to_string(), - ), - }, - ); - properties.insert( - "wait".to_string(), - JsonSchema::Object { - properties: wait_properties, - required: Some(Vec::new()), - additional_properties: Some(false.into()), - }, - ); - - let mut list_properties = BTreeMap::new(); - list_properties.insert( - "status_filter".to_string(), - JsonSchema::String { - description: Some( - "Optional status filter (pending, running, completed, failed, cancelled)".to_string(), - ), - allowed_values: None, - }, - ); - list_properties.insert( - "batch_id".to_string(), - JsonSchema::String { - description: Some("Limit results to a batch".to_string()), - allowed_values: None, - }, - ); - list_properties.insert( - "recent_only".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, only include agents from the last two hours".to_string(), - ), - }, - ); - properties.insert( - "list".to_string(), - JsonSchema::Object { - properties: list_properties, - required: Some(Vec::new()), - additional_properties: Some(false.into()), - }, - ); - - let required = Some(vec!["action".to_string()]); - - OpenAiTool::Function(ResponsesApiTool { - name: "agent".to_string(), - description: "Unified agent manager for launching, monitoring, and collecting results from asynchronous agents.".to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required, - additional_properties: Some(false.into()), - }, - }) -} - -// Parameter structs for handlers -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RunAgentParams { - pub task: String, - #[serde(default, deserialize_with = "deserialize_models_field")] - pub models: Vec, - pub context: Option, - pub output: Option, - pub files: Option>, - pub context_files: Option>, - #[serde(default, deserialize_with = "deserialize_optional_u64_number")] - pub context_budget_tokens: Option, - #[serde(default)] - pub write: Option, - #[serde(default)] - pub read_only: Option, - pub name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentCreateOptions { - pub task: Option, - #[serde(default, deserialize_with = "deserialize_models_field")] - pub models: Vec, - pub context: Option, - pub output: Option, - pub files: Option>, - pub context_files: Option>, - #[serde(default, deserialize_with = "deserialize_optional_u64_number")] - pub context_budget_tokens: Option, - #[serde(default)] - pub write: Option, - #[serde(default)] - pub read_only: Option, - pub name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentIdentifierOptions { - pub agent_id: Option, - pub batch_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentCancelOptions { - pub agent_id: Option, - pub batch_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentWaitOptions { - pub agent_id: Option, - pub batch_id: Option, - pub timeout_seconds: Option, - pub return_all: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentListOptions { - pub status_filter: Option, - pub batch_id: Option, - pub recent_only: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentToolRequest { - pub action: String, - pub create: Option, - pub status: Option, - pub result: Option, - pub cancel: Option, - pub wait: Option, - pub list: Option, -} - -pub(crate) fn normalize_agent_name(name: Option) -> Option { - let Some(name) = name.map(|value| value.trim().to_string()) else { - return None; - }; - - if name.is_empty() { - return None; - } - - let canonicalized = canonicalize_agent_word_boundaries(&name); - let words: Vec<&str> = canonicalized.split_whitespace().collect(); - if words.is_empty() { - return None; - } - - Some( - words - .into_iter() - .map(format_agent_word) - .collect::>() - .join(" "), - ) -} - -fn canonicalize_agent_word_boundaries(input: &str) -> String { - let mut tokens: Vec = Vec::new(); - let mut current = String::new(); - let mut chars = input.chars().peekable(); - let mut prev_char: Option = None; - let mut uppercase_run: usize = 0; - - while let Some(ch) = chars.next() { - if ch.is_whitespace() || matches!(ch, '_' | '-' | '/' | ':' | '.') { - if !current.is_empty() { - tokens.push(std::mem::take(&mut current)); - } - prev_char = None; - uppercase_run = 0; - continue; - } - - let next_char = chars.peek().copied(); - let mut split = false; - - if !current.is_empty() { - if let Some(prev) = prev_char { - if prev.is_ascii_lowercase() && ch.is_ascii_uppercase() { - split = true; - } else if prev.is_ascii_uppercase() - && ch.is_ascii_uppercase() - && uppercase_run > 0 - && next_char.map_or(false, |c| c.is_ascii_lowercase()) - { - split = true; - } - } - } - - if split { - tokens.push(std::mem::take(&mut current)); - uppercase_run = 0; - } - - current.push(ch); - - if ch.is_ascii_uppercase() { - uppercase_run += 1; - } else { - uppercase_run = 0; - } - - prev_char = Some(ch); - } - - if !current.is_empty() { - tokens.push(current); - } - - tokens.join(" ") -} - -const AGENT_NAME_ACRONYMS: &[&str] = &[ - "AI", "API", "CLI", "CPU", "DB", "GPU", "HTTP", "HTTPS", "ID", "LLM", "SDK", "SQL", "TUI", "UI", "UX", -]; - -fn format_agent_word(word: &str) -> String { - if word.is_empty() { - return String::new(); - } - - let uppercase = word.to_ascii_uppercase(); - if AGENT_NAME_ACRONYMS.contains(&uppercase.as_str()) { - return uppercase; - } - - let mut chars = word.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - - let mut formatted = String::new(); - formatted.extend(first.to_uppercase()); - formatted.push_str(&chars.flat_map(char::to_lowercase).collect::()); - formatted -} - -fn deserialize_models_field<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum ModelsInput { - Seq(Vec), - One(String), - } - - let parsed = Option::::deserialize(deserializer)?; - Ok(match parsed { - Some(ModelsInput::Seq(seq)) => seq, - Some(ModelsInput::One(single)) => vec![single], - None => Vec::new(), - }) -} - -fn deserialize_optional_u64_number<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - let Some(value) = value else { - return Ok(None); - }; - match value { - serde_json::Value::Number(number) => { - if let Some(int_value) = number.as_u64() { - return Ok(Some(int_value)); - } - if let Some(float_value) = number.as_f64() - && float_value.is_finite() - && float_value >= 0.0 - && float_value.fract() == 0.0 - && float_value <= u64::MAX as f64 - { - return Ok(Some(float_value as u64)); - } - Err(de::Error::custom(format!( - "expected context_budget_tokens to be a non-negative integer, got {number}. {CONTEXT_FILE_BUDGET_GUIDANCE}" - ))) - } - other => Err(de::Error::custom(format!( - "expected context_budget_tokens to be an integer token budget, got {other}. {CONTEXT_FILE_BUDGET_GUIDANCE}" - ))), - } -} - -#[cfg(test)] -mod tests { - use super::Agent; - use super::AgentCreateOptions; - use super::AgentManager; - use super::AgentExecutionOutput; - use super::AgentProviderFailureClass; - use super::AgentRetryMetadata; - use super::AgentStatus; - use super::AGENT_PROVIDER_RETRY_MAX_DELAY; - use super::create_agent_tool; - use super::MAX_AGENT_PROGRESS_ENTRIES; - use super::MAX_AGENT_RESULT_BYTES; - use super::MAX_TRACKED_TERMINAL_AGENTS; - use super::classify_agent_provider_failure; - use super::normalize_agent_name; - use super::maybe_set_gemini_config_dir; - use super::execute_model_with_permissions; - use super::execute_agent_provider_with_retries; - use super::extract_agent_token_count; - use super::resolve_program_path; - use super::should_use_current_exe_for_agent; - use super::prefer_json_result; - use super::prefer_json_result_detailed; - use super::remove_review_output_json; - use super::current_code_binary_path; - use super::agent_retry_delay; - use super::build_agent_full_prompt; - use super::build_context_files_prompt; - use super::AGENT_MANAGER; - use crate::config_types::AgentConfig; - use crate::openai_tools::{JsonSchema, OpenAiTool}; - use code_protocol::config_types::ReasoningEffort; - use serial_test::serial; - use std::collections::HashMap; - use std::ffi::OsString; - use tempfile::tempdir; - use std::path::Path; - use std::path::PathBuf; - use std::sync::atomic::Ordering; - use std::sync::{Mutex, OnceLock}; - use std::time::Duration as StdDuration; - use uuid::Uuid; - - #[cfg(unix)] - static STDIN_LOCK: Mutex<()> = Mutex::new(()); - - #[cfg(unix)] - struct StdinRedirectGuard { - saved_stdin_fd: i32, - read_fd: i32, - write_fd: i32, - } - - #[cfg(unix)] - impl StdinRedirectGuard { - fn install_pipe_as_stdin() -> Self { - let mut fds = [0; 2]; - assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe"); - let saved_stdin_fd = unsafe { libc::dup(libc::STDIN_FILENO) }; - assert!(saved_stdin_fd >= 0, "dup stdin"); - assert_eq!(unsafe { libc::dup2(fds[0], libc::STDIN_FILENO) }, libc::STDIN_FILENO, "dup2 stdin"); - Self { - saved_stdin_fd, - read_fd: fds[0], - write_fd: fds[1], - } - } - } - - #[cfg(unix)] - impl Drop for StdinRedirectGuard { - fn drop(&mut self) { - unsafe { - assert_eq!(libc::dup2(self.saved_stdin_fd, libc::STDIN_FILENO), libc::STDIN_FILENO, "restore stdin"); - libc::close(self.saved_stdin_fd); - libc::close(self.read_fd); - libc::close(self.write_fd); - } - } - } - - #[test] - fn drops_empty_names() { - assert_eq!(normalize_agent_name(None), None); - assert_eq!(normalize_agent_name(Some(" ".into())), None); - } - - #[test] - fn title_cases_and_restores_separators() { - assert_eq!( - normalize_agent_name(Some("plan_tui_refactor".into())), - Some("Plan TUI Refactor".into()) - ); - assert_eq!( - normalize_agent_name(Some("run-ui-tests".into())), - Some("Run UI Tests".into()) - ); - } - - #[test] - fn handles_camel_case_and_acronyms() { - assert_eq!( - normalize_agent_name(Some("shipCloudAPI".into())), - Some("Ship Cloud API".into()) - ); - } - - #[test] - fn prefer_json_result_uses_json_when_available() { - let dir = tempdir().unwrap(); - let path = dir.path().join("out.json"); - let payload = "{\"findings\":[],\"overall_explanation\":\"ok\"}"; - std::fs::write(&path, payload).unwrap(); - - let res = prefer_json_result(Some(&path), Err("fallback".to_string())); - assert_eq!(res.unwrap(), payload); - assert!(!path.exists(), "review output file should be cleaned up"); - } - - #[test] - fn prefer_json_result_falls_back_when_missing() { - let missing = PathBuf::from("/nonexistent/path.json"); - let res = prefer_json_result(Some(&missing), Ok("orig".to_string())); - assert_eq!(res.unwrap(), "orig"); - } - - #[test] - fn extracts_tokens_used_from_agent_output() { - let output = "[2026-06-04T00:24:57] codex\n\nOK\n[2026-06-04T00:24:57] tokens used: 25,915\n"; - assert_eq!(extract_agent_token_count(output), Some(25_915)); - assert_eq!( - extract_agent_token_count("cumulative tokens used: 5,000, session tokens used: 1,200"), - Some(5_000) - ); - assert_eq!(extract_agent_token_count("tokens used: nope"), None); - assert_eq!(extract_agent_token_count("no usage here"), None); - } - - #[test] - fn agent_execution_output_prefers_stdout_token_count() { - let output = AgentExecutionOutput::from_child_output( - "answer\ntokens used: 25,915\n".to_string(), - "tokens used: 0\n", - ); - assert_eq!(output.token_count, Some(25_915)); - } - - #[test] - fn prefer_json_result_preserves_token_count_from_failed_fallback() { - let dir = tempdir().unwrap(); - let path = dir.path().join("out.json"); - let payload = "{\"findings\":[],\"overall_explanation\":\"ok\"}"; - std::fs::write(&path, payload).unwrap(); - - let res = prefer_json_result_detailed( - Some(&path), - Err("Command failed: review error\ntokens used: 25,915".to_string()), - ) - .expect("sidecar should win"); - assert_eq!(res.output, payload); - assert_eq!(res.token_count, Some(25_915)); - } - - #[tokio::test] - async fn update_agent_result_persists_token_count_for_status() { - let mut manager = AgentManager::new(); - let session_id = Uuid::new_v4(); - let agent_id = manager - .create_agent_with_options( - "code-gpt-5.5".to_string(), - Some("Token Test".to_string()), - "prompt".to_string(), - None, - None, - Vec::new(), - Vec::new(), - None, - true, - None, - None, - session_id, - None, - None, - None, - None, - ReasoningEffort::Low, - ) - .await; - - manager - .update_agent_result_with_token_count(&agent_id, Ok("done".to_string()), Some(25_915)) - .await; - - let agent = manager.get_agent(&agent_id).expect("agent retained"); - assert_eq!(agent.token_count, Some(25_915)); - - let visible = manager.status_visible_agents_for_session(session_id); - let info = visible - .iter() - .find(|agent| agent.id == agent_id) - .expect("agent visible"); - assert_eq!(info.token_count, Some(25_915)); - } - - #[test] - fn remove_review_output_json_clears_stale_output() { - let dir = tempdir().unwrap(); - let path = dir.path().join("out.json"); - std::fs::write(&path, "stale").unwrap(); - - remove_review_output_json(Some(&path)); - - assert!(!path.exists(), "stale review output should be removed"); - remove_review_output_json(Some(&path)); - } - - fn agent_with_command(command: &str) -> AgentConfig { - AgentConfig { - name: "code-gpt-5.5".to_string(), - command: command.to_string(), - args: Vec::new(), - read_only: false, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - } - } - - fn test_agent( - id: &str, - owner_session_id: Uuid, - batch_id: &str, - status: AgentStatus, - ) -> Agent { - let now = chrono::Utc::now(); - Agent { - id: id.to_string(), - owner_session_id: Some(owner_session_id), - batch_id: Some(batch_id.to_string()), - model: "code-gpt-5.5".to_string(), - name: Some(id.to_string()), - prompt: "prompt".to_string(), - context: None, - output_goal: None, - files: Vec::new(), - context_files: Vec::new(), - context_budget_tokens: None, - read_only: true, - status, - result: None, - error: None, - token_count: None, - retry: AgentRetryMetadata { - original_model: "code-gpt-5.5".to_string(), - final_model: "code-gpt-5.5".to_string(), - ..AgentRetryMetadata::default() - }, - created_at: now, - started_at: Some(now), - completed_at: None, - progress: Vec::new(), - worktree_path: None, - branch_name: None, - worktree_base: None, - workspace_root: None, - source_kind: None, - log_tag: None, - config: None, - reasoning_effort: ReasoningEffort::Low, - last_activity: now, - } - } - - #[test] - fn code_family_falls_back_when_command_missing() { - let cfg = agent_with_command("definitely-not-present-429"); - let use_current = should_use_current_exe_for_agent("code", true, Some(&cfg)); - assert!(use_current); - } - - #[test] - fn code_family_prefers_current_exe_even_if_coder_in_path() { - let cfg = agent_with_command("coder"); - let use_current = should_use_current_exe_for_agent("code", false, Some(&cfg)); - assert!(use_current); - } - - #[test] - fn code_family_respects_custom_command_override() { - let cfg = agent_with_command("/usr/local/bin/my-coder"); - let use_current = should_use_current_exe_for_agent("code", false, Some(&cfg)); - assert!(!use_current); - } - - #[test] - fn program_path_uses_current_exe_when_requested() { - let expected = current_code_binary_path().expect("current binary path"); - let resolved = resolve_program_path(true, "coder").expect("resolved program"); - assert_eq!(resolved, expected); - - let custom = resolve_program_path(false, "custom-coder").expect("resolved custom"); - assert_eq!(custom, std::path::PathBuf::from("custom-coder")); - } - - #[test] - fn context_files_inline_text_and_preserve_files_as_hints() { - let tmp = tempfile::tempdir().expect("tempdir"); - let file = tmp.path().join("bundle.txt"); - std::fs::write(&file, "alpha beta gamma").expect("write context file"); - std::fs::create_dir(tmp.path().join("src")).expect("create src dir"); - std::fs::write( - tmp.path().join("src/main.rs"), - "DO_NOT_INLINE_FILE_HINT_SENTINEL", - ) - .expect("write hinted file"); - - let files = vec!["src/main.rs".to_string()]; - let context_files = vec!["bundle.txt".to_string()]; - let (prompt, summary) = build_agent_full_prompt( - "Inspect the bundle and report the sentinel.", - None, - None, - None, - &files, - &context_files, - Some(10), - tmp.path(), - ) - .expect("prompt built"); - - assert!(prompt.contains("Files to consider: src/main.rs")); - assert!(!prompt.contains("DO_NOT_INLINE_FILE_HINT_SENTINEL")); - assert!(prompt.contains(" 0); - assert!(summary.estimated_tokens <= summary.budget_tokens); - } - - #[test] - fn context_files_fail_when_budget_is_too_small() { - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write(tmp.path().join("large.txt"), "one two three four five") - .expect("write context file"); - - let err = build_context_files_prompt( - &["large.txt".to_string()], - Some(4), - tmp.path(), - ) - .expect_err("budget should fail"); - - assert!(err.contains("above the remaining budget")); - } - - #[test] - fn context_files_default_budget_requires_explicit_large_launch() { - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join("rollout.txt"), - "x".repeat(((super::DEFAULT_CONTEXT_FILE_BUDGET_TOKENS + 1) * 4) as usize), - ) - .expect("write context file"); - - let err = build_context_files_prompt(&["rollout.txt".to_string()], None, tmp.path()) - .expect_err("implicit budget should fail for large context files"); - - assert!(err.contains("context_files inline file contents")); - assert!(err.contains("context_budget_tokens explicitly")); - assert!(err.contains("code llm request --message-file")); - } - - #[test] - fn context_files_explicit_budget_allows_large_launch() { - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join("rollout.txt"), - "x".repeat(((super::DEFAULT_CONTEXT_FILE_BUDGET_TOKENS + 1) * 4) as usize), - ) - .expect("write context file"); - - let prompt = build_context_files_prompt( - &["rollout.txt".to_string()], - Some(super::DEFAULT_CONTEXT_FILE_BUDGET_TOKENS + 1), - tmp.path(), - ) - .expect("explicit budget should allow large context files") - .expect("prompt present"); - - assert_eq!(prompt.included_files, 1); - assert_eq!( - prompt.budget_tokens, - super::DEFAULT_CONTEXT_FILE_BUDGET_TOKENS + 1 - ); - } - - #[test] - fn context_files_reject_oversized_file_before_reading() { - let tmp = tempfile::tempdir().expect("tempdir"); - let large = tmp.path().join("large.txt"); - std::fs::write(&large, "x".repeat(128)).expect("write context file"); - - let err = build_context_files_prompt(&["large.txt".to_string()], Some(1), tmp.path()) - .expect_err("oversized file should fail before read"); - - assert!(err.contains("above the remaining budget")); - assert!(err.contains("bytes max")); - } - - #[test] - fn context_files_reject_budget_above_cap() { - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write(tmp.path().join("small.txt"), "ok").expect("write context file"); - - let err = build_context_files_prompt( - &["small.txt".to_string()], - Some(super::MAX_CONTEXT_FILE_BUDGET_TOKENS + 1), - tmp.path(), - ) - .expect_err("oversized budget should fail"); - - assert!(err.contains("exceeds the maximum")); - } - - #[test] - fn context_budget_schema_documents_integer_valued_number() { - let tool = create_agent_tool(&[]); - let function = match tool { - OpenAiTool::Function(function) => function, - _ => panic!("agent tool should be a function"), - }; - let JsonSchema::Object { properties, .. } = function.parameters else { - panic!("agent tool should have object parameters"); - }; - let create_schema = properties.get("create").expect("create schema"); - let JsonSchema::Object { properties: create_properties, .. } = create_schema else { - panic!("create schema should be an object"); - }; - let budget_schema = create_properties - .get("context_budget_tokens") - .expect("context_budget_tokens schema"); - - let JsonSchema::Number { description } = budget_schema else { - panic!("context_budget_tokens should be a number schema"); - }; - let description = description.as_deref().expect("budget description"); - assert!(description.contains("integer-valued token budget")); - } - - #[test] - fn context_budget_parse_error_includes_guidance() { - let err = serde_json::from_value::(serde_json::json!({ - "task": "review rollout", - "context_budget_tokens": 12.5, - })) - .expect_err("fractional budget should be rejected"); - let err = err.to_string(); - - assert!(err.contains("non-negative integer")); - assert!(err.contains("context_files inline file contents")); - assert!(err.contains("code llm request --message-file")); - } - - #[test] - fn context_files_reject_path_escape() { - let tmp = tempfile::tempdir().expect("tempdir"); - let outside = tempfile::NamedTempFile::new().expect("outside file"); - - let err = build_context_files_prompt( - &[outside.path().display().to_string()], - Some(100), - tmp.path(), - ) - .expect_err("outside file should fail"); - - assert!(err.contains("outside the workspace root")); - } - - #[test] - fn context_files_reject_binary_content() { - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write(tmp.path().join("blob.bin"), [b'a', 0, b'b']).expect("write binary"); - - let err = build_context_files_prompt( - &["blob.bin".to_string()], - Some(100), - tmp.path(), - ) - .expect_err("binary should fail"); - - assert!(err.contains("appears to be binary")); - } - - #[test] - fn context_files_escape_path_attributes() { - let tmp = tempfile::tempdir().expect("tempdir"); - let name = "a&b\"c.txt"; - std::fs::write(tmp.path().join(name), "ok").expect("write context file"); - - let prompt = build_context_files_prompt(&[name.to_string()], Some(100), tmp.path()) - .expect("prompt should build") - .expect("summary present"); - - assert!(prompt.block.contains("a&b"c.txt")); - } - - #[tokio::test] - #[serial] - async fn create_agent_preserves_explicit_workspace_root_for_context_files() { - let workspace = tempfile::tempdir().expect("workspace"); - let process_cwd = tempfile::tempdir().expect("process cwd"); - std::fs::write(workspace.path().join("context.txt"), "workspace context") - .expect("write workspace context"); - - let old_cwd = std::env::current_dir().expect("current dir"); - std::env::set_current_dir(process_cwd.path()).expect("set process cwd"); - let mut manager = AgentManager::new(); - let agent_id = manager - .create_agent_in_workspace( - "code-gpt-5.5".to_string(), - Some("workspace-root".to_string()), - "task".to_string(), - None, - None, - Vec::new(), - vec!["context.txt".to_string()], - Some(100), - true, - Some("batch".to_string()), - Uuid::new_v4(), - Some(workspace.path().to_path_buf()), - ReasoningEffort::Low, - ) - .await; - std::env::set_current_dir(old_cwd).expect("restore cwd"); - - let agent = manager.agents.get(&agent_id).expect("agent stored"); - assert_eq!(agent.workspace_root.as_deref(), Some(workspace.path())); - } - - #[tokio::test] - async fn agent_status_updates_are_broadcast_to_all_sessions() { - let mut manager = AgentManager::new(); - let session_a = Uuid::new_v4(); - let session_b = Uuid::new_v4(); - let (tx_a, mut rx_a) = tokio::sync::mpsc::unbounded_channel(); - let (tx_b, mut rx_b) = tokio::sync::mpsc::unbounded_channel(); - - manager.set_event_sender(session_a, tx_a); - manager.set_event_sender(session_b, tx_b); - manager.send_agent_status_update().await; - - assert!( - rx_a.try_recv().is_ok(), - "first session should still receive updates" - ); - assert!(rx_b.try_recv().is_ok(), "second session should receive updates"); - } - - #[tokio::test] - async fn agent_status_updates_include_only_owned_agents() { - let mut manager = AgentManager::new(); - let session_a = Uuid::new_v4(); - let session_b = Uuid::new_v4(); - let (tx_a, mut rx_a) = tokio::sync::mpsc::unbounded_channel(); - let (tx_b, mut rx_b) = tokio::sync::mpsc::unbounded_channel(); - - manager.set_event_sender(session_a, tx_a); - manager.set_event_sender(session_b, tx_b); - let agent_a = manager - .create_agent( - "code-gpt-5.5".to_string(), - Some("session-a".to_string()), - "task a".to_string(), - None, - None, - Vec::new(), - Vec::new(), - None, - true, - Some("batch-a".to_string()), - session_a, - ReasoningEffort::Low, - ) - .await; - let agent_b = manager - .create_agent( - "code-gpt-5.5".to_string(), - Some("session-b".to_string()), - "task b".to_string(), - None, - None, - Vec::new(), - Vec::new(), - None, - true, - Some("batch-b".to_string()), - session_b, - ReasoningEffort::Low, - ) - .await; - - let mut payload_a = rx_a - .try_recv() - .expect("session A should receive an update"); - while let Ok(next) = rx_a.try_recv() { - payload_a = next; - } - let mut payload_b = rx_b - .try_recv() - .expect("session B should receive an update"); - while let Ok(next) = rx_b.try_recv() { - payload_b = next; - } - - assert!(payload_a.agents.iter().any(|agent| agent.id == agent_a)); - assert!(!payload_a.agents.iter().any(|agent| agent.id == agent_b)); - assert!(payload_b.agents.iter().any(|agent| agent.id == agent_b)); - assert!(!payload_b.agents.iter().any(|agent| agent.id == agent_a)); - } - - #[tokio::test] - async fn model_facing_agent_queries_are_session_scoped() { - let mut manager = AgentManager::new(); - let session_a = Uuid::new_v4(); - let session_b = Uuid::new_v4(); - let shared_batch = "shared-batch"; - - manager.agents.insert( - "agent-a".to_string(), - test_agent("agent-a", session_a, shared_batch, AgentStatus::Running), - ); - manager.agents.insert( - "agent-b".to_string(), - test_agent("agent-b", session_b, shared_batch, AgentStatus::Running), - ); - manager.handles.insert("agent-a".to_string(), tokio::spawn(async {})); - manager.handles.insert("agent-b".to_string(), tokio::spawn(async {})); - manager.archived_terminal_agents.insert( - "archived-b".to_string(), - test_agent("archived-b", session_b, shared_batch, AgentStatus::Completed), - ); - let mut legacy_agent = test_agent( - "legacy-agent", - session_b, - shared_batch, - AgentStatus::Running, - ); - legacy_agent.owner_session_id = None; - manager - .agents - .insert("legacy-agent".to_string(), legacy_agent); - - let visible_to_a = manager.list_agents_for_session( - None, - Some(shared_batch.to_string()), - false, - session_a, - ); - assert_eq!(visible_to_a.len(), 2); - assert!(visible_to_a.iter().any(|agent| agent.id == "agent-a")); - assert!(visible_to_a.iter().any(|agent| agent.id == "legacy-agent")); - assert!(manager.get_agent_for_session("agent-b", session_a).is_none()); - assert!(manager.get_agent_for_session("archived-b", session_a).is_none()); - assert!(manager - .get_agent_for_session("legacy-agent", session_a) - .is_some()); - assert!(!manager.cancel_agent_for_session("agent-b", session_a).await); - - assert!(manager.agents.contains_key("agent-b")); - assert_eq!(manager.cancel_batch_for_session(shared_batch, session_a).await, 2); - assert_eq!( - manager.agents.get("agent-a").map(|agent| &agent.status), - Some(&AgentStatus::Cancelled), - ); - assert_eq!( - manager - .agents - .get("legacy-agent") - .map(|agent| &agent.status), - Some(&AgentStatus::Cancelled), - ); - assert_eq!( - manager.agents.get("agent-b").map(|agent| &agent.status), - Some(&AgentStatus::Running), - ); - } - - #[tokio::test] - async fn cancel_agent_reaps_stale_active_record_without_handle() { - let mut manager = AgentManager::new(); - let session_id = Uuid::new_v4(); - manager.agents.insert( - "stale-agent".to_string(), - test_agent("stale-agent", session_id, "batch-stale", AgentStatus::Running), - ); - - assert!(manager.has_active_agents()); - assert!(manager.cancel_agent_for_session("stale-agent", session_id).await); - assert!(!manager.has_active_agents()); - assert_eq!( - manager - .agents - .get("stale-agent") - .map(|agent| &agent.status), - Some(&AgentStatus::Cancelled), - ); - assert!( - manager - .agents - .get("stale-agent") - .and_then(|agent| agent.completed_at) - .is_some(), - "stale close should mark a completion time", - ); - } - - #[tokio::test] - async fn agent_status_updates_prune_closed_session_senders() { - let mut manager = AgentManager::new(); - let session_closed = Uuid::new_v4(); - let session_open = Uuid::new_v4(); - let (tx_closed, rx_closed) = tokio::sync::mpsc::unbounded_channel(); - let (tx_open, mut rx_open) = tokio::sync::mpsc::unbounded_channel(); - drop(rx_closed); - - manager.set_event_sender(session_closed, tx_closed); - manager.set_event_sender(session_open, tx_open); - manager.send_agent_status_update().await; - - assert!(rx_open.try_recv().is_ok(), "open session should receive updates"); - assert_eq!(manager.event_senders.len(), 1); - } - - #[tokio::test] - async fn registering_second_session_keeps_existing_archived_agents() { - let mut manager = AgentManager::new(); - let session_a = Uuid::new_v4(); - let (tx_a, _rx_a) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(session_a, tx_a); - - let now = chrono::Utc::now(); - let archived_id = "archived-agent".to_string(); - manager.archived_terminal_agents.insert( - archived_id.clone(), - Agent { - id: archived_id.clone(), - owner_session_id: Some(session_a), - batch_id: Some("batch-archived".to_string()), - model: "code-gpt-5.5".to_string(), - name: Some("Archived".to_string()), - prompt: String::new(), - context: None, - output_goal: None, - files: Vec::new(), - context_files: Vec::new(), - context_budget_tokens: None, - read_only: true, - status: AgentStatus::Completed, - result: Some("ok".to_string()), - error: None, - token_count: None, - retry: AgentRetryMetadata { - original_model: "code-gpt-5.5".to_string(), - final_model: "code-gpt-5.5".to_string(), - ..AgentRetryMetadata::default() - }, - created_at: now, - started_at: Some(now), - completed_at: Some(now), - progress: Vec::new(), - worktree_path: None, - branch_name: None, - worktree_base: None, - workspace_root: None, - source_kind: None, - log_tag: None, - config: None, - reasoning_effort: ReasoningEffort::Low, - last_activity: now, - }, - ); - - let (tx_b, _rx_b) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(Uuid::new_v4(), tx_b); - - assert!(manager.archived_terminal_agents.contains_key(&archived_id)); - } - - #[tokio::test] - async fn reconnect_after_sender_gap_keeps_existing_archived_agents() { - let mut manager = AgentManager::new(); - let session_id = Uuid::new_v4(); - let (tx_a, rx_a) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(session_id, tx_a); - drop(rx_a); - - let archived_id = "archived-after-gap".to_string(); - manager.archived_terminal_agents.insert( - archived_id.clone(), - test_agent( - &archived_id, - session_id, - "batch-archived-gap", - AgentStatus::Completed, - ), - ); - - let (tx_b, _rx_b) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(session_id, tx_b); - - assert!(manager.archived_terminal_agents.contains_key(&archived_id)); - } - - #[tokio::test] - async fn fresh_sender_set_drops_archives_from_other_sessions() { - let mut manager = AgentManager::new(); - let old_session = Uuid::new_v4(); - let new_session = Uuid::new_v4(); - let (tx_old, rx_old) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(old_session, tx_old); - drop(rx_old); - - manager.archived_terminal_agents.insert( - "old-archived".to_string(), - test_agent( - "old-archived", - old_session, - "batch-old-archived", - AgentStatus::Completed, - ), - ); - manager.archived_terminal_agents.insert( - "new-archived".to_string(), - test_agent( - "new-archived", - new_session, - "batch-new-archived", - AgentStatus::Completed, - ), - ); - - let (tx_new, _rx_new) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(new_session, tx_new); - - assert!(manager.archived_terminal_agents.contains_key("new-archived")); - assert!(!manager.archived_terminal_agents.contains_key("old-archived")); - assert_eq!(manager.diagnostics.archived_terminal_agents, 1); - } - - #[tokio::test] - async fn registering_sender_prunes_archives_from_disconnected_sessions() { - let mut manager = AgentManager::new(); - let connected_session = Uuid::new_v4(); - let disconnected_session = Uuid::new_v4(); - let new_session = Uuid::new_v4(); - let (tx_connected, _rx_connected) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(connected_session, tx_connected); - - manager.archived_terminal_agents.insert( - "connected-archived".to_string(), - test_agent( - "connected-archived", - connected_session, - "batch-connected-archived", - AgentStatus::Completed, - ), - ); - manager.archived_terminal_agents.insert( - "disconnected-archived".to_string(), - test_agent( - "disconnected-archived", - disconnected_session, - "batch-disconnected-archived", - AgentStatus::Completed, - ), - ); - let mut legacy_archived = test_agent( - "legacy-archived", - disconnected_session, - "batch-legacy-archived", - AgentStatus::Completed, - ); - legacy_archived.owner_session_id = None; - manager - .archived_terminal_agents - .insert("legacy-archived".to_string(), legacy_archived); - - let (tx_new, _rx_new) = tokio::sync::mpsc::unbounded_channel(); - manager.set_event_sender(new_session, tx_new); - - assert!(manager - .archived_terminal_agents - .contains_key("connected-archived")); - assert!(manager - .archived_terminal_agents - .contains_key("legacy-archived")); - assert!(!manager - .archived_terminal_agents - .contains_key("disconnected-archived")); - assert_eq!(manager.diagnostics.archived_terminal_agents, 2); - } - - #[tokio::test] - async fn read_only_agents_use_code_binary_path() { - let _lock = env_lock().lock().expect("env lock"); - let _reset_path = EnvReset::capture("PATH"); - let _reset_binary = EnvReset::capture("CODE_BINARY_PATH"); - - let dir = tempdir().expect("tempdir"); - let current = script_path(dir.path(), "current"); - let shim = script_path(dir.path(), "coder"); - - write_script(¤t, "current"); - write_script(&shim, "path"); - - unsafe { - std::env::set_var("CODE_BINARY_PATH", ¤t); - std::env::set_var("PATH", prepend_path(dir.path())); - } - - let output = execute_model_with_permissions( - "agent-test", - "code-gpt-5.5", - "ok", - true, - None, - None, - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute read-only agent"); - - assert_eq!(output.trim(), "current"); - } - - #[cfg(unix)] - #[tokio::test] - async fn read_only_agents_redirect_stdin_away_from_parent_pipe() { - let _env_lock = env_lock().lock().expect("env lock"); - let _stdin_lock = STDIN_LOCK.lock().expect("stdin lock"); - let _reset_binary = EnvReset::capture("CODE_BINARY_PATH"); - - let dir = tempdir().expect("tempdir"); - let current = script_path(dir.path(), "current"); - write_stdin_mode_script(¤t); - - let _stdin_guard = StdinRedirectGuard::install_pipe_as_stdin(); - - unsafe { - std::env::set_var("CODE_BINARY_PATH", ¤t); - } - - let output = execute_model_with_permissions( - "agent-test", - "code-gpt-5.5", - "ok", - true, - None, - None, - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute read-only agent"); - - assert_eq!(output.trim(), "detached"); - } - - #[cfg(unix)] - #[tokio::test] - async fn large_code_agent_prompt_is_delivered_over_stdin() { - let _env_lock = env_lock().lock().expect("env lock"); - let _reset_binary = EnvReset::capture("CODE_BINARY_PATH"); - - let dir = tempdir().expect("tempdir"); - let current = script_path(dir.path(), "current"); - write_large_prompt_probe_script(¤t); - - unsafe { - std::env::set_var("CODE_BINARY_PATH", ¤t); - } - - let prompt = "x".repeat(super::AGENT_PROMPT_STDIN_THRESHOLD_BYTES + 1); - let output = execute_model_with_permissions( - "agent-test", - "code-gpt-5.4", - &prompt, - true, - None, - None, - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute read-only agent"); - - assert_eq!( - output.trim(), - format!("prompt_arg=- stdin_len={}", prompt.len()) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn large_code_agent_prompt_streams_output_while_writing_stdin() { - let _env_lock = env_lock().lock().expect("env lock"); - let _reset_binary = EnvReset::capture("CODE_BINARY_PATH"); - - let dir = tempdir().expect("tempdir"); - let current = script_path(dir.path(), "current"); - write_stdout_before_stdin_probe_script(¤t); - - unsafe { - std::env::set_var("CODE_BINARY_PATH", ¤t); - } - - let prompt = "x".repeat(super::AGENT_PROMPT_STDIN_THRESHOLD_BYTES + 1); - let output = tokio::time::timeout( - StdDuration::from_secs(5), - execute_model_with_permissions( - "agent-test", - "code-gpt-5.4", - &prompt, - true, - None, - None, - ReasoningEffort::Low, - None, - None, - None, - ), - ) - .await - .expect("large prompt execution should not deadlock") - .expect("execute read-only agent"); - - assert!(output.contains(&format!("stdin_len={}", prompt.len()))); - } - - #[tokio::test] - async fn large_external_agent_prompt_fails_before_spawn() { - let dir = tempdir().expect("tempdir"); - let copilot = script_path(dir.path(), "copilot"); - write_argv_script(&copilot); - - let cfg = AgentConfig { - name: "github-copilot".to_string(), - command: copilot.display().to_string(), - args: Vec::new(), - read_only: true, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - let prompt = "x".repeat(super::AGENT_PROMPT_STDIN_THRESHOLD_BYTES + 1); - - let err = execute_model_with_permissions( - "agent-test", - "github-copilot", - &prompt, - true, - None, - Some(cfg), - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect_err("large external prompt should fail"); - - assert!(err.contains("argv delivery threshold")); - } - - #[cfg(unix)] - #[tokio::test] - async fn large_custom_code_command_prompt_uses_stdin() { - let dir = tempdir().expect("tempdir"); - let custom_code = script_path(dir.path(), "custom-code"); - write_large_prompt_probe_script(&custom_code); - - let cfg = AgentConfig { - name: "code-gpt-5.4".to_string(), - command: custom_code.display().to_string(), - args: Vec::new(), - read_only: true, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - let prompt = "x".repeat(super::AGENT_PROMPT_STDIN_THRESHOLD_BYTES + 1); - - let output = execute_model_with_permissions( - "agent-test", - "code-gpt-5.4", - &prompt, - true, - None, - Some(cfg), - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("large custom code prompt should use stdin"); - - assert_eq!( - output.trim(), - format!("prompt_arg=- stdin_len={}", prompt.len()) - ); - } - - #[cfg(not(target_os = "windows"))] - #[tokio::test] - async fn claude_agent_uses_local_install_when_not_on_path() { - let _lock = env_lock().lock().expect("env lock"); - let _reset_path = EnvReset::capture("PATH"); - let _reset_home = EnvReset::capture("HOME"); - - let dir = tempdir().expect("tempdir"); - let claude_dir = dir.path().join(".claude").join("local"); - std::fs::create_dir_all(&claude_dir).expect("create claude dir"); - let claude_script = claude_dir.join("claude"); - write_script(&claude_script, "local-claude"); - - unsafe { - std::env::set_var("HOME", dir.path()); - std::env::set_var("PATH", "/usr/bin:/bin"); - } - - let cfg = AgentConfig { - name: "claude-sonnet-4.6".to_string(), - command: "claude".to_string(), - args: Vec::new(), - read_only: true, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - - let output = execute_model_with_permissions( - "agent-test", - "claude-sonnet-4.6", - "ok", - true, - None, - Some(cfg), - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute claude with local fallback"); - - assert_eq!(output.trim(), "local-claude"); - } - - #[tokio::test] - async fn github_copilot_launches_with_agent_mode_flags() { - let dir = tempdir().expect("tempdir"); - let copilot = script_path(dir.path(), "copilot"); - write_argv_script(&copilot); - - let cfg = AgentConfig { - name: "github-copilot".to_string(), - command: copilot.display().to_string(), - args: Vec::new(), - read_only: true, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - - let output = execute_model_with_permissions( - "agent-test", - "github-copilot", - "hello from copilot", - true, - None, - Some(cfg), - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute copilot agent"); - - let args: Vec<&str> = output.trim().split('|').collect(); - assert_eq!( - args, - vec![ - "--autopilot", - "--allow-all-tools", - "--no-ask-user", - "-s", - "--reasoning-effort", - "low", - "-p", - "hello from copilot", - ] - ); - } - - #[tokio::test] - async fn github_copilot_write_mode_uses_yolo() { - let dir = tempdir().expect("tempdir"); - let copilot = script_path(dir.path(), "copilot"); - write_argv_script(&copilot); - - let cfg = AgentConfig { - name: "github-copilot".to_string(), - command: copilot.display().to_string(), - args: Vec::new(), - read_only: false, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - - let output = execute_model_with_permissions( - "agent-test", - "github-copilot", - "hello from copilot", - false, - None, - Some(cfg), - ReasoningEffort::High, - None, - None, - None, - ) - .await - .expect("execute copilot write agent"); - - let args: Vec<&str> = output.trim().split('|').collect(); - assert_eq!( - args, - vec![ - "--autopilot", - "--yolo", - "--no-ask-user", - "-s", - "--reasoning-effort", - "high", - "-p", - "hello from copilot", - ] - ); - } - - #[tokio::test] - async fn antigravity_receives_workspace_add_dir() { - let dir = tempdir().expect("tempdir"); - let agy = script_path(dir.path(), "agy"); - write_argv_script(&agy); - let workspace = dir.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("create workspace"); - - let cfg = AgentConfig { - name: "antigravity".to_string(), - command: agy.display().to_string(), - args: Vec::new(), - read_only: true, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - - let output = execute_model_with_permissions( - "agent-test", - "antigravity", - "hello from agy", - true, - Some(workspace.clone()), - Some(cfg), - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute antigravity agent"); - - let args: Vec<&str> = output.trim().split('|').collect(); - assert_eq!( - args, - vec![ - "--add-dir", - workspace.to_str().expect("workspace path utf-8"), - "-p", - "hello from agy", - ] - ); - } - - #[tokio::test] - async fn antigravity_runs_from_private_launch_dir() { - let _lock = env_lock().lock().expect("env lock"); - let _reset_code_home = EnvReset::capture("CODE_HOME"); - let _reset_home = EnvReset::capture("HOME"); - - let dir = tempdir().expect("tempdir"); - let code_home = dir.path().join("code-home"); - let home = dir.path().join("home"); - std::fs::create_dir_all(&code_home).expect("create code home"); - std::fs::create_dir_all(&home).expect("create home"); - - unsafe { - std::env::set_var("CODE_HOME", &code_home); - std::env::set_var("HOME", &home); - } - - let agy = script_path(dir.path(), "agy"); - write_cwd_and_argv_script(&agy); - let workspace = dir.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("create workspace"); - - let cfg = AgentConfig { - name: "antigravity".to_string(), - command: agy.display().to_string(), - args: Vec::new(), - read_only: true, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - - let output = execute_model_with_permissions( - "agent-test", - "antigravity", - "hello from agy", - true, - Some(workspace.clone()), - Some(cfg), - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute antigravity agent"); - - let expected_cwd = code_home - .join("agent-cache") - .join("antigravity") - .canonicalize() - .expect("canonical launch dir"); - let expected_cwd_line = format!("cwd={}", expected_cwd.display()); - let expected_args_line = format!( - "args=--add-dir|{}|-p|hello from agy", - workspace.to_str().expect("workspace path utf-8") - ); - let mut lines = output.lines(); - assert_eq!(lines.next(), Some(expected_cwd_line.as_str())); - assert_eq!(lines.next(), Some(expected_args_line.as_str())); - } - - #[tokio::test] - async fn gemini_selector_uses_antigravity_cli() { - let _lock = env_lock().lock().expect("env lock"); - let _reset_path = EnvReset::capture("PATH"); - - let dir = tempdir().expect("tempdir"); - let agy = script_path(dir.path(), "agy"); - write_argv_script(&agy); - let workspace = dir.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("create workspace"); - - unsafe { - std::env::set_var("PATH", prepend_path(dir.path())); - } - - let output = execute_model_with_permissions( - "agent-test", - "gemini", - "hello from google lane", - true, - Some(workspace.clone()), - None, - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute gemini selector through antigravity"); - - let args: Vec<&str> = output.trim().split('|').collect(); - assert_eq!( - args, - vec![ - "--add-dir", - workspace.to_str().expect("workspace path utf-8"), - "-p", - "hello from google lane", - ] - ); - } - - #[tokio::test] - async fn explicit_gemini_command_keeps_gemini_cli_args() { - let _lock = env_lock().lock().expect("env lock"); - let _reset_path = EnvReset::capture("PATH"); - - let dir = tempdir().expect("tempdir"); - let gemini = script_path(dir.path(), "gemini"); - write_argv_script(&gemini); - let workspace = dir.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("create workspace"); - - unsafe { - std::env::set_var("PATH", prepend_path(dir.path())); - } - - let cfg = AgentConfig { - name: "corp-gemini".to_string(), - command: "gemini".to_string(), - args: Vec::new(), - read_only: true, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - }; - - let output = execute_model_with_permissions( - "agent-test", - "corp-gemini", - "hello from gemini cli", - true, - Some(workspace), - Some(cfg), - ReasoningEffort::Low, - None, - None, - None, - ) - .await - .expect("execute explicit gemini CLI config"); - - let args: Vec<&str> = output.trim().split('|').collect(); - assert_eq!(args, vec!["-p", "hello from gemini cli"]); - } - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - struct EnvReset { - key: &'static str, - value: Option, - } - - impl EnvReset { - fn capture(key: &'static str) -> Self { - let value = std::env::var_os(key); - Self { key, value } - } - } - - impl Drop for EnvReset { - fn drop(&mut self) { - unsafe { - match &self.value { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - - fn prepend_path(dir: &Path) -> OsString { - let original = std::env::var_os("PATH"); - let mut parts: Vec = Vec::new(); - parts.push(dir.as_os_str().to_os_string()); - if let Some(orig) = original { - parts.extend(std::env::split_paths(&orig).map(|p| p.into_os_string())); - } - std::env::join_paths(parts).expect("join PATH") - } - - #[cfg(target_os = "windows")] - fn script_path(dir: &Path, name: &str) -> PathBuf { - dir.join(format!("{name}.cmd")) - } - - #[cfg(not(target_os = "windows"))] - fn script_path(dir: &Path, name: &str) -> PathBuf { - dir.join(name) - } - - #[cfg(target_os = "windows")] - fn write_script(path: &Path, marker: &str) { - let script = format!("@echo off\r\necho {marker}\r\nexit /b 0\r\n"); - std::fs::write(path, script).expect("write cmd"); - } - - #[cfg(not(target_os = "windows"))] - fn write_script(path: &Path, marker: &str) { - let script = format!("#!/bin/sh\necho {marker}\nexit 0\n"); - std::fs::write(path, script).expect("write script"); - let mut perms = std::fs::metadata(path) - .expect("script metadata") - .permissions(); - use std::os::unix::fs::PermissionsExt; - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).expect("chmod script"); - } - - #[cfg(target_os = "windows")] - fn write_argv_script(path: &Path) { - let script = "@echo off\r\nsetlocal enabledelayedexpansion\r\nset out=\r\n:loop\r\nif \"%~1\"==\"\" goto done\r\nif defined out (set out=!out!|%~1) else (set out=%~1)\r\nshift\r\ngoto loop\r\n:done\r\necho %out%\r\nexit /b 0\r\n"; - std::fs::write(path, script).expect("write argv cmd"); - } - - #[cfg(not(target_os = "windows"))] - fn write_argv_script(path: &Path) { - let script = "#!/bin/sh\nprintf '%s' \"$1\"\nshift\nfor arg in \"$@\"; do\n printf '|%s' \"$arg\"\ndone\nprintf '\\n'\nexit 0\n"; - std::fs::write(path, script).expect("write argv script"); - let mut perms = std::fs::metadata(path) - .expect("script metadata") - .permissions(); - use std::os::unix::fs::PermissionsExt; - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).expect("chmod script"); - } - - #[cfg(target_os = "windows")] - fn write_cwd_and_argv_script(path: &Path) { - let script = "@echo off\r\nsetlocal enabledelayedexpansion\r\necho cwd=%CD%\r\nset out=\r\n:loop\r\nif \"%~1\"==\"\" goto done\r\nif defined out (set out=!out!|%~1) else (set out=%~1)\r\nshift\r\ngoto loop\r\n:done\r\necho args=%out%\r\nexit /b 0\r\n"; - std::fs::write(path, script).expect("write cwd argv cmd"); - } - - #[cfg(not(target_os = "windows"))] - fn write_cwd_and_argv_script(path: &Path) { - let script = "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf 'args=%s' \"$1\"\nshift\nfor arg in \"$@\"; do\n printf '|%s' \"$arg\"\ndone\nprintf '\\n'\nexit 0\n"; - std::fs::write(path, script).expect("write cwd argv script"); - let mut perms = std::fs::metadata(path) - .expect("script metadata") - .permissions(); - use std::os::unix::fs::PermissionsExt; - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).expect("chmod script"); - } - - #[cfg(unix)] - fn write_stdin_mode_script(path: &Path) { - let script = r#"#!/bin/sh -python3 -c 'import os, stat; print("fifo" if stat.S_ISFIFO(os.fstat(0).st_mode) else "detached")' -exit 0 -"#; - std::fs::write(path, script).expect("write stdin mode script"); - let mut perms = std::fs::metadata(path) - .expect("script metadata") - .permissions(); - use std::os::unix::fs::PermissionsExt; - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).expect("chmod script"); - } - - #[cfg(unix)] - fn write_large_prompt_probe_script(path: &Path) { - let script = r#"#!/bin/sh -prompt_arg="" -for arg in "$@"; do - prompt_arg="$arg" -done -stdin_len=$(wc -c | awk '{print $1}') -printf 'prompt_arg=%s stdin_len=%s\n' "$prompt_arg" "$stdin_len" -exit 0 -"#; - std::fs::write(path, script).expect("write prompt probe script"); - let mut perms = std::fs::metadata(path) - .expect("script metadata") - .permissions(); - use std::os::unix::fs::PermissionsExt; - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).expect("chmod script"); - } - - #[cfg(unix)] - fn write_stdout_before_stdin_probe_script(path: &Path) { - let script = r#"#!/bin/sh -python3 -c 'print("o" * 200000)' -stdin_len=$(wc -c | awk '{print $1}') -printf 'stdin_len=%s\n' "$stdin_len" -exit 0 -"#; - std::fs::write(path, script).expect("write output probe script"); - let mut perms = std::fs::metadata(path) - .expect("script metadata") - .permissions(); - use std::os::unix::fs::PermissionsExt; - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).expect("chmod script"); - } - - #[test] - fn gemini_config_dir_is_injected_when_missing_api_key() { - let tmp = tempfile::tempdir().expect("tempdir"); - let gem_dir = tmp.path().join(".gemini"); - std::fs::create_dir_all(&gem_dir).expect("create .gemini"); - - let mut env: HashMap = HashMap::new(); - maybe_set_gemini_config_dir(&mut env, Some(tmp.path().to_string_lossy().to_string())); - - assert_eq!( - env.get("GEMINI_CONFIG_DIR"), - Some(&gem_dir.to_string_lossy().to_string()) - ); - } - - #[test] - fn gemini_config_dir_not_overwritten_when_api_key_present() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut env: HashMap = HashMap::new(); - env.insert("GEMINI_API_KEY".to_string(), "abc".to_string()); - - maybe_set_gemini_config_dir(&mut env, Some(tmp.path().to_string_lossy().to_string())); - - assert!(!env.contains_key("GEMINI_CONFIG_DIR")); - } - - #[test] - fn prune_terminal_agents_caps_completed_state() { - let mut manager = AgentManager::new(); - let now = chrono::Utc::now(); - let total = MAX_TRACKED_TERMINAL_AGENTS + 64; - - for idx in 0..total { - let id = format!("agent-{idx}"); - manager.agents.insert( - id.clone(), - Agent { - id, - owner_session_id: None, - batch_id: Some("batch-1".to_string()), - model: "code-gpt-5.5".to_string(), - name: Some("Prune Test".to_string()), - prompt: "prompt".repeat(256), - context: Some("ctx".repeat(256)), - output_goal: Some("goal".repeat(256)), - files: vec!["a".repeat(256)], - context_files: vec!["context".repeat(128)], - context_budget_tokens: Some(10_000), - read_only: false, - status: AgentStatus::Completed, - result: Some("result".repeat(1024)), - error: None, - token_count: None, - retry: AgentRetryMetadata { - original_model: "code-gpt-5.5".to_string(), - final_model: "code-gpt-5.5".to_string(), - ..AgentRetryMetadata::default() - }, - created_at: now, - started_at: Some(now), - completed_at: Some(now + chrono::Duration::seconds(idx as i64)), - progress: vec!["progress".repeat(1024)], - worktree_path: Some("/tmp/wt".to_string()), - branch_name: Some("code-branch".to_string()), - worktree_base: None, - workspace_root: None, - source_kind: None, - log_tag: None, - config: None, - reasoning_effort: ReasoningEffort::Low, - last_activity: now, - }, - ); - } - - manager.prune_terminal_agents(); - - assert!(manager.agents.len() <= MAX_TRACKED_TERMINAL_AGENTS); - assert!( - manager - .agents - .values() - .all(|agent| !agent.worktree_path.as_deref().unwrap_or_default().is_empty()), - "worktree metadata should be retained for remaining terminal agents" - ); - } - - #[test] - fn finalize_terminal_agent_compacts_heavy_fields_and_keeps_worktree() { - let mut manager = AgentManager::new(); - let now = chrono::Utc::now(); - let agent_id = "agent-finalize".to_string(); - - manager.agents.insert( - agent_id.clone(), - Agent { - id: agent_id.clone(), - owner_session_id: None, - batch_id: Some("batch-compact".to_string()), - model: "code-gpt-5.5".to_string(), - name: Some("Finalize".to_string()), - prompt: "prompt".repeat(1024), - context: Some("context".repeat(1024)), - output_goal: Some("goal".repeat(1024)), - files: vec!["file".repeat(1024)], - context_files: vec!["context-file".repeat(1024)], - context_budget_tokens: Some(10_000), - read_only: false, - status: AgentStatus::Completed, - result: Some("result".repeat(32 * 1024)), - error: None, - token_count: None, - retry: AgentRetryMetadata { - original_model: "code-gpt-5.5".to_string(), - final_model: "code-gpt-5.5".to_string(), - ..AgentRetryMetadata::default() - }, - created_at: now, - started_at: Some(now), - completed_at: Some(now), - progress: (0..(MAX_AGENT_PROGRESS_ENTRIES + 20)) - .map(|idx| format!("progress-entry-{idx}-{}", "x".repeat(512))) - .collect(), - worktree_path: Some("/tmp/wt-stays".to_string()), - branch_name: Some("branch-stays".to_string()), - worktree_base: None, - workspace_root: None, - source_kind: None, - log_tag: None, - config: None, - reasoning_effort: ReasoningEffort::Low, - last_activity: now, - }, - ); - - manager.finalize_terminal_agent(&agent_id); - - let agent = manager - .agents - .get(&agent_id) - .expect("agent should still be tracked"); - assert!(agent.prompt.is_empty()); - assert!(agent.context.is_none()); - assert!(agent.output_goal.is_none()); - assert!(agent.files.is_empty()); - assert!(agent.context_files.is_empty()); - assert!(agent.context_budget_tokens.is_none()); - assert_eq!(agent.worktree_path.as_deref(), Some("/tmp/wt-stays")); - assert_eq!(agent.branch_name.as_deref(), Some("branch-stays")); - assert!(agent.progress.len() <= MAX_AGENT_PROGRESS_ENTRIES); - - let result_len = agent - .result - .as_ref() - .map(String::len) - .expect("result retained"); - assert!( - result_len <= MAX_AGENT_RESULT_BYTES + 4, - "result should be compacted to bounded size" - ); - } - - #[test] - fn pruned_terminal_agent_remains_queryable_for_session() { - let mut manager = AgentManager::new(); - let now = chrono::Utc::now(); - let batch_id = "batch-session".to_string(); - let total = MAX_TRACKED_TERMINAL_AGENTS + 8; - - for idx in 0..total { - let id = format!("agent-{idx}"); - manager.agents.insert( - id.clone(), - Agent { - id, - owner_session_id: None, - batch_id: Some(batch_id.clone()), - model: "code-gpt-5.5".to_string(), - name: Some("Archive Test".to_string()), - prompt: "prompt".to_string(), - context: None, - output_goal: None, - files: Vec::new(), - context_files: Vec::new(), - context_budget_tokens: None, - read_only: false, - status: AgentStatus::Completed, - result: Some("ok".to_string()), - error: None, - token_count: None, - retry: AgentRetryMetadata { - original_model: "code-gpt-5.5".to_string(), - final_model: "code-gpt-5.5".to_string(), - ..AgentRetryMetadata::default() - }, - created_at: now, - started_at: Some(now), - completed_at: Some(now + chrono::Duration::seconds(idx as i64)), - progress: vec!["progress".to_string()], - worktree_path: Some(format!("/tmp/worktree-{idx}")), - branch_name: Some(format!("code-branch-{idx}")), - worktree_base: None, - workspace_root: None, - source_kind: None, - log_tag: None, - config: None, - reasoning_effort: ReasoningEffort::Low, - last_activity: now, - }, - ); - } - - manager.prune_terminal_agents(); - - let archived_id = "agent-0"; - assert!(manager.agents.get(archived_id).is_none()); - let archived = manager - .get_agent(archived_id) - .expect("pruned agent should remain queryable"); - assert_eq!(archived.worktree_path.as_deref(), Some("/tmp/worktree-0")); - assert_eq!(archived.branch_name.as_deref(), Some("code-branch-0")); - - let listed = manager.list_agents(None, Some(batch_id), false); - assert!(listed.iter().any(|agent| { - agent.id == archived_id - && agent.worktree_path.as_deref() == Some("/tmp/worktree-0") - && agent.branch_name.as_deref() == Some("code-branch-0") - })); - } - - #[test] - fn classifies_retryable_agent_provider_failures() { - for error in [ - "API Error: Overloaded", - "HTTP 429: too many requests", - "request timed out while waiting for provider", - "upstream service unavailable", - "stream disconnected: connection reset", - "broken pipe while reading response", - ] { - assert_eq!( - classify_agent_provider_failure(error), - AgentProviderFailureClass::Retryable, - "expected retryable: {error}" - ); - } - } - - #[test] - fn classifies_non_retryable_agent_provider_failures() { - for error in [ - "unauthorized: invalid token", - "permission denied by provider", - "Agent 'claude' could not be found.", - "invalid config: missing command", - "run cancelled by user", - "policy violation", - ] { - assert_eq!( - classify_agent_provider_failure(error), - AgentProviderFailureClass::NonRetryable, - "expected non-retryable: {error}" - ); - } - } - - #[test] - fn retry_delay_is_bounded_exponential_backoff() { - assert_eq!(agent_retry_delay(0), StdDuration::from_secs(2)); - assert_eq!(agent_retry_delay(1), StdDuration::from_secs(4)); - assert_eq!(agent_retry_delay(2), StdDuration::from_secs(8)); - assert_eq!(agent_retry_delay(8), AGENT_PROVIDER_RETRY_MAX_DELAY); - } - - #[test] - fn agent_tool_models_description_guides_google_family_delegation() { - let tool = create_agent_tool(&[ - "code-gpt-5.5".to_string(), - "claude-sonnet-4.6".to_string(), - "antigravity".to_string(), - ]); - - let function = match tool { - OpenAiTool::Function(function) => function, - _ => panic!("agent tool should be a function"), - }; - let JsonSchema::Object { properties, .. } = function.parameters else { - panic!("agent tool should have object parameters"); - }; - let create_schema = properties.get("create").expect("create schema"); - let JsonSchema::Object { properties: create_properties, .. } = create_schema else { - panic!("create schema should be an object"); - }; - let models_schema = create_properties.get("models").expect("models schema"); - let JsonSchema::Array { items, description } = models_schema else { - panic!("models schema should be an array"); - }; - let description = description.as_deref().expect("models description"); - assert!(description.contains("diverse families")); - assert!(description.contains("proactively use `antigravity` for Google/Gemini-family perspective")); - assert!(description.contains("release/workflow, infrastructure, security, or product-risk")); - assert!(description.contains("unless there is a clear reason to skip it")); - assert!(description.contains("AGY uses its configured model")); - - let JsonSchema::String { allowed_values, .. } = items.as_ref() else { - panic!("models items should be strings"); - }; - let values = allowed_values.as_ref().expect("allowed models"); - assert!(values.contains(&"antigravity".to_string())); - } - - #[tokio::test] - #[serial] - async fn agent_provider_retry_wrapper_recovers_from_transient_failure() { - let agent_id = "retry-success".to_string(); - let owner_session_id = Uuid::new_v4(); - let mut manager = AgentManager::new(); - manager.agents.insert( - agent_id.clone(), - test_agent( - &agent_id, - owner_session_id, - "batch-retry", - AgentStatus::Running, - ), - ); - - let old_manager = { - let mut global = AGENT_MANAGER.write().await; - std::mem::replace(&mut *global, manager) - }; - - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let attempts_for_run = attempts.clone(); - let result = execute_agent_provider_with_retries(&agent_id, "code-gpt-5.5", || { - let attempts_for_run = attempts_for_run.clone(); - async move { - let attempt = attempts_for_run.fetch_add(1, Ordering::SeqCst); - if attempt == 0 { - Err("API Error: Overloaded".to_string()) - } else { - Ok(AgentExecutionOutput::new("ok".to_string(), Some(123))) - } - } - }) - .await; - - let output = result.expect("retry should eventually succeed"); - assert_eq!(output.output, "ok"); - assert_eq!(output.token_count, Some(123)); - assert_eq!(attempts.load(Ordering::SeqCst), 2); - - let manager = { - let mut global = AGENT_MANAGER.write().await; - std::mem::replace(&mut *global, old_manager) - }; - let agent = manager - .agents - .get(&agent_id) - .expect("agent should remain tracked"); - assert_eq!(agent.retry.retry_count, 1); - assert_eq!( - agent.retry.last_retryable_error.as_deref(), - Some("API Error: Overloaded") - ); - assert!( - agent - .progress - .iter() - .any(|line| line.contains("Retrying code-gpt-5.5")) - ); - } - - #[tokio::test] - async fn agent_provider_retry_wrapper_does_not_retry_non_retryable_failure() { - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let attempts_for_run = attempts.clone(); - let result = execute_agent_provider_with_retries("missing-agent", "claude", || { - let attempts_for_run = attempts_for_run.clone(); - async move { - attempts_for_run.fetch_add(1, Ordering::SeqCst); - Err("Agent 'claude' could not be found.".to_string()) - } - }) - .await; - - assert_eq!( - result.as_ref().map(|output| output.output.as_str()).map_err(String::as_str), - Err("Agent 'claude' could not be found.") - ); - assert_eq!(attempts.load(Ordering::SeqCst), 1); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckAgentStatusParams { - pub agent_id: String, - pub batch_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GetAgentResultParams { - pub agent_id: String, - pub batch_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CancelAgentParams { - pub agent_id: Option, - pub batch_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WaitForAgentParams { - pub agent_id: Option, - pub batch_id: Option, - pub timeout_seconds: Option, - pub return_all: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ListAgentsParams { - pub status_filter: Option, - pub batch_id: Option, - pub recent_only: Option, -} diff --git a/code-rs/core/src/agents_md.rs b/code-rs/core/src/agents_md.rs new file mode 100644 index 00000000000..7a9fd749329 --- /dev/null +++ b/code-rs/core/src/agents_md.rs @@ -0,0 +1,325 @@ +//! AGENTS.md discovery and user instruction assembly. +//! +//! Project-level documentation is primarily stored in files named `AGENTS.md`. +//! Additional fallback filenames can be configured via `project_doc_fallback_filenames`. +//! We include the concatenation of all files found along the path from the +//! project root to the current working directory as follows: +//! +//! 1. Determine the project root by walking upwards from the current working +//! directory until a configured `project_root_markers` entry is found. +//! When `project_root_markers` is unset, the default marker list is used +//! (`.git`). If no marker is found, only the current working directory is +//! considered. An empty marker list disables parent traversal. +//! 2. Collect every `AGENTS.md` found from the project root down to the +//! current working directory (inclusive) and concatenate their contents in +//! that order. +//! 3. We do **not** walk past the project root. + +use crate::config::Config; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStackOrdering; +use codex_config::default_project_root_markers; +use codex_config::merge_toml_values; +use codex_config::project_root_markers_from_config; +use codex_exec_server::Environment; +use codex_exec_server::ExecutorFileSystem; +use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; +use dunce::canonicalize as normalize_path; +use std::io; +use toml::Value as TomlValue; +use tracing::error; + +pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str = + include_str!("../hierarchical_agents_message.md"); + +/// Default filename scanned for AGENTS.md instructions. +pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; +/// Preferred local override for AGENTS.md instructions. +pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; + +/// When both `Config::instructions` and AGENTS.md docs are present, they will +/// be concatenated with the following separator. +const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; + +/// Resolves AGENTS.md files into model-visible user instructions and source +/// paths. +pub struct AgentsMdManager<'a> { + config: &'a Config, +} + +pub(crate) struct LoadedAgentsMd { + pub(crate) contents: String, + pub(crate) path: AbsolutePathBuf, +} + +impl<'a> AgentsMdManager<'a> { + pub fn new(config: &'a Config) -> Self { + Self { config } + } + + pub(crate) fn load_global_instructions( + codex_dir: Option<&AbsolutePathBuf>, + ) -> Option { + let base = codex_dir?; + for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { + let path = base.join(candidate); + if let Ok(contents) = std::fs::read_to_string(&path) { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Some(LoadedAgentsMd { + contents: trimmed.to_string(), + path, + }); + } + } + } + None + } + + /// Combines configured user instructions and AGENTS.md content into a + /// single model-visible instruction string. + pub(crate) async fn user_instructions( + &self, + environment: Option<&Environment>, + ) -> Option { + let fs = environment?.get_filesystem(); + self.user_instructions_with_fs(fs.as_ref()).await + } + + pub(crate) async fn user_instructions_with_fs( + &self, + fs: &dyn ExecutorFileSystem, + ) -> Option { + let agents_md_docs = self.read_agents_md(fs).await; + + let mut output = String::new(); + + if let Some(instructions) = self.config.user_instructions.clone() { + output.push_str(&instructions); + } + + match agents_md_docs { + Ok(Some(docs)) => { + if !output.is_empty() { + output.push_str(AGENTS_MD_SEPARATOR); + } + output.push_str(&docs); + } + Ok(None) => {} + Err(e) => { + error!("error trying to find AGENTS.md docs: {e:#}"); + } + }; + + if self.config.features.enabled(Feature::ChildAgentsMd) { + if !output.is_empty() { + output.push_str("\n\n"); + } + output.push_str(HIERARCHICAL_AGENTS_MESSAGE); + } + + if !output.is_empty() { + Some(output) + } else { + None + } + } + + /// Returns all instruction source files included in the current config. + pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { + let mut paths = Self::load_global_instructions(Some(&self.config.codex_home)) + .map(|loaded| vec![loaded.path]) + .unwrap_or_default(); + match self.agents_md_paths(fs).await { + Ok(agents_md_paths) => paths.extend(agents_md_paths), + Err(err) => { + tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); + } + } + paths + } + + /// Attempt to locate and load AGENTS.md documentation. + /// + /// On success returns `Ok(Some(contents))` where `contents` is the + /// concatenation of all discovered docs. If no documentation file is found + /// the function returns `Ok(None)`. Unexpected I/O failures bubble up as + /// `Err` so callers can decide how to handle them. + async fn read_agents_md(&self, fs: &dyn ExecutorFileSystem) -> io::Result> { + let max_total = self.config.project_doc_max_bytes; + + if max_total == 0 { + return Ok(None); + } + + let paths = self.agents_md_paths(fs).await?; + if paths.is_empty() { + return Ok(None); + } + + let mut remaining: u64 = max_total as u64; + let mut parts: Vec = Vec::new(); + + for p in paths { + if remaining == 0 { + break; + } + + match fs.get_metadata(&p, /*sandbox*/ None).await { + Ok(metadata) if !metadata.is_file => continue, + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + } + + let mut data = match fs.read_file(&p, /*sandbox*/ None).await { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + }; + let size = data.len() as u64; + if size > remaining { + data.truncate(remaining as usize); + } + + if size > remaining { + tracing::warn!( + "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", + p.display(), + remaining, + ); + } + + let text = String::from_utf8_lossy(&data).to_string(); + if !text.trim().is_empty() { + parts.push(text); + remaining = remaining.saturating_sub(data.len() as u64); + } + } + + if parts.is_empty() { + Ok(None) + } else { + Ok(Some(parts.join("\n\n"))) + } + } + + /// Discover the list of AGENTS.md files using the same search rules as + /// `read_agents_md`, but return the file paths instead of concatenated + /// contents. The list is ordered from project root to the current working + /// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes` + /// is zero, returns an empty list. + async fn agents_md_paths( + &self, + fs: &dyn ExecutorFileSystem, + ) -> io::Result> { + if self.config.project_doc_max_bytes == 0 { + return Ok(Vec::new()); + } + + let mut dir = self.config.cwd.clone(); + if let Ok(canon) = normalize_path(&dir) { + dir = AbsolutePathBuf::try_from(canon)?; + } + + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in self.config.config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + if matches!(layer.name, ConfigLayerSource::Project { .. }) { + continue; + } + merge_toml_values(&mut merged, &layer.config); + } + let project_root_markers = match project_root_markers_from_config(&merged) { + Ok(Some(markers)) => markers, + Ok(None) => default_project_root_markers(), + Err(err) => { + tracing::warn!("invalid project_root_markers: {err}"); + default_project_root_markers() + } + }; + let mut project_root = None; + if !project_root_markers.is_empty() { + for ancestor in dir.ancestors() { + for marker in &project_root_markers { + let marker_path = ancestor.join(marker); + let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await + { + Ok(_) => true, + Err(err) if err.kind() == io::ErrorKind::NotFound => false, + Err(err) => return Err(err), + }; + if marker_exists { + project_root = Some(ancestor.clone()); + break; + } + } + if project_root.is_some() { + break; + } + } + } + + let search_dirs: Vec = if let Some(root) = project_root { + let mut dirs = Vec::new(); + let mut cursor = dir.clone(); + loop { + dirs.push(cursor.clone()); + if cursor == root { + break; + } + let Some(parent) = cursor.parent() else { + break; + }; + cursor = parent; + } + dirs.reverse(); + dirs + } else { + vec![dir] + }; + + let mut found: Vec = Vec::new(); + let candidate_filenames = self.candidate_filenames(); + for d in search_dirs { + for name in &candidate_filenames { + let candidate = d.join(name); + match fs.get_metadata(&candidate, /*sandbox*/ None).await { + Ok(md) if md.is_file => { + found.push(candidate); + break; + } + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + } + } + } + + Ok(found) + } + + fn candidate_filenames(&self) -> Vec<&str> { + let mut names: Vec<&str> = + Vec::with_capacity(2 + self.config.project_doc_fallback_filenames.len()); + names.push(LOCAL_AGENTS_MD_FILENAME); + names.push(DEFAULT_AGENTS_MD_FILENAME); + for candidate in &self.config.project_doc_fallback_filenames { + let candidate = candidate.as_str(); + if candidate.is_empty() { + continue; + } + if !names.contains(&candidate) { + names.push(candidate); + } + } + names + } +} + +#[cfg(test)] +#[path = "agents_md_tests.rs"] +mod tests; diff --git a/code-rs/core/src/agents_md_tests.rs b/code-rs/core/src/agents_md_tests.rs new file mode 100644 index 00000000000..a3a75448236 --- /dev/null +++ b/code-rs/core/src/agents_md_tests.rs @@ -0,0 +1,507 @@ +use super::*; +use crate::config::ConfigBuilder; +use codex_exec_server::LOCAL_FS; +use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +async fn get_user_instructions(config: &Config) -> Option { + AgentsMdManager::new(config) + .user_instructions_with_fs(LOCAL_FS.as_ref()) + .await +} + +async fn agents_md_paths(config: &Config) -> std::io::Result> { + AgentsMdManager::new(config) + .agents_md_paths(LOCAL_FS.as_ref()) + .await +} + +/// Helper that returns a `Config` pointing at `root` and using `limit` as +/// the maximum number of bytes to embed from AGENTS.md. The caller can +/// optionally specify a custom `instructions` string – when `None` the +/// value is cleared to mimic a scenario where no system instructions have +/// been configured. +async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config { + let codex_home = TempDir::new().unwrap(); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("defaults for test should always succeed"); + + config.cwd = root.abs(); + config.project_doc_max_bytes = limit; + + config.user_instructions = instructions.map(ToOwned::to_owned); + config +} + +async fn make_config_with_fallback( + root: &TempDir, + limit: usize, + instructions: Option<&str>, + fallbacks: &[&str], +) -> Config { + let mut config = make_config(root, limit, instructions).await; + config.project_doc_fallback_filenames = fallbacks + .iter() + .map(std::string::ToString::to_string) + .collect(); + config +} + +async fn make_config_with_project_root_markers( + root: &TempDir, + limit: usize, + instructions: Option<&str>, + markers: &[&str], +) -> Config { + let codex_home = TempDir::new().unwrap(); + let cli_overrides = vec![( + "project_root_markers".to_string(), + TomlValue::Array( + markers + .iter() + .map(|marker| TomlValue::String((*marker).to_string())) + .collect(), + ), + )]; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(cli_overrides) + .build() + .await + .expect("defaults for test should always succeed"); + + config.cwd = root.abs(); + config.project_doc_max_bytes = limit; + config.user_instructions = instructions.map(ToOwned::to_owned); + config +} + +/// AGENTS.md missing – should yield `None`. +#[tokio::test] +async fn no_doc_file_returns_none() { + let tmp = tempfile::tempdir().expect("tempdir"); + + let res = + get_user_instructions(&make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await) + .await; + assert!( + res.is_none(), + "Expected None when AGENTS.md is absent and no system instructions provided" + ); + assert!(res.is_none(), "Expected None when AGENTS.md is absent"); +} + +#[tokio::test] +async fn no_environment_returns_none() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await; + + let res = AgentsMdManager::new(&config) + .user_instructions(/*environment*/ None) + .await; + + assert_eq!(res, None); +} + +/// Small file within the byte-limit is returned unmodified. +#[tokio::test] +async fn doc_smaller_than_limit_is_returned() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap(); + + let res = + get_user_instructions(&make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await) + .await + .expect("doc expected"); + + assert_eq!( + res, "hello world", + "The document should be returned verbatim when it is smaller than the limit and there are no existing instructions" + ); +} + +/// Oversize file is truncated to `project_doc_max_bytes`. +#[tokio::test] +async fn doc_larger_than_limit_is_truncated() { + const LIMIT: usize = 1024; + let tmp = tempfile::tempdir().expect("tempdir"); + + let huge = "A".repeat(LIMIT * 2); // 2 KiB + fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap(); + + let res = get_user_instructions(&make_config(&tmp, LIMIT, /*instructions*/ None).await) + .await + .expect("doc expected"); + + assert_eq!(res.len(), LIMIT, "doc should be truncated to LIMIT bytes"); + assert_eq!(res, huge[..LIMIT]); +} + +/// When `cwd` is nested inside a repo, the search should locate AGENTS.md +/// placed at the repository root (identified by `.git`). +#[tokio::test] +async fn finds_doc_in_repo_root() { + let repo = tempfile::tempdir().expect("tempdir"); + + // Simulate a git repository. Note .git can be a file or a directory. + std::fs::write( + repo.path().join(".git"), + "gitdir: /path/to/actual/git/dir\n", + ) + .unwrap(); + + // Put the doc at the repo root. + fs::write(repo.path().join("AGENTS.md"), "root level doc").unwrap(); + + // Now create a nested working directory: repo/workspace/crate_a + let nested = repo.path().join("workspace/crate_a"); + std::fs::create_dir_all(&nested).unwrap(); + + // Build config pointing at the nested dir. + let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await; + cfg.cwd = nested.abs(); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "root level doc"); +} + +/// Explicitly setting the byte-limit to zero disables project docs. +#[tokio::test] +async fn zero_byte_limit_disables_docs() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); + + let res = + get_user_instructions(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await).await; + assert!( + res.is_none(), + "With limit 0 the function should return None" + ); +} + +#[tokio::test] +async fn zero_byte_limit_disables_discovery() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); + + let discovery = agents_md_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await) + .await + .expect("discover paths"); + assert_eq!(discovery, Vec::::new()); +} + +/// When both system instructions and AGENTS.md docs are present the two +/// should be concatenated with the separator. +#[tokio::test] +async fn merges_existing_instructions_with_agents_md() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "proj doc").unwrap(); + + const INSTRUCTIONS: &str = "base instructions"; + + let res = get_user_instructions(&make_config(&tmp, /*limit*/ 4096, Some(INSTRUCTIONS)).await) + .await + .expect("should produce a combined instruction string"); + + let expected = format!("{INSTRUCTIONS}{AGENTS_MD_SEPARATOR}{}", "proj doc"); + + assert_eq!(res, expected); +} + +/// If there are existing system instructions but AGENTS.md docs are +/// missing we expect the original instructions to be returned unchanged. +#[tokio::test] +async fn keeps_existing_instructions_when_doc_missing() { + let tmp = tempfile::tempdir().expect("tempdir"); + + const INSTRUCTIONS: &str = "some instructions"; + + let res = + get_user_instructions(&make_config(&tmp, /*limit*/ 4096, Some(INSTRUCTIONS)).await).await; + + assert_eq!(res, Some(INSTRUCTIONS.to_string())); +} + +/// When both the repository root and the working directory contain +/// AGENTS.md files, their contents are concatenated from root to cwd. +#[tokio::test] +async fn concatenates_root_and_cwd_docs() { + let repo = tempfile::tempdir().expect("tempdir"); + + // Simulate a git repository. + std::fs::write( + repo.path().join(".git"), + "gitdir: /path/to/actual/git/dir\n", + ) + .unwrap(); + + // Repo root doc. + fs::write(repo.path().join("AGENTS.md"), "root doc").unwrap(); + + // Nested working directory with its own doc. + let nested = repo.path().join("workspace/crate_a"); + std::fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); + + let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await; + cfg.cwd = nested.abs(); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "root doc\n\ncrate doc"); +} + +#[tokio::test] +async fn project_root_markers_are_honored_for_agents_discovery() { + let root = tempfile::tempdir().expect("tempdir"); + fs::write(root.path().join(".codex-root"), "").unwrap(); + fs::write(root.path().join("AGENTS.md"), "parent doc").unwrap(); + + let nested = root.path().join("dir1"); + fs::create_dir_all(nested.join(".git")).unwrap(); + fs::write(nested.join("AGENTS.md"), "child doc").unwrap(); + + let mut cfg = make_config_with_project_root_markers( + &root, + /*limit*/ 4096, + /*instructions*/ None, + &[".codex-root"], + ) + .await; + cfg.cwd = nested.abs(); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + let expected_parent = AbsolutePathBuf::try_from( + dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"), + ) + .expect("absolute parent doc path"); + let expected_child = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"), + ) + .expect("absolute child doc path"); + assert_eq!(discovery.len(), 2); + assert_eq!(discovery[0], expected_parent); + assert_eq!(discovery[1], expected_child); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "parent doc\n\nchild doc"); +} + +#[tokio::test] +async fn instruction_sources_include_global_before_agents_md_docs() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + fs::create_dir_all(&cfg.codex_home).unwrap(); + fs::write(&global_agents, "global doc").unwrap(); + + let sources = AgentsMdManager::new(&cfg) + .instruction_sources(LOCAL_FS.as_ref()) + .await; + let project_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), + ) + .expect("absolute project doc path"); + + assert_eq!(sources, vec![global_agents, project_agents]); +} + +/// AGENTS.override.md is preferred over AGENTS.md when both are present. +#[tokio::test] +async fn agents_local_md_preferred() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join(DEFAULT_AGENTS_MD_FILENAME), "versioned").unwrap(); + fs::write(tmp.path().join(LOCAL_AGENTS_MD_FILENAME), "local").unwrap(); + + let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + + let res = get_user_instructions(&cfg) + .await + .expect("local doc expected"); + + assert_eq!(res, "local"); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery.len(), 1); + assert_eq!( + discovery[0].file_name().unwrap().to_string_lossy(), + LOCAL_AGENTS_MD_FILENAME + ); +} + +/// When AGENTS.md is absent but a configured fallback exists, the fallback is used. +#[tokio::test] +async fn uses_configured_fallback_when_agents_missing() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("EXAMPLE.md"), "example instructions").unwrap(); + + let cfg = make_config_with_fallback( + &tmp, + /*limit*/ 4096, + /*instructions*/ None, + &["EXAMPLE.md"], + ) + .await; + + let res = get_user_instructions(&cfg) + .await + .expect("fallback doc expected"); + + assert_eq!(res, "example instructions"); +} + +/// AGENTS.md remains preferred when both AGENTS.md and fallbacks are present. +#[tokio::test] +async fn agents_md_preferred_over_fallbacks() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "primary").unwrap(); + fs::write(tmp.path().join("EXAMPLE.md"), "secondary").unwrap(); + + let cfg = make_config_with_fallback( + &tmp, + /*limit*/ 4096, + /*instructions*/ None, + &["EXAMPLE.md", ".example.md"], + ) + .await; + + let res = get_user_instructions(&cfg) + .await + .expect("AGENTS.md should win"); + + assert_eq!(res, "primary"); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery.len(), 1); + assert!( + discovery[0] + .file_name() + .unwrap() + .to_string_lossy() + .eq(DEFAULT_AGENTS_MD_FILENAME) + ); +} + +#[tokio::test] +async fn agents_md_directory_is_ignored() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::create_dir(tmp.path().join("AGENTS.md")).unwrap(); + + let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + + let res = get_user_instructions(&cfg).await; + assert_eq!(res, None); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery, Vec::::new()); +} + +#[cfg(unix)] +#[tokio::test] +async fn agents_md_special_file_is_ignored() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("AGENTS.md"); + let c_path = CString::new(path.as_os_str().as_bytes()).expect("path without nul"); + // SAFETY: `c_path` is a valid, nul-terminated path and `mkfifo` does not + // retain the pointer after the call. + let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }; + assert_eq!(rc, 0); + + let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + + let res = get_user_instructions(&cfg).await; + assert_eq!(res, None); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery, Vec::::new()); +} + +#[tokio::test] +async fn override_directory_falls_back_to_agents_md_file() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::create_dir(tmp.path().join(LOCAL_AGENTS_MD_FILENAME)).unwrap(); + fs::write(tmp.path().join(DEFAULT_AGENTS_MD_FILENAME), "primary").unwrap(); + + let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + + let res = get_user_instructions(&cfg) + .await + .expect("AGENTS.md should be used when override is a directory"); + assert_eq!(res, "primary"); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery.len(), 1); + assert_eq!( + discovery[0] + .file_name() + .expect("file name") + .to_string_lossy(), + DEFAULT_AGENTS_MD_FILENAME + ); +} + +#[tokio::test] +async fn skills_are_not_appended_to_agents_md() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); + + let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + create_skill( + cfg.codex_home.to_path_buf(), + "pdf-processing", + "extract from pdfs", + ); + + let res = get_user_instructions(&cfg) + .await + .expect("instructions expected"); + assert_eq!(res, "base doc"); +} + +#[tokio::test] +async fn apps_feature_does_not_emit_user_instructions_by_itself() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.features + .enable(Feature::Apps) + .expect("test config should allow apps"); + + let res = get_user_instructions(&cfg).await; + assert_eq!(res, None); +} + +#[tokio::test] +async fn apps_feature_does_not_append_to_agents_md_user_instructions() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.features + .enable(Feature::Apps) + .expect("test config should allow apps"); + + let res = get_user_instructions(&cfg) + .await + .expect("instructions expected"); + assert_eq!(res, "base doc"); +} + +fn create_skill(codex_home: PathBuf, name: &str, description: &str) { + let skill_dir = codex_home.join(format!("skills/{name}")); + fs::create_dir_all(&skill_dir).unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); +} diff --git a/code-rs/core/src/apply_patch.rs b/code-rs/core/src/apply_patch.rs index c31d1ecc270..2463d69c2be 100644 --- a/code-rs/core/src/apply_patch.rs +++ b/code-rs/core/src/apply_patch.rs @@ -1,319 +1,104 @@ -use anyhow::Context as _; -use anyhow::Result; -use crate::acp::AcpFileSystem; -use crate::codex::Session; -use crate::patch_harness::run_patch_harness; -use crate::protocol::FileChange; -use crate::protocol::ReviewDecision; -use crate::safety::assess_patch_safety; +use crate::function_tool::FunctionCallError; use crate::safety::SafetyCheck; -use code_apply_patch::AffectedPaths; -use code_apply_patch::ApplyPatchAction; -use code_apply_patch::ApplyPatchFileChange; -use code_apply_patch::FileSystem; -use code_apply_patch::StdFileSystem; -use code_apply_patch::print_summary; -use code_protocol::models::FunctionCallOutputPayload; -use code_protocol::models::ResponseInputItem; -use serde_json::json; +use crate::safety::assess_patch_safety; +use crate::session::turn_context::TurnContext; +use crate::tools::sandboxing::ExecApprovalRequirement; +use codex_apply_patch::ApplyPatchAction; +use codex_apply_patch::ApplyPatchFileChange; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::FileSystemSandboxPolicy; use std::collections::HashMap; -use std::path::Path; use std::path::PathBuf; -pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; - -pub(crate) struct ApplyPatchRun { - pub auto_approved: bool, - pub stdout: String, - pub stderr: String, - pub success: bool, - pub harness_summary_json: Option, +pub(crate) enum InternalApplyPatchInvocation { + /// The `apply_patch` call was handled programmatically, without any sort + /// of sandbox, because the user explicitly approved it. This is the + /// result to use with the `shell` function call that contained `apply_patch`. + Output(Result), + + /// The `apply_patch` call was approved, either automatically because it + /// appears that it should be allowed based on the user's sandbox policy + /// *or* because the user explicitly approved it. The runtime realizes the + /// patch through the selected environment filesystem. + DelegateToRuntime(ApplyPatchRuntimeInvocation), } -pub(crate) enum ApplyPatchResult { - Applied(ApplyPatchRun), - Reply(ResponseInputItem), +#[derive(Debug)] +pub(crate) struct ApplyPatchRuntimeInvocation { + pub(crate) action: ApplyPatchAction, + pub(crate) auto_approved: bool, + pub(crate) exec_approval_requirement: ExecApprovalRequirement, } pub(crate) async fn apply_patch( - sess: &Session, - sub_id: &str, - call_id: &str, - attempt_req: u64, - output_index: Option, + turn_context: &TurnContext, + file_system_sandbox_policy: &FileSystemSandboxPolicy, action: ApplyPatchAction, -) -> ApplyPatchResult { - let (harness_summary_json, harness_status_message) = { - let mut summary_json: Option = None; - let mut status_message: Option = None; - let validation_cfg = sess.validation_config(); - let github_cfg = sess.get_github_config(); - if let (Ok(validation_cfg), Ok(github_cfg)) = (validation_cfg.read(), github_cfg.read()) { - if let Some((mut findings, mut ran_checks)) = run_patch_harness( - &action, - sess.get_cwd(), - &*validation_cfg, - &*github_cfg, - ) { - const MAX_ISSUES: usize = 12; - let total_issues = findings.len(); - let truncated = total_issues > MAX_ISSUES; - if truncated { - findings.truncate(MAX_ISSUES); - } - findings.retain(|finding| { - finding.tool.trim().len() <= 120 && finding.message.trim().len() <= 800 - }); - let issues_json: Vec = findings - .iter() - .map(|finding| { - let relative_file = finding - .file - .as_ref() - .and_then(|path| path.strip_prefix(sess.get_cwd()).ok()) - .map(|path| path.display().to_string()); - json!({ - "tool": finding.tool, - "file": relative_file, - "msg": finding.message, - }) - }) - .collect(); - summary_json = Some( - json!({ - "validation": { - "issues": issues_json, - "checks": ran_checks, - "issue_count": total_issues, - "truncated": truncated, - } - }) - .to_string(), - ); - - let mut lines: Vec = Vec::new(); - if total_issues == 0 { - lines.push("✅ Validate New Code: no issues".to_string()); - } else { - lines.push(format!("❌ Validate New Code: {total_issues} issue(s)")); - for finding in findings.iter() { - let mut parts = vec![finding.tool.clone()]; - if let Some(rel) = finding - .file - .as_ref() - .and_then(|p| p.strip_prefix(sess.get_cwd()).ok()) - .map(|p| p.display().to_string()) - { - parts.push(rel); - } - let mut msg = finding.message.clone(); - if msg.len() > 160 { - msg.truncate(157); - msg.push_str("…"); - } - parts.push(msg); - lines.push(format!("• {}", parts.join(" — "))); - } - if truncated { - let remaining = total_issues - findings.len(); - lines.push(format!("… plus {remaining} more issue(s)")); - } - } - if ran_checks.is_empty() { - lines.push("Checks run: none".to_string()); - } else { - ran_checks.sort(); - lines.push(format!("Checks run: {}", ran_checks.join(", "))); - } - status_message = Some(lines.join("\n")); - } - } - (summary_json, status_message) - }; - - if let Some(message) = harness_status_message.as_ref() { - let order = sess.next_background_order(sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order(sub_id, order, message.clone()) - .await; - } - - let auto_approved = match assess_patch_safety( +) -> InternalApplyPatchInvocation { + match assess_patch_safety( &action, - sess.get_approval_policy(), - sess.get_sandbox_policy(), - sess.get_cwd(), + turn_context.approval_policy.value(), + &turn_context.permission_profile(), + file_system_sandbox_policy, + &turn_context.cwd, + turn_context.windows_sandbox_level, ) { - SafetyCheck::AutoApprove { .. } => true, + SafetyCheck::AutoApprove { + user_explicitly_approved, + .. + } => InternalApplyPatchInvocation::DelegateToRuntime(ApplyPatchRuntimeInvocation { + action, + auto_approved: !user_explicitly_approved, + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + }), SafetyCheck::AskUser => { - let rx = sess - .request_patch_approval(sub_id.to_owned(), call_id.to_owned(), &action, None, None) - .await; - match rx.await.unwrap_or_default() { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false, - ReviewDecision::Denied | ReviewDecision::Abort => { - return ApplyPatchResult::Reply(ResponseInputItem::FunctionCallOutput { - call_id: call_id.to_owned(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("patch rejected by user".to_string()), - success: Some(false)}, - }); - } - } + // Delegate the approval prompt (including cached approvals) to the + // tool runtime, consistent with how shell/unified_exec approvals + // are orchestrator-driven. + InternalApplyPatchInvocation::DelegateToRuntime(ApplyPatchRuntimeInvocation { + action, + auto_approved: false, + exec_approval_requirement: ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + }) } - SafetyCheck::Reject { reason } => { - return ApplyPatchResult::Reply(ResponseInputItem::FunctionCallOutput { - call_id: call_id.to_owned(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("patch rejected: {reason}")), - success: Some(false)}, - }); - } - }; - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let result = if let Some(client_tools) = sess.client_tools() { - let fs = AcpFileSystem::new(sess.session_uuid(), client_tools, sess.mcp_connection_manager()); - apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr, &fs).await - } else { - apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr, &StdFileSystem).await - }; - - let stdout = String::from_utf8_lossy(&stdout).to_string(); - let stderr = String::from_utf8_lossy(&stderr).to_string(); - let success = result.is_ok(); - - ApplyPatchResult::Applied(ApplyPatchRun { - auto_approved, - stdout, - stderr, - success, - harness_summary_json, - }) + SafetyCheck::Reject { reason } => InternalApplyPatchInvocation::Output(Err( + FunctionCallError::RespondToModel(format!("patch rejected: {reason}")), + )), + } } pub(crate) fn convert_apply_patch_to_protocol( action: &ApplyPatchAction, ) -> HashMap { - let changes = action.changes(); - let mut result = HashMap::with_capacity(changes.len()); - for (path, change) in changes { + let mut result = HashMap::with_capacity(action.changes().len()); + for (path, change) in action.changes() { let protocol_change = match change { - ApplyPatchFileChange::Add { content } => FileChange::Add { + ApplyPatchFileChange::Add { content, .. } => FileChange::Add { + content: content.clone(), + }, + ApplyPatchFileChange::Delete { content } => FileChange::Delete { content: content.clone(), }, - ApplyPatchFileChange::Delete { content: _ } => FileChange::Delete, ApplyPatchFileChange::Update { unified_diff, move_path, - new_content, - } => { - let original_content = std::fs::read_to_string(path).unwrap_or_default(); - FileChange::Update { - unified_diff: unified_diff.clone(), - move_path: move_path.clone(), - original_content, - new_content: new_content.clone(), - } - } + new_content: _new_content, + } => FileChange::Update { + unified_diff: unified_diff.clone(), + move_path: move_path.clone(), + }, }; - result.insert(path.clone(), protocol_change); + result.insert(path.to_path_buf(), protocol_change); } result } -pub(crate) fn get_writable_roots(cwd: &Path) -> Vec { - let mut writable_roots = Vec::new(); - if cfg!(target_os = "macos") { - writable_roots.push(std::env::temp_dir()); - - if let Ok(home_dir) = std::env::var("HOME") { - let pyenv_dir = PathBuf::from(home_dir).join(".pyenv"); - writable_roots.push(pyenv_dir); - } - } - - writable_roots.push(cwd.to_path_buf()); - - writable_roots -} - -async fn apply_changes_from_apply_patch_and_report( - action: &ApplyPatchAction, - stdout: &mut impl std::io::Write, - stderr: &mut impl std::io::Write, - fs: &impl FileSystem, -) -> std::io::Result<()> { - match apply_changes_from_apply_patch(action, fs).await { - Ok(affected_paths) => { - print_summary(&affected_paths, stdout)?; - } - Err(err) => { - writeln!(stderr, "{err:#}")?; - } - } - - Ok(()) -} - -async fn apply_changes_from_apply_patch( - action: &ApplyPatchAction, - fs: &impl FileSystem, -) -> Result { - let mut added: Vec = Vec::new(); - let mut modified: Vec = Vec::new(); - let mut deleted: Vec = Vec::new(); - - for (path, change) in action.changes() { - match change { - ApplyPatchFileChange::Add { content } => { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", path.display()) - })?; - } - } - fs.write_text_file(path, content.clone()) - .await - .with_context(|| format!("Failed to write file {}", path.display()))?; - added.push(path.clone()); - } - ApplyPatchFileChange::Delete { content: _ } => { - std::fs::remove_file(path) - .with_context(|| format!("Failed to delete file {}", path.display()))?; - deleted.push(path.clone()); - } - ApplyPatchFileChange::Update { - move_path, - new_content, - .. - } => { - if let Some(move_path) = move_path { - if let Some(parent) = move_path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", move_path.display()) - })?; - } - } - - std::fs::rename(path, move_path) - .with_context(|| format!("Failed to rename file {}", path.display()))?; - fs.write_text_file(move_path, new_content.clone()).await?; - modified.push(move_path.clone()); - deleted.push(path.clone()); - } else { - fs.write_text_file(path, new_content.clone()).await?; - modified.push(path.clone()); - } - } - } - } - - Ok(AffectedPaths { - added, - modified, - deleted, - }) -} +#[cfg(test)] +#[path = "apply_patch_tests.rs"] +mod tests; diff --git a/code-rs/core/src/apply_patch_tests.rs b/code-rs/core/src/apply_patch_tests.rs new file mode 100644 index 00000000000..c0190c3708b --- /dev/null +++ b/code-rs/core/src/apply_patch_tests.rs @@ -0,0 +1,22 @@ +use super::*; +use core_test_support::PathBufExt; +use pretty_assertions::assert_eq; + +use tempfile::tempdir; + +#[test] +fn convert_apply_patch_maps_add_variant() { + let tmp = tempdir().expect("tmp"); + let p = tmp.path().join("a.txt").abs(); + // Create an action with a single Add change + let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); + + let got = convert_apply_patch_to_protocol(&action); + + assert_eq!( + got.get(p.as_path()), + Some(&FileChange::Add { + content: "hello".to_string() + }) + ); +} diff --git a/code-rs/core/src/apps/mod.rs b/code-rs/core/src/apps/mod.rs new file mode 100644 index 00000000000..5a58d22204e --- /dev/null +++ b/code-rs/core/src/apps/mod.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod render; diff --git a/code-rs/core/src/apps/render.rs b/code-rs/core/src/apps/render.rs new file mode 100644 index 00000000000..3793231e105 --- /dev/null +++ b/code-rs/core/src/apps/render.rs @@ -0,0 +1,61 @@ +use crate::context::AppsInstructions; +use crate::context::ContextualUserFragment; +use codex_app_server_protocol::AppInfo; +use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; +use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; + +pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option { + AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible, + is_enabled, + plugin_display_names: Vec::new(), + } + } + + #[test] + fn omits_apps_section_without_accessible_and_enabled_apps() { + assert_eq!(render_apps_section(&[]), None); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ false + )]), + None + ); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ false, /*is_enabled*/ true + )]), + None + ); + } + + #[test] + fn renders_apps_section_with_an_accessible_and_enabled_app() { + let rendered = render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ true, + )]) + .expect("expected apps section"); + + assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG)); + assert!(rendered.contains("## Apps (Connectors)")); + assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG)); + } +} diff --git a/code-rs/core/src/arc_monitor.rs b/code-rs/core/src/arc_monitor.rs new file mode 100644 index 00000000000..d1e679a6315 --- /dev/null +++ b/code-rs/core/src/arc_monitor.rs @@ -0,0 +1,415 @@ +use std::env; +use std::time::Duration; + +use serde::Deserialize; +use serde::Serialize; +use tracing::warn; + +use crate::compact::content_items_to_text; +use crate::event_mapping::is_contextual_user_message_content; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use codex_login::default_client::build_reqwest_client; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; + +const ARC_MONITOR_TIMEOUT: Duration = Duration::from_secs(30); +const CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE: &str = "CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE"; +const CODEX_ARC_MONITOR_TOKEN: &str = "CODEX_ARC_MONITOR_TOKEN"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ArcMonitorOutcome { + Ok, + SteerModel(String), + AskUser(String), +} + +#[derive(Debug, Serialize, PartialEq)] +struct ArcMonitorRequest { + metadata: ArcMonitorMetadata, + #[serde(skip_serializing_if = "Option::is_none")] + messages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + input: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + policies: Option, + action: serde_json::Map, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ArcMonitorResult { + outcome: ArcMonitorResultOutcome, + short_reason: String, + rationale: String, + risk_score: u8, + risk_level: ArcMonitorRiskLevel, + evidence: Vec, +} + +#[derive(Debug, Serialize, PartialEq)] +struct ArcMonitorChatMessage { + role: String, + content: serde_json::Value, +} + +#[derive(Debug, Serialize, PartialEq)] +struct ArcMonitorPolicies { + user: Option, + developer: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct ArcMonitorMetadata { + codex_thread_id: String, + codex_turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + protection_client_callsite: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct ArcMonitorEvidence { + message: String, + why: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum ArcMonitorResultOutcome { + Ok, + SteerModel, + AskUser, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ArcMonitorRiskLevel { + Low, + Medium, + High, + Critical, +} + +pub(crate) async fn monitor_action( + sess: &Session, + turn_context: &TurnContext, + action: serde_json::Value, + protection_client_callsite: &'static str, +) -> ArcMonitorOutcome { + let auth = match turn_context.auth_manager.as_ref() { + Some(auth_manager) => match auth_manager.auth().await { + Some(auth) if auth.uses_codex_backend() => Some(auth), + _ => None, + }, + None => None, + }; + let env_token = read_non_empty_env_var(CODEX_ARC_MONITOR_TOKEN); + if env_token.is_none() && auth.is_none() { + return ArcMonitorOutcome::Ok; + } + + let url = read_non_empty_env_var(CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE).unwrap_or_else(|| { + format!( + "{}/codex/safety/arc", + turn_context.config.chatgpt_base_url.trim_end_matches('/') + ) + }); + let action = match action { + serde_json::Value::Object(action) => action, + _ => { + warn!("skipping safety monitor because action payload is not an object"); + return ArcMonitorOutcome::Ok; + } + }; + let body = + build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await; + let client = build_reqwest_client(); + let mut request = client.post(&url).timeout(ARC_MONITOR_TIMEOUT).json(&body); + if let Some(token) = env_token { + request = request.bearer_auth(token); + } else if let Some(auth) = auth.as_ref() { + request = + request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + } + + let response = match request.send().await { + Ok(response) => response, + Err(err) => { + warn!(error = %err, %url, "safety monitor request failed"); + return ArcMonitorOutcome::Ok; + } + }; + let status = response.status(); + if !status.is_success() { + let response_text = response.text().await.unwrap_or_default(); + warn!( + %status, + %url, + response_text, + "safety monitor returned non-success status" + ); + return ArcMonitorOutcome::Ok; + } + + let response = match response.json::().await { + Ok(response) => response, + Err(err) => { + warn!(error = %err, %url, "failed to parse safety monitor response"); + return ArcMonitorOutcome::Ok; + } + }; + tracing::debug!( + risk_score = response.risk_score, + risk_level = ?response.risk_level, + evidence_count = response.evidence.len(), + "safety monitor completed" + ); + + let short_reason = response.short_reason.trim(); + let rationale = response.rationale.trim(); + match response.outcome { + ArcMonitorResultOutcome::Ok => ArcMonitorOutcome::Ok, + ArcMonitorResultOutcome::AskUser => { + if !short_reason.is_empty() { + ArcMonitorOutcome::AskUser(short_reason.to_string()) + } else if !rationale.is_empty() { + ArcMonitorOutcome::AskUser(rationale.to_string()) + } else { + ArcMonitorOutcome::AskUser( + "Additional confirmation is required before this tool call can continue." + .to_string(), + ) + } + } + ArcMonitorResultOutcome::SteerModel => { + if !rationale.is_empty() { + ArcMonitorOutcome::SteerModel(rationale.to_string()) + } else if !short_reason.is_empty() { + ArcMonitorOutcome::SteerModel(short_reason.to_string()) + } else { + ArcMonitorOutcome::SteerModel( + "Tool call was cancelled because of safety risks.".to_string(), + ) + } + } + } +} + +fn read_non_empty_env_var(key: &str) -> Option { + match env::var(key) { + Ok(value) => { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + } + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => { + warn!( + env_var = key, + "ignoring non-unicode safety monitor env override" + ); + None + } + } +} + +async fn build_arc_monitor_request( + sess: &Session, + turn_context: &TurnContext, + action: serde_json::Map, + protection_client_callsite: &'static str, +) -> ArcMonitorRequest { + let history = sess.clone_history().await; + let mut messages = build_arc_monitor_messages(history.raw_items()); + if messages.is_empty() { + messages.push(build_arc_monitor_message( + "user", + serde_json::Value::String( + "No prior conversation history is available for this ARC evaluation.".to_string(), + ), + )); + } + + let conversation_id = sess.conversation_id.to_string(); + ArcMonitorRequest { + metadata: ArcMonitorMetadata { + codex_thread_id: conversation_id.clone(), + codex_turn_id: turn_context.sub_id.clone(), + conversation_id: Some(conversation_id), + protection_client_callsite: Some(protection_client_callsite.to_string()), + }, + messages: Some(messages), + input: None, + policies: Some(ArcMonitorPolicies { + user: None, + developer: None, + }), + action, + } +} + +fn build_arc_monitor_messages(items: &[ResponseItem]) -> Vec { + let last_tool_call_index = items + .iter() + .enumerate() + .rev() + .find(|(_, item)| { + matches!( + item, + ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::WebSearchCall { .. } + ) + }) + .map(|(index, _)| index); + let last_encrypted_reasoning_index = items + .iter() + .enumerate() + .rev() + .find(|(_, item)| { + matches!( + item, + ResponseItem::Reasoning { + encrypted_content: Some(encrypted_content), + .. + } if !encrypted_content.trim().is_empty() + ) + }) + .map(|(index, _)| index); + + items + .iter() + .enumerate() + .filter_map(|(index, item)| { + build_arc_monitor_message_item( + item, + index, + last_tool_call_index, + last_encrypted_reasoning_index, + ) + }) + .collect() +} + +fn build_arc_monitor_message_item( + item: &ResponseItem, + index: usize, + last_tool_call_index: Option, + last_encrypted_reasoning_index: Option, +) -> Option { + match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + if is_contextual_user_message_content(content) { + None + } else { + content_items_to_text(content) + .map(|text| build_arc_monitor_text_message("user", "input_text", text)) + } + } + ResponseItem::Message { + role, + content, + phase: Some(MessagePhase::FinalAnswer), + .. + } if role == "assistant" => content_items_to_text(content) + .map(|text| build_arc_monitor_text_message("assistant", "output_text", text)), + ResponseItem::Message { .. } => None, + ResponseItem::Reasoning { + encrypted_content: Some(encrypted_content), + .. + } if Some(index) == last_encrypted_reasoning_index + && !encrypted_content.trim().is_empty() => + { + Some(build_arc_monitor_message( + "assistant", + serde_json::json!([{ + "type": "encrypted_reasoning", + "encrypted_content": encrypted_content, + }]), + )) + } + ResponseItem::Reasoning { .. } => None, + ResponseItem::LocalShellCall { action, .. } if Some(index) == last_tool_call_index => { + Some(build_arc_monitor_message( + "assistant", + serde_json::json!([{ + "type": "tool_call", + "tool_name": "shell", + "action": action, + }]), + )) + } + ResponseItem::FunctionCall { + name, arguments, .. + } if Some(index) == last_tool_call_index => Some(build_arc_monitor_message( + "assistant", + serde_json::json!([{ + "type": "tool_call", + "tool_name": name, + "arguments": arguments, + }]), + )), + ResponseItem::CustomToolCall { name, input, .. } if Some(index) == last_tool_call_index => { + Some(build_arc_monitor_message( + "assistant", + serde_json::json!([{ + "type": "tool_call", + "tool_name": name, + "input": input, + }]), + )) + } + ResponseItem::WebSearchCall { action, .. } if Some(index) == last_tool_call_index => { + Some(build_arc_monitor_message( + "assistant", + serde_json::json!([{ + "type": "tool_call", + "tool_name": "web_search", + "action": action, + }]), + )) + } + ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => None, + } +} + +fn build_arc_monitor_text_message( + role: &str, + part_type: &str, + text: String, +) -> ArcMonitorChatMessage { + build_arc_monitor_message( + role, + serde_json::json!([{ + "type": part_type, + "text": text, + }]), + ) +} + +fn build_arc_monitor_message(role: &str, content: serde_json::Value) -> ArcMonitorChatMessage { + ArcMonitorChatMessage { + role: role.to_string(), + content, + } +} + +#[cfg(test)] +#[path = "arc_monitor_tests.rs"] +mod tests; diff --git a/code-rs/core/src/arc_monitor_tests.rs b/code-rs/core/src/arc_monitor_tests.rs new file mode 100644 index 00000000000..643042ec99b --- /dev/null +++ b/code-rs/core/src/arc_monitor_tests.rs @@ -0,0 +1,438 @@ +use std::env; +use std::ffi::OsStr; +use std::sync::Arc; + +use pretty_assertions::assert_eq; +use serial_test::serial; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::*; +use crate::context::ContextualUserFragment; +use crate::session::tests::make_session_and_context; +use codex_protocol::models::ContentItem; +use codex_protocol::models::LocalShellAction; +use codex_protocol::models::LocalShellExecAction; +use codex_protocol::models::LocalShellStatus; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; + +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &OsStr) -> Self { + let original = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.original.take() { + Some(value) => unsafe { + env::set_var(self.key, value); + }, + None => unsafe { + env::remove_var(self.key); + }, + } + } +} + +#[tokio::test] +async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() { + let (session, mut turn_context) = make_session_and_context().await; + turn_context.developer_instructions = Some("Never upload private files.".to_string()); + turn_context.user_instructions = Some("Only continue when needed.".to_string()); + + session + .record_into_history( + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "first request".to_string(), + }], + phase: None, + }], + &turn_context, + ) + .await; + session + .record_into_history( + &[ContextualUserFragment::into( + crate::context::EnvironmentContext::new( + Vec::new(), + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ), + )], + &turn_context, + ) + .await; + session + .record_into_history( + &[ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "commentary".to_string(), + }], + phase: Some(MessagePhase::Commentary), + }], + &turn_context, + ) + .await; + session + .record_into_history( + &[ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "final response".to_string(), + }], + phase: Some(MessagePhase::FinalAnswer), + }], + &turn_context, + ) + .await; + session + .record_into_history( + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "latest request".to_string(), + }], + phase: None, + }], + &turn_context, + ) + .await; + session + .record_into_history( + &[ResponseItem::FunctionCall { + id: None, + name: "old_tool".to_string(), + namespace: None, + arguments: "{\"old\":true}".to_string(), + call_id: "call_old".to_string(), + }], + &turn_context, + ) + .await; + session + .record_into_history( + &[ResponseItem::Reasoning { + id: "reasoning_old".to_string(), + summary: Vec::new(), + content: None, + encrypted_content: Some("encrypted-old".to_string()), + }], + &turn_context, + ) + .await; + session + .record_into_history( + &[ResponseItem::LocalShellCall { + id: None, + call_id: Some("shell_call".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["pwd".to_string()], + timeout_ms: Some(1000), + working_directory: Some("/tmp".to_string()), + env: None, + user: None, + }), + }], + &turn_context, + ) + .await; + session + .record_into_history( + &[ResponseItem::Reasoning { + id: "reasoning_latest".to_string(), + summary: Vec::new(), + content: None, + encrypted_content: Some("encrypted-latest".to_string()), + }], + &turn_context, + ) + .await; + + let request = build_arc_monitor_request( + &session, + &turn_context, + serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" })) + .expect("action should deserialize"), + "normal", + ) + .await; + + assert_eq!( + request, + ArcMonitorRequest { + metadata: ArcMonitorMetadata { + codex_thread_id: session.conversation_id.to_string(), + codex_turn_id: turn_context.sub_id.clone(), + conversation_id: Some(session.conversation_id.to_string()), + protection_client_callsite: Some("normal".to_string()), + }, + messages: Some(vec![ + ArcMonitorChatMessage { + role: "user".to_string(), + content: serde_json::json!([{ + "type": "input_text", + "text": "first request", + }]), + }, + ArcMonitorChatMessage { + role: "assistant".to_string(), + content: serde_json::json!([{ + "type": "output_text", + "text": "final response", + }]), + }, + ArcMonitorChatMessage { + role: "user".to_string(), + content: serde_json::json!([{ + "type": "input_text", + "text": "latest request", + }]), + }, + ArcMonitorChatMessage { + role: "assistant".to_string(), + content: serde_json::json!([{ + "type": "tool_call", + "tool_name": "shell", + "action": { + "type": "exec", + "command": ["pwd"], + "timeout_ms": 1000, + "working_directory": "/tmp", + "env": null, + "user": null, + }, + }]), + }, + ArcMonitorChatMessage { + role: "assistant".to_string(), + content: serde_json::json!([{ + "type": "encrypted_reasoning", + "encrypted_content": "encrypted-latest", + }]), + }, + ]), + input: None, + policies: Some(ArcMonitorPolicies { + user: None, + developer: None, + }), + action: serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" })) + .expect("action should deserialize"), + } + ); +} + +#[tokio::test] +#[serial(arc_monitor_env)] +async fn monitor_action_posts_expected_arc_request() { + let server = MockServer::start().await; + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + turn_context.developer_instructions = Some("Developer policy".to_string()); + turn_context.user_instructions = Some("User policy".to_string()); + + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + session + .record_into_history( + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "please run the tool".to_string(), + }], + phase: None, + }], + &turn_context, + ) + .await; + + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(serde_json::json!({ + "metadata": { + "codex_thread_id": session.conversation_id.to_string(), + "codex_turn_id": turn_context.sub_id.clone(), + "conversation_id": session.conversation_id.to_string(), + "protection_client_callsite": "normal", + }, + "messages": [{ + "role": "user", + "content": [{ + "type": "input_text", + "text": "please run the tool", + }], + }], + "policies": { + "developer": null, + "user": null, + }, + "action": { + "tool": "mcp_tool_call", + }, + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "ask-user", + "short_reason": "needs confirmation", + "rationale": "tool call needs additional review", + "risk_score": 42, + "risk_level": "medium", + "evidence": [{ + "message": "browser_navigate", + "why": "tool call needs additional review", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let outcome = monitor_action( + &session, + &turn_context, + serde_json::json!({ "tool": "mcp_tool_call" }), + "normal", + ) + .await; + + assert_eq!( + outcome, + ArcMonitorOutcome::AskUser("needs confirmation".to_string()) + ); +} + +#[tokio::test] +#[serial(arc_monitor_env)] +async fn monitor_action_uses_env_url_and_token_overrides() { + let server = MockServer::start().await; + let _url_guard = EnvVarGuard::set( + CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE, + OsStr::new(&format!("{}/override/arc", server.uri())), + ); + let _token_guard = EnvVarGuard::set(CODEX_ARC_MONITOR_TOKEN, OsStr::new("override-token")); + + let (session, turn_context) = make_session_and_context().await; + session + .record_into_history( + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "please run the tool".to_string(), + }], + phase: None, + }], + &turn_context, + ) + .await; + + Mock::given(method("POST")) + .and(path("/override/arc")) + .and(header("authorization", "Bearer override-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "browser_navigate", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let outcome = monitor_action( + &session, + &turn_context, + serde_json::json!({ "tool": "mcp_tool_call" }), + "normal", + ) + .await; + + assert_eq!( + outcome, + ArcMonitorOutcome::SteerModel("high-risk action".to_string()) + ); +} + +#[tokio::test] +#[serial(arc_monitor_env)] +async fn monitor_action_rejects_legacy_response_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "reason": "legacy high-risk action", + "monitorRequestId": "arc_456", + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + session + .record_into_history( + &[ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "please run the tool".to_string(), + }], + phase: None, + }], + &turn_context, + ) + .await; + + let outcome = monitor_action( + &session, + &turn_context, + serde_json::json!({ "tool": "mcp_tool_call" }), + "normal", + ) + .await; + + assert_eq!(outcome, ArcMonitorOutcome::Ok); +} diff --git a/code-rs/core/src/auth.rs b/code-rs/core/src/auth.rs deleted file mode 100644 index 402a1ddb078..00000000000 --- a/code-rs/core/src/auth.rs +++ /dev/null @@ -1,2477 +0,0 @@ -use chrono::DateTime; -use chrono::Utc; -use reqwest::StatusCode; -use serde::Deserialize; -use serde::Serialize; -use std::env; -use std::fs::File; -use std::io::Read; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::Duration; -use tempfile::NamedTempFile; - -use code_app_server_protocol::AuthMode; - -use crate::token_data::TokenData; -use crate::token_data::KnownPlan; -use crate::token_data::PlanType; -use crate::token_data::parse_id_token; -use crate::token_data::parse_jwt_expiration; -use crate::config::resolve_code_path_for_read; -use crate::util::backoff; - -#[derive(Debug, Clone)] -pub struct CodexAuth { - pub mode: AuthMode, - - pub(crate) api_key: Option, - pub(crate) auth_dot_json: Arc>>, - pub(crate) auth_file: PathBuf, - pub(crate) client: reqwest::Client, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RefreshTokenErrorKind { - Permanent, - Transient, -} - -#[derive(Debug, Clone)] -pub struct RefreshTokenError { - pub kind: RefreshTokenErrorKind, - pub message: String, -} - -impl RefreshTokenError { - pub fn permanent(message: impl Into) -> Self { - Self { - kind: RefreshTokenErrorKind::Permanent, - message: message.into(), - } - } - - pub fn transient(message: impl Into) -> Self { - Self { - kind: RefreshTokenErrorKind::Transient, - message: message.into(), - } - } - - pub fn is_permanent(&self) -> bool { - matches!(self.kind, RefreshTokenErrorKind::Permanent) - } - - pub fn is_refresh_token_reused(&self) -> bool { - self.message.contains("refresh_token_reused") - } -} - -impl std::fmt::Display for RefreshTokenError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for RefreshTokenError {} - -const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = - "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; -const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; -pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; - -impl PartialEq for CodexAuth { - fn eq(&self, other: &Self) -> bool { - self.mode == other.mode - } -} - -impl CodexAuth { - pub async fn refresh_token(&self) -> Result { - if self.mode == AuthMode::ChatgptAuthTokens { - return Err(RefreshTokenError::permanent( - "ChatGPT auth tokens are managed externally and cannot be refreshed.", - )); - } - let token_data = self - .get_current_token_data() - .ok_or_else(|| RefreshTokenError::permanent("Token data is not available."))?; - let refresh_token = token_data.refresh_token.clone(); - - let mut attempt: u32 = 0; - loop { - attempt = attempt.saturating_add(1); - match try_refresh_token(refresh_token.clone(), &self.client).await { - Ok(refresh_response) => { - return self.persist_refresh_response(refresh_response).await - } - Err(err) => { - if err.is_refresh_token_reused() { - if let Some(access) = - self.adopt_rotated_refresh_token_from_disk(&refresh_token)? - { - return Ok(access); - } - } - if err.kind == RefreshTokenErrorKind::Transient && attempt < 4 { - let delay = backoff(attempt as u64); - tokio::time::sleep(delay).await; - continue; - } - return Err(err); - } - } - } - } - - fn adopt_rotated_refresh_token_from_disk( - &self, - stale_refresh_token: &str, - ) -> Result, RefreshTokenError> { - let auth_dot_json = try_read_auth_json(&self.auth_file) - .map_err(|err| RefreshTokenError::permanent(err.to_string()))?; - - let Some(tokens) = auth_dot_json.tokens.clone() else { - return Ok(None); - }; - - if tokens.refresh_token == stale_refresh_token { - return Ok(None); - } - - if let Ok(mut auth_lock) = self.auth_dot_json.lock() { - *auth_lock = Some(auth_dot_json); - } - - Ok(Some(tokens.access_token)) - } - - async fn persist_refresh_response( - &self, - refresh_response: RefreshResponse, - ) -> Result { - let updated = update_tokens( - &self.auth_file, - refresh_response.id_token, - refresh_response.access_token, - refresh_response.refresh_token, - ) - .await - .map_err(|err| RefreshTokenError::permanent(err.to_string()))?; - - if let Ok(mut auth_lock) = self.auth_dot_json.lock() { - *auth_lock = Some(updated.clone()); - } - - let access = match updated.tokens { - Some(t) => t.access_token, - None => { - return Err(RefreshTokenError::permanent( - "Token data is not available after refresh.", - )); - } - }; - Ok(access) - } - - /// Loads the available auth information from the auth.json or - /// OPENAI_API_KEY environment variable. - pub fn from_code_home( - code_home: &Path, - preferred_auth_method: AuthMode, - originator: &str, - ) -> std::io::Result> { - load_auth(code_home, true, preferred_auth_method, originator) - } - - pub async fn get_token_data(&self) -> Result { - let auth_dot_json: Option = self.get_current_auth_json(); - match auth_dot_json { - Some(auth_dot_json) => { - let mut tokens = auth_dot_json - .tokens - .clone() - .ok_or(std::io::Error::other("Token data is not available."))?; - if self.mode == AuthMode::ChatgptAuthTokens { - return Ok(tokens); - } - if should_proactively_refresh_auth( - auth_dot_json.last_refresh, - auth_dot_json - .tokens - .as_ref() - .map(|tokens| tokens.access_token.as_str()), - ) { - let refresh_result = tokio::time::timeout( - Duration::from_secs(60), - self.refresh_token(), - ) - .await - .map_err(|_| { - std::io::Error::other("timed out while refreshing OpenAI API key") - }) - .and_then(|result| result.map_err(std::io::Error::other)); - - match refresh_result { - Ok(_) => { - tokens = self - .get_current_token_data() - .ok_or(std::io::Error::other( - "Token data is not available after refresh.", - ))?; - } - Err(err) => { - if !access_token_is_still_valid(&tokens.access_token, Utc::now()) { - return Err(err); - } - self.record_proactive_refresh_fallback(Utc::now()); - } - } - } - - Ok(tokens) - } - _ => Err(std::io::Error::other("Token data is not available.")), - } - } - - pub async fn get_token(&self) -> Result { - match self.mode { - AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { - let id_token = self.get_token_data().await?.access_token; - Ok(id_token) - } - } - } - - pub fn get_account_id(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.account_id.clone()) - } - - pub fn get_plan_type(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) - } - - pub fn is_fedramp_account(&self) -> bool { - self.get_current_token_data() - .is_some_and(|t| t.id_token.is_fedramp_account()) - } - - pub fn uses_codex_backend(&self) -> bool { - matches!(self.mode, AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens) - } - - pub fn supports_pro_only_models(&self) -> bool { - self.uses_codex_backend() - && self - .get_plan_type() - .is_some_and(|plan| plan.eq_ignore_ascii_case("pro")) - } - - fn get_current_auth_json(&self) -> Option { - #[expect(clippy::unwrap_used)] - self.auth_dot_json.lock().unwrap().clone() - } - - fn get_current_token_data(&self) -> Option { - self.get_current_auth_json().and_then(|t| t.tokens.clone()) - } - - fn record_proactive_refresh_fallback(&self, timestamp: DateTime) { - let updated = { - let mut guard = self.auth_dot_json.lock().unwrap(); - let Some(auth_dot_json) = guard.as_mut() else { - return; - }; - auth_dot_json.last_refresh = Some(timestamp); - auth_dot_json.clone() - }; - - if !self.auth_file.as_os_str().is_empty() { - if let Err(err) = write_auth_json(&self.auth_file, &updated) { - tracing::warn!("failed to persist proactive refresh fallback cooldown: {err}"); - } - } - } - - /// Consider this private to integration tests. - pub fn create_dummy_chatgpt_auth_for_testing() -> Self { - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: Some(TokenData { - id_token: Default::default(), - access_token: "Access Token".to_string(), - refresh_token: "test".to_string(), - account_id: Some("account_id".to_string()), - }), - last_refresh: Some(Utc::now()), - }; - - let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); - Self { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file: PathBuf::new(), - auth_dot_json, - client: crate::default_client::create_client("code_cli_rs"), - } - } - - fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self { - Self { - api_key: Some(api_key.to_owned()), - mode: AuthMode::ApiKey, - auth_file: PathBuf::new(), - auth_dot_json: Arc::new(Mutex::new(None)), - client, - } - } - - pub fn from_api_key(api_key: &str) -> Self { - Self::from_api_key_with_client( - api_key, - crate::default_client::create_client(crate::default_client::DEFAULT_ORIGINATOR), - ) - } - - pub fn from_tokens_with_originator( - tokens: TokenData, - last_refresh: Option>, - originator: &str, - ) -> Self { - Self::from_tokens_with_originator_and_mode( - tokens, - last_refresh, - originator, - AuthMode::ChatGPT, - ) - } - - pub fn from_tokens_with_originator_and_mode( - tokens: TokenData, - last_refresh: Option>, - originator: &str, - mode: AuthMode, - ) -> Self { - let auth_dot_json = AuthDotJson { - auth_mode: Some(mode), - openai_api_key: None, - tokens: Some(tokens), - last_refresh, - }; - - Self { - api_key: None, - mode, - auth_file: PathBuf::new(), - auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client: crate::default_client::create_client(originator), - } - } -} - -fn should_proactively_refresh_auth( - last_refresh: Option>, - access_token: Option<&str>, -) -> bool { - let now = Utc::now(); - if let Some(access_token) = access_token - && let Ok(Some(expires_at)) = parse_jwt_expiration(access_token) - { - if expires_at <= now { - return true; - } - if expires_at - <= now + chrono::Duration::minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES) - { - return last_refresh.is_none_or(|last_refresh| { - last_refresh - < now - - chrono::Duration::minutes( - CHATGPT_ACCESS_TOKEN_REFRESH_RETRY_COOLDOWN_MINUTES, - ) - }); - } - return false; - } - - last_refresh.is_some_and(|last_refresh| { - last_refresh < now - chrono::Duration::days(28) - }) -} - -fn access_token_is_still_valid(access_token: &str, now: DateTime) -> bool { - parse_jwt_expiration(access_token) - .ok() - .flatten() - .is_some_and(|expires_at| expires_at > now) -} - -pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; -pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; - -fn read_openai_api_key_from_env() -> Option { - env::var(OPENAI_API_KEY_ENV_VAR) - .ok() - .filter(|s| !s.is_empty()) -} - -pub fn read_code_api_key_from_env() -> Option { - env::var(CODEX_API_KEY_ENV_VAR) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -pub fn get_auth_file(code_home: &Path) -> PathBuf { - code_home.join("auth.json") -} - -/// Delete the auth.json file inside `code_home` if it exists. Returns `Ok(true)` -/// if a file was removed, `Ok(false)` if no auth file was present. -pub fn remove_auth_file(code_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(code_home); - let removed = match std::fs::remove_file(&auth_file) { - Ok(_) => true, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, - Err(err) => return Err(err), - }; - Ok(removed) -} - -/// Log out of the current account. This removes auth.json and disconnects the -/// active stored account while preserving unrelated stored accounts. -pub fn logout(code_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(code_home); - let current_auth = try_read_auth_json(&auth_file).ok(); - let removed = remove_auth_file(code_home)?; - let active_account_id = crate::auth_accounts::get_active_account_id(code_home)?; - - let removed_account = if let Some(account_id) = active_account_id { - let removed = crate::auth_accounts::remove_account(code_home, &account_id)?.is_some(); - if !removed { - let _ = crate::auth_accounts::set_active_account_id(code_home, None)?; - } - let removed_matching = if let Some(auth) = ¤t_auth { - remove_account_matching_auth(code_home, auth)? - } else { - false - }; - removed || removed_matching - } else if let Some(auth) = ¤t_auth { - remove_account_matching_auth(code_home, auth)? - } else { - let _ = crate::auth_accounts::set_active_account_id(code_home, None)?; - false - }; - Ok(removed || removed_account) -} - -fn remove_account_matching_auth(code_home: &Path, auth: &AuthDotJson) -> std::io::Result { - let AuthDotJson { - auth_mode, - openai_api_key, - tokens, - last_refresh: _, - } = auth; - let mut candidate_modes = Vec::new(); - if let Some(mode) = auth_mode { - candidate_modes.push(*mode); - } - if tokens.is_some() && !candidate_modes.contains(&AuthMode::ChatGPT) { - candidate_modes.push(AuthMode::ChatGPT); - } - if openai_api_key.is_some() && !candidate_modes.contains(&AuthMode::ApiKey) { - candidate_modes.push(AuthMode::ApiKey); - } - - let mut removed = false; - for mode in candidate_modes { - removed |= crate::auth_accounts::remove_account_matching_credentials( - code_home, - mode, - openai_api_key.as_deref(), - tokens.as_ref(), - )? - .is_some(); - } - - if auth_mode.is_none() && tokens.is_none() && openai_api_key.is_none() { - let _ = crate::auth_accounts::set_active_account_id(code_home, None)?; - } - Ok(removed) -} - -/// Writes an `auth.json` that contains only the API key. Intended for CLI use. -pub fn login_with_api_key(code_home: &Path, api_key: &str) -> std::io::Result<()> { - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some(api_key.to_string()), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(code_home), &auth_dot_json)?; - let _ = crate::auth_accounts::upsert_api_key_account( - code_home, - api_key.to_string(), - None, - true, - )?; - Ok(()) -} - -pub fn login_with_chatgpt_auth_tokens( - code_home: &Path, - access_token: &str, - chatgpt_account_id: &str, - chatgpt_plan_type: Option<&str>, -) -> std::io::Result<()> { - let mut id_token = parse_id_token(access_token).map_err(std::io::Error::other)?; - if let Some(plan_type) = chatgpt_plan_type { - id_token.chatgpt_plan_type = Some(match plan_type.trim().to_ascii_lowercase().as_str() { - "free" => PlanType::Known(KnownPlan::Free), - "plus" => PlanType::Known(KnownPlan::Plus), - "pro" => PlanType::Known(KnownPlan::Pro), - "team" => PlanType::Known(KnownPlan::Team), - "business" => PlanType::Known(KnownPlan::Business), - "enterprise" => PlanType::Known(KnownPlan::Enterprise), - "edu" => PlanType::Known(KnownPlan::Edu), - _ => PlanType::Unknown(plan_type.to_string()), - }); - } - - let tokens = TokenData { - id_token, - access_token: access_token.to_string(), - refresh_token: String::new(), - account_id: Some(chatgpt_account_id.to_string()), - }; - let last_refresh = Utc::now(); - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatgptAuthTokens), - openai_api_key: None, - tokens: Some(tokens.clone()), - last_refresh: Some(last_refresh), - }; - write_auth_json(&get_auth_file(code_home), &auth_dot_json)?; - - let email_for_store = tokens.id_token.email.clone(); - let _ = crate::auth_accounts::upsert_chatgpt_account( - code_home, - tokens, - last_refresh, - email_for_store, - true, - )?; - - Ok(()) -} - -pub async fn auth_for_stored_account( - code_home: &Path, - account: &crate::auth_accounts::StoredAccount, - originator: &str, -) -> std::io::Result { - match account.mode { - AuthMode::ApiKey => { - let api_key = account.openai_api_key.clone().ok_or_else(|| { - std::io::Error::other("stored API key account is missing the key value") - })?; - Ok(CodexAuth::from_api_key_with_client( - &api_key, - crate::default_client::create_client(originator), - )) - } - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { - let mut tokens = account.tokens.clone().ok_or_else(|| { - std::io::Error::other("stored ChatGPT account is missing token data") - })?; - let mut last_refresh = account.last_refresh; - let now = Utc::now(); - let refresh_needed = account.mode == AuthMode::ChatGPT - && should_refresh_stored_account_auth(last_refresh, &tokens.access_token); - - if refresh_needed { - let client = crate::default_client::create_client(originator); - let refresh_response = tokio::time::timeout( - Duration::from_secs(60), - try_refresh_token(tokens.refresh_token.clone(), &client), - ) - .await - .map_err(|_| { - std::io::Error::other("timed out while refreshing OpenAI API key") - }); - - let refresh_response = match refresh_response { - Ok(response) => response, - Err(err) => { - if access_token_is_still_valid(&tokens.access_token, Utc::now()) { - last_refresh = Some(record_stored_account_proactive_refresh_fallback( - code_home, - &account.id, - )); - return Ok(CodexAuth::from_tokens_with_originator_and_mode( - tokens, - last_refresh, - originator, - account.mode, - )); - } - return Err(err); - } - }; - - let refresh_response = match refresh_response { - Ok(response) => response, - Err(err) => { - if err.is_refresh_token_reused() { - if let Ok(Some(updated)) = - crate::auth_accounts::find_account(code_home, &account.id) - { - if let Some(updated_tokens) = updated.tokens { - return Ok(CodexAuth::from_tokens_with_originator_and_mode( - updated_tokens, - updated.last_refresh, - originator, - account.mode, - )); - } - } - } - if access_token_is_still_valid(&tokens.access_token, Utc::now()) { - last_refresh = Some(record_stored_account_proactive_refresh_fallback( - code_home, - &account.id, - )); - return Ok(CodexAuth::from_tokens_with_originator_and_mode( - tokens, - last_refresh, - originator, - account.mode, - )); - } - return Err(std::io::Error::other(err)); - } - }; - if let Some(id_token) = refresh_response.id_token { - tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; - } - if let Some(access_token) = refresh_response.access_token { - tokens.access_token = access_token; - } - if let Some(refresh_token) = refresh_response.refresh_token { - tokens.refresh_token = refresh_token; - } - last_refresh = Some(now); - let _ = crate::auth_accounts::upsert_chatgpt_account( - code_home, - tokens.clone(), - now, - account.label.clone(), - false, - )?; - } - - Ok(CodexAuth::from_tokens_with_originator_and_mode( - tokens, - last_refresh, - originator, - account.mode, - )) - } - } -} - -fn record_stored_account_proactive_refresh_fallback( - code_home: &Path, - account_id: &str, -) -> DateTime { - let now = Utc::now(); - if let Err(err) = crate::auth_accounts::update_account_last_refresh(code_home, account_id, now) { - tracing::warn!("failed to persist proactive refresh fallback cooldown: {err}"); - } - now -} - -fn should_refresh_stored_account_auth( - last_refresh: Option>, - access_token: &str, -) -> bool { - if let Ok(Some(_)) = parse_jwt_expiration(access_token) { - return should_proactively_refresh_auth(last_refresh, Some(access_token)); - } - - last_refresh - .map(|last| last < Utc::now() - chrono::Duration::days(28)) - .unwrap_or(true) -} - -/// Activate a stored account by writing its credentials to auth.json and -/// marking it active in the account store. -pub fn activate_account(code_home: &Path, account_id: &str) -> std::io::Result<()> { - let Some(account) = crate::auth_accounts::find_account(code_home, account_id)? else { - return Err(std::io::Error::other(format!( - "account with id {account_id} was not found" - ))); - }; - - let auth_file = get_auth_file(code_home); - let account_id_owned = account.id.clone(); - match account.mode { - AuthMode::ApiKey => { - let api_key = account.openai_api_key.clone().ok_or_else(|| { - std::io::Error::other("stored API key account is missing the key value") - })?; - let auth = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some(api_key), - tokens: None, - last_refresh: None, - }; - write_auth_json(&auth_file, &auth)?; - } - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => { - let tokens = account.tokens.clone().ok_or_else(|| { - std::io::Error::other("stored ChatGPT account is missing token data") - })?; - let auth = AuthDotJson { - auth_mode: Some(account.mode), - openai_api_key: None, - tokens: Some(tokens), - last_refresh: account.last_refresh, - }; - write_auth_json(&auth_file, &auth)?; - } - } - - let _ = crate::auth_accounts::set_active_account_id(code_home, Some(account_id_owned))?; - Ok(()) -} - -fn load_auth( - code_home: &Path, - include_env_var: bool, - preferred_auth_method: AuthMode, - originator: &str, -) -> std::io::Result> { - // First, check to see if there is a valid auth.json file. If not, we fall - // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable - // (if it is set). - let auth_file = get_auth_file(code_home); - let auth_read_path = resolve_code_path_for_read(code_home, Path::new("auth.json")); - let client = crate::default_client::create_client(originator); - let auth_dot_json = match try_read_auth_json(&auth_read_path) { - Ok(auth) => auth, - // If auth.json does not exist, try to read the OPENAI_API_KEY from the - // environment variable. - Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { - return match read_openai_api_key_from_env() { - Some(api_key) => Ok(Some(CodexAuth::from_api_key_with_client(&api_key, client))), - None => Ok(None), - }; - } - // Though if auth.json exists but is malformed, do not fall back to the - // env var because the user may be expecting to use AuthMode::ChatGPT. - Err(e) => { - return Err(e); - } - }; - - let AuthDotJson { - auth_mode, - openai_api_key: auth_json_api_key, - tokens, - last_refresh, - } = auth_dot_json; - - let mut effective_preference = preferred_auth_method; - if let Some(mode) = auth_mode { - match mode { - AuthMode::ApiKey => { - let Some(api_key) = auth_json_api_key.as_ref() else { - return Err(std::io::Error::other( - "auth.json requests API key auth but no API key is stored", - )); - }; - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); - } - AuthMode::ChatgptAuthTokens => { - let Some(tokens) = tokens.clone() else { - return Err(std::io::Error::other( - "auth.json requests ChatGPT auth tokens but token data is missing", - )); - }; - return Ok(Some(CodexAuth { - api_key: None, - mode: AuthMode::ChatgptAuthTokens, - auth_file, - auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { - auth_mode: Some(AuthMode::ChatgptAuthTokens), - openai_api_key: None, - tokens: Some(tokens), - last_refresh, - }))), - client, - })); - } - AuthMode::ChatGPT => { - effective_preference = AuthMode::ChatGPT; - } - } - } - - // If the auth.json has an API key, decide whether to use it. - if let Some(api_key) = &auth_json_api_key { - let plan_requires_api_key = tokens - .as_ref() - .and_then(|t| t.id_token.chatgpt_plan_type.as_ref()) - .is_some_and(|plan| matches!(plan, PlanType::Known(KnownPlan::Enterprise))); - - if plan_requires_api_key { - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); - } - - // Should any of these be AuthMode::ChatGPT with the api_key set? - // Does AuthMode::ChatGPT indicate that there is an auth.json that is - // "refreshable" even if we are using the API key for auth? - match &tokens { - Some(_tokens) => { - // When tokens are present, honor the caller's preference strictly: - // - If the caller prefers API key, use it. - // - Otherwise, prefer ChatGPT and ignore the API key. - if effective_preference == AuthMode::ApiKey { - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); - } - // else: fall through to ChatGPT auth - } - None => { - // We have an API key but no tokens in the auth.json file. - // Perhaps the user ran `codex login --api-key ` or updated - // auth.json by hand. Either way, let's assume they are trying - // to use their API key. - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); - } - } - } - - // For the AuthMode::ChatGPT variant, perhaps neither api_key nor - // openai_api_key should exist? - Ok(Some(CodexAuth { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file, - auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens, - last_refresh, - }))), - client, - })) -} - -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. -/// Returns the full AuthDotJson structure after refreshing if necessary. -pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { - let mut file = File::open(auth_file)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - - Ok(auth_dot_json) -} - -pub fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { - let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let parent = auth_file.parent().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("auth path has no parent: {}", auth_file.display()), - ) - })?; - if !parent.exists() { - std::fs::create_dir_all(parent)?; - } - let mut file = NamedTempFile::new_in(parent)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - file.as_file_mut().sync_all()?; - file.persist(auth_file).map_err(|err| err.error)?; - Ok(()) -} - -async fn update_tokens( - auth_file: &Path, - id_token: Option, - access_token: Option, - refresh_token: Option, -) -> std::io::Result { - let mut auth_dot_json = try_read_auth_json(auth_file)?; - - let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); - if let Some(id_token) = id_token { - tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; - } - if let Some(access_token) = access_token { - tokens.access_token = access_token.to_string(); - } - if let Some(refresh_token) = refresh_token { - tokens.refresh_token = refresh_token.to_string(); - } - auth_dot_json.last_refresh = Some(Utc::now()); - write_auth_json(auth_file, &auth_dot_json)?; - - if let Some(code_home) = auth_file.parent() { - if let Some(tokens) = auth_dot_json.tokens.clone() { - let last_refresh = auth_dot_json - .last_refresh - .unwrap_or_else(Utc::now); - let email = tokens.id_token.email.clone(); - let _ = crate::auth_accounts::upsert_chatgpt_account( - code_home, - tokens, - last_refresh, - email, - true, - )?; - } - } - Ok(auth_dot_json) -} - -async fn try_refresh_token( - refresh_token: String, - client: &reqwest::Client, -) -> Result { - let refresh_request = RefreshRequest { - client_id: CLIENT_ID, - grant_type: "refresh_token", - refresh_token, - }; - - // Use shared client factory to include standard headers - let response = client - .post(refresh_token_endpoint()) - .header("Content-Type", "application/json") - .json(&refresh_request) - .send() - .await - .map_err(|err| RefreshTokenError::transient(format!("network error: {err}")))?; - - if response.status().is_success() { - let refresh_response = response - .json::() - .await - .map_err(|err| RefreshTokenError::transient(format!("invalid response: {err}")))?; - return Ok(refresh_response); - } - - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - Err(classify_refresh_failure(status, &body)) -} - -#[derive(Serialize)] -struct RefreshRequest { - client_id: &'static str, - grant_type: &'static str, - refresh_token: String, -} - -#[derive(Deserialize, Clone)] -struct RefreshResponse { - id_token: Option, - access_token: Option, - refresh_token: Option, -} - -#[derive(Deserialize)] -struct OAuthErrorBody { - error: Option, - error_description: Option, -} - -#[derive(Deserialize)] -struct OpenAiErrorWrapper { - error: Option, -} - -#[derive(Deserialize)] -struct OpenAiErrorData { - code: Option, - message: Option, -} - -fn classify_refresh_failure(status: StatusCode, body: &str) -> RefreshTokenError { - if let Ok(parsed) = serde_json::from_str::(body) { - if let Some(error) = parsed.error { - if let Some(code) = error.code.as_deref() - && matches!( - code, - "refresh_token_expired" - | "refresh_token_reused" - | "refresh_token_invalidated" - ) - { - let message = error.message.unwrap_or_else(|| match code { - "refresh_token_expired" => { - "refresh token expired; please sign in again".to_string() - } - "refresh_token_reused" => { - "refresh token already rotated; please sign in again".to_string() - } - "refresh_token_invalidated" => { - "refresh token revoked; please sign in again".to_string() - } - _ => "refresh token unavailable; please sign in again".to_string(), - }); - return RefreshTokenError::permanent(format!("{code}: {message}")); - } - } - } - - if let Ok(parsed) = serde_json::from_str::(body) { - if let Some(code) = parsed.error.as_deref() { - let description = parsed - .error_description - .as_deref() - .unwrap_or(code) - .trim(); - let formatted = format!("OAuth error ({code}): {description}"); - match code { - "invalid_grant" | "invalid_client" | "invalid_scope" => { - return RefreshTokenError::permanent(formatted) - } - "access_denied" => { - return RefreshTokenError::permanent(formatted); - } - "temporarily_unavailable" => { - return RefreshTokenError::transient(formatted); - } - _ => { - if status.is_server_error() { - return RefreshTokenError::transient(formatted); - } - if status.is_client_error() { - return RefreshTokenError::permanent(formatted); - } - } - } - } - } - - if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED { - return RefreshTokenError::permanent(format!( - "OAuth refresh rejected ({status}): {}", - summarize_body(body) - )); - } - - if status.is_client_error() { - return RefreshTokenError::permanent(format!( - "OAuth refresh failed ({status}): {}", - summarize_body(body) - )); - } - - if status.is_server_error() { - return RefreshTokenError::transient(format!( - "OAuth refresh temporarily unavailable ({status}): {}", - summarize_body(body) - )); - } - - RefreshTokenError::transient(format!( - "OAuth refresh failed with unexpected response ({status})" - )) -} - -fn refresh_token_endpoint() -> String { - env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) - .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string()) -} - -fn summarize_body(body: &str) -> String { - let trimmed = body.trim(); - if trimmed.is_empty() { - return "".to_string(); - } - const MAX_LEN: usize = 240; - if trimmed.len() > MAX_LEN { - format!("{}…", &trimmed[..MAX_LEN]) - } else { - trimmed.to_string() - } -} - -/// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -pub struct AuthDotJson { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_mode: Option, - - #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, -} - -// Shared constant for token refresh (client id used for oauth token refresh flow) -pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -const CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5; -const CHATGPT_ACCESS_TOKEN_REFRESH_RETRY_COOLDOWN_MINUTES: i64 = 5; - -use std::sync::RwLock; - -/// Internal cached auth state. -#[derive(Clone, Debug)] -struct CachedAuth { - preferred_auth_mode: AuthMode, - auth: Option, - permanent_refresh_failure: Option, -} - -#[derive(Clone, Debug)] -struct AuthScopedRefreshFailure { - auth: CodexAuth, - error: RefreshTokenError, -} - -enum ReloadOutcome { - /// Reload was performed and the cached auth changed. - ReloadedChanged, - /// Reload was performed and the cached auth remained the same. - ReloadedNoChange, - /// Reload was skipped (missing or mismatched account id). - Skipped, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token_data::IdTokenInfo; - use crate::token_data::KnownPlan; - use crate::token_data::PlanType; - use base64::Engine; - use reqwest::StatusCode; - use pretty_assertions::assert_eq; - use serial_test::serial; - use serde::Serialize; - use serde_json::json; - use tempfile::tempdir; - use wiremock::matchers::method; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - - const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; - - #[tokio::test] - async fn roundtrip_auth_dot_json() { - let code_home = tempdir().unwrap(); - let _ = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - code_home.path(), - ) - .expect("failed to write auth file"); - - let file = get_auth_file(code_home.path()); - let auth_dot_json = try_read_auth_json(&file).unwrap(); - write_auth_json(&file, &auth_dot_json).unwrap(); - - let same_auth_dot_json = try_read_auth_json(&file).unwrap(); - assert_eq!(auth_dot_json, same_auth_dot_json); - } - - #[test] - fn login_with_api_key_overwrites_existing_auth_json() { - let dir = tempdir().unwrap(); - let auth_path = dir.path().join("auth.json"); - let stale_auth = json!({ - "OPENAI_API_KEY": "sk-old", - "tokens": { - "id_token": "stale.header.payload", - "access_token": "stale-access", - "refresh_token": "stale-refresh", - "account_id": "stale-acc" - } - }); - std::fs::write( - &auth_path, - serde_json::to_string_pretty(&stale_auth).unwrap(), - ) - .unwrap(); - - super::login_with_api_key(dir.path(), "sk-new").expect("login_with_api_key should succeed"); - - let auth = super::try_read_auth_json(&auth_path).expect("auth.json should parse"); - assert_eq!(auth.openai_api_key.as_deref(), Some("sk-new")); - assert!(auth.tokens.is_none(), "tokens should be cleared"); - } - - #[tokio::test] - async fn pro_account_with_no_api_key_uses_chatgpt_auth() { - let code_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - code_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - .. - } = super::load_auth(code_home.path(), false, AuthMode::ChatGPT, "code_cli_rs") - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - chatgpt_account_is_fedramp: false, - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in - /// [`TokenData::is_plan_that_should_use_api_key`], it should use - /// [`AuthMode::ChatGPT`]. - #[tokio::test] - async fn pro_account_with_api_key_still_uses_chatgpt_auth() { - let code_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "pro".to_string(), - }, - code_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - .. - } = super::load_auth(code_home.path(), false, AuthMode::ChatGPT, "code_cli_rs") - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - chatgpt_account_is_fedramp: false, - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise - /// account, then it should use [`AuthMode::ApiKey`]. - #[tokio::test] - async fn enterprise_account_with_api_key_uses_apikey_auth() { - let code_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "enterprise".to_string(), - }, - code_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - .. - } = super::load_auth(code_home.path(), false, AuthMode::ChatGPT, "code_cli_rs") - .unwrap() - .unwrap(); - assert_eq!(Some("sk-test-key".to_string()), api_key); - assert_eq!(AuthMode::ApiKey, mode); - - let guard = auth_dot_json.lock().expect("should unwrap"); - assert!(guard.is_none(), "auth_dot_json should be None"); - } - - #[tokio::test] - async fn loads_api_key_from_auth_json() { - let dir = tempdir().unwrap(); - let auth_file = dir.path().join("auth.json"); - std::fs::write( - auth_file, - r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#, - ) - .unwrap(); - - let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT, "code_cli_rs") - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some("sk-test-key".to_string())); - - assert!(auth.get_token_data().await.is_err()); - } - - #[test] - fn logout_removes_auth_file() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some("sk-test-key".to_string()), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - assert!(dir.path().join("auth.json").exists()); - let removed = logout(dir.path())?; - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); - Ok(()) - } - - #[test] - fn remove_auth_file_does_not_touch_stored_accounts() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let active = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-active".to_string(), - Some("Active".to_string()), - true, - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: active.openai_api_key.clone(), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = remove_auth_file(dir.path())?; - - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); - assert_eq!( - crate::auth_accounts::get_active_account_id(dir.path())?.as_deref(), - Some(active.id.as_str()) - ); - assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_some()); - Ok(()) - } - - #[test] - fn logout_removes_only_active_stored_account() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let active = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-active".to_string(), - Some("Active".to_string()), - true, - )?; - let other = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-other".to_string(), - Some("Other".to_string()), - false, - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: active.openai_api_key.clone(), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some()); - let accounts = crate::auth_accounts::list_accounts(dir.path())?; - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, other.id); - Ok(()) - } - - #[test] - fn logout_removes_stale_active_chatgpt_account() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let stale_account = crate::auth_accounts::upsert_chatgpt_account( - dir.path(), - token_data_for_access("expired-access".to_string()), - Utc::now() - chrono::Duration::days(3), - Some("Stale".to_string()), - true, - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: stale_account.tokens.clone(), - last_refresh: stale_account.last_refresh, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none()); - assert!(crate::auth_accounts::list_accounts(dir.path())?.is_empty()); - Ok(()) - } - - #[test] - fn logout_matches_auth_json_when_active_account_missing() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let stale_account = crate::auth_accounts::upsert_chatgpt_account( - dir.path(), - token_data_for_access("expired-access".to_string()), - Utc::now() - chrono::Duration::days(3), - Some("Stale".to_string()), - false, - )?; - let other = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-other".to_string(), - Some("Other".to_string()), - false, - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: stale_account.tokens.clone(), - last_refresh: stale_account.last_refresh, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some()); - Ok(()) - } - - #[test] - fn logout_matches_auth_json_when_active_pointer_is_stale() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let stale_account = crate::auth_accounts::upsert_chatgpt_account( - dir.path(), - token_data_for_access("expired-access".to_string()), - Utc::now() - chrono::Duration::days(3), - Some("Stale".to_string()), - false, - )?; - let other = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-other".to_string(), - Some("Other".to_string()), - false, - )?; - let _ = crate::auth_accounts::set_active_account_id( - dir.path(), - Some("missing-account".to_string()), - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: stale_account.tokens.clone(), - last_refresh: stale_account.last_refresh, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some()); - Ok(()) - } - - #[test] - fn logout_clears_stale_active_pointer_without_matching_auth() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let _ = crate::auth_accounts::set_active_account_id( - dir.path(), - Some("missing-account".to_string()), - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some("sk-missing".to_string()), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::list_accounts(dir.path())?.is_empty()); - Ok(()) - } - - #[test] - fn logout_removes_auth_json_account_when_active_points_elsewhere() -> Result<(), std::io::Error> - { - let dir = tempdir()?; - let active = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-active".to_string(), - Some("Active".to_string()), - true, - )?; - let stale_account = crate::auth_accounts::upsert_chatgpt_account( - dir.path(), - token_data_for_access("expired-access".to_string()), - Utc::now() - chrono::Duration::days(3), - Some("Stale".to_string()), - false, - )?; - let other = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-other".to_string(), - Some("Other".to_string()), - false, - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: stale_account.tokens.clone(), - last_refresh: stale_account.last_refresh, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some()); - Ok(()) - } - - #[test] - fn logout_matches_api_key_when_auth_mode_is_stale() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let api_key_account = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-active".to_string(), - Some("Active".to_string()), - true, - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: api_key_account.openai_api_key.clone(), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &api_key_account.id)?.is_none()); - Ok(()) - } - - #[test] - fn logout_matches_chatgpt_tokens_when_auth_mode_is_stale() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let chatgpt_account = crate::auth_accounts::upsert_chatgpt_account( - dir.path(), - token_data_for_access("expired-access".to_string()), - Utc::now() - chrono::Duration::days(3), - Some("Stale".to_string()), - true, - )?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: None, - tokens: chatgpt_account.tokens.clone(), - last_refresh: chatgpt_account.last_refresh, - }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &chatgpt_account.id)?.is_none()); - Ok(()) - } - - #[test] - fn logout_removes_active_stored_account_without_auth_json() -> Result<(), std::io::Error> { - let dir = tempdir()?; - let active = crate::auth_accounts::upsert_api_key_account( - dir.path(), - "sk-active".to_string(), - Some("Active".to_string()), - true, - )?; - - let removed = logout(dir.path())?; - - assert!(removed); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_none()); - Ok(()) - } - - #[test] - fn logout_without_auth_or_active_account_returns_false() -> Result<(), std::io::Error> { - let dir = tempdir()?; - - let removed = logout(dir.path())?; - - assert!(!removed); - assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none()); - assert!(crate::auth_accounts::list_accounts(dir.path())?.is_empty()); - Ok(()) - } - - fn assert_permanent(body: &str, status: StatusCode) { - let err = classify_refresh_failure(status, body); - assert!(err.is_permanent(), "expected permanent error, got {:?}", err.kind); - } - - fn assert_transient(body: &str, status: StatusCode) { - let err = classify_refresh_failure(status, body); - assert!( - matches!(err.kind, RefreshTokenErrorKind::Transient), - "expected transient error, got {:?}", - err.kind - ); - } - - #[test] - fn invalid_grant_is_permanent() { - assert_permanent( - r#"{"error":"invalid_grant","error_description":"refresh token revoked"}"#, - StatusCode::BAD_REQUEST, - ); - } - - #[test] - fn invalid_client_is_permanent() { - assert_permanent( - r#"{"error":"invalid_client","error_description":"client mismatch"}"#, - StatusCode::UNAUTHORIZED, - ); - } - - #[test] - fn temporarily_unavailable_is_transient() { - assert_transient( - r#"{"error":"temporarily_unavailable","error_description":"please retry"}"#, - StatusCode::SERVICE_UNAVAILABLE, - ); - } - - #[test] - fn refresh_token_reused_is_permanent_and_detected() { - let body = r#"{ - "error": { - "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", - "type": "invalid_request_error", - "code": "refresh_token_reused" - } -}"#; - - let err = classify_refresh_failure(StatusCode::UNAUTHORIZED, body); - assert!(matches!(err.kind, RefreshTokenErrorKind::Permanent)); - assert!(err.is_refresh_token_reused()); - } - - #[test] - fn five_hundred_without_body_is_transient() { - assert_transient("", StatusCode::BAD_GATEWAY); - } - - #[test] - fn forbidden_without_body_is_permanent() { - assert_permanent("", StatusCode::FORBIDDEN); - } - - #[test] - fn adopts_rotated_refresh_token_from_disk() { - let dir = tempdir().unwrap(); - let auth_file = get_auth_file(dir.path()); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - dir.path(), - ) - .expect("failed to write auth file"); - - let cached_tokens = TokenData { - id_token: parse_id_token(&fake_jwt).expect("failed to parse id token"), - access_token: "cached-access".to_string(), - refresh_token: "stale-refresh".to_string(), - account_id: None, - }; - - let cached_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: Some(cached_tokens.clone()), - last_refresh: None, - }; - - let rotated_tokens = TokenData { - id_token: parse_id_token(&fake_jwt).expect("failed to parse id token"), - access_token: "rotated-access".to_string(), - refresh_token: "rotated-refresh".to_string(), - account_id: None, - }; - - let rotated_auth = AuthDotJson { - auth_mode: Some(AuthMode::ChatGPT), - openai_api_key: None, - tokens: Some(rotated_tokens.clone()), - last_refresh: Some(Utc::now()), - }; - - write_auth_json(&auth_file, &rotated_auth).expect("failed to write rotated auth"); - - let auth = CodexAuth { - mode: AuthMode::ChatGPT, - api_key: None, - auth_dot_json: Arc::new(Mutex::new(Some(cached_auth))), - auth_file, - client: reqwest::Client::new(), - }; - - let rotated_access = rotated_tokens.access_token.clone(); - - let adopted = auth - .adopt_rotated_refresh_token_from_disk(&cached_tokens.refresh_token) - .expect("adoption should succeed"); - - assert_eq!(adopted, Some(rotated_access.clone())); - - let guard = auth.auth_dot_json.lock().expect("mutex poisoned"); - let updated = guard - .as_ref() - .and_then(|auth| auth.tokens.as_ref()) - .expect("tokens should exist after adoption"); - - assert_eq!(updated.refresh_token, rotated_tokens.refresh_token); - assert_eq!(updated.access_token, rotated_access); - } - - #[test] - fn proactive_refresh_only_triggers_for_stale_chatgpt_auth() { - let fresh = Utc::now() - chrono::Duration::days(1); - let stale = Utc::now() - chrono::Duration::days(29); - let future_access = build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() + 3600 })); - let expiring_access = - build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() + 240 })); - let expired_access = build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() - 60 })); - - assert!(!should_proactively_refresh_auth(Some(fresh), None)); - assert!(should_proactively_refresh_auth(Some(stale), None)); - assert!(!should_proactively_refresh_auth(None, None)); - assert!(!should_proactively_refresh_auth(Some(stale), Some(&future_access))); - assert!(should_proactively_refresh_auth(Some(fresh), Some(&expiring_access))); - assert!(should_proactively_refresh_auth(Some(fresh), Some(&expired_access))); - - let just_attempted = Utc::now(); - assert!(!should_proactively_refresh_auth( - Some(just_attempted), - Some(&expiring_access) - )); - assert!(should_proactively_refresh_auth( - Some(just_attempted), - Some(&expired_access) - )); - } - - #[test] - fn access_token_validity_uses_jwt_expiration() { - let future_access = build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() + 3600 })); - let expired_access = build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() - 60 })); - - assert!(access_token_is_still_valid(&future_access, Utc::now())); - assert!(!access_token_is_still_valid(&expired_access, Utc::now())); - assert!(!access_token_is_still_valid("not-a-jwt", Utc::now())); - } - - #[tokio::test] - #[serial] - async fn auth_for_stored_account_uses_valid_cached_token_if_proactive_refresh_fails() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(503)) - .mount(&server) - .await; - let _guard = EnvVarGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, server.uri()); - let code_home = tempdir().unwrap(); - let access_token = build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() + 240 })); - let account = stored_chatgpt_account( - access_token.clone(), - Some(Utc::now() - chrono::Duration::minutes(10)), - ); - let account = crate::auth_accounts::upsert_chatgpt_account( - code_home.path(), - account.tokens.clone().expect("account has tokens"), - account.last_refresh.expect("account has refresh time"), - account.label.clone(), - false, - ) - .expect("seed stored account"); - - let auth = auth_for_stored_account(code_home.path(), &account, "test") - .await - .expect("valid cached token should survive proactive refresh failure"); - - assert_eq!(auth.get_token().await.unwrap(), access_token); - let returned_last_refresh = auth - .get_current_auth_json() - .and_then(|auth| auth.last_refresh) - .expect("fallback should record refresh cooldown"); - assert!(returned_last_refresh > account.last_refresh.unwrap()); - - let accounts = crate::auth_accounts::list_accounts(code_home.path()) - .expect("list stored accounts"); - assert_eq!(accounts.len(), 1, "fallback should not duplicate account"); - let stored = crate::auth_accounts::find_account(code_home.path(), &account.id) - .expect("read stored account") - .expect("original account should remain stored"); - assert_eq!(stored.id, account.id); - let stored_tokens = stored.tokens.expect("stored account keeps tokens"); - let account_tokens = account.tokens.expect("seeded account has tokens"); - assert_eq!(stored_tokens.id_token.raw_jwt, account_tokens.id_token.raw_jwt); - assert_eq!(stored_tokens.access_token, account_tokens.access_token); - assert_eq!(stored_tokens.refresh_token, account_tokens.refresh_token); - assert_eq!(stored_tokens.account_id, account_tokens.account_id); - assert!(stored.last_refresh.unwrap() > account.last_refresh.unwrap()); - } - - #[tokio::test] - #[serial] - async fn token_data_uses_valid_cached_token_if_proactive_refresh_fails() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(503)) - .mount(&server) - .await; - let _guard = EnvVarGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, server.uri()); - let access_token = build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() + 240 })); - let tokens = token_data_for_access(access_token.clone()); - let auth = CodexAuth::from_tokens_with_originator_and_mode( - tokens, - Some(Utc::now() - chrono::Duration::minutes(10)), - "test", - AuthMode::ChatGPT, - ); - - let token_data = auth - .get_token_data() - .await - .expect("valid cached token should survive proactive refresh failure"); - - assert_eq!(token_data.access_token, access_token); - let requests_after_fallback = server.received_requests().await.unwrap().len(); - assert_eq!(requests_after_fallback, 4); - - let token_data_again = auth - .get_token_data() - .await - .expect("fallback should suppress immediate retry"); - - assert_eq!(token_data_again.access_token, access_token); - assert_eq!(server.received_requests().await.unwrap().len(), requests_after_fallback); - } - - #[tokio::test] - #[serial] - async fn auth_for_stored_account_errors_if_expired_token_refresh_fails() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(503)) - .mount(&server) - .await; - let _guard = EnvVarGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, server.uri()); - let code_home = tempdir().unwrap(); - let access_token = build_jwt(serde_json::json!({ "exp": Utc::now().timestamp() - 60 })); - let account = stored_chatgpt_account(access_token, Some(Utc::now())); - - let err = auth_for_stored_account(code_home.path(), &account, "test") - .await - .expect_err("expired token should still require successful refresh"); - - assert!(err.to_string().contains("temporarily unavailable")); - } - - #[tokio::test] - async fn auth_manager_skips_refresh_for_api_key_auth() { - let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("sk-test")); - assert_eq!(manager.refresh_token_classified().await.unwrap(), None); - } - - #[test] - fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { - let auth = CodexAuth::from_api_key("sk-before"); - let manager = AuthManager::from_auth_for_testing(auth.clone()); - let error = RefreshTokenError::permanent("refresh token already used"); - - manager.record_permanent_refresh_failure_if_unchanged(&auth, &error); - assert_eq!(manager.refresh_failure_for_auth(&auth).unwrap().message, error.message); - - let updated_auth = CodexAuth::from_api_key("sk-after"); - if let Ok(mut guard) = manager.inner.write() { - guard.auth = Some(updated_auth.clone()); - } - assert!(manager.refresh_failure_for_auth(&updated_auth).is_none()); - } - - struct AuthFileParams { - openai_api_key: Option, - chatgpt_plan_type: String, - } - - fn build_jwt(payload: serde_json::Value) -> String { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - - let header = Header { - alg: "none", - typ: "JWT", - }; - let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); - let header_b64 = b64(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64(b"sig"); - format!("{header_b64}.{payload_b64}.{signature_b64}") - } - - fn stored_chatgpt_account( - access_token: String, - last_refresh: Option>, - ) -> crate::auth_accounts::StoredAccount { - crate::auth_accounts::StoredAccount { - id: "account-id".to_string(), - mode: AuthMode::ChatGPT, - label: None, - openai_api_key: None, - tokens: Some(token_data_for_access(access_token)), - last_refresh, - created_at: None, - last_used_at: None, - } - } - - fn token_data_for_access(access_token: String) -> TokenData { - let raw_jwt = build_jwt(serde_json::json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_plan_type": "plus" - } - })); - TokenData { - id_token: IdTokenInfo { - raw_jwt, - email: None, - ..Default::default() - }, - access_token, - refresh_token: "refresh-token".to_string(), - account_id: Some("account-id".to_string()), - } - } - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: String) -> Self { - let previous = std::env::var(key).ok(); - unsafe { std::env::set_var(key, value) }; - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => unsafe { std::env::set_var(self.key, value) }, - None => unsafe { std::env::remove_var(self.key) }, - } - } - } - - fn write_auth_file(params: AuthFileParams, code_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(code_home); - let fake_jwt = build_jwt(serde_json::json!({ - "email": "user@example.com", - "email_verified": true, - "https://api.openai.com/auth": { - "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", - "chatgpt_plan_type": params.chatgpt_plan_type, - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", - } - })); - - let auth_json_data = json!({ - "OPENAI_API_KEY": params.openai_api_key, - "tokens": { - "id_token": fake_jwt, - "access_token": "test-access-token", - "refresh_token": "test-refresh-token" - }, - "last_refresh": LAST_REFRESH, - }); - let auth_json = serde_json::to_string_pretty(&auth_json_data)?; - std::fs::write(auth_file, auth_json)?; - Ok(fake_jwt) - } - - #[tokio::test] - async fn update_tokens_keeps_existing_id_token_when_refresh_omits_it() { - let tmp = tempfile::tempdir().expect("tempdir"); - let original_id_token = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "plus".to_string(), - }, - tmp.path(), - ) - .expect("write auth file"); - - let updated = update_tokens( - &get_auth_file(tmp.path()), - None, - Some("updated-access-token".to_string()), - Some("updated-refresh-token".to_string()), - ) - .await - .expect("update tokens"); - - let tokens = updated.tokens.expect("tokens after refresh"); - let expected = parse_id_token(&original_id_token).expect("parse original id token"); - assert_eq!(tokens.id_token, expected); - assert_eq!(tokens.access_token, "updated-access-token"); - assert_eq!(tokens.refresh_token, "updated-refresh-token"); - } - - #[test] - fn refresh_response_deserializes_without_id_token() { - let parsed = serde_json::from_value::(serde_json::json!({ - "access_token": "updated-access-token", - "refresh_token": "updated-refresh-token" - })) - .expect("deserialize refresh response"); - - assert!(parsed.id_token.is_none()); - assert_eq!(parsed.access_token.as_deref(), Some("updated-access-token")); - assert_eq!(parsed.refresh_token.as_deref(), Some("updated-refresh-token")); - } -} - -/// Central manager providing a single source of truth for auth.json derived -/// authentication data. It loads once (or on preference change) and then -/// hands out cloned `CodexAuth` values so the rest of the program has a -/// consistent snapshot. -/// -/// External modifications to `auth.json` will NOT be observed until -/// `reload()` is called explicitly. This matches the design goal of avoiding -/// different parts of the program seeing inconsistent auth data mid‑run. -#[derive(Debug)] -pub struct AuthManager { - code_home: PathBuf, - originator: String, - inner: RwLock, - enable_code_api_key_env: bool, -} - -impl AuthManager { - /// Create a new manager loading the initial auth using the provided - /// preferred auth method. Errors loading auth are swallowed; `auth()` will - /// simply return `None` in that case so callers can treat it as an - /// unauthenticated state. - pub fn new(code_home: PathBuf, preferred_auth_mode: AuthMode, originator: String) -> Self { - let mut effective_mode = preferred_auth_mode; - let auth = if let Some(api_key) = read_code_api_key_from_env() { - effective_mode = AuthMode::ApiKey; - Some(CodexAuth::from_api_key(&api_key)) - } else { - CodexAuth::from_code_home(&code_home, preferred_auth_mode, &originator) - .ok() - .flatten() - }; - Self { - code_home, - originator, - inner: RwLock::new(CachedAuth { - preferred_auth_mode: effective_mode, - auth, - permanent_refresh_failure: None, - }), - enable_code_api_key_env: true, - } - } - - /// Create an AuthManager with a specific CodexAuth, for testing only. - pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { - let preferred_auth_mode = auth.mode; - let cached = CachedAuth { - preferred_auth_mode, - auth: Some(auth), - permanent_refresh_failure: None, - }; - Arc::new(Self { - code_home: PathBuf::new(), - originator: "code_cli_rs".to_string(), - inner: RwLock::new(cached), - enable_code_api_key_env: false, - }) - } - - /// Test helper used by dependent crates to simulate a terminal refresh failure. - pub fn seed_refresh_failure_for_testing( - &self, - auth: &CodexAuth, - error: RefreshTokenError, - ) { - self.record_permanent_refresh_failure_if_unchanged(auth, &error); - } - - pub fn from_auth(auth: CodexAuth, code_home: PathBuf, originator: String) -> Arc { - let preferred_auth_mode = auth.mode; - Arc::new(Self { - code_home, - originator, - inner: RwLock::new(CachedAuth { - preferred_auth_mode, - auth: Some(auth), - permanent_refresh_failure: None, - }), - enable_code_api_key_env: false, - }) - } - - /// Current cached auth (clone). May be `None` if not logged in or load failed. - pub fn auth(&self) -> Option { - self.inner.read().ok().and_then(|c| c.auth.clone()) - } - - pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { - self.inner.read().ok().and_then(|cached| { - cached - .permanent_refresh_failure - .as_ref() - .filter(|failure| Self::auths_equal_for_refresh(&Some(auth.clone()), &Some(failure.auth.clone()))) - .map(|failure| failure.error.clone()) - }) - } - - pub fn supports_pro_only_models(&self) -> bool { - self.auth() - .is_some_and(|auth| auth.supports_pro_only_models()) - } - - /// Preferred auth method used when (re)loading. - pub fn preferred_auth_method(&self) -> AuthMode { - self.inner - .read() - .map(|c| c.preferred_auth_mode) - .unwrap_or(AuthMode::ApiKey) - } - - /// Force a reload using the existing preferred auth method. Returns - /// whether the auth value changed. - pub fn reload(&self) -> bool { - let preferred = self.preferred_auth_method(); - let env_auth = if self.enable_code_api_key_env { - read_code_api_key_from_env().map(|api_key| CodexAuth::from_api_key(&api_key)) - } else { - None - }; - let new_auth = env_auth.clone().or_else(|| { - CodexAuth::from_code_home(&self.code_home, preferred, &self.originator) - .ok() - .flatten() - }); - if let Ok(mut guard) = self.inner.write() { - let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); - let auth_changed_for_refresh = !AuthManager::auths_equal_for_refresh(&guard.auth, &new_auth); - if auth_changed_for_refresh { - guard.permanent_refresh_failure = None; - } - guard.auth = new_auth; - guard.preferred_auth_mode = env_auth - .as_ref() - .map(|auth| auth.mode) - .unwrap_or(preferred); - changed - } else { - false - } - } - - fn reload_if_account_id_matches(&self, expected_account_id: Option<&str>) -> ReloadOutcome { - let expected_account_id = match expected_account_id { - Some(account_id) => account_id, - None => { - tracing::info!("Skipping auth reload because no account id is available."); - return ReloadOutcome::Skipped; - } - }; - - let preferred = self.preferred_auth_method(); - let env_auth = if self.enable_code_api_key_env { - read_code_api_key_from_env().map(|api_key| CodexAuth::from_api_key(&api_key)) - } else { - None - }; - let new_auth = env_auth.clone().or_else(|| { - CodexAuth::from_code_home(&self.code_home, preferred, &self.originator) - .ok() - .flatten() - }); - - let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id); - if new_account_id.as_deref() != Some(expected_account_id) { - let found_account_id = new_account_id.as_deref().unwrap_or("unknown"); - tracing::info!( - "Skipping auth reload due to account id mismatch (expected: {expected_account_id}, found: {found_account_id})" - ); - return ReloadOutcome::Skipped; - } - - tracing::info!("Reloading auth for account {expected_account_id}"); - if let Ok(mut guard) = self.inner.write() { - let changed = !Self::auths_equal_for_refresh(&guard.auth, &new_auth); - if changed { - guard.permanent_refresh_failure = None; - } - guard.auth = new_auth; - guard.preferred_auth_mode = env_auth - .as_ref() - .map(|auth| auth.mode) - .unwrap_or(preferred); - if changed { - ReloadOutcome::ReloadedChanged - } else { - ReloadOutcome::ReloadedNoChange - } - } else { - ReloadOutcome::Skipped - } - } - - fn auths_equal(a: &Option, b: &Option) -> bool { - match (a, b) { - (None, None) => true, - (Some(a), Some(b)) => a == b, - _ => false, - } - } - - fn auths_equal_for_refresh(a: &Option, b: &Option) -> bool { - match (a, b) { - (None, None) => true, - (Some(a), Some(b)) => match (a.mode, b.mode) { - (AuthMode::ApiKey, AuthMode::ApiKey) => a.api_key == b.api_key, - (AuthMode::ChatGPT, AuthMode::ChatGPT) - | (AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => { - a.get_current_auth_json() == b.get_current_auth_json() - } - _ => false, - }, - _ => false, - } - } - - fn record_permanent_refresh_failure_if_unchanged( - &self, - attempted_auth: &CodexAuth, - error: &RefreshTokenError, - ) { - if !error.is_permanent() { - return; - } - - if let Ok(mut guard) = self.inner.write() { - let current_auth_matches = - Self::auths_equal_for_refresh(&Some(attempted_auth.clone()), &guard.auth); - if current_auth_matches { - guard.permanent_refresh_failure = Some(AuthScopedRefreshFailure { - auth: attempted_auth.clone(), - error: error.clone(), - }); - } - } - } - - /// Convenience constructor returning an `Arc` wrapper with default auth mode + originator. - pub fn shared(code_home: PathBuf) -> Arc { - Arc::new(Self::new( - code_home, - AuthMode::ApiKey, - crate::default_client::DEFAULT_ORIGINATOR.to_string(), - )) - } - - /// Convenience constructor returning an `Arc` wrapper with explicit auth mode and originator. - pub fn shared_with_mode_and_originator( - code_home: PathBuf, - preferred_auth_mode: AuthMode, - originator: String, - ) -> Arc { - Arc::new(Self::new(code_home, preferred_auth_mode, originator)) - } - - /// Attempt to refresh the current auth token (if any). On success, reload - /// the auth state from disk so other components observe refreshed token. - pub async fn refresh_token_classified(&self) -> Result, RefreshTokenError> { - let auth_before_reload = match self.auth() { - Some(auth) => auth, - None => return Ok(None), - }; - if auth_before_reload.mode == AuthMode::ApiKey { - return Ok(None); - } - - let expected_account_id = auth_before_reload.get_account_id(); - if expected_account_id.is_some() { - match self.reload_if_account_id_matches(expected_account_id.as_deref()) { - ReloadOutcome::ReloadedChanged => { - let token = self - .auth() - .and_then(|auth| auth.get_current_token_data().map(|data| data.access_token)); - return Ok(token); - } - ReloadOutcome::ReloadedNoChange => {} - ReloadOutcome::Skipped => { - return Err(RefreshTokenError::permanent( - REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE, - )); - } - } - } - - let auth = match self.auth() { - Some(auth) => auth, - None => return Ok(None), - }; - - if let Some(error) = self.refresh_failure_for_auth(&auth) { - return Err(error); - } - - let attempted_auth = auth.clone(); - let result = match auth.refresh_token().await { - Ok(token) => { - // Reload to pick up persisted changes. - self.reload(); - Ok(Some(token)) - } - Err(e) => Err(e), - }; - - if let Err(error) = &result { - self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); - } - - result - } - - pub async fn refresh_token(&self) -> std::io::Result> { - self.refresh_token_classified() - .await - .map_err(|err| std::io::Error::other(err)) - } - - /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) - /// if a file was removed, Ok(false) if no auth file existed. On success, - /// reloads the in‑memory auth cache so callers immediately observe the - /// unauthenticated state. - pub fn logout(&self) -> std::io::Result { - let removed = super::auth::logout(&self.code_home)?; - // Always reload to clear any cached auth (even if file absent). - self.reload(); - Ok(removed) - } -} diff --git a/code-rs/core/src/auth_accounts.rs b/code-rs/core/src/auth_accounts.rs deleted file mode 100644 index 4140a72a846..00000000000 --- a/code-rs/core/src/auth_accounts.rs +++ /dev/null @@ -1,651 +0,0 @@ -use chrono::{DateTime, Utc}; -use code_app_server_protocol::AuthMode; -use serde::{Deserialize, Serialize}; -use std::fs::File; -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; -use tempfile::NamedTempFile; -use uuid::Uuid; - -use crate::token_data::TokenData; - -const ACCOUNTS_FILE_NAME: &str = "auth_accounts.json"; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StoredAccount { - pub id: String, - pub mode: AuthMode, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_at: Option>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_used_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct AccountsFile { - #[serde(default = "default_version")] - version: u32, - - #[serde(default, skip_serializing_if = "Option::is_none")] - active_account_id: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - accounts: Vec, -} - -impl Default for AccountsFile { - fn default() -> Self { - Self { - version: default_version(), - active_account_id: None, - accounts: Vec::new(), - } - } -} - -fn default_version() -> u32 { - 1 -} - -fn accounts_file_path(code_home: &Path) -> PathBuf { - code_home.join(ACCOUNTS_FILE_NAME) -} - -fn read_accounts_file(path: &Path) -> io::Result { - match File::open(path) { - Ok(mut file) => { - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let (parsed, repaired) = parse_accounts_file(&contents)?; - if repaired { - write_accounts_file(path, &parsed)?; - } - Ok(parsed) - } - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(AccountsFile::default()), - Err(e) => Err(e), - } -} - -fn parse_accounts_file(contents: &str) -> io::Result<(AccountsFile, bool)> { - if contents.trim().is_empty() { - return Ok((AccountsFile::default(), false)); - } - - match serde_json::from_str(contents) { - Ok(parsed) => Ok((parsed, false)), - Err(original_err) => { - let mut latest: Option = None; - let mut recovered_count = 0usize; - let stream = serde_json::Deserializer::from_str(contents).into_iter::(); - for value in stream { - match value { - Ok(parsed) => { - recovered_count += 1; - latest = Some(parsed); - } - Err(_) => return Err(original_err.into()), - } - } - - match (recovered_count, latest) { - (count, Some(parsed)) if count > 1 => Ok((parsed, true)), - _ => Err(original_err.into()), - } - } - } -} - -fn write_accounts_file(path: &Path, data: &AccountsFile) -> io::Result<()> { - let parent = path.parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("accounts path has no parent: {}", path.display()), - ) - })?; - if !parent.exists() { - std::fs::create_dir_all(parent)?; - } - - let json = serde_json::to_string_pretty(data)?; - let mut file = NamedTempFile::new_in(parent)?; - file.write_all(json.as_bytes())?; - file.flush()?; - file.as_file_mut().sync_all()?; - file.persist(path).map_err(|err| err.error)?; - Ok(()) -} - -fn normalize_email(email: &str) -> String { - email.trim().to_ascii_lowercase() -} - -fn now() -> DateTime { - Utc::now() -} - -fn next_id() -> String { - Uuid::new_v4().to_string() -} - -fn match_chatgpt_account(existing: &StoredAccount, tokens: &TokenData) -> bool { - if !existing.mode.is_chatgpt() { - return false; - } - - let existing_tokens = match &existing.tokens { - Some(tokens) => tokens, - None => return false, - }; - - let account_id_matches = match (&existing_tokens.account_id, &tokens.account_id) { - (Some(a), Some(b)) => a == b, - _ => false, - }; - - let email_matches = match ( - existing_tokens.id_token.email.as_ref(), - tokens.id_token.email.as_ref(), - ) { - (Some(a), Some(b)) => normalize_email(a) == normalize_email(b), - _ => false, - }; - - account_id_matches && email_matches -} - -fn match_api_key_account(existing: &StoredAccount, api_key: &str) -> bool { - existing.mode == AuthMode::ApiKey - && existing - .openai_api_key - .as_ref() - .is_some_and(|stored| stored == api_key) -} - -fn touch_account(account: &mut StoredAccount, used: bool) { - if account.created_at.is_none() { - account.created_at = Some(now()); - } - if used { - account.last_used_at = Some(now()); - } -} - -fn upsert_account(mut data: AccountsFile, mut new_account: StoredAccount) -> (AccountsFile, StoredAccount) { - let existing_idx = match new_account.mode { - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => new_account - .tokens - .as_ref() - .and_then(|tokens| data.accounts.iter().position(|acc| match_chatgpt_account(acc, tokens))), - AuthMode::ApiKey => new_account - .openai_api_key - .as_ref() - .and_then(|api_key| data.accounts.iter().position(|acc| match_api_key_account(acc, api_key))), - }; - - if let Some(idx) = existing_idx { - let mut account = data.accounts[idx].clone(); - if new_account.label.is_some() { - account.label = new_account.label; - } - if new_account.last_refresh.is_some() { - account.last_refresh = new_account.last_refresh; - } - if let Some(tokens) = new_account.tokens { - account.tokens = Some(tokens); - } - if let Some(api_key) = new_account.openai_api_key { - account.openai_api_key = Some(api_key); - } - if let Some(last_used) = new_account.last_used_at { - account.last_used_at = Some(last_used); - } - data.accounts[idx] = account.clone(); - return (data, account); - } - - if new_account.created_at.is_none() { - new_account.created_at = Some(now()); - } - - data.accounts.push(new_account.clone()); - (data, new_account) -} - -pub fn list_accounts(code_home: &Path) -> io::Result> { - let path = accounts_file_path(code_home); - let data = read_accounts_file(&path)?; - Ok(data.accounts) -} - -pub fn get_active_account_id(code_home: &Path) -> io::Result> { - let path = accounts_file_path(code_home); - let data = read_accounts_file(&path)?; - Ok(data.active_account_id) -} - -pub fn find_account(code_home: &Path, account_id: &str) -> io::Result> { - let path = accounts_file_path(code_home); - let data = read_accounts_file(&path)?; - Ok(data - .accounts - .into_iter() - .find(|acc| acc.id == account_id)) -} - -pub fn update_account_last_refresh( - code_home: &Path, - account_id: &str, - last_refresh: DateTime, -) -> io::Result> { - let path = accounts_file_path(code_home); - let mut data = read_accounts_file(&path)?; - - let Some(account) = data.accounts.iter_mut().find(|acc| acc.id == account_id) else { - return Ok(None); - }; - account.last_refresh = Some(last_refresh); - let updated = account.clone(); - write_accounts_file(&path, &data)?; - Ok(Some(updated)) -} - -pub fn set_active_account_id( - code_home: &Path, - account_id: Option, -) -> io::Result> { - let path = accounts_file_path(code_home); - let mut data = read_accounts_file(&path)?; - - data.active_account_id = account_id.clone(); - - if let Some(id) = account_id { - if let Some(account) = data.accounts.iter_mut().find(|acc| acc.id == id) { - touch_account(account, true); - let updated = account.clone(); - write_accounts_file(&path, &data)?; - return Ok(Some(updated)); - } - write_accounts_file(&path, &data)?; - Ok(None) - } else { - write_accounts_file(&path, &data)?; - Ok(None) - } -} - -pub fn remove_account(code_home: &Path, account_id: &str) -> io::Result> { - let path = accounts_file_path(code_home); - let mut data = read_accounts_file(&path)?; - - let removed = if let Some(pos) = data.accounts.iter().position(|acc| acc.id == account_id) { - Some(data.accounts.remove(pos)) - } else { - None - }; - - if data - .active_account_id - .as_ref() - .is_some_and(|active| active == account_id) - { - data.active_account_id = None; - } - - write_accounts_file(&path, &data)?; - Ok(removed) -} - -pub fn remove_account_matching_credentials( - code_home: &Path, - mode: AuthMode, - openai_api_key: Option<&str>, - tokens: Option<&TokenData>, -) -> io::Result> { - let path = accounts_file_path(code_home); - let mut data = read_accounts_file(&path)?; - - let removed = match mode { - AuthMode::ApiKey => openai_api_key.and_then(|api_key| { - data.accounts - .iter() - .position(|account| match_api_key_account(account, api_key)) - }), - AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => tokens.and_then(|tokens| { - data.accounts - .iter() - .position(|account| match_chatgpt_account(account, tokens)) - }), - } - .map(|pos| data.accounts.remove(pos)); - - if let Some(removed) = &removed { - if data - .active_account_id - .as_ref() - .is_some_and(|active| active == &removed.id) - { - data.active_account_id = None; - } - } - - write_accounts_file(&path, &data)?; - Ok(removed) -} - -pub fn upsert_api_key_account( - code_home: &Path, - api_key: String, - label: Option, - make_active: bool, -) -> io::Result { - let path = accounts_file_path(code_home); - let data = read_accounts_file(&path)?; - - let new_account = StoredAccount { - id: next_id(), - mode: AuthMode::ApiKey, - label, - openai_api_key: Some(api_key), - tokens: None, - last_refresh: None, - created_at: None, - last_used_at: None, - }; - - let (mut data, mut stored) = upsert_account(data, new_account); - - if make_active { - data.active_account_id = Some(stored.id.clone()); - if let Some(account) = data - .accounts - .iter_mut() - .find(|acc| acc.id == stored.id) - { - touch_account(account, true); - stored = account.clone(); - } - } - - write_accounts_file(&path, &data)?; - Ok(stored) -} - -pub fn upsert_chatgpt_account( - code_home: &Path, - tokens: TokenData, - last_refresh: DateTime, - label: Option, - make_active: bool, -) -> io::Result { - let path = accounts_file_path(code_home); - let data = read_accounts_file(&path)?; - - let new_account = StoredAccount { - id: next_id(), - mode: AuthMode::ChatGPT, - label, - openai_api_key: None, - tokens: Some(tokens), - last_refresh: Some(last_refresh), - created_at: None, - last_used_at: None, - }; - - let (mut data, mut stored) = upsert_account(data, new_account); - - if make_active { - data.active_account_id = Some(stored.id.clone()); - if let Some(account) = data - .accounts - .iter_mut() - .find(|acc| acc.id == stored.id) - { - touch_account(account, true); - stored = account.clone(); - } - } - - write_accounts_file(&path, &data)?; - Ok(stored) -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use crate::token_data::{IdTokenInfo, TokenData}; - use tempfile::tempdir; - - fn make_chatgpt_tokens(account_id: Option<&str>, email: Option<&str>) -> TokenData { - fn fake_jwt(account_id: Option<&str>, email: Option<&str>, plan: &str) -> String { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": email, - "https://api.openai.com/auth": { - "chatgpt_plan_type": plan, - "chatgpt_account_id": account_id.unwrap_or("acct"), - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", - } - }); - let b64 = |value: &serde_json::Value| { - base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(value).expect("json to vec")) - }; - let header_b64 = b64(&serde_json::to_value(header).expect("header value")); - let payload_b64 = b64(&payload); - let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig"); - format!("{header_b64}.{payload_b64}.{signature_b64}") - } - - TokenData { - id_token: IdTokenInfo { - email: email.map(|s| s.to_string()), - chatgpt_plan_type: None, - chatgpt_account_is_fedramp: false, - raw_jwt: fake_jwt(account_id, email, "pro"), - }, - access_token: "access".to_string(), - refresh_token: "refresh".to_string(), - account_id: account_id.map(|s| s.to_string()), - } - } - - #[test] - fn upsert_api_key_creates_and_updates() { - let home = tempdir().expect("tempdir"); - let api_key = "sk-test".to_string(); - let stored = upsert_api_key_account(home.path(), api_key.clone(), None, true) - .expect("upsert api key"); - - assert_eq!(stored.mode, AuthMode::ApiKey); - assert_eq!(stored.openai_api_key.as_deref(), Some("sk-test")); - - let again = upsert_api_key_account(home.path(), api_key, None, false) - .expect("upsert same key"); - assert_eq!(stored.id, again.id); - - let accounts = list_accounts(home.path()).expect("list accounts"); - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, stored.id); - } - - #[test] - fn missing_accounts_file_defaults_to_empty_state() { - let home = tempdir().expect("tempdir"); - - let accounts = list_accounts(home.path()).expect("list accounts"); - assert!(accounts.is_empty()); - - let active = get_active_account_id(home.path()).expect("active account id"); - assert!(active.is_none()); - } - - #[test] - fn empty_accounts_file_defaults_to_empty_state() { - let home = tempdir().expect("tempdir"); - let path = accounts_file_path(home.path()); - std::fs::write(&path, "\n \t").expect("write empty accounts file"); - - let accounts = list_accounts(home.path()).expect("list accounts"); - assert!(accounts.is_empty()); - - let active = get_active_account_id(home.path()).expect("active account id"); - assert!(active.is_none()); - } - - #[test] - fn upsert_chatgpt_dedupes_by_account_id() { - let home = tempdir().expect("tempdir"); - let tokens = make_chatgpt_tokens(Some("acct-1"), Some("user@example.com")); - let stored = upsert_chatgpt_account( - home.path(), - tokens.clone(), - Utc::now(), - None, - true, - ) - .expect("insert chatgpt"); - - let tokens_updated = make_chatgpt_tokens(Some("acct-1"), Some("user@example.com")); - let again = upsert_chatgpt_account( - home.path(), - tokens_updated, - Utc::now(), - None, - false, - ) - .expect("update chatgpt"); - - assert_eq!(stored.id, again.id); - let accounts = list_accounts(home.path()).expect("list accounts"); - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, stored.id); - } - - #[test] - fn chatgpt_accounts_with_same_email_but_different_ids_are_distinct() { - let home = tempdir().expect("tempdir"); - - let personal = make_chatgpt_tokens(Some("acct-personal"), Some("user@example.com")); - let personal_id = upsert_chatgpt_account( - home.path(), - personal, - Utc::now(), - None, - true, - ) - .expect("insert personal account") - .id; - - let team = make_chatgpt_tokens(Some("acct-team"), Some("user@example.com")); - let team_id = upsert_chatgpt_account( - home.path(), - team, - Utc::now(), - None, - false, - ) - .expect("insert team account") - .id; - - assert_ne!(personal_id, team_id, "accounts with different IDs should not be merged"); - - let accounts = list_accounts(home.path()).expect("list accounts"); - assert_eq!(accounts.len(), 2, "both accounts should remain listed"); - } - - #[test] - fn remove_account_clears_active() { - let home = tempdir().expect("tempdir"); - let tokens = make_chatgpt_tokens(Some("acct-remove"), Some("user@example.com")); - let stored = upsert_chatgpt_account( - home.path(), - tokens, - Utc::now(), - None, - true, - ) - .expect("insert chatgpt"); - - let active_before = get_active_account_id(home.path()).expect("active id"); - assert_eq!(active_before.as_deref(), Some(stored.id.as_str())); - - let removed = remove_account(home.path(), &stored.id).expect("remove"); - assert!(removed.is_some()); - - let active_after = get_active_account_id(home.path()).expect("active id"); - assert!(active_after.is_none()); - } - - #[test] - fn recovers_from_trailing_json_documents_by_keeping_latest_accounts_file() { - let home = tempdir().expect("tempdir"); - let path = accounts_file_path(home.path()); - - let first = AccountsFile { - version: default_version(), - active_account_id: Some("first-active".to_string()), - accounts: vec![StoredAccount { - id: "first-active".to_string(), - mode: AuthMode::ApiKey, - label: Some("first".to_string()), - openai_api_key: Some("sk-first".to_string()), - tokens: None, - last_refresh: None, - created_at: None, - last_used_at: None, - }], - }; - let second = AccountsFile { - version: default_version(), - active_account_id: Some("second-active".to_string()), - accounts: vec![StoredAccount { - id: "second-active".to_string(), - mode: AuthMode::ApiKey, - label: Some("second".to_string()), - openai_api_key: Some("sk-second".to_string()), - tokens: None, - last_refresh: None, - created_at: None, - last_used_at: None, - }], - }; - - let first_json = serde_json::to_string_pretty(&first).expect("serialize first"); - let second_json = serde_json::to_string_pretty(&second).expect("serialize second"); - std::fs::write(&path, format!("{first_json}\n{second_json}\n")).expect("write corrupt accounts file"); - - let accounts = list_accounts(home.path()).expect("recover accounts"); - assert_eq!(accounts, second.accounts); - - let active = get_active_account_id(home.path()).expect("active id"); - assert_eq!(active.as_deref(), Some("second-active")); - - let repaired = std::fs::read_to_string(&path).expect("read repaired accounts file"); - assert_eq!(repaired, second_json); - } -} diff --git a/code-rs/core/src/auto_drive_pid.rs b/code-rs/core/src/auto_drive_pid.rs deleted file mode 100644 index 6dad83dd99c..00000000000 --- a/code-rs/core/src/auto_drive_pid.rs +++ /dev/null @@ -1,91 +0,0 @@ -use chrono::Utc; -use serde::Serialize; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; - -/// Distinguishes which part of the product started Auto Drive so external -/// tooling can annotate the dump appropriately. -#[derive(Copy, Clone, Debug)] -pub enum AutoDriveMode { - Exec, - Tui, -} - -impl AutoDriveMode { - fn as_str(&self) -> &'static str { - match self { - AutoDriveMode::Exec => "exec", - AutoDriveMode::Tui => "tui", - } - } -} - -#[derive(Serialize)] -struct AutoDrivePidMetadata { - pid: u32, - started_at: String, - mode: &'static str, - goal: Option, - cwd: Option, - command: Option, -} - -/// Small RAII helper that writes `~/.code/auto-drive/pid-.json` and -/// removes it when dropped or explicitly cleaned up. -pub struct AutoDrivePidFile { - path: PathBuf, -} - -impl AutoDrivePidFile { - /// Write the PID file under the provided code_home, returning a guard that - /// will delete it on drop. Errors are swallowed so Auto Drive startup never - /// fails because of telemetry bookkeeping. - pub fn write( - code_home: &Path, - goal: Option<&str>, - mode: AutoDriveMode, - ) -> Option { - let dir = code_home.join("auto-drive"); - fs::create_dir_all(&dir).ok()?; - - let pid = std::process::id(); - let cwd = env::current_dir().ok(); - let command = env::args().collect::>().join(" "); - - let metadata = AutoDrivePidMetadata { - pid, - started_at: Utc::now().to_rfc3339(), - mode: mode.as_str(), - goal: goal.map(truncate_goal), - cwd, - command: if command.is_empty() { None } else { Some(command) }, - }; - - let path = dir.join(format!("pid-{pid}.json")); - let contents = serde_json::to_vec_pretty(&metadata).ok()?; - fs::write(&path, contents).ok()?; - - Some(Self { path }) - } - - /// Eagerly remove the PID file. Safe to call multiple times. - pub fn cleanup(self) { - let _ = fs::remove_file(&self.path); - } -} - -impl Drop for AutoDrivePidFile { - fn drop(&mut self) { - let _ = fs::remove_file(&self.path); - } -} - -fn truncate_goal(goal: &str) -> String { - let trimmed = goal.trim(); - if trimmed.len() <= 800 { - return trimmed.to_string(); - } - - trimmed.chars().take(800).collect() -} diff --git a/code-rs/core/src/bash.rs b/code-rs/core/src/bash.rs deleted file mode 100644 index f25b4f7f67e..00000000000 --- a/code-rs/core/src/bash.rs +++ /dev/null @@ -1,222 +0,0 @@ -use tree_sitter::Node; -use tree_sitter::Parser; -use tree_sitter::Tree; -use tree_sitter_bash::LANGUAGE as BASH; - -/// Parse the provided bash source using tree-sitter-bash, returning a Tree on -/// success or None if parsing failed. -pub fn try_parse_bash(bash_lc_arg: &str) -> Option { - let lang = BASH.into(); - let mut parser = Parser::new(); - #[expect(clippy::expect_used)] - parser.set_language(&lang).expect("load bash grammar"); - let old_tree: Option<&Tree> = None; - parser.parse(bash_lc_arg, old_tree) -} - -/// Parse a script which may contain multiple simple commands joined only by -/// the safe logical/pipe/sequencing operators: `&&`, `||`, `;`, `|`. -/// -/// Returns `Some(Vec)` if every command is a plain word‑only -/// command and the parse tree does not contain disallowed constructs -/// (parentheses, redirections, substitutions, control flow, etc.). Otherwise -/// returns `None`. -pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option>> { - if tree.root_node().has_error() { - return None; - } - - // List of allowed (named) node kinds for a "word only commands sequence". - // If we encounter a named node that is not in this list we reject. - const ALLOWED_KINDS: &[&str] = &[ - // top level containers - "program", - "list", - "pipeline", - // commands & words - "command", - "command_name", - "word", - "string", - "string_content", - "raw_string", - "number", - ]; - // Allow only safe punctuation / operator tokens; anything else causes reject. - const ALLOWED_PUNCT_TOKENS: &[&str] = &["&&", "||", ";", "|", "\"", "'"]; - - let root = tree.root_node(); - let mut cursor = root.walk(); - let mut stack = vec![root]; - let mut command_nodes = Vec::new(); - while let Some(node) = stack.pop() { - let kind = node.kind(); - if node.is_named() { - if !ALLOWED_KINDS.contains(&kind) { - return None; - } - if kind == "command" { - command_nodes.push(node); - } - } else { - // Reject any punctuation / operator tokens that are not explicitly allowed. - if kind.chars().any(|c| "&;|".contains(c)) && !ALLOWED_PUNCT_TOKENS.contains(&kind) { - return None; - } - if !(ALLOWED_PUNCT_TOKENS.contains(&kind) || kind.trim().is_empty()) { - // If it's a quote token or operator it's allowed above; we also allow whitespace tokens. - // Any other punctuation like parentheses, braces, redirects, backticks, etc are rejected. - return None; - } - } - for child in node.children(&mut cursor) { - stack.push(child); - } - } - - // Walk uses a stack (LIFO), so re-sort by position to restore source order. - command_nodes.sort_by_key(Node::start_byte); - - let mut commands = Vec::new(); - for node in command_nodes { - if let Some(words) = parse_plain_command_from_node(node, src) { - commands.push(words); - } else { - return None; - } - } - Some(commands) -} - -fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option> { - if cmd.kind() != "command" { - return None; - } - let mut words = Vec::new(); - let mut cursor = cmd.walk(); - for child in cmd.named_children(&mut cursor) { - match child.kind() { - "command_name" => { - let word_node = child.named_child(0)?; - if word_node.kind() != "word" { - return None; - } - words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned()); - } - "word" | "number" => { - words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned()); - } - "string" => { - if child.child_count() == 3 - && child.child(0)?.kind() == "\"" - && child.child(1)?.kind() == "string_content" - && child.child(2)?.kind() == "\"" - { - words.push(child.child(1)?.utf8_text(src.as_bytes()).ok()?.to_owned()); - } else { - return None; - } - } - "raw_string" => { - let raw_string = child.utf8_text(src.as_bytes()).ok()?; - let stripped = raw_string - .strip_prefix('\'') - .and_then(|s| s.strip_suffix('\'')); - if let Some(s) = stripped { - words.push(s.to_owned()); - } else { - return None; - } - } - _ => return None, - } - } - Some(words) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse_seq(src: &str) -> Option>> { - let tree = try_parse_bash(src)?; - try_parse_word_only_commands_sequence(&tree, src) - } - - #[test] - fn accepts_single_simple_command() { - let cmds = parse_seq("ls -1").unwrap(); - assert_eq!(cmds, vec![vec!["ls".to_string(), "-1".to_string()]]); - } - - #[test] - fn accepts_multiple_commands_with_allowed_operators() { - let src = "ls && pwd; echo 'hi there' | wc -l"; - let cmds = parse_seq(src).unwrap(); - let expected: Vec> = vec![ - vec!["ls".to_string()], - vec!["pwd".to_string()], - vec!["echo".to_string(), "hi there".to_string()], - vec!["wc".to_string(), "-l".to_string()], - ]; - assert_eq!(cmds, expected); - } - - #[test] - fn extracts_double_and_single_quoted_strings() { - let cmds = parse_seq("echo \"hello world\"").unwrap(); - assert_eq!( - cmds, - vec![vec!["echo".to_string(), "hello world".to_string()]] - ); - - let cmds2 = parse_seq("echo 'hi there'").unwrap(); - assert_eq!( - cmds2, - vec![vec!["echo".to_string(), "hi there".to_string()]] - ); - } - - #[test] - fn accepts_numbers_as_words() { - let cmds = parse_seq("echo 123 456").unwrap(); - assert_eq!( - cmds, - vec![vec![ - "echo".to_string(), - "123".to_string(), - "456".to_string() - ]] - ); - } - - #[test] - fn rejects_parentheses_and_subshells() { - assert!(parse_seq("(ls)").is_none()); - assert!(parse_seq("ls || (pwd && echo hi)").is_none()); - } - - #[test] - fn rejects_redirections_and_unsupported_operators() { - assert!(parse_seq("ls > out.txt").is_none()); - assert!(parse_seq("echo hi & echo bye").is_none()); - } - - #[test] - fn rejects_command_and_process_substitutions_and_expansions() { - assert!(parse_seq("echo $(pwd)").is_none()); - assert!(parse_seq("echo `pwd`").is_none()); - assert!(parse_seq("echo $HOME").is_none()); - assert!(parse_seq("echo \"hi $USER\"").is_none()); - } - - #[test] - fn rejects_variable_assignment_prefix() { - assert!(parse_seq("FOO=bar ls").is_none()); - } - - #[test] - fn rejects_trailing_operator_parse_error() { - assert!(parse_seq("ls &&").is_none()); - } -} diff --git a/code-rs/core/src/bin/config_schema.rs b/code-rs/core/src/bin/config_schema.rs new file mode 100644 index 00000000000..f92ce62307a --- /dev/null +++ b/code-rs/core/src/bin/config_schema.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +/// Generate the JSON Schema for `config.toml` and write it to `config.schema.json`. +#[derive(Parser)] +#[command(name = "codex-write-config-schema")] +struct Args { + #[arg(short, long, value_name = "PATH")] + out: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let out_path = args + .out + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.schema.json")); + codex_config::schema::write_config_schema(&out_path)?; + Ok(()) +} diff --git a/code-rs/core/src/bin/model_guide.rs b/code-rs/core/src/bin/model_guide.rs deleted file mode 100644 index 15b0c0ae49d..00000000000 --- a/code-rs/core/src/bin/model_guide.rs +++ /dev/null @@ -1,7 +0,0 @@ -use code_core::agent_defaults::agent_model_specs; - -fn main() { - for spec in agent_model_specs() { - println!("- `{}`: {}", spec.slug, spec.description); - } -} diff --git a/code-rs/core/src/bridge_client.rs b/code-rs/core/src/bridge_client.rs deleted file mode 100644 index 06ae8b8f461..00000000000 --- a/code-rs/core/src/bridge_client.rs +++ /dev/null @@ -1,974 +0,0 @@ -use std::collections::hash_map::DefaultHasher; -use std::fs; -use std::hash::{Hash, Hasher}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::Result; -use anyhow::bail; -use chrono::{DateTime, Duration as ChronoDuration, Utc}; -use futures_util::{SinkExt, StreamExt}; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::time::{sleep, sleep_until, Instant}; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; -use tracing::{info, warn}; - -use crate::codex::Session; - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct BridgeMeta { - url: String, - secret: String, - #[allow(dead_code)] - port: Option, - #[allow(dead_code)] - workspace_path: Option, - #[allow(dead_code)] - started_at: Option, - #[allow(dead_code)] - heartbeat_at: Option, -} - -const HEARTBEAT_STALE_MS: i64 = 20_000; -const SUBSCRIPTION_OVERRIDE_FILE: &str = "code-bridge.subscription.json"; -const BATCH_WINDOW: Duration = Duration::from_secs(3); -const MAX_EVENTS_PER_BATCH: usize = 50; -const MAX_EVENT_SUMMARY_CHARS: usize = 1200; - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Subscription { - #[serde(default = "default_levels")] - pub levels: Vec, - #[serde(default)] - pub capabilities: Vec, - #[serde(default = "default_filter")] - pub llm_filter: String, -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct SubscriptionState { - workspace: Option, - session: Option, - last_sent: Option, -} - -static SUBSCRIPTIONS: Lazy> = Lazy::new(|| Mutex::new(SubscriptionState::default())); - -static CONTROL_SENDER: Lazy>>> = - Lazy::new(|| Mutex::new(None)); -static LAST_OVERRIDE_FINGERPRINT: Lazy>> = Lazy::new(|| Mutex::new(None)); -static BRIDGE_HINT_EMITTED: Lazy = Lazy::new(|| AtomicBool::new(false)); - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BridgeBatchEvent { - summary: String, - level: Option, - truncated: bool, -} - -fn default_levels() -> Vec { - vec!["errors".to_string()] -} - -fn default_filter() -> String { - "off".to_string() -} - -fn default_subscription() -> Subscription { - Subscription { - levels: default_levels(), - capabilities: vec![ - "console".to_string(), - "error".to_string(), - "pageview".to_string(), - "screenshot".to_string(), - "control".to_string(), - ], - llm_filter: default_filter(), - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct SubscriptionOverride { - #[serde(default = "default_levels")] - levels: Vec, - #[serde(default)] - capabilities: Vec, - #[serde(default = "default_filter", alias = "llm_filter")] - llm_filter: String, -} - -impl SubscriptionOverride { - fn fingerprint(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut lvls = self.levels.clone(); - lvls.iter_mut().for_each(|l| *l = l.to_lowercase()); - lvls.sort(); - lvls.hash(&mut hasher); - - let mut caps = self.capabilities.clone(); - caps.iter_mut().for_each(|c| *c = c.to_lowercase()); - caps.sort(); - caps.hash(&mut hasher); - - self.llm_filter.to_lowercase().hash(&mut hasher); - hasher.finish() - } - - fn normalised(mut self) -> Self { - self.levels = normalise_vec(self.levels); - self.capabilities = normalise_vec(self.capabilities); - self.llm_filter = self.llm_filter.to_lowercase(); - self - } -} - -fn normalise_vec(values: Vec) -> Vec { - let mut vals: Vec = values - .into_iter() - .map(|v| v.trim().to_lowercase()) - .filter(|v| !v.is_empty()) - .collect(); - vals.sort(); - vals.dedup(); - vals -} - -fn parse_level(raw: &str) -> Option { - serde_json::from_str::(raw) - .ok() - .and_then(|val| { - val.get("level") - .and_then(|v| v.as_str()) - .map(|s| s.to_lowercase()) - .or_else(|| { - val.get("type") - .and_then(|v| v.as_str()) - .map(|s| s.to_lowercase()) - }) - }) -} - -fn is_error_level(level: &str) -> bool { - matches!(level, "error" | "errors" | "err" | "fatal" | "critical" | "panic") -} - -fn truncate_summary(text: &str) -> (String, bool) { - let mut chars = text.chars(); - let truncated: String = chars.by_ref().take(MAX_EVENT_SUMMARY_CHARS).collect(); - if text.chars().count() > MAX_EVENT_SUMMARY_CHARS { - let remaining = text.chars().count().saturating_sub(MAX_EVENT_SUMMARY_CHARS); - (format!("{}... [truncated {remaining} chars]", truncated), true) - } else { - (truncated, false) - } -} - -fn summarize_event(raw: &str) -> BridgeBatchEvent { - let level = parse_level(raw); - let summary = summarize(raw); - let (summary, truncated) = truncate_summary(&summary); - - BridgeBatchEvent { - summary, - level, - truncated, - } -} - -#[derive(Debug)] -struct CoalescedBatch { - entries: Vec<(String, usize)>, - total_events: usize, - truncated_events: usize, - dropped_events: usize, - saw_error: bool, -} - -fn coalesce_events(events: Vec) -> CoalescedBatch { - let mut entries: Vec<(String, usize)> = Vec::new(); - let mut truncated_events = 0; - let mut dropped_events = 0; - let mut saw_error = false; - - for event in events { - let BridgeBatchEvent { - summary, - level, - truncated, - } = event; - - if truncated { - truncated_events += 1; - } - - if let Some(level) = level.as_deref() { - if is_error_level(level) { - saw_error = true; - } - } - - if let Some((_, count)) = entries.iter_mut().find(|(msg, _)| msg == &summary) { - *count += 1; - continue; - } - - if entries.len() < MAX_EVENTS_PER_BATCH { - entries.push((summary, 1)); - } else { - dropped_events += 1; - } - } - - let total_events = entries.iter().map(|(_, count)| *count).sum::() + dropped_events; - - CoalescedBatch { - entries, - total_events, - truncated_events, - dropped_events, - saw_error, - } -} - -fn format_batch_message(batch: &CoalescedBatch) -> String { - if batch.entries.is_empty() { - return "(no bridge events)".to_string(); - } - - let mut lines = Vec::new(); - let header = if batch.total_events == 1 { - "Code Bridge event".to_string() - } else { - format!( - "Code Bridge events ({} in last {}s)", - batch.total_events, - BATCH_WINDOW.as_secs() - ) - }; - lines.push(header); - - for (msg, count) in batch.entries.iter() { - let prefix = if *count > 1 { - format!("[{count}x] ") - } else { - String::new() - }; - let indented = msg.replace('\n', "\n "); - lines.push(format!("- {}{}", prefix, indented)); - } - - if batch.dropped_events > 0 { - lines.push(format!( - "(dropped {} events beyond batch limit of {})", - batch.dropped_events, MAX_EVENTS_PER_BATCH - )); - } - - if batch.truncated_events > 0 { - lines.push(format!( - "(truncated {} event bodies to {} chars)", - batch.truncated_events, MAX_EVENT_SUMMARY_CHARS - )); - } - - lines.join("\n") -} - -async fn flush_batch(session: &Arc, events: Vec) { - let batch = coalesce_events(events); - if batch.entries.is_empty() { - return; - } - - let message = format_batch_message(&batch); - session.record_bridge_event(message).await; - - if batch.saw_error { - session.start_pending_only_turn_if_idle().await; - } -} - -pub(crate) fn merge_effective_subscription(state: &SubscriptionState) -> Subscription { - // Start with defaults - let mut effective = default_subscription(); - - if let Some(ws) = &state.workspace { - if !ws.levels.is_empty() { - effective.levels = ws.levels.clone(); - } - if !ws.capabilities.is_empty() { - effective.capabilities = ws.capabilities.clone(); - } - effective.llm_filter = ws.llm_filter.clone(); - } - - if let Some(sess) = &state.session { - // Session overrides always win, even when the intent is to clear values - effective.levels = sess.levels.clone(); - effective.capabilities = sess.capabilities.clone(); - effective.llm_filter = sess.llm_filter.clone(); - } - - effective -} - -#[allow(dead_code)] -pub(crate) fn set_bridge_levels(levels: Vec) { - let mut state = SUBSCRIPTIONS.lock().unwrap(); - let mut sub = state - .session - .clone() - .unwrap_or_else(|| merge_effective_subscription(&state)); - sub.levels = if levels.is_empty() { default_levels() } else { normalise_vec(levels) }; - state.session = Some(sub); - maybe_resubscribe(&mut state); -} - -#[allow(dead_code)] -pub(crate) fn set_bridge_subscription(levels: Vec, capabilities: Vec) { - let mut state = SUBSCRIPTIONS.lock().unwrap(); - let mut sub = state - .session - .clone() - .unwrap_or_else(|| merge_effective_subscription(&state)); - sub.levels = if levels.is_empty() { default_levels() } else { normalise_vec(levels) }; - sub.capabilities = normalise_vec(capabilities); - state.session = Some(sub); - maybe_resubscribe(&mut state); -} - -#[allow(dead_code)] -pub(crate) fn set_bridge_filter(filter: &str) { - let mut state = SUBSCRIPTIONS.lock().unwrap(); - let mut sub = state - .session - .clone() - .unwrap_or_else(|| merge_effective_subscription(&state)); - sub.llm_filter = filter.trim().to_lowercase(); - state.session = Some(sub); - maybe_resubscribe(&mut state); -} - -#[allow(dead_code)] -pub(crate) fn send_bridge_control(action: &str, args: serde_json::Value) { - let msg = serde_json::json!({ - "type": "control", - "action": action, - "args": args, - }) - .to_string(); - - if let Some(sender) = CONTROL_SENDER.lock().unwrap().as_ref() { - let _ = sender.send(msg); - } -} - -/// Spawn a background task that watches `.code/code-bridge.json` and -/// connects as a consumer to the external bridge host when available. -pub(crate) fn spawn_bridge_listener(session: std::sync::Arc) { - let cwd = session.get_cwd().to_path_buf(); - tokio::spawn(async move { - let mut last_notice: Option<&str> = None; - let mut last_override_seen: Option = None; - loop { - // Poll subscription override (if any) each loop so runtime changes apply quickly. - if let Some(path) = subscription_override_path(&cwd) { - match read_subscription_override(path.as_path()) { - Ok(sub) => { - let fp = sub.fingerprint(); - if Some(fp) != last_override_seen { - set_workspace_subscription(Some(Subscription { - levels: sub.levels.clone(), - capabilities: sub.capabilities.clone(), - llm_filter: sub.llm_filter.clone(), - })); - session - .record_bridge_event(format!( - "Code Bridge subscription updated from {} (levels: [{}], capabilities: [{}], filter: {})", - path.display(), - sub.levels.join(", "), - sub.capabilities.join(", "), - sub.llm_filter - )) - .await; - *LAST_OVERRIDE_FINGERPRINT.lock().unwrap() = Some(fp); - last_override_seen = Some(fp); - } - } - Err(_) => { - if last_override_seen.is_some() { - set_workspace_subscription(None); - session - .record_bridge_event("Code Bridge subscription override removed or invalid; reverted to defaults (errors only).".to_string()) - .await; - *LAST_OVERRIDE_FINGERPRINT.lock().unwrap() = None; - last_override_seen = None; - } - } - } - } else if last_override_seen.is_some() { - set_workspace_subscription(None); - session - .record_bridge_event( - "Code Bridge subscription override removed; reverted to defaults (errors only)." - .to_string(), - ) - .await; - *LAST_OVERRIDE_FINGERPRINT.lock().unwrap() = None; - last_override_seen = None; - } - - match find_meta_path(&cwd) { - None => { - last_notice = Some("missing"); - } - Some(meta_path) => match read_meta(meta_path.as_path()) { - Ok(meta) => { - last_notice = None; - info!("[bridge] host metadata found, connecting"); - if let Err(err) = connect_and_listen(meta, Arc::clone(&session), &cwd).await { - warn!("[bridge] connect failed: {err:?}"); - } - } - Err(err) => { - if last_notice != Some("stale") { - session - .record_bridge_event(format!( - "Code Bridge metadata is stale at {} ({err}); waiting for a fresh host...", - meta_path.display() - )) - .await; - last_notice = Some("stale"); - } - } - }, - } - sleep(Duration::from_secs(5)).await; - } - }); -} - -fn read_meta(path: &Path) -> Result { - let data = std::fs::read_to_string(path)?; - let meta: BridgeMeta = serde_json::from_str(&data)?; - - if is_meta_stale(&meta, path) { - bail!("heartbeat missing or stale"); - } - - Ok(meta) -} - -fn read_subscription_override(path: &Path) -> Result { - let data = fs::read_to_string(path)?; - let sub: SubscriptionOverride = serde_json::from_str(&data)?; - Ok(sub.normalised()) -} - -pub(crate) fn set_workspace_subscription(sub: Option) { - let mut state = SUBSCRIPTIONS.lock().unwrap(); - state.workspace = sub; - maybe_resubscribe(&mut state); -} - -pub(crate) fn set_session_subscription(sub: Option) { - let mut state = SUBSCRIPTIONS.lock().unwrap(); - state.session = sub; - maybe_resubscribe(&mut state); -} - -pub(crate) fn force_resubscribe() { - let mut state = SUBSCRIPTIONS.lock().unwrap(); - state.last_sent = None; - maybe_resubscribe(&mut state); -} - -pub(crate) fn get_effective_subscription() -> Subscription { - let state = SUBSCRIPTIONS.lock().unwrap(); - merge_effective_subscription(&state) -} - -#[allow(dead_code)] -pub(crate) fn get_workspace_subscription() -> Option { - SUBSCRIPTIONS.lock().unwrap().workspace.clone() -} - -pub(crate) fn persist_workspace_subscription(cwd: &Path, sub: Option) -> anyhow::Result<()> { - let path = resolve_subscription_override_path(cwd); - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - if let Some(sub) = sub { - let tmp = path.with_extension("tmp"); - let payload = serde_json::to_string_pretty(&SubscriptionOverride { - levels: sub.levels.clone(), - capabilities: sub.capabilities.clone(), - llm_filter: sub.llm_filter.clone(), - })?; - fs::write(&tmp, payload)?; - fs::rename(tmp, &path)?; - } else { - if path.exists() { - fs::remove_file(&path)?; - } - } - - Ok(()) -} - -fn maybe_resubscribe(state: &mut SubscriptionState) { - let effective = merge_effective_subscription(state); - if state.last_sent.as_ref() == Some(&effective) { - return; - } - - let msg = serde_json::json!({ - "type": "subscribe", - "levels": effective.levels, - "capabilities": effective.capabilities, - "llm_filter": effective.llm_filter, - }) - .to_string(); - - if let Some(sender) = CONTROL_SENDER.lock().unwrap().as_ref() { - let _ = sender.send(msg); - } - - state.last_sent = Some(effective); -} - -fn find_meta_path(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join(".code/code-bridge.json"); - if candidate.exists() { - return Some(candidate); - } - current = dir.parent(); - } - None -} - -fn subscription_override_path(start: &Path) -> Option { - if let Some(meta) = find_meta_path(start) { - if let Some(dir) = meta.parent() { - let candidate = dir.join(SUBSCRIPTION_OVERRIDE_FILE); - if candidate.exists() { - return Some(candidate); - } - } - } - - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join(".code").join(SUBSCRIPTION_OVERRIDE_FILE); - if candidate.exists() { - return Some(candidate); - } - current = dir.parent(); - } - None -} - -fn resolve_subscription_override_path(start: &Path) -> PathBuf { - if let Some(path) = subscription_override_path(start) { - return path; - } - - if let Some(dir) = find_meta_dir(start) { - return dir.join(SUBSCRIPTION_OVERRIDE_FILE); - } - - if let Some(dir) = find_code_dir(start) { - return dir.join(SUBSCRIPTION_OVERRIDE_FILE); - } - - start.join(".code").join(SUBSCRIPTION_OVERRIDE_FILE) -} - -fn find_meta_dir(start: &Path) -> Option { - find_meta_path(start).and_then(|p| p.parent().map(Path::to_path_buf)) -} - -fn find_code_dir(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join(".code"); - if candidate.is_dir() { - return Some(candidate); - } - current = dir.parent(); - } - None -} - -fn find_package_json(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join("package.json"); - if candidate.exists() { - return Some(candidate); - } - current = dir.parent(); - } - None -} - -fn workspace_has_code_bridge(start: &Path) -> bool { - let pkg = match find_package_json(start) { - Some(p) => p, - None => return false, - }; - - let Ok(data) = fs::read_to_string(pkg.as_path()) else { - return false; - }; - let Ok(json) = serde_json::from_str::(&data) else { - return false; - }; - - let contains_dep = |section: &str| -> bool { - json.get(section) - .and_then(|v| v.as_object()) - .map(|map| map.contains_key("@just-every/code-bridge")) - .unwrap_or(false) - }; - - contains_dep("dependencies") || contains_dep("devDependencies") || contains_dep("peerDependencies") -} - -fn is_meta_stale(meta: &BridgeMeta, path: &Path) -> bool { - if let Some(hb) = &meta.heartbeat_at { - if let Ok(ts) = DateTime::parse_from_rfc3339(hb) { - let age = Utc::now().signed_duration_since(ts.with_timezone(&Utc)); - return age.num_milliseconds() > HEARTBEAT_STALE_MS; - } - } - - // Fallback for hosts that don't emit heartbeat: use file mtime as staleness signal - if let Ok(stat) = std::fs::metadata(path) { - if let Ok(modified) = stat.modified() { - let modified: DateTime = modified.into(); - let age = Utc::now().signed_duration_since(modified); - return age > ChronoDuration::milliseconds(HEARTBEAT_STALE_MS); - } - } - false -} - -async fn connect_and_listen(meta: BridgeMeta, session: Arc, cwd: &Path) -> Result<()> { - let (ws, _) = connect_async(&meta.url).await?; - let (mut tx, mut rx) = ws.split(); - - // auth frame - let auth = serde_json::json!({ - "type": "auth", - "role": "consumer", - "secret": meta.secret, - "clientId": format!("code-consumer-{}", session.session_uuid()), - }) - .to_string(); - tx.send(Message::Text(auth)).await?; - - // initial subscribe using effective merged subscription - let initial = { - let state = SUBSCRIPTIONS.lock().unwrap(); - merge_effective_subscription(&state) - }; - let subscribe = serde_json::json!({ - "type": "subscribe", - "levels": initial.levels, - "capabilities": initial.capabilities, - "llm_filter": initial.llm_filter, - }) - .to_string(); - tx.send(Message::Text(subscribe)).await?; - { - let mut state = SUBSCRIPTIONS.lock().unwrap(); - state.last_sent = Some(initial); - } - - // set up control sender channel and forwarder (moves tx) - let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::unbounded_channel::(); - { - let mut guard = CONTROL_SENDER.lock().unwrap(); - *guard = Some(ctrl_tx); - } - - // Ensure any pending session overrides are pushed via control channel after it is set up - force_resubscribe(); - - tokio::spawn(async move { - while let Some(msg) = ctrl_rx.recv().await { - if let Err(err) = tx.send(Message::Text(msg)).await { - warn!("[bridge] control send error: {err:?}"); - break; - } - } - }); - - // announce developer message - let announce = format!( - "Code Bridge host available.\n- url: {url}\n- secret: {secret}\n", - url = meta.url, - secret = meta.secret - ); - session.record_bridge_event(announce).await; - - if !BRIDGE_HINT_EMITTED.swap(true, Ordering::SeqCst) && workspace_has_code_bridge(cwd) { - session - .record_bridge_event( - "Code Bridge is a local, real-time debug stream (errors/console like Sentry, plus pageviews/screenshots and a control channel). Use the `code_bridge` tool: `action=subscribe` with level (errors|warn|info|trace) to persist full-capability logging, `action=screenshot` to request a capture, or `action=javascript` with `code` to run JS on the bridge client." - .to_string(), - ) - .await; - } - - let (batch_tx, mut batch_rx) = tokio::sync::mpsc::unbounded_channel::(); - let session_for_batch = Arc::clone(&session); - - let batch_handle = tokio::spawn(async move { - let mut buffer: Vec = Vec::new(); - let mut deadline: Option = None; - - loop { - tokio::select! { - Some(item) = batch_rx.recv() => { - buffer.push(item); - - if buffer.len() >= MAX_EVENTS_PER_BATCH { - flush_batch(&session_for_batch, std::mem::take(&mut buffer)).await; - deadline = None; - continue; - } - - if deadline.is_none() { - deadline = Some(Instant::now() + BATCH_WINDOW); - } - } - _ = async { - if let Some(when) = deadline { - sleep_until(when).await; - } - }, if deadline.is_some() => { - if !buffer.is_empty() { - flush_batch(&session_for_batch, std::mem::take(&mut buffer)).await; - } - deadline = None; - } - else => { - break; - } - } - } - - if !buffer.is_empty() { - flush_batch(&session_for_batch, buffer).await; - } - }); - - while let Some(msg) = rx.next().await { - match msg { - Ok(Message::Text(text)) => { - let event = summarize_event(&text); - let _ = batch_tx.send(event); - } - Ok(Message::Binary(_)) => {} - Ok(Message::Close(_)) => break, - Ok(Message::Ping(_)) => {} - Ok(Message::Pong(_)) => {} - Ok(Message::Frame(_)) => {} - Err(err) => { - warn!("[bridge] websocket error: {err:?}"); - break; - } - } - } - - drop(batch_tx); - let _ = batch_handle.await; - // clear sender on exit - { - let mut guard = CONTROL_SENDER.lock().unwrap(); - *guard = None; - } - Ok(()) -} - -fn summarize(raw: &str) -> String { - if let Ok(val) = serde_json::from_str::(raw) { - let mut parts = Vec::new(); - if let Some(t) = val.get("type").and_then(|v| v.as_str()) { - parts.push(format!("type: {t}")); - } - if let Some(platform) = val.get("platform").and_then(|v| v.as_str()) { - parts.push(format!("platform: {platform}")); - } - if let Some(level) = val.get("level").and_then(|v| v.as_str()) { - parts.push(format!("level: {level}")); - } - if let Some(msg) = val.get("message").and_then(|v| v.as_str()) { - parts.push(format!("message: {msg}")); - } - return format!("\n{}\n", parts.join("\n")); - } - raw.to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn reset_state() { - *SUBSCRIPTIONS.lock().unwrap() = SubscriptionState::default(); - *CONTROL_SENDER.lock().unwrap() = None; - *LAST_OVERRIDE_FINGERPRINT.lock().unwrap() = None; - } - - #[test] - fn merge_respects_session_over_workspace() { - reset_state(); - set_workspace_subscription(Some(Subscription { - levels: vec!["info".into()], - capabilities: vec!["console".into()], - llm_filter: "minimal".into(), - })); - - set_session_subscription(Some(Subscription { - levels: vec!["trace".into()], - capabilities: vec!["screenshot".into()], - llm_filter: "off".into(), - })); - - let state = SUBSCRIPTIONS.lock().unwrap(); - let eff = merge_effective_subscription(&state); - assert_eq!(eff.levels, vec!["trace"]); - assert_eq!(eff.capabilities, vec!["screenshot"]); - assert_eq!(eff.llm_filter, "off".to_string()); - } - - #[test] - fn session_can_clear_workspace_capabilities() { - reset_state(); - set_workspace_subscription(Some(Subscription { - levels: vec!["info".into()], - capabilities: vec!["screenshot".into(), "pageview".into()], - llm_filter: "minimal".into(), - })); - - set_session_subscription(Some(Subscription { - levels: vec!["info".into()], - capabilities: Vec::new(), - llm_filter: "minimal".into(), - })); - - let state = SUBSCRIPTIONS.lock().unwrap(); - let eff = merge_effective_subscription(&state); - assert!(eff.capabilities.is_empty()); - assert_eq!(eff.levels, vec!["info"]); - assert_eq!(eff.llm_filter, "minimal".to_string()); - } - - #[test] - fn resubscribe_sends_message_on_change() { - reset_state(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - *CONTROL_SENDER.lock().unwrap() = Some(tx); - - set_session_subscription(Some(Subscription { - levels: vec!["trace".into()], - capabilities: vec!["console".into()], - llm_filter: "off".into(), - })); - - let msg = rx.try_recv().expect("expected subscribe message"); - assert!(msg.contains("\"type\":\"subscribe\"")); - assert!(msg.contains("trace")); - } - - #[test] - fn batch_coalesces_and_truncates() { - let long_msg = "a".repeat(MAX_EVENT_SUMMARY_CHARS + 10); - let events = vec![ - BridgeBatchEvent { - summary: "alpha".to_string(), - level: Some("info".to_string()), - truncated: false, - }, - BridgeBatchEvent { - summary: "alpha".to_string(), - level: Some("info".to_string()), - truncated: false, - }, - BridgeBatchEvent { - summary: long_msg.clone(), - level: Some("error".to_string()), - truncated: true, - }, - ]; - - let batch = coalesce_events(events); - assert_eq!(batch.entries.len(), 2); - assert_eq!(batch.entries[0].0, "alpha"); - assert_eq!(batch.entries[0].1, 2); - assert!(batch.saw_error); - assert_eq!(batch.truncated_events, 1); - assert_eq!(batch.dropped_events, 0); - } - - #[test] - fn batch_enforces_limit_and_marks_error() { - let mut events = Vec::new(); - for idx in 0..(MAX_EVENTS_PER_BATCH + 5) { - events.push(BridgeBatchEvent { - summary: format!("msg-{idx}"), - level: Some(if idx == 0 { "error" } else { "info" }.to_string()), - truncated: false, - }); - } - - let batch = coalesce_events(events); - assert_eq!(batch.entries.len(), MAX_EVENTS_PER_BATCH); - assert_eq!(batch.dropped_events, 5); - assert!(batch.saw_error); - assert_eq!(batch.total_events, MAX_EVENTS_PER_BATCH + 5); - } - - #[test] - fn format_batch_includes_multipliers() { - let batch = CoalescedBatch { - entries: vec![ - ("one".to_string(), 1), - ("two".to_string(), 3), - ], - total_events: 4, - truncated_events: 0, - dropped_events: 0, - saw_error: false, - }; - - let text = format_batch_message(&batch); - assert!(text.contains("Code Bridge events (4 in last")); - assert!(text.contains("- one")); - assert!(text.contains("- [3x] two")); - } - - #[test] - fn summarize_includes_platform_when_present() { - let raw = r#"{"type":"console","level":"info","platform":"roblox","message":"hi"}"#; - let summary = summarize(raw); - assert!(summary.contains("platform: roblox")); - assert!(summary.contains("message: hi")); - } -} diff --git a/code-rs/core/src/cgroup.rs b/code-rs/core/src/cgroup.rs deleted file mode 100644 index 84d08ecd4a8..00000000000 --- a/code-rs/core/src/cgroup.rs +++ /dev/null @@ -1,175 +0,0 @@ -#[cfg(target_os = "linux")] -use std::path::{Path, PathBuf}; - -#[cfg(target_os = "linux")] -const CGROUP_MOUNT: &str = "/sys/fs/cgroup"; - -#[cfg(target_os = "linux")] -const EXEC_CGROUP_SUBDIR: &str = "code-exec"; - -#[cfg(target_os = "linux")] -const EXEC_CGROUP_OOM_SCORE_ADJ: &str = "500"; - -#[cfg(target_os = "linux")] -pub(crate) fn default_exec_memory_max_bytes() -> Option { - if let Ok(raw) = std::env::var("CODEX_EXEC_MEMORY_MAX_BYTES") { - if let Ok(value) = raw.trim().parse::() { - if value > 0 { - return Some(value); - } - } - } - if let Ok(raw) = std::env::var("CODEX_EXEC_MEMORY_MAX_MB") { - if let Ok(value) = raw.trim().parse::() { - if value > 0 { - return Some(value.saturating_mul(1024 * 1024)); - } - } - } - - let available = read_mem_available_bytes()?; - // Leave headroom for the parent TUI + other background processes. - // Keep the cap within a reasonable range so we still protect the parent - // on larger machines. - let sixty_percent = available.saturating_mul(60) / 100; - let min = 512_u64.saturating_mul(1024 * 1024); - let max = 4_u64.saturating_mul(1024 * 1024 * 1024); - Some(sixty_percent.clamp(min, max)) -} - -#[cfg(target_os = "linux")] -fn read_mem_available_bytes() -> Option { - let contents = std::fs::read_to_string("/proc/meminfo").ok()?; - for line in contents.lines() { - let line = line.trim_start(); - if let Some(rest) = line.strip_prefix("MemAvailable:") { - let kb = rest - .split_whitespace() - .next() - .and_then(|n| n.parse::().ok())?; - return Some(kb.saturating_mul(1024)); - } - } - None -} - -#[cfg(target_os = "linux")] -fn is_cgroup_v2() -> bool { - std::fs::metadata(Path::new(CGROUP_MOUNT).join("cgroup.controllers")).is_ok() -} - -#[cfg(target_os = "linux")] -fn current_cgroup_relative() -> Option { - let contents = std::fs::read_to_string("/proc/self/cgroup").ok()?; - for line in contents.lines() { - if let Some(path) = line.strip_prefix("0::") { - let trimmed = path.trim(); - if trimmed.is_empty() { - return None; - } - return Some(PathBuf::from(trimmed.trim_start_matches('/'))); - } - } - None -} - -#[cfg(target_os = "linux")] -fn exec_cgroup_parent_abs() -> Option { - if !is_cgroup_v2() { - return None; - } - let rel = current_cgroup_relative()?; - Some(Path::new(CGROUP_MOUNT).join(rel).join(EXEC_CGROUP_SUBDIR)) -} - -#[cfg(target_os = "linux")] -pub(crate) fn exec_cgroup_abs_for_pid(pid: u32) -> Option { - exec_cgroup_parent_abs().map(|parent| parent.join(format!("pid-{pid}"))) -} - -#[cfg(target_os = "linux")] -fn best_effort_enable_memory_controller(parent: &Path) { - let controllers = std::fs::read_to_string(parent.join("cgroup.controllers")).ok(); - if controllers.as_deref().unwrap_or_default().split_whitespace().all(|c| c != "memory") { - return; - } - let subtree = parent.join("cgroup.subtree_control"); - let _ = std::fs::write(subtree, "+memory"); -} - -#[cfg(target_os = "linux")] -pub(crate) fn best_effort_attach_self_to_exec_cgroup(pid: u32, memory_max_bytes: u64) { - let Some(parent) = exec_cgroup_parent_abs() else { - return; - }; - - let _ = std::fs::create_dir_all(&parent); - best_effort_enable_memory_controller(&parent); - - let cgroup_dir = parent.join(format!("pid-{pid}")); - if std::fs::create_dir_all(&cgroup_dir).is_err() { - return; - } - - let memory_max_path = cgroup_dir.join("memory.max"); - if memory_max_path.exists() { - let _ = std::fs::write(&memory_max_path, memory_max_bytes.to_string()); - } else { - // Memory controller not active for this subtree. - return; - } - - let oom_group_path = cgroup_dir.join("memory.oom.group"); - if oom_group_path.exists() { - let _ = std::fs::write(oom_group_path, "1"); - } - - // Prefer killing the exec subtree first if the host does hit global OOM. - let _ = std::fs::write("/proc/self/oom_score_adj", EXEC_CGROUP_OOM_SCORE_ADJ); - - let procs_path = cgroup_dir.join("cgroup.procs"); - if procs_path.exists() { - let _ = std::fs::write(procs_path, pid.to_string()); - } -} - -#[cfg(target_os = "linux")] -pub(crate) fn exec_cgroup_oom_killed(pid: u32) -> Option { - let dir = exec_cgroup_abs_for_pid(pid)?; - let contents = std::fs::read_to_string(dir.join("memory.events")).ok()?; - for line in contents.lines() { - let mut parts = line.split_whitespace(); - let Some(key) = parts.next() else { - continue; - }; - let Some(val) = parts.next() else { - continue; - }; - if key == "oom_kill" { - if let Ok(parsed) = val.parse::() { - return Some(parsed > 0); - } - } - } - None -} - -#[cfg(target_os = "linux")] -pub(crate) fn exec_cgroup_memory_max_bytes(pid: u32) -> Option { - let dir = exec_cgroup_abs_for_pid(pid)?; - let raw = std::fs::read_to_string(dir.join("memory.max")).ok()?; - let trimmed = raw.trim(); - if trimmed == "max" { - return None; - } - trimmed.parse::().ok() -} - -#[cfg(target_os = "linux")] -pub(crate) fn best_effort_cleanup_exec_cgroup(pid: u32) { - let Some(dir) = exec_cgroup_abs_for_pid(pid) else { - return; - }; - // Only remove the per-pid directory. The parent container stays. - let _ = std::fs::remove_dir(&dir); -} diff --git a/code-rs/core/src/chat_completions.rs b/code-rs/core/src/chat_completions.rs deleted file mode 100644 index 65f2b673514..00000000000 --- a/code-rs/core/src/chat_completions.rs +++ /dev/null @@ -1,1362 +0,0 @@ -use std::collections::BTreeMap; -use std::time::Duration; - -use bytes::Bytes; -use code_otel::otel_event_manager::OtelEventManager; -use eventsource_stream::Eventsource; -use futures::Stream; -use futures::StreamExt; -use futures::TryStreamExt; -use reqwest::StatusCode; -use reqwest::header::HeaderMap; -use serde_json::Value; -use serde_json::json; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; -use tokio::sync::mpsc; -use tokio::time::timeout; -use tracing::debug; -use tracing::trace; - -use crate::auth::AuthManager; -use crate::ModelProviderInfo; -use crate::client_common::Prompt; -use crate::client_common::ResponseEvent; -use crate::client_common::ResponseStream; -use crate::client_common::replace_image_payloads_for_model; -use crate::client_common::rewrite_image_generation_calls_for_input; -use crate::debug_logger::DebugLogger; -use crate::error::CodexErr; -use crate::error::Result; -use crate::error::RetryLimitReachedError; -use crate::error::UnexpectedResponseError; -use crate::model_family::ModelFamily; -use crate::openai_tools::create_tools_json_for_chat_completions_api; -use crate::util::backoff; -use std::sync::{Arc, Mutex}; -use code_protocol::models::ContentItem; -use code_protocol::models::ReasoningItemContent; -use code_protocol::models::ResponseItem; - -/// Implementation for the classic Chat Completions API. -pub(crate) async fn stream_chat_completions( - prompt: &Prompt, - model_family: &ModelFamily, - model_slug: &str, - client: &reqwest::Client, - provider: &ModelProviderInfo, - responses_originator_header: &str, - debug_logger: &Arc>, - auth_manager: Option>, - otel_event_manager: Option, - log_tag: Option<&str>, -) -> Result { - if prompt.output_schema.is_some() { - return Err(CodexErr::UnsupportedOperation( - "output_schema is not supported for Chat Completions API".to_string(), - )); - } - - // Build messages array - let mut messages = Vec::::new(); - - let full_instructions = prompt.get_full_instructions(model_family); - messages.push(json!({"role": "system", "content": full_instructions})); - - let mut input = prompt.get_formatted_input(); - rewrite_image_generation_calls_for_input(&mut input); - replace_image_payloads_for_model(&mut input, model_slug); - - // Pre-scan: map Reasoning blocks to the adjacent assistant anchor after the last user. - // - If the last emitted message is a user message, drop all reasoning. - // - Otherwise, for each Reasoning item after the last user message, attach it - // to the immediate previous assistant message (stop turns) or the immediate - // next assistant anchor (tool-call turns: function/local shell call, or assistant message). - let mut reasoning_by_anchor_index: std::collections::HashMap = - std::collections::HashMap::new(); - - // Determine the last role that would be emitted to Chat Completions. - let mut last_emitted_role: Option<&str> = None; - for item in &input { - match item { - ResponseItem::Message { role, .. } => last_emitted_role = Some(role.as_str()), - ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::LocalShellCall { .. } => { - last_emitted_role = Some("assistant") - } - ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } => { - last_emitted_role = Some("tool") - } - ResponseItem::CompactionSummary { .. } | ResponseItem::ContextCompaction { .. } => { - last_emitted_role = Some("assistant") - } - ResponseItem::Reasoning { .. } | ResponseItem::Other => {} - ResponseItem::CustomToolCall { .. } => {} - ResponseItem::CustomToolCallOutput { .. } => {} - ResponseItem::WebSearchCall { .. } => {} - ResponseItem::ImageGenerationCall { .. } => {} - ResponseItem::GhostSnapshot { .. } => {} - } - } - - // Find the last user message index in the input. - let mut last_user_index: Option = None; - for (idx, item) in input.iter().enumerate() { - if let ResponseItem::Message { role, .. } = item { - if role == "user" { - last_user_index = Some(idx); - } - } - } - - // Attach reasoning only if the conversation does not end with a user message. - if !matches!(last_emitted_role, Some("user")) { - for (idx, item) in input.iter().enumerate() { - // Only consider reasoning that appears after the last user message. - if let Some(u_idx) = last_user_index { - if idx <= u_idx { - continue; - } - } - - if let ResponseItem::Reasoning { - content: Some(items), - .. - } = item - { - let mut text = String::new(); - for c in items { - match c { - ReasoningItemContent::ReasoningText { text: t } - | ReasoningItemContent::Text { text: t } => text.push_str(t), - } - } - if text.trim().is_empty() { - continue; - } - - // Prefer immediate previous assistant message (stop turns) - let mut attached = false; - if idx > 0 { - if let ResponseItem::Message { role, .. } = &input[idx - 1] { - if role == "assistant" { - reasoning_by_anchor_index - .entry(idx - 1) - .and_modify(|v| v.push_str(&text)) - .or_insert(text.clone()); - attached = true; - } - } - } - - // Otherwise, attach to immediate next assistant anchor (tool-calls or assistant message) - if !attached && idx + 1 < input.len() { - match &input[idx + 1] { - ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::LocalShellCall { .. } => { - reasoning_by_anchor_index - .entry(idx + 1) - .and_modify(|v| v.push_str(&text)) - .or_insert(text.clone()); - } - ResponseItem::Message { role, .. } if role == "assistant" => { - reasoning_by_anchor_index - .entry(idx + 1) - .and_modify(|v| v.push_str(&text)) - .or_insert(text.clone()); - } - _ => {} - } - } - } - } - } - - // Track last assistant text we emitted to avoid duplicate assistant messages - // in the outbound Chat Completions payload (can happen if a final - // aggregated assistant message was recorded alongside an earlier partial). - let _last_assistant_text: Option = None; - - for (idx, item) in input.iter().enumerate() { - match item { - ResponseItem::Message { role, content, .. } => { - // If the message contains any images, we must use the - // multi-modal array form supported by Chat Completions: - // [{ type: "text", text: "..." }, { type: "image_url", image_url: { url: "data:..." } }] - let contains_image = content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })); - - if contains_image { - let mut parts = Vec::::new(); - for c in content { - match c { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - parts.push(json!({ "type": "text", "text": text })); - } - ContentItem::InputImage { image_url } => { - parts.push(json!({ - "type": "image_url", - "image_url": { "url": image_url } - })); - } - } - } - messages.push(json!({"role": role, "content": parts})); - } else { - // Text-only messages can be sent as a single string for - // maximal compatibility with providers that only accept - // plain text in Chat Completions. - let mut text = String::new(); - for c in content { - match c { - ContentItem::InputText { text: t } - | ContentItem::OutputText { text: t } => { - text.push_str(t); - } - _ => {} - } - } - messages.push(json!({"role": role, "content": text})); - } - } - ResponseItem::CompactionSummary { .. } | ResponseItem::ContextCompaction { .. } => { - // Compaction summaries are only meaningful to the Responses API; omit them - // when translating to Chat Completions. - continue; - } - ResponseItem::FunctionCall { - name, - arguments, - call_id, - .. - } => { - let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str); - let tool_call = json!({ - "id": call_id, - "type": "function", - "function": { - "name": name, - "arguments": arguments, - } - }); - push_tool_call_message(&mut messages, tool_call, reasoning); - } - ResponseItem::ToolSearchCall { - call_id, - status, - execution, - arguments, - .. - } => { - let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str); - let tool_call = json!({ - "id": call_id.clone().unwrap_or_default(), - "type": "tool_search_call", - "call_id": call_id, - "status": status, - "execution": execution, - "arguments": arguments, - }); - push_tool_call_message(&mut messages, tool_call, reasoning); - } - ResponseItem::LocalShellCall { - id, - call_id: _, - status, - action, - } => { - // Confirm with API team. - let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str); - let tool_call = json!({ - "id": id.clone().unwrap_or_default(), - "type": "local_shell_call", - "status": status, - "action": action, - }); - push_tool_call_message(&mut messages, tool_call, reasoning); - } - ResponseItem::FunctionCallOutput { call_id, output } => { - messages.push(json!({ - "role": "tool", - "tool_call_id": call_id, - "content": output.to_string(), - })); - } - ResponseItem::ToolSearchOutput { - call_id, - status, - execution, - tools, - } => { - messages.push(json!({ - "role": "tool", - "tool_call_id": call_id.clone().unwrap_or_default(), - "content": serde_json::json!({ - "status": status, - "execution": execution, - "tools": tools, - }) - .to_string(), - })); - } - ResponseItem::CustomToolCall { - id, - call_id: _, - name, - input, - status: _, - } => { - let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str); - let tool_call = json!({ - "id": id, - "type": "custom", - "custom": { - "name": name, - "input": input, - } - }); - push_tool_call_message(&mut messages, tool_call, reasoning); - } - ResponseItem::CustomToolCallOutput { - call_id, - output, - .. - } => { - messages.push(json!({ - "role": "tool", - "tool_call_id": call_id, - "content": output, - })); - } - ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } - | ResponseItem::Other => { - // Omit these items from the conversation history. - continue; - } - } - } - - let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?; - let context_ledger = prompt.context_ledger_for_request(model_family, &input, &tools_json); - debug!( - target: "code_core::context_ledger", - summary = %context_ledger.compact_summary(), - entries = ?context_ledger.entries(), - "assembled context ledger for chat completions request" - ); - let mut payload = json!({ - "model": model_slug, - "messages": messages, - "stream": true, - "tools": tools_json, - }); - - if let Some(openrouter_cfg) = provider.openrouter_config() { - if let Some(obj) = payload.as_object_mut() { - if let Some(provider_cfg) = &openrouter_cfg.provider { - obj.insert( - "provider".to_string(), - serde_json::to_value(provider_cfg)? - ); - } - if let Some(route) = &openrouter_cfg.route { - obj.insert("route".to_string(), route.clone()); - } - for (key, value) in &openrouter_cfg.extra { - obj.entry(key.clone()).or_insert(value.clone()); - } - } - } - - // If an Ollama context override is present, propagate it. Some Ollama - // builds honor `num_ctx` directly in OpenAI-compatible Chat Completions, - // and others accept it under an `options` object – include both. - if let Ok(val) = std::env::var("CODEX_OLLAMA_NUM_CTX") { - if let Ok(n) = val.parse::() { - if let Some(obj) = payload.as_object_mut() { - obj.insert("num_ctx".to_string(), json!(n)); - // Also set options.num_ctx for native-style compatibility. - let mut options = serde_json::Map::new(); - options.insert("num_ctx".to_string(), json!(n)); - obj.entry("options").or_insert(json!(options)); - } - } - } - - let endpoint = provider.get_full_url(&None); - debug!( - "POST to {}: {}", - endpoint, - serde_json::to_string_pretty(&payload).unwrap_or_default() - ); - - let mut attempt = 0; - let max_retries = provider.request_max_retries(); - let mut request_id = String::new(); - loop { - attempt += 1; - - let base_auth = auth_manager.as_ref().and_then(|m| m.auth()); - let auth = provider.effective_auth(&base_auth).await?; - let mut req_builder = provider.create_request_builder_with_auth(client, &auth).await?; - req_builder = req_builder.headers(crate::default_client::requested_model_headers( - Some(responses_originator_header), - model_slug, - )); - - if let Some(auth) = auth.as_ref() { - if auth.mode.is_chatgpt() { - if let Some(account_id) = auth.get_account_id() { - req_builder = req_builder.header("chatgpt-account-id", account_id); - } - } - } - - req_builder = req_builder - .header(reqwest::header::ACCEPT, "text/event-stream") - .json(&payload); - - if request_id.is_empty() { - let endpoint_for_log = provider.get_full_url(&auth); - let header_snapshot = req_builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map(|req| header_map_to_json(req.headers())); - - if let Ok(logger) = debug_logger.lock() { - request_id = logger - .start_request_log( - &endpoint_for_log, - &payload, - header_snapshot.as_ref(), - log_tag, - ) - .unwrap_or_default(); - } - } - - let res = req_builder.send().await; - - match res { - Ok(resp) if resp.status().is_success() => { - // Log successful response initiation - if let Ok(logger) = debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "stream_initiated", - &serde_json::json!({ - "status": "success", - "status_code": resp.status().as_u16() - }), - ); - } - let (tx_event, rx_event) = mpsc::channel::>(1600); - let _ = tx_event - .send(Ok(ResponseEvent::ContextLedger(context_ledger.clone()))) - .await; - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); - let debug_logger_clone = Arc::clone(&debug_logger); - let request_id_clone = request_id.clone(); - tokio::spawn(process_chat_sse( - stream, - tx_event, - provider.stream_idle_timeout(), - debug_logger_clone, - request_id_clone, - otel_event_manager.clone(), - )); - return Ok(ResponseStream { rx_event }); - } - Ok(res) => { - let status = res.status(); - if status == StatusCode::UNAUTHORIZED && provider.has_command_auth() { - provider.invalidate_cached_auth_token(); - if attempt > max_retries { - return Err(CodexErr::RetryLimit(RetryLimitReachedError { - status, - request_id: None, - retryable: true, - })); - } - let delay = backoff(attempt); - tokio::time::sleep(delay).await; - continue; - } - if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) { - let body = (res.text().await).unwrap_or_default(); - if let Ok(logger) = debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "error", - &serde_json::json!({ - "status": status.as_u16(), - "body": body - }), - ); - let _ = logger.end_request_log(&request_id); - } - return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError { - status, - body, - request_id: None, - })); - } - - if attempt > max_retries { - return Err(CodexErr::RetryLimit(RetryLimitReachedError { - status, - request_id: None, - retryable: status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS, - })); - } - - let retry_after_secs = res - .headers() - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()); - - let delay = retry_after_secs - .map(|s| Duration::from_millis(s * 1_000)) - .unwrap_or_else(|| backoff(attempt)); - tokio::time::sleep(delay).await; - } - Err(e) => { - let is_connectivity = e.is_connect() || e.is_timeout() || e.is_request(); - if attempt > max_retries { - if let Ok(logger) = debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "network_error", - &serde_json::json!({ - "error": e.to_string() - }), - ); - let _ = logger.end_request_log(&request_id); - } - if is_connectivity { - let req_id = (!request_id.is_empty()).then(|| request_id.clone()); - return Err(CodexErr::Stream( - format!("[transport] network unavailable: {e}"), - None, - req_id, - )); - } - return Err(e.into()); - } - let delay = backoff(attempt); - tokio::time::sleep(delay).await; - } - } - } -} - -fn push_tool_call_message(messages: &mut Vec, tool_call: Value, reasoning: Option<&str>) { - // Chat Completions requires that tool calls are grouped into a single assistant message - // (with `tool_calls: [...]`) followed by tool role responses. - if let Some(Value::Object(obj)) = messages.last_mut() - && obj.get("role").and_then(Value::as_str) == Some("assistant") - && obj.get("content").is_some_and(Value::is_null) - && let Some(tool_calls) = obj.get_mut("tool_calls").and_then(Value::as_array_mut) - { - tool_calls.push(tool_call); - if let Some(reasoning) = reasoning { - if let Some(Value::String(existing)) = obj.get_mut("reasoning") { - if !existing.is_empty() { - existing.push('\n'); - } - existing.push_str(reasoning); - } else { - obj.insert("reasoning".to_string(), Value::String(reasoning.to_string())); - } - } - return; - } - - let mut msg = json!({ - "role": "assistant", - "content": null, - "tool_calls": [tool_call], - }); - if let Some(reasoning) = reasoning - && let Some(obj) = msg.as_object_mut() - { - obj.insert("reasoning".to_string(), json!(reasoning)); - } - messages.push(msg); -} - -/// Lightweight SSE processor for the Chat Completions streaming format. The -/// output is mapped onto Codex's internal [`ResponseEvent`] so that the rest -/// of the pipeline can stay agnostic of the underlying wire format. -async fn process_chat_sse( - stream: S, - tx_event: mpsc::Sender>, - idle_timeout: Duration, - debug_logger: Arc>, - request_id: String, - otel_event_manager: Option, -) where - S: Stream> + Unpin, -{ - let mut stream = stream.eventsource(); - - // State to accumulate a function call across streaming chunks. - // OpenAI may split the `arguments` string over multiple `delta` events - // until the chunk whose `finish_reason` is `tool_calls` is emitted. We - // keep collecting the pieces here and forward a single - // `ResponseItem::FunctionCall` once the call is complete. - #[derive(Default)] - struct FunctionCallState { - name: Option, - arguments: String, - call_id: Option, - active: bool, - } - - let mut fn_call_state = FunctionCallState::default(); - let mut assistant_text = String::new(); - let mut reasoning_text = String::new(); - let mut current_item_id: Option = None; - let mut current_response_id: Option = None; - let mut current_response_model: Option = None; - let mut created_emitted = false; - - async fn flush_and_complete( - tx_event: &mpsc::Sender>, - assistant_text: &mut String, - reasoning_text: &mut String, - current_item_id: &Option, - response_id: Option<&str>, - debug_logger: &Arc>, - request_id: &str, - ) { - // Emit any finalized items before closing so downstream consumers receive - // terminal events for both assistant content and raw reasoning. - if !assistant_text.is_empty() { - let item = ResponseItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: std::mem::take(assistant_text), - }], - id: current_item_id.clone(), end_turn: None, phase: None}; - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemDone { - item, - sequence_number: None, - output_index: None, - })) - .await; - } - - if !reasoning_text.is_empty() { - let item = ResponseItem::Reasoning { - id: current_item_id.clone().unwrap_or_else(String::new), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: std::mem::take(reasoning_text), - }]), - encrypted_content: None, - }; - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemDone { - item, - sequence_number: None, - output_index: None, - })) - .await; - } - - let _ = tx_event - .send(Ok(ResponseEvent::Completed { - response_id: response_id.unwrap_or_default().to_string(), - token_usage: None, - })) - .await; - if let Ok(logger) = debug_logger.lock() { - let _ = logger.end_request_log(request_id); - } - } - - loop { - let next_event = if let Some(manager) = otel_event_manager.as_ref() { - manager - .log_sse_event(|| timeout(idle_timeout, stream.next())) - .await - } else { - timeout(idle_timeout, stream.next()).await - }; - - let sse = match next_event { - Ok(Some(Ok(ev))) => ev, - Ok(Some(Err(e))) => { - let _ = tx_event - .send(Err(CodexErr::Stream( - format!("[transport] {e}"), - None, - Some(request_id.clone()), - ))) - .await; - return; - } - Ok(None) => { - // Stream closed by server without an explicit end marker – log for diagnostics - tracing::debug!("chat SSE stream closed without [DONE] marker"); - if let Ok(logger) = debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "stream_closed_without_done", - &serde_json::json!({ - "assistant_len": assistant_text.len(), - "reasoning_len": reasoning_text.len(), - }), - ); - } - flush_and_complete( - &tx_event, - &mut assistant_text, - &mut reasoning_text, - ¤t_item_id, - current_response_id.as_deref(), - &debug_logger, - &request_id, - ) - .await; - return; - } - Err(_) => { - let _ = tx_event - .send(Err(CodexErr::Stream( - "[idle] timeout waiting for SSE".into(), - None, - Some(request_id.clone()), - ))) - .await; - return; - } - }; - - let data = sse.data.trim(); - - if data.is_empty() { - continue; - } - - // OpenAI Chat streaming sends a literal string "[DONE]" when finished. - if data == "[DONE]" || data == "DONE" { - flush_and_complete( - &tx_event, - &mut assistant_text, - &mut reasoning_text, - ¤t_item_id, - current_response_id.as_deref(), - &debug_logger, - &request_id, - ) - .await; - return; - } - - // Parse JSON chunk - let chunk: serde_json::Value = match serde_json::from_str(data) { - Ok(v) => v, - Err(e) => { - // Surface parse errors to logs and debug logger for diagnostics, then skip - let mut excerpt = sse.data.clone(); - const MAX: usize = 600; - if excerpt.len() > MAX { excerpt.truncate(MAX); } - tracing::debug!("chat SSE parse error: {} | data: {}", e, excerpt); - if let Ok(logger) = debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "sse_parse_error", - &serde_json::json!({ - "error": e.to_string(), - "data_excerpt": excerpt, - }), - ); - } - continue; - } - }; - trace!("chat_completions received SSE chunk: {chunk:?}"); - - // Log the SSE chunk to debug log - if let Ok(logger) = debug_logger.lock() { - let _ = logger.append_response_event(&request_id, "sse_event", &chunk); - } - - if current_response_id.is_none() { - current_response_id = chunk - .get("id") - .and_then(|id| id.as_str()) - .map(ToString::to_string); - } - if current_response_model.is_none() { - current_response_model = chunk - .get("model") - .and_then(|model| model.as_str()) - .map(ToString::to_string); - } - if !created_emitted - && (current_response_id.is_some() || current_response_model.is_some()) - { - let _ = tx_event - .send(Ok(ResponseEvent::Created { - response_id: current_response_id.clone(), - response_model: current_response_model.clone(), - })) - .await; - created_emitted = true; - } - - // Extract item_id if present at the top level or in choice - if let Some(item_id) = chunk.get("item_id").and_then(|id| id.as_str()) { - current_item_id = Some(item_id.to_string()); - } - - let choice_opt = chunk.get("choices").and_then(|c| c.get(0)); - - if let Some(choice) = choice_opt { - // Check for item_id in the choice as well - if let Some(item_id) = choice.get("item_id").and_then(|id| id.as_str()) { - current_item_id = Some(item_id.to_string()); - } - - // Handle assistant content tokens as streaming deltas. - if let Some(content) = choice - .get("delta") - .and_then(|d| d.get("content")) - .and_then(|c| c.as_str()) - { - if !content.is_empty() { - assistant_text.push_str(content); - let _ = tx_event - .send(Ok(ResponseEvent::OutputTextDelta { - delta: content.to_string(), - item_id: current_item_id.clone(), - sequence_number: None, - output_index: None, - })) - .await; - } - } - - // Forward any reasoning/thinking deltas if present. - // Some providers stream `reasoning` as a plain string while others - // nest the text under an object (e.g. `{ "reasoning": { "text": "…" } }`). - if let Some(reasoning_val) = choice.get("delta").and_then(|d| d.get("reasoning")) { - let mut maybe_text = reasoning_val - .as_str() - .map(str::to_string) - .filter(|s| !s.is_empty()); - - if maybe_text.is_none() && reasoning_val.is_object() { - if let Some(s) = reasoning_val - .get("text") - .and_then(|t| t.as_str()) - .filter(|s| !s.is_empty()) - { - maybe_text = Some(s.to_string()); - } else if let Some(s) = reasoning_val - .get("content") - .and_then(|t| t.as_str()) - .filter(|s| !s.is_empty()) - { - maybe_text = Some(s.to_string()); - } - } - - if let Some(reasoning) = maybe_text { - // Accumulate so we can emit a terminal Reasoning item at the end. - reasoning_text.push_str(&reasoning); - let _ = tx_event - .send(Ok(ResponseEvent::ReasoningContentDelta { - delta: reasoning, - item_id: current_item_id.clone(), - sequence_number: None, - output_index: None, - content_index: None, - })) - .await; - } - } - - // Some providers only include reasoning on the final message object. - if let Some(message_reasoning) = choice.get("message").and_then(|m| m.get("reasoning")) - { - // Accept either a plain string or an object with { text | content } - if let Some(s) = message_reasoning.as_str() { - if !s.is_empty() { - reasoning_text.push_str(s); - let _ = tx_event - .send(Ok(ResponseEvent::ReasoningContentDelta { - delta: s.to_string(), - item_id: current_item_id.clone(), - sequence_number: None, - output_index: None, - content_index: None, - })) - .await; - } - } else if let Some(obj) = message_reasoning.as_object() { - if let Some(s) = obj - .get("text") - .and_then(|v| v.as_str()) - .or_else(|| obj.get("content").and_then(|v| v.as_str())) - { - if !s.is_empty() { - reasoning_text.push_str(s); - let _ = tx_event - .send(Ok(ResponseEvent::ReasoningContentDelta { - delta: s.to_string(), - item_id: current_item_id.clone(), - sequence_number: None, - output_index: None, - content_index: None, - })) - .await; - } - } - } - } - - // Handle streaming function / tool calls. - if let Some(tool_calls) = choice - .get("delta") - .and_then(|d| d.get("tool_calls")) - .and_then(|tc| tc.as_array()) - { - if let Some(tool_call) = tool_calls.first() { - // Mark that we have an active function call in progress. - fn_call_state.active = true; - - // Extract call_id if present. - if let Some(id) = tool_call.get("id").and_then(|v| v.as_str()) { - fn_call_state.call_id.get_or_insert_with(|| id.to_string()); - } - - // Extract function details if present. - if let Some(function) = tool_call.get("function") { - if let Some(name) = function.get("name").and_then(|n| n.as_str()) { - fn_call_state.name.get_or_insert_with(|| name.to_string()); - } - - if let Some(args_fragment) = - function.get("arguments").and_then(|a| a.as_str()) - { - fn_call_state.arguments.push_str(args_fragment); - } - } - } - } - - // Emit end-of-turn when finish_reason signals completion. - if let Some(finish_reason) = choice.get("finish_reason").and_then(|v| v.as_str()) { - match finish_reason { - "tool_calls" if fn_call_state.active => { - // First, flush the terminal raw reasoning so UIs can finalize - // the reasoning stream before any exec/tool events begin. - if !reasoning_text.is_empty() { - let item = ResponseItem::Reasoning { - id: current_item_id.clone().unwrap_or_else(String::new), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: std::mem::take(&mut reasoning_text), - }]), - encrypted_content: None, - }; - let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await; - } - - // Then emit the FunctionCall response item. - let item = ResponseItem::FunctionCall { - id: current_item_id.clone(), - name: fn_call_state.name.clone().unwrap_or_else(|| "".to_string()), - namespace: None, - arguments: fn_call_state.arguments.clone(), - call_id: fn_call_state.call_id.clone().unwrap_or_else(String::new), - }; - - let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await; - } - "stop" => { - // Regular turn without tool-call. Emit the final assistant message - // as a single OutputItemDone so non-delta consumers see the result. - if !assistant_text.is_empty() { - let item = ResponseItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: std::mem::take(&mut assistant_text), - }], - id: current_item_id.clone(), end_turn: None, phase: None}; - let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await; - } - // Also emit a terminal Reasoning item so UIs can finalize raw reasoning. - if !reasoning_text.is_empty() { - let item = ResponseItem::Reasoning { - id: current_item_id.clone().unwrap_or_else(String::new), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: std::mem::take(&mut reasoning_text), - }]), - encrypted_content: None, - }; - let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await; - } - } - _ => {} - } - - // Emit Completed regardless of reason so the agent can advance. - let _ = tx_event - .send(Ok(ResponseEvent::Completed { - response_id: String::new(), - token_usage: None, - })) - .await; - - // Prepare for potential next turn (should not happen in same stream). - // fn_call_state = FunctionCallState::default(); - - // Mark the request log as complete - if let Ok(logger) = debug_logger.lock() { - let _ = logger.end_request_log(&request_id); - } - - return; // End processing for this SSE stream. - } - } - } -} - -/// Optional client-side aggregation helper -/// -/// Stream adapter that merges the incremental `OutputItemDone` chunks coming from -/// [`process_chat_sse`] into a *running* assistant message, **suppressing the -/// per-token deltas**. The stream stays silent while the model is thinking -/// and only emits two events per turn: -/// -/// 1. `ResponseEvent::OutputItemDone` with the *complete* assistant message -/// (fully concatenated). -/// 2. The original `ResponseEvent::Completed` right after it. -/// -/// This mirrors the behaviour the TypeScript CLI exposes to its higher layers. -/// -/// The adapter is intentionally *lossless*: callers who do **not** opt in via -/// [`AggregateStreamExt::aggregate()`] keep receiving the original unmodified -/// events. -#[derive(Copy, Clone, Eq, PartialEq)] -enum AggregateMode { - AggregatedOnly, - Streaming, -} -pub(crate) struct AggregatedChatStream { - inner: S, - cumulative: String, - cumulative_reasoning: String, - cumulative_item_id: Option, - pending: std::collections::VecDeque, - mode: AggregateMode, -} - -impl Stream for AggregatedChatStream -where - S: Stream> + Unpin, -{ - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - - // First, flush any buffered events from the previous call. - if let Some(ev) = this.pending.pop_front() { - return Poll::Ready(Some(Ok(ev))); - } - - loop { - match Pin::new(&mut this.inner).poll_next(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(None) => return Poll::Ready(None), - Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))), - Poll::Ready(Some(Ok(ResponseEvent::ContextLedger(ledger)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::ContextLedger(ledger)))); - } - Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone { item, sequence_number: _, .. }))) => { - // If this is an incremental assistant message chunk, accumulate but - // do NOT emit yet. Forward any other item (e.g. FunctionCall) right - // away so downstream consumers see it. - - let is_assistant_delta = matches!(&item, code_protocol::models::ResponseItem::Message { role, .. } if role == "assistant"); - - if is_assistant_delta { - // Only use the final assistant message if we have not - // seen any deltas; otherwise, deltas already built the - // cumulative text and this would duplicate it. - if this.cumulative.is_empty() { - if let ResponseItem::Message { content, id, .. } = &item - { - // Capture the item_id if present - if let Some(item_id) = id { - this.cumulative_item_id = Some(item_id.clone()); - } - if let Some(text) = content.iter().find_map(|c| match c { - ContentItem::OutputText { text } => Some(text), - _ => None, - }) { - this.cumulative.push_str(text); - } - } - } - } - - // Also capture item_id from Reasoning items - if let ResponseItem::Reasoning { id, .. } = &item { - if !id.is_empty() { - this.cumulative_item_id = Some(id.clone()); - } - } - - // Not an assistant message – forward immediately. - return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None }))); - } - Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))); - } - Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::ServerReasoningIncluded(included)))); - } - Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))); - } - Poll::Ready(Some(Ok(ResponseEvent::ResponseHeaders(headers)))) => { - return Poll::Ready(Some(Ok(ResponseEvent::ResponseHeaders(headers)))); - } - Poll::Ready(Some(Ok(ResponseEvent::Completed { - response_id, - token_usage, - }))) => { - // Build any aggregated items in the correct order: Reasoning first, then Message. - let mut emitted_any = false; - - if !this.cumulative_reasoning.is_empty() - && matches!(this.mode, AggregateMode::AggregatedOnly) - { - let aggregated_reasoning = ResponseItem::Reasoning { - id: this.cumulative_item_id.clone().unwrap_or_else(String::new), - summary: Vec::new(), - content: Some(vec![ - ReasoningItemContent::ReasoningText { - text: std::mem::take(&mut this.cumulative_reasoning), - }, - ]), - encrypted_content: None, - }; - this.pending - .push_back(ResponseEvent::OutputItemDone { item: aggregated_reasoning, sequence_number: None, output_index: None }); - emitted_any = true; - } - - // Always emit the final aggregated assistant message when any - // content deltas have been observed. In AggregatedOnly mode this - // is the sole assistant output; in Streaming mode this finalizes - // the streamed deltas into a terminal OutputItemDone so callers - // can persist/render the message once per turn. - if !this.cumulative.is_empty() { - let aggregated_message = ResponseItem::Message { - id: this.cumulative_item_id.clone(), - role: "assistant".to_string(), - content: vec![code_protocol::models::ContentItem::OutputText { - text: std::mem::take(&mut this.cumulative), - }], end_turn: None, phase: None}; - this.pending - .push_back(ResponseEvent::OutputItemDone { item: aggregated_message, sequence_number: None, output_index: None }); - emitted_any = true; - } - - // Always emit Completed last when anything was aggregated. - if emitted_any { - this.pending.push_back(ResponseEvent::Completed { - response_id: response_id.clone(), - token_usage: token_usage.clone(), - }); - // Return the first pending event now. - if let Some(ev) = this.pending.pop_front() { - return Poll::Ready(Some(Ok(ev))); - } - } - - // Nothing aggregated – forward Completed directly. - return Poll::Ready(Some(Ok(ResponseEvent::Completed { - response_id, - token_usage, - }))); - } - Poll::Ready(Some(Ok(ResponseEvent::Created { - response_id, - response_model, - }))) => { - // Preserve response metadata so downstream consumers can - // surface effective model routing details uniformly. - return Poll::Ready(Some(Ok(ResponseEvent::Created { - response_id, - response_model, - }))); - } - Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { delta, item_id, sequence_number, .. }))) => { - // Always accumulate deltas so we can emit a final OutputItemDone at Completed. - this.cumulative.push_str(&delta); - // Capture the item_id if we haven't already - if item_id.is_some() && this.cumulative_item_id.is_none() { - this.cumulative_item_id = item_id.clone(); - } - if matches!(this.mode, AggregateMode::Streaming) { - // In streaming mode, also forward the delta immediately. - return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { - delta, - item_id, - sequence_number, - output_index: None, - }))); - } else { - continue; - } - } - Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { delta, item_id, sequence_number, .. }))) => { - // Always accumulate reasoning deltas so we can emit a final Reasoning item at Completed. - this.cumulative_reasoning.push_str(&delta); - // Capture the item_id if we haven't already - if item_id.is_some() && this.cumulative_item_id.is_none() { - this.cumulative_item_id = item_id.clone(); - } - if matches!(this.mode, AggregateMode::Streaming) { - // In streaming mode, also forward the delta immediately. - return Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { - delta, - item_id, - sequence_number, - output_index: None, - content_index: None, - }))); - } else { - continue; - } - } - Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => { - continue; - } - Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => { - continue; - } - Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))) => { - return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))); - } - Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallCompleted { call_id, query }))) => { - return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallCompleted { - call_id, - query, - }))); - } - } - } - } -} - -/// Extension trait that activates aggregation on any stream of [`ResponseEvent`]. -pub(crate) trait AggregateStreamExt: Stream> + Sized { - /// Returns a new stream that emits **only** the final assistant message - /// per turn instead of every incremental delta. The produced - /// `ResponseEvent` sequence for a typical text turn looks like: - /// - /// ```ignore - /// OutputItemDone { item: , .. } - /// Completed - /// ``` - /// - /// No other `OutputItemDone` events will be seen by the caller. - /// - /// Usage: - /// - /// ```ignore - /// let agg_stream = client.stream(&prompt).await?.aggregate(); - /// while let Some(event) = agg_stream.next().await { - /// // event now contains cumulative text - /// } - /// ``` - fn aggregate(self) -> AggregatedChatStream { - AggregatedChatStream::new(self, AggregateMode::AggregatedOnly) - } -} - -impl AggregateStreamExt for T where T: Stream> + Sized {} - -impl AggregatedChatStream { - fn new(inner: S, mode: AggregateMode) -> Self { - AggregatedChatStream { - inner, - cumulative: String::new(), - cumulative_reasoning: String::new(), - cumulative_item_id: None, - pending: std::collections::VecDeque::new(), - mode, - } - } - - pub(crate) fn streaming_mode(inner: S) -> Self { - Self::new(inner, AggregateMode::Streaming) - } -} - -fn header_map_to_json(headers: &HeaderMap) -> serde_json::Value { - let mut ordered: BTreeMap> = BTreeMap::new(); - for (name, value) in headers.iter() { - let entry = ordered.entry(name.as_str().to_string()).or_default(); - entry.push(value.to_str().unwrap_or_default().to_string()); - } - - serde_json::to_value(ordered).unwrap_or(serde_json::Value::Null) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::context_ledger::ContextLedger; - use crate::context_ledger::ContextPersistence; - use crate::context_ledger::ContextSourceKind; - use futures::stream; - - #[tokio::test] - async fn aggregate_forwards_context_ledger() { - let mut ledger = ContextLedger::default(); - ledger.push( - ContextSourceKind::ToolSchema, - ContextPersistence::Contextual, - "tool schemas", - 1, - 40, - Some("tools".to_string()), - ); - let stream = stream::iter(vec![ - Ok(ResponseEvent::ContextLedger(ledger.clone())), - Ok(ResponseEvent::Completed { - response_id: "resp".to_string(), - token_usage: None, - }), - ]); - - let events = stream.aggregate().collect::>().await; - - match &events[0] { - Ok(ResponseEvent::ContextLedger(observed)) => assert_eq!(observed, &ledger), - other => panic!("unexpected first event: {other:?}"), - } - assert!(matches!(events[1], Ok(ResponseEvent::Completed { .. }))); - } -} diff --git a/code-rs/core/src/client.rs b/code-rs/core/src/client.rs index a127a595533..39e6e85e202 100644 --- a/code-rs/core/src/client.rs +++ b/code-rs/core/src/client.rs @@ -1,4523 +1,2176 @@ -use std::collections::BTreeMap; -use std::io::BufRead; -use std::path::Path; +//! Session- and turn-scoped helpers for talking to model provider APIs. +//! +//! `ModelClient` is intended to live for the lifetime of a Codex session and holds the stable +//! configuration and state needed to talk to a provider (auth, provider selection, conversation id, +//! and transport fallback state). +//! +//! Per-turn settings (model selection, reasoning controls, telemetry context, and turn metadata) +//! are passed explicitly to streaming and unary methods so that the turn lifetime is visible at the +//! call site. +//! +//! A [`ModelClientSession`] is created per turn and is used to stream one or more Responses API +//! requests during that turn. It caches a Responses WebSocket connection (opened lazily) and stores +//! per-turn state such as the `x-codex-turn-state` token used for sticky routing. +//! +//! WebSocket prewarm is a v2-only `response.create` with `generate=false`; it waits for completion +//! so the next request can reuse the same connection and `previous_response_id`. +//! +//! Turn execution performs prewarm as a best-effort step before the first stream request so the +//! subsequent request can reuse the same connection. +//! +//! ## Retry-Budget Tradeoff +//! +//! WebSocket prewarm is treated as the first websocket connection attempt for a turn. If it +//! fails, normal stream retry/fallback logic handles recovery on the same turn. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use crate::AuthManager; -use crate::RefreshTokenError; -use crate::account_usage; -use crate::auth; -use crate::auth_accounts; -use bytes::Bytes; -use code_app_server_protocol::AuthMode; -use code_protocol::models::ResponseItem; -use eventsource_stream::Eventsource; -use futures::prelude::*; -use httpdate::parse_http_date; -use regex_lite::Regex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +use codex_api::ApiError; +use codex_api::AuthProvider; +use codex_api::CompactClient as ApiCompactClient; +use codex_api::CompactionInput as ApiCompactionInput; +use codex_api::Compression; +use codex_api::MemoriesClient as ApiMemoriesClient; +use codex_api::MemorySummarizeInput as ApiMemorySummarizeInput; +use codex_api::MemorySummarizeOutput as ApiMemorySummarizeOutput; +use codex_api::Provider as ApiProvider; +use codex_api::RawMemory as ApiRawMemory; +use codex_api::RealtimeCallClient as ApiRealtimeCallClient; +use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig; +use codex_api::Reasoning; +use codex_api::RequestTelemetry; +use codex_api::ReqwestTransport; +use codex_api::ResponseCreateWsRequest; +use codex_api::ResponsesApiRequest; +use codex_api::ResponsesClient as ApiResponsesClient; +use codex_api::ResponsesOptions as ApiResponsesOptions; +use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient; +use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection; +use codex_api::ResponsesWsRequest; +use codex_api::SharedAuthProvider; +use codex_api::SseTelemetry; +use codex_api::TransportError; +use codex_api::WebsocketTelemetry; +use codex_api::auth_header_telemetry; +use codex_api::build_session_headers; +use codex_api::create_text_param_for_request; +use codex_api::response_create_client_metadata; +use codex_app_server_protocol::AuthMode; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::RefreshTokenError; +use codex_login::UnauthorizedRecovery; +use codex_login::default_client::build_reqwest_client; +use codex_otel::SessionTelemetry; +use codex_otel::current_span_w3c_trace_context; + +use codex_protocol::SessionId; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::Verbosity as VerbosityConfig; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::InternalSessionSource; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::W3cTraceContext; +use codex_rollout_trace::CompactionTraceContext; +use codex_rollout_trace::InferenceTraceAttempt; +use codex_rollout_trace::InferenceTraceContext; +use codex_tools::create_tools_json_for_responses_api; +use eventsource_stream::Event; +use eventsource_stream::EventStreamError; +use futures::StreamExt; +use http::HeaderMap as ApiHeaderMap; +use http::HeaderValue; +use http::StatusCode as HttpStatusCode; use reqwest::StatusCode; -use reqwest::header::HeaderMap; -use reqwest::header::HeaderValue; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value; -use tokio::sync::Mutex as TokioMutex; +use std::time::Duration; +use std::time::Instant; use tokio::sync::mpsc; use tokio::sync::oneshot; -use tokio::time::timeout; -use tokio_util::io::ReaderStream; -use tokio_stream::wrappers::ReceiverStream; +use tokio::sync::oneshot::error::TryRecvError; +use tokio_tungstenite::tungstenite::Error; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::sync::CancellationToken; use tracing::debug; +use tracing::instrument; use tracing::trace; use tracing::warn; -use uuid::Uuid; -use chrono::{DateTime, Duration as ChronoDuration, Utc}; -use tokio_tungstenite::MaybeTlsStream; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::Error as WsError; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -const AUTH_REQUIRED_MESSAGE: &str = "Authentication required. Run `code login` to continue."; - -use crate::agent_defaults::{ - default_agent_configs, - enabled_agent_model_specs_for_auth, - filter_agent_model_names_for_auth, -}; -use crate::chat_completions::AggregateStreamExt; -use crate::chat_completions::stream_chat_completions; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; -use crate::client_common::ResponsesApiRequest; -use crate::client_common::create_reasoning_param_for_request; -use crate::client_common::replace_image_payloads_for_model; -use crate::client_common::rewrite_image_generation_calls_for_input; -use crate::config::Config; -use crate::config_types::ReasoningEffort as ReasoningEffortConfig; -use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; -use crate::config_types::ContextMode; -use crate::config_types::ServiceTier; -use crate::config_types::TextVerbosity as TextVerbosityConfig; -use crate::debug_logger::DebugLogger; -use crate::default_client::create_client; -use crate::error::{CodexErr, RetryAfter}; -use crate::error::Result; -use crate::error::ModelCapError; -use crate::error::RetryLimitReachedError; -use crate::error::UnexpectedResponseError; -use crate::error::UsageLimitReachedError; +use crate::feedback_tags; use crate::flags::CODEX_RS_SSE_FIXTURE; -use crate::model_family::{find_family_for_model, ModelFamily}; -use crate::model_provider_info::ModelProviderInfo; -use crate::model_provider_info::WireApi; -use crate::openai_tools::create_tools_json_for_responses_api; -use crate::openai_tools::ConfigShellToolType; -use crate::openai_tools::ToolsConfig; -use crate::protocol::RateLimitSnapshotEvent; -use crate::protocol::RateLimitReachedType; -use crate::protocol::SandboxPolicy; -use crate::protocol::TokenUsage; -use crate::reasoning::clamp_reasoning_effort_for_model; -use crate::slash_commands::get_enabled_agents; -use crate::util::backoff; -use code_otel::otel_event_manager::{OtelEventManager, TurnLatencyPayload}; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::RwLock; - -const RESPONSES_BETA_HEADER_V1: &str = "responses=v1"; -const RESPONSES_BETA_HEADER_EXPERIMENTAL: &str = "responses=experimental"; -const RESPONSES_WEBSOCKETS_BETA_HEADER_V1: &str = "responses_websockets=2026-02-04"; -const RESPONSES_WEBSOCKETS_BETA_HEADER_V2: &str = "responses_websockets=2026-02-06"; -const RESPONSES_WEBSOCKET_INGRESS_BUFFER: usize = 256; +use crate::util::emit_feedback_auth_recovery_tags; +use codex_api::map_api_error; +use codex_feedback::FeedbackRequestTags; +use codex_feedback::emit_feedback_request_tags_with_auth_env; +use codex_login::auth_env_telemetry::AuthEnvTelemetry; +use codex_login::auth_env_telemetry::collect_auth_env_telemetry; +use codex_model_provider::SharedModelProvider; +use codex_model_provider::create_model_provider; +#[cfg(test)] +use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::WireApi; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result; +use codex_response_debug_context::extract_response_debug_context; +use codex_response_debug_context::extract_response_debug_context_from_api_error; +use codex_response_debug_context::telemetry_api_error_message; +use codex_response_debug_context::telemetry_transport_error_message; + +pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +pub const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; +pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; +pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; +pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id"; +pub const X_CODEX_WINDOW_ID_HEADER: &str = "x-codex-window-id"; +pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request"; +pub const X_OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent"; +pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = + "x-responsesapi-include-timing-metrics"; +const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const RESPONSES_ENDPOINT: &str = "/responses"; +const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; +const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; +#[cfg(test)] +pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = + Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponsesWebsocketVersion { - V1, - V2, +pub(crate) struct CompactConversationRequestSettings { + pub(crate) effort: Option, + pub(crate) summary: ReasoningSummaryConfig, + pub(crate) service_tier: Option, } -fn preferred_ws_version_from_env() -> ResponsesWebsocketVersion { - match std::env::var("CODE_RESPONSES_WEBSOCKET_VERSION") { - Ok(value) if value.eq_ignore_ascii_case("v1") => ResponsesWebsocketVersion::V1, - _ => ResponsesWebsocketVersion::V2, - } +/// Session-scoped state shared by all [`ModelClient`] clones. +/// +/// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most +/// configuration is per turn and is passed explicitly to streaming/unary methods. +#[derive(Debug)] +struct ModelClientState { + session_id: SessionId, + thread_id: ThreadId, + window_generation: AtomicU64, + installation_id: String, + provider: SharedModelProvider, + auth_env_telemetry: AuthEnvTelemetry, + session_source: SessionSource, + model_verbosity: Option, + enable_request_compression: bool, + include_timing_metrics: bool, + beta_features_header: Option, + disable_websockets: AtomicBool, + cached_websocket_session: StdMutex, } -// Sticky-routing token captured at the start of a turn. When present, it must -// be replayed on every subsequent request within the same turn (retries, -// continuations, websocket reconnects). -const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; -const X_CODEX_WINDOW_ID_HEADER: &str = "x-codex-window-id"; - -const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model"; -const MODEL_CAP_RESET_AFTER_HEADER: &str = "x-codex-model-cap-reset-after-seconds"; - -const CODE_OPENAI_SUBAGENT_ENV: &str = "CODE_OPENAI_SUBAGENT"; - -type ResponsesWebSocketStream = WebSocketStream>; - -#[derive(Clone, Debug, PartialEq)] -struct ResponsesRequestSnapshot { - comparable_payload: Value, - input: Vec, +/// Resolved API client setup for a single request attempt. +/// +/// Keeping this as a single bundle ensures prewarm and normal request paths +/// share the same auth/provider setup flow. +struct CurrentClientSetup { + auth: Option, + api_provider: ApiProvider, + api_auth: SharedAuthProvider, } -#[derive(Default)] -struct ResponsesWebsocketSession { - connection: Option, - turn_state: Arc>, - last_request: Option, - last_response_id: Option, - last_response_from_warmup: bool, +#[derive(Clone, Copy)] +struct RequestRouteTelemetry { + endpoint: &'static str, } -impl std::fmt::Debug for ResponsesWebsocketSession { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ResponsesWebsocketSession") - .field("connected", &self.connection.is_some()) - .field("has_turn_state", &self.turn_state.get().is_some()) - .field("has_last_request", &self.last_request.is_some()) - .field("has_last_response_id", &self.last_response_id.is_some()) - .field("last_response_from_warmup", &self.last_response_from_warmup) - .finish() +impl RequestRouteTelemetry { + fn for_endpoint(endpoint: &'static str) -> Self { + Self { endpoint } } } -fn responses_request_snapshot( - payload_json: &Value, - input: &[ResponseItem], -) -> ResponsesRequestSnapshot { - let mut comparable_payload = payload_json.clone(); - if let Some(obj) = comparable_payload.as_object_mut() { - obj.remove("input"); - } - ResponsesRequestSnapshot { - comparable_payload, - input: input.to_vec(), - } +/// A session-scoped client for model-provider API calls. +/// +/// This holds configuration and state that should be shared across turns within a Codex session +/// (auth, provider selection, thread id, and transport fallback state). +/// +/// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns +/// will also use HTTP for the remainder of the session. +/// +/// Turn-scoped settings (model selection, reasoning controls, telemetry context, and turn +/// metadata) are passed explicitly to the relevant methods to keep turn lifetime visible at the +/// call site. +#[derive(Debug, Clone)] +pub struct ModelClient { + state: Arc, } -fn incremental_input_for_websocket_request( - previous: &ResponsesRequestSnapshot, - current: &ResponsesRequestSnapshot, -) -> Option> { - if previous.comparable_payload != current.comparable_payload { - return None; - } - if !current.input.starts_with(&previous.input) { - return None; - } - Some(current.input[previous.input.len()..].to_vec()) +/// A turn-scoped streaming session created from a [`ModelClient`]. +/// +/// The session establishes a Responses WebSocket connection lazily and reuses it across multiple +/// requests within the turn. It also caches per-turn state: +/// +/// - The last full request, so subsequent calls can reuse incremental websocket request payloads +/// only when the current request is an incremental extension of the previous one. +/// - The `x-codex-turn-state` sticky-routing token, which must be replayed for all requests within +/// the same turn. +/// +/// Create a fresh `ModelClientSession` for each Codex turn. Reusing it across turns would replay +/// the previous turn's sticky-routing token into the next turn, which violates the client/server +/// contract and can cause routing bugs. +pub struct ModelClientSession { + client: ModelClient, + websocket_session: WebsocketSession, + /// Turn state for sticky routing. + /// + /// This is an `OnceLock` that stores the turn state value received from the server + /// on turn start via the `x-codex-turn-state` response header. Once set, this value + /// should be sent back to the server in the `x-codex-turn-state` request header for + /// all subsequent requests within the same turn to maintain sticky routing. + /// + /// This is a contract between the client and server: we receive it at turn start, + /// keep sending it unchanged between turn requests (e.g., for retries, incremental + /// appends, or continuation requests), and must not send it between different turns. + turn_state: Arc>, } -fn build_responses_websocket_payload( - payload_json: &Value, - input_override: Option>, - previous_response_id: Option, - generate: Option, -) -> Result { - let mut ws_payload = serde_json::Map::new(); - ws_payload.insert( - "type".to_string(), - serde_json::Value::String("response.create".to_string()), - ); - if let Some(obj) = payload_json.as_object() { - for (k, v) in obj { - ws_payload.insert(k.clone(), v.clone()); - } - } - if let Some(input) = input_override { - ws_payload.insert("input".to_string(), serde_json::to_value(input)?); - } - if let Some(previous_response_id) = previous_response_id { - ws_payload.insert( - "previous_response_id".to_string(), - Value::String(previous_response_id), - ); - } - if let Some(generate) = generate { - ws_payload.insert("generate".to_string(), Value::Bool(generate)); - } - Ok(serde_json::to_string(&Value::Object(ws_payload))?) +#[derive(Debug, Clone)] +struct LastResponse { + response_id: String, + items_added: Vec, } -fn terminal_response_id_from_websocket_event(text: &str) -> Option> { - let event: SseEvent = serde_json::from_str(text).ok()?; - match event.kind.as_str() { - "response.completed" => { - let response = event.response?; - let completed: ResponseCompleted = serde_json::from_value(response).ok()?; - Some(Some(completed.id)) - } - "response.done" => { - let response = event.response?; - let done: ResponseDone = serde_json::from_value(response).ok()?; - Some(done.id) - } - "response.failed" | "response.incomplete" => Some(None), - _ => None, - } +#[derive(Debug, Default)] +struct WebsocketSession { + connection: Option, + last_request: Option, + last_response_rx: Option>, + connection_reused: StdMutex, } -#[derive(Default, Debug)] -struct StreamCheckpoint { - /// Highest sequence_number observed across attempts. Used to drop replayed deltas. - last_sequence: Option, -} +impl WebsocketSession { + fn set_connection_reused(&self, connection_reused: bool) { + *self + .connection_reused + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = connection_reused; + } -#[derive(Debug, Deserialize)] -struct ErrorResponse { - error: Error, + fn connection_reused(&self) -> bool { + *self + .connection_reused + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } } -#[derive(Debug, Deserialize)] -struct WrappedWebsocketErrorEvent { - #[serde(rename = "type")] - kind: String, - #[serde(alias = "status_code")] - status: Option, - #[serde(default)] - error: Option, +enum WebsocketStreamOutcome { + Stream(ResponseStream), + FallbackToHttp, } -#[derive(Debug, Deserialize)] -struct Error { - r#type: Option, - #[allow(dead_code)] - code: Option, - /// Optional parameter that triggered the error (e.g. "reasoning.summary"). - #[allow(dead_code)] - param: Option, - message: Option, - - // Optional fields available on "usage_limit_reached" and "usage_not_included" errors - plan_type: Option, - resets_in_seconds: Option, - rate_limit_reached_type: Option, +/// Result of opening a WebRTC Realtime call. +/// +/// The SDP answer goes back to the client. The call id and auth headers stay on the server so the +/// ordinary Realtime WebSocket machinery can join the same in-progress call as a sideband +/// controller. +pub(crate) struct RealtimeWebrtcCallStart { + pub(crate) sdp: String, + pub(crate) call_id: String, + pub(crate) sideband_headers: ApiHeaderMap, } -#[derive(Serialize)] -struct CompactHistoryRequest<'a> { - model: &'a str, - #[serde(borrow)] - input: &'a [ResponseItem], - instructions: String, - #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option, - #[serde(skip_serializing_if = "Option::is_none")] - prompt_cache_key: Option<&'a str>, +/// Reuses the API-auth material that created the WebRTC call for the sideband WebSocket join. +/// +/// API-key sessions send that API bearer. ChatGPT-auth sessions send their bearer plus account id; +/// transceiver is responsible for accepting that same call-create identity on the direct +/// `api.openai.com` sideband path. +fn sideband_websocket_auth_headers(api_auth: &dyn AuthProvider) -> ApiHeaderMap { + let mut headers = ApiHeaderMap::new(); + api_auth.add_auth_headers(&mut headers); + headers } -#[derive(Debug, Deserialize)] -struct CompactHistoryResponse { - output: Vec, -} +impl ModelClient { + #[allow(clippy::too_many_arguments)] + /// Creates a new session-scoped `ModelClient`. + /// + /// All arguments are expected to be stable for the lifetime of a Codex session. Per-turn values + /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. + pub fn new( + auth_manager: Option>, + session_id: SessionId, + thread_id: ThreadId, + installation_id: String, + provider_info: ModelProviderInfo, + session_source: SessionSource, + model_verbosity: Option, + enable_request_compression: bool, + include_timing_metrics: bool, + beta_features_header: Option, + ) -> Self { + let model_provider = create_model_provider(provider_info, auth_manager); + let codex_api_key_env_enabled = model_provider + .auth_manager() + .as_ref() + .is_some_and(|manager| manager.codex_api_key_env_enabled()); + let auth_env_telemetry = + collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled); + Self { + state: Arc::new(ModelClientState { + session_id, + thread_id, + window_generation: AtomicU64::new(0), + installation_id, + provider: model_provider, + auth_env_telemetry, + session_source, + model_verbosity, + enable_request_compression, + include_timing_metrics, + beta_features_header, + disable_websockets: AtomicBool::new(false), + cached_websocket_session: StdMutex::new(WebsocketSession::default()), + }), + } + } -fn rate_limit_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new( - r"(?i)(?:please\s+try\s+again|try\s+again|please\s+retry|retry|try)\s+(?:in|after)\s*(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|sec|secs|seconds?)" - ) - .expect("valid rate limit regex") - }) -} + /// Creates a fresh turn-scoped streaming session. + /// + /// This constructor does not perform network I/O itself; the session opens a websocket lazily + /// when the first stream request is issued. + pub fn new_session(&self) -> ModelClientSession { + ModelClientSession { + client: self.clone(), + websocket_session: self.take_cached_websocket_session(), + turn_state: Arc::new(OnceLock::new()), + } + } -fn try_parse_retry_after(err: &Error, now: DateTime) -> Option { - if let Some(seconds) = err.resets_in_seconds { - return Some(RetryAfter::from_duration(Duration::from_secs(seconds), now)); + pub(crate) fn auth_manager(&self) -> Option> { + self.state.provider.auth_manager() } - let message = err.message.as_deref()?; - let re = rate_limit_regex(); - let captures = re.captures(message)?; - let value = captures.get(1)?.as_str().trim().parse::().ok()?; - if value.is_sign_negative() { - return None; + pub(crate) fn set_window_generation(&self, window_generation: u64) { + self.state + .window_generation + .store(window_generation, Ordering::Relaxed); + self.store_cached_websocket_session(WebsocketSession::default()); } - let unit = captures.get(2)?.as_str().trim().to_ascii_lowercase(); - if unit.starts_with("ms") { - Some(RetryAfter::from_duration(Duration::from_millis(value.round() as u64), now)) - } else if unit.starts_with("sec") || unit == "s" || unit.starts_with("second") { - Some(RetryAfter::from_duration(Duration::from_secs_f64(value), now)) - } else { - None + pub(crate) fn advance_window_generation(&self) { + self.state.window_generation.fetch_add(1, Ordering::Relaxed); + self.store_cached_websocket_session(WebsocketSession::default()); } -} -fn is_quota_exceeded_error(error: &Error) -> bool { - matches!( - error.code.as_deref().or_else(|| error.r#type.as_deref()), - Some("insufficient_quota") - ) -} + fn current_window_id(&self) -> String { + let thread_id = self.state.thread_id; + let window_generation = self.state.window_generation.load(Ordering::Relaxed); + format!("{thread_id}:{window_generation}") + } -fn is_quota_exceeded_http_error(status: StatusCode, error: &Error) -> bool { - status.is_client_error() && is_quota_exceeded_error(error) -} + fn take_cached_websocket_session(&self) -> WebsocketSession { + let mut cached_websocket_session = self + .state + .cached_websocket_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::mem::take(&mut *cached_websocket_session) + } -fn is_server_overloaded_error(error: &Error) -> bool { - matches!( - error.code.as_deref(), - Some("server_is_overloaded") | Some("slow_down") - ) -} + fn store_cached_websocket_session(&self, websocket_session: WebsocketSession) { + *self + .state + .cached_websocket_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = websocket_session; + } -fn is_reasoning_summary_rejected(error: &Error) -> bool { - let param_matches = matches!(error.param.as_deref(), Some("reasoning.summary")); - let code_matches = matches!(error.code.as_deref(), Some("unsupported_value")); + pub(crate) fn force_http_fallback( + &self, + session_telemetry: &SessionTelemetry, + _model_info: &ModelInfo, + ) -> bool { + let websocket_enabled = self.responses_websocket_enabled(); + let activated = + websocket_enabled && !self.state.disable_websockets.swap(true, Ordering::Relaxed); + if activated { + warn!("falling back to HTTP"); + session_telemetry.counter( + "codex.transport.fallback_to_http", + /*inc*/ 1, + &[("from_wire_api", "responses_websocket")], + ); + } - let message_matches = error - .message - .as_deref() - .map(|msg| { - let msg = msg.to_ascii_lowercase(); - msg.contains("organization must be verified") && msg.contains("reasoning summar") - }) - .unwrap_or(false); + self.store_cached_websocket_session(WebsocketSession::default()); + activated + } - // Only treat as rejection if it's specifically an "unsupported_value" error - // for the reasoning.summary parameter, or if the message explicitly says - // the organization must be verified for reasoning summaries. - (param_matches && code_matches) || (code_matches && message_matches) -} + /// Compacts the current conversation history using the Compact endpoint. + /// + /// This is a unary call (no streaming) that returns a new list of + /// `ResponseItem`s representing the compacted transcript. + /// + /// The model selection and telemetry context are passed explicitly to keep `ModelClient` + /// session-scoped. + pub(crate) async fn compact_conversation_history( + &self, + prompt: &Prompt, + model_info: &ModelInfo, + settings: CompactConversationRequestSettings, + session_telemetry: &SessionTelemetry, + compaction_trace: &CompactionTraceContext, + ) -> Result> { + if prompt.input.is_empty() { + return Ok(Vec::new()); + } + let client_setup = self.current_client_setup().await?; + let transport = ReqwestTransport::new(build_reqwest_client()); + let request_telemetry = Self::build_request_telemetry( + session_telemetry, + AuthRequestTelemetryContext::new( + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + PendingUnauthorizedRetry::default(), + ), + RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT), + self.state.auth_env_telemetry.clone(), + ); + let request = self.build_responses_request( + &client_setup.api_provider, + prompt, + model_info, + settings.effort, + settings.summary, + settings.service_tier, + )?; + let ResponsesApiRequest { + model, + instructions, + input, + tools, + parallel_tool_calls, + reasoning, + service_tier, + prompt_cache_key, + text, + .. + } = request; + let client = + ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) + .with_telemetry(Some(request_telemetry)); + let payload = ApiCompactionInput { + model: &model, + input: &input, + instructions: &instructions, + tools, + parallel_tool_calls, + reasoning, + service_tier: service_tier.as_deref(), + prompt_cache_key: prompt_cache_key.as_deref(), + text, + }; -fn map_unauthorized_outcome( - had_auth: bool, - refresh_error: Option<&RefreshTokenError>, -) -> Option { - if let Some(err) = refresh_error { - if err.is_permanent() { - return Some(CodexErr::AuthRefreshPermanent(err.message.clone())); + let mut extra_headers = ApiHeaderMap::new(); + if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) { + extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value); } - return None; + extra_headers.extend(build_responses_headers( + self.state.beta_features_header.as_deref(), + /*turn_state*/ None, + /*turn_metadata_header*/ None, + )); + extra_headers.extend(self.build_responses_identity_headers()); + extra_headers.extend(build_session_headers( + Some(self.state.session_id.to_string()), + Some(self.state.thread_id.to_string()), + )); + let trace_attempt = compaction_trace.start_attempt(&payload); + let result = client + .compact_input(&payload, extra_headers) + .await + .map_err(map_api_error); + trace_attempt.record_result(result.as_deref()); + result } - if !had_auth { - return Some(CodexErr::AuthRefreshPermanent( - AUTH_REQUIRED_MESSAGE.to_string(), + pub(crate) async fn create_realtime_call_with_headers( + &self, + sdp: String, + session_config: ApiRealtimeSessionConfig, + extra_headers: ApiHeaderMap, + ) -> Result { + // Create the media call over HTTP first, then retain matching auth so realtime can attach + // the server-side control WebSocket to the call id from that HTTP response. + let client_setup = self.current_client_setup().await?; + let mut sideband_headers = extra_headers.clone(); + sideband_headers.extend(sideband_websocket_auth_headers( + client_setup.api_auth.as_ref(), )); + let transport = ReqwestTransport::new(build_reqwest_client()); + let response = + ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth) + .create_with_session_and_headers(sdp, session_config, extra_headers) + .await + .map_err(map_api_error)?; + Ok(RealtimeWebrtcCallStart { + sdp: response.sdp, + call_id: response.call_id, + sideband_headers, + }) } - None -} - -#[derive(Debug)] -pub struct ModelClient { - config: Arc, - auth_manager: Option>, - otel_event_manager: Option, - client: reqwest::Client, - provider: ModelProviderInfo, - session_id: Uuid, - effort: ReasoningEffortConfig, - summary: ReasoningSummaryConfig, - reasoning_summary_disabled: AtomicBool, - websockets_disabled: AtomicBool, - websocket_session: Arc>, - verbosity: TextVerbosityConfig, - debug_logger: Arc>, -} + /// Builds memory summaries for each provided normalized raw memory. + /// + /// This is a unary call (no streaming) to `/v1/memories/trace_summarize`. + /// + /// The model selection, reasoning effort, and telemetry context are passed explicitly to keep + /// `ModelClient` session-scoped. + pub async fn summarize_memories( + &self, + raw_memories: Vec, + model_info: &ModelInfo, + effort: Option, + session_telemetry: &SessionTelemetry, + ) -> Result> { + if raw_memories.is_empty() { + return Ok(Vec::new()); + } -impl Clone for ModelClient { - fn clone(&self) -> Self { - Self { - config: Arc::clone(&self.config), - auth_manager: self.auth_manager.clone(), - otel_event_manager: self.otel_event_manager.clone(), - client: self.client.clone(), - provider: self.provider.clone(), - session_id: self.session_id, - effort: self.effort, - summary: self.summary, - reasoning_summary_disabled: AtomicBool::new( - self.reasoning_summary_disabled.load(Ordering::Relaxed), + let client_setup = self.current_client_setup().await?; + let transport = ReqwestTransport::new(build_reqwest_client()); + let request_telemetry = Self::build_request_telemetry( + session_telemetry, + AuthRequestTelemetryContext::new( + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + PendingUnauthorizedRetry::default(), ), - websockets_disabled: AtomicBool::new( - self.websockets_disabled.load(Ordering::Relaxed), - ), - websocket_session: Arc::new(TokioMutex::new(ResponsesWebsocketSession::default())), - verbosity: self.verbosity, - debug_logger: Arc::clone(&self.debug_logger), - } - } -} + RequestRouteTelemetry::for_endpoint(MEMORIES_SUMMARIZE_ENDPOINT), + self.state.auth_env_telemetry.clone(), + ); + let client = + ApiMemoriesClient::new(transport, client_setup.api_provider, client_setup.api_auth) + .with_telemetry(Some(request_telemetry)); + + let payload = ApiMemorySummarizeInput { + model: model_info.slug.clone(), + raw_memories, + reasoning: effort.map(|effort| Reasoning { + effort: Some(effort), + summary: None, + }), + }; -impl ModelClient { - pub fn new( - config: Arc, - auth_manager: Option>, - otel_event_manager: Option, - provider: ModelProviderInfo, - effort: ReasoningEffortConfig, - summary: ReasoningSummaryConfig, - verbosity: TextVerbosityConfig, - session_id: Uuid, - debug_logger: Arc>, - ) -> Self { - let effective_verbosity = clamp_text_verbosity_for_model(config.model.as_str(), verbosity); - let clamped_effort = clamp_reasoning_effort_for_model(config.model.as_str(), effort); - let client = create_client(&config.responses_originator_header); + client + .summarize_input(&payload, self.build_subagent_headers()) + .await + .map_err(map_api_error) + } - Self { - config, - auth_manager, - otel_event_manager, - client, - provider, - session_id, - effort: clamped_effort, - summary, - reasoning_summary_disabled: AtomicBool::new(false), - websockets_disabled: AtomicBool::new(false), - websocket_session: Arc::new(TokioMutex::new(ResponsesWebsocketSession::default())), - verbosity: effective_verbosity, - debug_logger, + fn build_subagent_headers(&self) -> ApiHeaderMap { + let mut extra_headers = ApiHeaderMap::new(); + if let Some(subagent) = subagent_header_value(&self.state.session_source) + && let Ok(val) = HeaderValue::from_str(&subagent) + { + extra_headers.insert(X_OPENAI_SUBAGENT_HEADER, val); } + if matches!( + self.state.session_source, + SessionSource::Internal(InternalSessionSource::MemoryConsolidation) + ) { + extra_headers.insert( + X_OPENAI_MEMGEN_REQUEST_HEADER, + HeaderValue::from_static("true"), + ); + } + extra_headers } - fn active_ws_version_for_prompt(&self, prompt: &Prompt) -> Option { - if self.websockets_disabled.load(Ordering::Relaxed) { - return None; + fn build_responses_identity_headers(&self) -> ApiHeaderMap { + let mut extra_headers = self.build_subagent_headers(); + if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) + && let Ok(val) = HeaderValue::from_str(&parent_thread_id) + { + extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val); } - - match self.provider.wire_api { - WireApi::ResponsesWebsocket => Some(preferred_ws_version_from_env()), - WireApi::Responses => { - let prefer_websockets = prompt - .model_family_override - .as_ref() - .map(|family| family.prefer_websockets) - .or_else(|| { - prompt - .model_override - .as_deref() - .and_then(find_family_for_model) - .map(|family| family.prefer_websockets) - }) - .unwrap_or(self.config.model_family.prefer_websockets); - - prefer_websockets.then_some(preferred_ws_version_from_env()) - } - WireApi::Chat => None, + if let Ok(val) = HeaderValue::from_str(&self.current_window_id()) { + extra_headers.insert(X_CODEX_WINDOW_ID_HEADER, val); } + extra_headers } - /// Get the reasoning effort configuration - pub fn get_reasoning_effort(&self) -> ReasoningEffortConfig { - self.effort + fn build_ws_client_metadata( + &self, + turn_metadata_header: Option<&str>, + ) -> HashMap { + let mut client_metadata = HashMap::new(); + client_metadata.insert( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.state.installation_id.clone(), + ); + client_metadata.insert( + X_CODEX_WINDOW_ID_HEADER.to_string(), + self.current_window_id(), + ); + if let Some(subagent) = subagent_header_value(&self.state.session_source) { + client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent); + } + if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) { + client_metadata.insert( + X_CODEX_PARENT_THREAD_ID_HEADER.to_string(), + parent_thread_id, + ); + } + if let Some(turn_metadata_header) = parse_turn_metadata_header(turn_metadata_header) + && let Ok(turn_metadata) = turn_metadata_header.to_str() + { + client_metadata.insert( + X_CODEX_TURN_METADATA_HEADER.to_string(), + turn_metadata.to_string(), + ); + } + client_metadata + } + + /// Builds request telemetry for unary API calls (e.g., Compact endpoint). + fn build_request_telemetry( + session_telemetry: &SessionTelemetry, + auth_context: AuthRequestTelemetryContext, + request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, + ) -> Arc { + let telemetry = Arc::new(ApiTelemetry::new( + session_telemetry.clone(), + auth_context, + request_route_telemetry, + auth_env_telemetry, + )); + let request_telemetry: Arc = telemetry; + request_telemetry } - /// Get the reasoning summary configuration - pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig { - if self.reasoning_summary_disabled.load(Ordering::Relaxed) { - ReasoningSummaryConfig::None + fn build_reasoning( + model_info: &ModelInfo, + effort: Option, + summary: ReasoningSummaryConfig, + ) -> Option { + if model_info.supports_reasoning_summaries { + Some(Reasoning { + effort: effort.or(model_info.default_reasoning_level), + summary: if summary == ReasoningSummaryConfig::None { + None + } else { + Some(summary) + }, + }) } else { - self.summary + None } } - fn apply_requested_model_headers( + fn build_responses_request( &self, - req_builder: reqwest::RequestBuilder, - model: &str, - ) -> reqwest::RequestBuilder { - req_builder.headers(crate::default_client::requested_model_headers( - Some(self.config.responses_originator_header.as_str()), - model, - )) + provider: &codex_api::Provider, + prompt: &Prompt, + model_info: &ModelInfo, + effort: Option, + summary: ReasoningSummaryConfig, + service_tier: Option, + ) -> Result { + let instructions = &prompt.base_instructions.text; + let input = prompt.get_formatted_input(); + let tools = create_tools_json_for_responses_api(&prompt.tools)?; + let reasoning = Self::build_reasoning(model_info, effort, summary); + let include = if reasoning.is_some() { + vec!["reasoning.encrypted_content".to_string()] + } else { + Vec::new() + }; + let verbosity = if model_info.support_verbosity { + self.state.model_verbosity.or(model_info.default_verbosity) + } else { + if self.state.model_verbosity.is_some() { + warn!( + "model_verbosity is set but ignored as the model does not support verbosity: {}", + model_info.slug + ); + } + None + }; + let text = create_text_param_for_request( + verbosity, + &prompt.output_schema, + prompt.output_schema_strict, + ); + let prompt_cache_key = Some(self.state.thread_id.to_string()); + let request = ResponsesApiRequest { + model: model_info.slug.clone(), + instructions: instructions.clone(), + input, + tools, + tool_choice: "auto".to_string(), + parallel_tool_calls: prompt.parallel_tool_calls, + reasoning, + store: provider.is_azure_responses_endpoint(), + stream: true, + include, + service_tier, + prompt_cache_key, + text, + client_metadata: Some(HashMap::from([( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.state.installation_id.clone(), + )])), + }; + Ok(request) } - fn current_reasoning_param( - &self, - family: &ModelFamily, - effort: ReasoningEffortConfig, - ) -> Option { - if self.reasoning_summary_disabled.load(Ordering::Relaxed) { - return None; + /// Returns whether the Responses-over-WebSocket transport is active for this session. + /// + /// WebSocket use is controlled by provider capability and session-scoped fallback state. + pub fn responses_websocket_enabled(&self) -> bool { + if !self.state.provider.info().supports_websockets + || self.state.disable_websockets.load(Ordering::Relaxed) + || (*CODEX_RS_SSE_FIXTURE).is_some() + { + return false; } - create_reasoning_param_for_request( - family, - Some(effort), - self.summary, + true + } + + /// Returns auth + provider configuration resolved from the current session auth state. + /// + /// This centralizes setup used by both prewarm and normal request paths so they stay in + /// lockstep when auth/provider resolution changes. + async fn current_client_setup(&self) -> Result { + let auth = self.state.provider.auth().await; + let api_provider = self.state.provider.api_provider().await?; + let api_auth = self.state.provider.api_auth().await?; + Ok(CurrentClientSetup { + auth, + api_provider, + api_auth, + }) + } + + /// Opens a websocket connection using the same header and telemetry wiring as normal turns. + /// + /// Both startup prewarm and in-turn `needs_new` reconnects call this path so handshake + /// behavior remains consistent across both flows. + #[allow(clippy::too_many_arguments)] + async fn connect_websocket( + &self, + session_telemetry: &SessionTelemetry, + api_provider: codex_api::Provider, + api_auth: SharedAuthProvider, + turn_state: Option>>, + turn_metadata_header: Option<&str>, + auth_context: AuthRequestTelemetryContext, + request_route_telemetry: RequestRouteTelemetry, + ) -> std::result::Result { + let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); + let websocket_telemetry = ModelClientSession::build_websocket_telemetry( + session_telemetry, + auth_context, + request_route_telemetry, + self.state.auth_env_telemetry.clone(), + ); + let websocket_connect_timeout = self.state.provider.info().websocket_connect_timeout(); + let start = Instant::now(); + let result = match tokio::time::timeout( + websocket_connect_timeout, + ApiWebSocketResponsesClient::new(api_provider, api_auth).connect( + headers, + codex_login::default_client::default_headers(), + turn_state, + Some(websocket_telemetry), + ), ) + .await + { + Ok(result) => result, + Err(_) => Err(ApiError::Transport(TransportError::Timeout)), + }; + let error_message = result.as_ref().err().map(telemetry_api_error_message); + let response_debug = result + .as_ref() + .err() + .map(extract_response_debug_context_from_api_error) + .unwrap_or_default(); + let status = result.as_ref().err().and_then(api_error_http_status); + session_telemetry.record_websocket_connect( + start.elapsed(), + status, + error_message.as_deref(), + auth_context.auth_header_attached, + auth_context.auth_header_name, + auth_context.retry_after_unauthorized, + auth_context.recovery_mode, + auth_context.recovery_phase, + request_route_telemetry.endpoint, + /*connection_reused*/ false, + response_debug.request_id.as_deref(), + response_debug.cf_ray.as_deref(), + response_debug.auth_error.as_deref(), + response_debug.auth_error_code.as_deref(), + ); + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: request_route_telemetry.endpoint, + auth_header_attached: auth_context.auth_header_attached, + auth_header_name: auth_context.auth_header_name, + auth_mode: auth_context.auth_mode, + auth_retry_after_unauthorized: Some(auth_context.retry_after_unauthorized), + auth_recovery_mode: auth_context.recovery_mode, + auth_recovery_phase: auth_context.recovery_phase, + auth_connection_reused: Some(false), + auth_request_id: response_debug.request_id.as_deref(), + auth_cf_ray: response_debug.cf_ray.as_deref(), + auth_error: response_debug.auth_error.as_deref(), + auth_error_code: response_debug.auth_error_code.as_deref(), + auth_recovery_followup_success: auth_context + .retry_after_unauthorized + .then_some(result.is_ok()), + auth_recovery_followup_status: auth_context + .retry_after_unauthorized + .then_some(status) + .flatten(), + }, + &self.state.auth_env_telemetry, + ); + result } - fn disable_reasoning_summary(&self) { - if !self.reasoning_summary_disabled.swap(true, Ordering::Relaxed) { - tracing::warn!("disabling reasoning summaries after API rejection"); + /// Builds websocket handshake headers for both prewarm and turn-time reconnect. + /// + /// Callers should pass the current turn-state lock when available so sticky-routing state is + /// replayed on reconnect within the same turn. + fn build_websocket_headers( + &self, + turn_state: Option<&Arc>>, + turn_metadata_header: Option<&str>, + ) -> ApiHeaderMap { + let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); + let session_id = self.state.session_id.to_string(); + let thread_id = self.state.thread_id.to_string(); + let mut headers = build_responses_headers( + self.state.beta_features_header.as_deref(), + turn_state, + turn_metadata_header.as_ref(), + ); + if let Ok(header_value) = HeaderValue::from_str(&thread_id) { + headers.insert("x-client-request-id", header_value); } + headers.extend(build_session_headers(Some(session_id), Some(thread_id))); + headers.extend(self.build_responses_identity_headers()); + headers.insert( + OPENAI_BETA_HEADER, + HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), + ); + if self.state.include_timing_metrics { + headers.insert( + X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER, + HeaderValue::from_static("true"), + ); + } + headers } +} - /// Get the text verbosity configuration - #[allow(dead_code)] - pub fn get_text_verbosity(&self) -> TextVerbosityConfig { - self.verbosity +impl Drop for ModelClientSession { + fn drop(&mut self) { + let websocket_session = std::mem::take(&mut self.websocket_session); + self.client + .store_cached_websocket_session(websocket_session); } +} - pub fn get_otel_event_manager(&self) -> Option { - self.otel_event_manager.clone() +impl ModelClientSession { + pub(crate) fn reset_websocket_session(&mut self) { + self.websocket_session.connection = None; + self.websocket_session.last_request = None; + self.websocket_session.last_response_rx = None; + self.websocket_session + .set_connection_reused(/*connection_reused*/ false); } - pub fn log_turn_latency_debug(&self, payload: &TurnLatencyPayload) { - if let Ok(logger) = self.debug_logger.lock() { - let _ = logger.log_turn_latency(payload); + pub(crate) async fn send_response_processed(&self, response_id: &str) { + let Some(connection) = self.websocket_session.connection.as_ref() else { + return; + }; + if let Err(err) = connection + .send_response_processed(response_id.to_string()) + .await + { + debug!("failed to send response.processed websocket request: {err}"); } } - pub fn code_home(&self) -> &Path { - &self.config.code_home - } - - fn current_window_id(&self, session_id: Uuid) -> String { - format!("{session_id}:0") + #[allow(clippy::too_many_arguments)] + /// Builds shared Responses API transport options and request-body options. + /// + /// Keeping option construction in one place ensures request-scoped headers are consistent + /// regardless of transport choice. + fn build_responses_options( + &self, + turn_metadata_header: Option<&str>, + compression: Compression, + ) -> ApiResponsesOptions { + let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); + let session_id = self.client.state.session_id.to_string(); + let thread_id = self.client.state.thread_id.to_string(); + ApiResponsesOptions { + session_id: Some(session_id), + thread_id: Some(thread_id), + session_source: Some(self.client.state.session_source.clone()), + extra_headers: { + let mut headers = build_responses_headers( + self.client.state.beta_features_header.as_deref(), + Some(&self.turn_state), + turn_metadata_header.as_ref(), + ); + headers.extend(self.client.build_responses_identity_headers()); + headers + }, + compression, + turn_state: Some(Arc::clone(&self.turn_state)), + } } - pub(crate) fn config(&self) -> &crate::config::Config { - &self.config - } + fn get_incremental_items( + &self, + request: &ResponsesApiRequest, + last_response: Option<&LastResponse>, + allow_empty_delta: bool, + ) -> Option> { + // Checks whether the current request is an incremental extension of the previous request. + // We only reuse an incremental input delta when non-input request fields are unchanged and + // `input` is a strict + // extension of the previous known input. Server-returned output items are treated as part + // of the baseline so we do not resend them. + let previous_request = self.websocket_session.last_request.as_ref()?; + let mut previous_without_input = previous_request.clone(); + previous_without_input.input.clear(); + let mut request_without_input = request.clone(); + request_without_input.input.clear(); + if previous_without_input != request_without_input { + trace!( + "incremental request failed, properties didn't match {previous_without_input:?} != {request_without_input:?}" + ); + return None; + } - pub fn debug_enabled(&self) -> bool { - self.config.debug - } + let mut baseline = previous_request.input.clone(); + if let Some(last_response) = last_response { + baseline.extend(last_response.items_added.clone()); + } - pub fn auto_switch_accounts_on_rate_limit(&self) -> bool { - self.config.auto_switch_accounts_on_rate_limit + let baseline_len = baseline.len(); + if request.input.starts_with(&baseline) + && (allow_empty_delta || baseline_len < request.input.len()) + { + Some(request.input[baseline_len..].to_vec()) + } else { + trace!("incremental request failed, items didn't match"); + None + } } - pub fn api_key_fallback_on_all_accounts_limited(&self) -> bool { - self.config.api_key_fallback_on_all_accounts_limited + fn get_last_response(&mut self) -> Option { + self.websocket_session + .last_response_rx + .take() + .and_then(|mut receiver| match receiver.try_recv() { + Ok(last_response) => Some(last_response), + Err(TryRecvError::Closed) | Err(TryRecvError::Empty) => None, + }) } - pub fn memories_enabled(&self) -> bool { - self.config.memories_enabled - } + fn prepare_websocket_request( + &mut self, + payload: ResponseCreateWsRequest, + request: &ResponsesApiRequest, + ) -> ResponsesWsRequest { + let Some(last_response) = self.get_last_response() else { + return ResponsesWsRequest::ResponseCreate(payload); + }; + let Some(incremental_items) = self.get_incremental_items( + request, + Some(&last_response), + /*allow_empty_delta*/ true, + ) else { + return ResponsesWsRequest::ResponseCreate(payload); + }; - pub fn memories_generate_enabled(&self) -> bool { - self.config.memories.generate_memories - } + if last_response.response_id.is_empty() { + trace!("incremental request failed, no previous response id"); + return ResponsesWsRequest::ResponseCreate(payload); + } - pub fn memories_use_enabled(&self) -> bool { - self.config.memories.use_memories + ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { + previous_response_id: Some(last_response.response_id), + input: incremental_items, + ..payload + }) } - pub fn build_tools_config_with_sandbox( - &self, - sandbox_policy: SandboxPolicy, - ) -> ToolsConfig { - self.build_tools_config_with_sandbox_for_family(sandbox_policy, &self.config.model_family) - } + /// Opportunistically preconnects a websocket for this turn-scoped client session. + /// + /// This performs only connection setup; it never sends prompt payloads. + pub async fn preconnect_websocket( + &mut self, + session_telemetry: &SessionTelemetry, + _model_info: &ModelInfo, + ) -> std::result::Result<(), ApiError> { + if !self.client.responses_websocket_enabled() { + return Ok(()); + } + if self.websocket_session.connection.is_some() { + return Ok(()); + } - pub fn build_tools_config_with_sandbox_for_family( - &self, - sandbox_policy: SandboxPolicy, - model_family: &ModelFamily, - ) -> ToolsConfig { - let mut tools_config = ToolsConfig::new( - model_family, - self.config.approval_policy, - sandbox_policy.clone(), - self.config.include_plan_tool, - self.config.include_apply_patch_tool, - self.config.tools_web_search_request, - self.config.use_experimental_streamable_shell_tool, - self.config.include_view_image_tool, + let client_setup = self.client.current_client_setup().await.map_err(|err| { + ApiError::Stream(format!( + "failed to build websocket prewarm client setup: {err}" + )) + })?; + let auth_context = AuthRequestTelemetryContext::new( + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + PendingUnauthorizedRetry::default(), ); - tools_config.web_search_allowed_domains = self.config.tools_web_search_allowed_domains.clone(); - tools_config.web_search_external = self.config.tools_web_search_external; - tools_config.search_tool = self.config.tools_search_tool; + let connection = self + .client + .connect_websocket( + session_telemetry, + client_setup.api_provider, + client_setup.api_auth, + Some(Arc::clone(&self.turn_state)), + /*turn_metadata_header*/ None, + auth_context, + RequestRouteTelemetry::for_endpoint(RESPONSES_ENDPOINT), + ) + .await?; + self.websocket_session.connection = Some(connection); + self.websocket_session + .set_connection_reused(/*connection_reused*/ false); + Ok(()) + } + /// Returns a websocket connection for this turn. + #[instrument( + name = "model_client.websocket_connection", + level = "info", + skip_all, + fields( + provider = %self.client.state.provider.info().name, + wire_api = %self.client.state.provider.info().wire_api, + transport = "responses_websocket", + api.path = "responses", + turn.has_metadata_header = params.turn_metadata_header.is_some() + ) + )] + async fn websocket_connection( + &mut self, + params: WebsocketConnectParams<'_>, + ) -> std::result::Result<&ApiWebSocketConnection, ApiError> { + let WebsocketConnectParams { + session_telemetry, + api_provider, + api_auth, + turn_metadata_header, + options, + auth_context, + request_route_telemetry, + } = params; + let needs_new = match self.websocket_session.connection.as_ref() { + Some(conn) => conn.is_closed().await, + None => true, + }; - let auth_mode = self - .auth_manager - .as_ref() - .and_then(|manager| manager.auth().map(|auth| auth.mode)) - .or(Some(if self.config.using_chatgpt_auth { - AuthMode::Chatgpt - } else { - AuthMode::ApiKey - })); - let image_generation_auth_allowed = self - .auth_manager - .as_ref() - .and_then(|manager| manager.auth().map(|auth| auth.mode)) - .is_some_and(|mode| matches!(mode, AuthMode::Chatgpt)); - tools_config.image_gen_tool = model_family.supports_image_generation - && image_generation_auth_allowed; - let supports_pro_only_models = self - .auth_manager + if needs_new { + self.websocket_session.last_request = None; + self.websocket_session.last_response_rx = None; + let turn_state = options + .turn_state + .clone() + .unwrap_or_else(|| Arc::clone(&self.turn_state)); + let new_conn = match self + .client + .connect_websocket( + session_telemetry, + api_provider, + api_auth, + Some(turn_state), + turn_metadata_header, + auth_context, + request_route_telemetry, + ) + .await + { + Ok(new_conn) => new_conn, + Err(err) => { + if matches!(err, ApiError::Transport(TransportError::Timeout)) { + self.reset_websocket_session(); + } + return Err(err); + } + }; + self.websocket_session.connection = Some(new_conn); + self.websocket_session + .set_connection_reused(/*connection_reused*/ false); + } else { + self.websocket_session + .set_connection_reused(/*connection_reused*/ true); + } + + self.websocket_session + .connection .as_ref() - .is_some_and(|manager| manager.supports_pro_only_models()); + .ok_or(ApiError::Stream( + "websocket connection is unavailable".to_string(), + )) + } - let mut agent_models: Vec = if self.config.agents.is_empty() { - default_agent_configs() - .into_iter() - .filter(|cfg| cfg.enabled) - .map(|cfg| cfg.name) - .collect() + fn responses_request_compression(&self, auth: Option<&CodexAuth>) -> Compression { + if self.client.state.enable_request_compression + && auth.is_some_and(CodexAuth::uses_codex_backend) + && self.client.state.provider.info().is_openai() + { + Compression::Zstd } else { - get_enabled_agents(&self.config.agents) - }; - agent_models = filter_agent_model_names_for_auth( - agent_models, - auth_mode, - supports_pro_only_models, - ); - if agent_models.is_empty() { - agent_models = enabled_agent_model_specs_for_auth(auth_mode, supports_pro_only_models) - .into_iter() - .map(|spec| spec.slug.to_string()) - .collect(); + Compression::None } - agent_models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())); - agent_models.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - tools_config.set_agent_models(agent_models); + } - let base_shell_type = tools_config.shell_type.clone(); - let base_uses_native_shell = matches!( - &base_shell_type, - ConfigShellToolType::LocalShell - | ConfigShellToolType::StreamableShell - | ConfigShellToolType::ShellCommand { .. } - ); + /// Streams a turn via the OpenAI Responses API. + /// + /// Handles SSE fixtures, reasoning summaries, verbosity, and the + /// `text` controls used for output schemas. + #[allow(clippy::too_many_arguments)] + #[instrument( + name = "model_client.stream_responses_api", + level = "info", + skip_all, + fields( + model = %model_info.slug, + wire_api = %self.client.state.provider.info().wire_api, + transport = "responses_http", + http.method = "POST", + api.path = "responses", + turn.has_metadata_header = turn_metadata_header.is_some() + ) + )] + async fn stream_responses_api( + &self, + prompt: &Prompt, + model_info: &ModelInfo, + session_telemetry: &SessionTelemetry, + effort: Option, + summary: ReasoningSummaryConfig, + service_tier: Option, + turn_metadata_header: Option<&str>, + inference_trace: &InferenceTraceContext, + ) -> Result { + if let Some(path) = &*CODEX_RS_SSE_FIXTURE { + warn!(path, "Streaming from fixture"); + let stream = codex_api::stream_from_fixture( + path, + self.client.state.provider.info().stream_idle_timeout(), + ) + .map_err(map_api_error)?; + let (stream, _last_request_rx) = map_response_stream( + stream, + session_telemetry.clone(), + InferenceTraceAttempt::disabled(), + ); + return Ok(stream); + } - tools_config.shell_type = match sandbox_policy.clone() { - SandboxPolicy::ReadOnly => { - if base_uses_native_shell { - base_shell_type.clone() - } else { - ConfigShellToolType::ShellWithRequest { - sandbox_policy: SandboxPolicy::ReadOnly, - } + let auth_manager = self.client.state.provider.auth_manager(); + let mut auth_recovery = auth_manager + .as_ref() + .map(AuthManager::unauthorized_recovery); + let mut pending_retry = PendingUnauthorizedRetry::default(); + loop { + let client_setup = self.client.current_client_setup().await?; + let transport = ReqwestTransport::new(build_reqwest_client()); + let request_auth_context = AuthRequestTelemetryContext::new( + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + pending_retry, + ); + let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry( + session_telemetry, + request_auth_context, + RequestRouteTelemetry::for_endpoint(RESPONSES_ENDPOINT), + self.client.state.auth_env_telemetry.clone(), + ); + let compression = self.responses_request_compression(client_setup.auth.as_ref()); + let options = self.build_responses_options(turn_metadata_header, compression); + + let request = self.client.build_responses_request( + &client_setup.api_provider, + prompt, + model_info, + effort, + summary, + service_tier.clone(), + )?; + let inference_trace_attempt = inference_trace.start_attempt(); + inference_trace_attempt.record_started(&request); + let client = ApiResponsesClient::new( + transport, + client_setup.api_provider, + client_setup.api_auth, + ) + .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); + let stream_result = client.stream_request(request, options).await; + + match stream_result { + Ok(stream) => { + let (stream, _) = map_response_stream( + stream, + session_telemetry.clone(), + inference_trace_attempt, + ); + return Ok(stream); } - } - sp @ SandboxPolicy::WorkspaceWrite { .. } => { - if base_uses_native_shell { - base_shell_type.clone() - } else { - ConfigShellToolType::ShellWithRequest { sandbox_policy: sp } + Err(ApiError::Transport( + unauthorized_transport @ TransportError::Http { status, .. }, + )) if status == StatusCode::UNAUTHORIZED => { + let response_debug_context = + extract_response_debug_context(&unauthorized_transport); + inference_trace_attempt.record_failed( + &unauthorized_transport, + response_debug_context.request_id.as_deref(), + /*output_items*/ &[], + ); + pending_retry = PendingUnauthorizedRetry::from_recovery( + handle_unauthorized( + unauthorized_transport, + &mut auth_recovery, + session_telemetry, + ) + .await?, + ); + continue; + } + Err(err) => { + let response_debug_context = + extract_response_debug_context_from_api_error(&err); + let err = map_api_error(err); + inference_trace_attempt.record_failed( + &err, + response_debug_context.request_id.as_deref(), + /*output_items*/ &[], + ); + return Err(err); } } - SandboxPolicy::DangerFullAccess => base_shell_type, - }; - - tools_config - } - - pub fn build_tools_config(&self) -> ToolsConfig { - self.build_tools_config_with_sandbox(self.config.sandbox_policy.clone()) + } } - pub fn get_auto_compact_token_limit(&self) -> Option { - self.config - .model_auto_compact_token_limit - .or_else(|| self.config.model_family.auto_compact_token_limit()) - } + /// Streams a turn via the Responses API over WebSocket transport. + #[allow(clippy::too_many_arguments)] + #[instrument( + name = "model_client.stream_responses_websocket", + level = "info", + skip_all, + fields( + model = %model_info.slug, + wire_api = %self.client.state.provider.info().wire_api, + transport = "responses_websocket", + api.path = "responses", + turn.has_metadata_header = turn_metadata_header.is_some(), + websocket.warmup = warmup + ) + )] + async fn stream_responses_websocket( + &mut self, + prompt: &Prompt, + model_info: &ModelInfo, + session_telemetry: &SessionTelemetry, + effort: Option, + summary: ReasoningSummaryConfig, + service_tier: Option, + turn_metadata_header: Option<&str>, + warmup: bool, + request_trace: Option, + inference_trace: &InferenceTraceContext, + ) -> Result { + let auth_manager = self.client.state.provider.auth_manager(); - pub fn get_context_mode(&self) -> Option { - self.config.context_mode - } - - pub fn default_model_slug(&self) -> &str { - self.config.model.as_str() - } - - pub fn default_model_family(&self) -> &ModelFamily { - &self.config.model_family - } - - /// Dispatches to either the Responses or Chat implementation depending on - /// the provider config. Public callers always invoke `stream()` – the - /// specialised helpers are private to avoid accidental misuse. - pub async fn stream(&self, prompt: &Prompt) -> Result { - let env_log_tag = std::env::var("CODE_DEBUG_LOG_TAG").ok(); - let log_tag = env_log_tag - .as_deref() - .or(prompt.log_tag.as_deref()); - match self.provider.wire_api { - WireApi::Responses => { - if let Some(ws_version) = self.active_ws_version_for_prompt(prompt) { - let ws_result = match self - .prewarm_responses_websocket_if_needed(prompt, log_tag, ws_version) - .await - { - Ok(()) => self - .stream_responses_websocket(prompt, log_tag, ws_version, false) - .await, - Err(err) => Err(err), - }; - match ws_result { - Ok(stream) => Ok(stream), - Err(err) => { - self.websockets_disabled.store(true, Ordering::Relaxed); - self.reset_responses_websocket_session().await; - warn!( - "preferred websocket transport failed; falling back to responses HTTP stream: {err}" - ); - self.stream_responses(prompt, log_tag).await - } - } - } else { - self.stream_responses(prompt, log_tag).await - } - } - WireApi::ResponsesWebsocket => { - if self.websockets_disabled.load(Ordering::Relaxed) { - warn!( - "responses_websocket transport disabled for this session; using responses HTTP stream" - ); - return self.stream_responses(prompt, log_tag).await; - } - let ws_version = self - .active_ws_version_for_prompt(prompt) - .unwrap_or(preferred_ws_version_from_env()); - let ws_result = match self - .prewarm_responses_websocket_if_needed(prompt, log_tag, ws_version) - .await - { - Ok(()) => self - .stream_responses_websocket(prompt, log_tag, ws_version, false) - .await, - Err(err) => Err(err), - }; - match ws_result { - Ok(stream) => Ok(stream), - Err(err) => { - self.websockets_disabled.store(true, Ordering::Relaxed); - self.reset_responses_websocket_session().await; - warn!( - "responses_websocket transport failed; falling back to responses HTTP stream: {err}" - ); - self.stream_responses(prompt, log_tag).await - } - } - } - WireApi::Chat => { - let effective_family = prompt - .model_family_override - .as_ref() - .unwrap_or(&self.config.model_family); - let model_slug = prompt - .model_override - .as_deref() - .unwrap_or(self.config.model.as_str()); - // Create the raw streaming connection first. - let response_stream = stream_chat_completions( - prompt, - effective_family, - model_slug, - &self.client, - &self.provider, - self.config.responses_originator_header.as_str(), - &self.debug_logger, - self.auth_manager.clone(), - self.otel_event_manager.clone(), - log_tag, - ) - .await?; - - // Wrap it with the aggregation adapter so callers see *only* - // the final assistant message per turn (matching the - // behaviour of the Responses API). - let mut aggregated = if self.config.show_raw_agent_reasoning { - crate::chat_completions::AggregatedChatStream::streaming_mode(response_stream) - } else { - response_stream.aggregate() - }; - - // Bridge the aggregated stream back into a standard - // `ResponseStream` by forwarding events through a channel. - let (tx, rx) = mpsc::channel::>(16); - - tokio::spawn(async move { - use futures::StreamExt; - while let Some(ev) = aggregated.next().await { - // Exit early if receiver hung up. - if tx.send(ev).await.is_err() { - break; - } - } - }); - - Ok(ResponseStream { rx_event: rx }) - } - } - } - - async fn reset_responses_websocket_session(&self) { - let mut session = self.websocket_session.lock().await; - *session = ResponsesWebsocketSession::default(); - } - - async fn prewarm_responses_websocket_if_needed( - &self, - prompt: &Prompt, - log_tag: Option<&str>, - ws_version: ResponsesWebsocketVersion, - ) -> Result<()> { - { - let session = self.websocket_session.lock().await; - if session.last_request.is_some() { - return Ok(()); - } - } - - let mut stream = self - .stream_responses_websocket(prompt, log_tag, ws_version, true) - .await?; - while let Some(event) = stream.next().await { - match event? { - ResponseEvent::Completed { .. } => return Ok(()), - _ => {} - } - } - Err(CodexErr::Stream( - "websocket prewarm ended before response.completed".to_string(), - None, - None, - )) - } - - async fn stream_responses_websocket( - &self, - prompt: &Prompt, - log_tag: Option<&str>, - ws_version: ResponsesWebsocketVersion, - warmup: bool, - ) -> Result { - let auth_manager = self.auth_manager.clone(); - let auth_mode = auth_manager - .as_ref() - .and_then(|m| m.auth()) + let mut auth_recovery = auth_manager .as_ref() - .map(|a| a.mode); - - // Use non-stored turns on all paths for stability. - let store = false; - - let request_model = prompt - .model_override - .as_deref() - .unwrap_or(self.config.model.as_str()); - let effective_effort = clamp_reasoning_effort_for_model(request_model, self.effort); - let request_family = prompt - .model_family_override - .clone() - .or_else(|| find_family_for_model(request_model)) - .unwrap_or_else(|| self.config.model_family.clone()); - - let full_instructions = prompt.get_full_instructions(&request_family); - let mut tools_json = create_tools_json_for_responses_api(&prompt.tools)?; - if matches!(effective_effort, ReasoningEffortConfig::Minimal) { - tools_json.retain(|tool| { - tool.get("type") - .and_then(|value| value.as_str()) - .map(|tool_type| tool_type != "web_search") - .unwrap_or(true) - }); - } - - let mut input_with_instructions = prompt.get_formatted_input(); - rewrite_image_generation_calls_for_input(&mut input_with_instructions); - replace_image_payloads_for_model(&mut input_with_instructions, request_model); - let context_ledger = - prompt.context_ledger_for_request(&request_family, &input_with_instructions, &tools_json); - debug!( - target: "code_core::context_ledger", - summary = %context_ledger.compact_summary(), - entries = ?context_ledger.entries(), - "assembled context ledger for responses request" - ); - - let want_format = prompt.text_format.clone().or_else(|| { - prompt.output_schema.as_ref().map(|schema| crate::client_common::TextFormat { - r#type: "json_schema".to_string(), - name: Some("code_output_schema".to_string()), - strict: Some(true), - schema: Some(schema.clone()), - }) - }); - - let effective_verbosity = clamp_text_verbosity_for_model(request_model, self.verbosity); - let verbosity = match &request_family.family { - family if family == "gpt-5" || family == "gpt-5.1" => Some(effective_verbosity), - _ => None, - }; - - let text_template = match (auth_mode, want_format, verbosity) { - (Some(mode), None, _) if mode.is_chatgpt() => None, - (_, Some(fmt), _) => Some(crate::client_common::Text { - verbosity: effective_verbosity.into(), - format: Some(fmt), - }), - (_, None, Some(_)) => Some(crate::client_common::Text { - verbosity: effective_verbosity.into(), - format: None, - }), - (_, None, None) => None, - }; - - let model_slug = request_model; - let session_id = prompt.session_id_override.unwrap_or(self.session_id); - let session_id_str = session_id.to_string(); - let mut attempt = 0; - let max_retries = self.provider.request_max_retries(); - let mut request_id = String::new(); - + .map(AuthManager::unauthorized_recovery); + let mut pending_retry = PendingUnauthorizedRetry::default(); loop { - attempt += 1; - - let reasoning = self.current_reasoning_param(&request_family, effective_effort); - let include: Vec = if !store && reasoning.is_some() { - vec!["reasoning.encrypted_content".to_string()] - } else { - Vec::new() - }; - - let payload = ResponsesApiRequest { - model: model_slug, - instructions: &full_instructions, - input: &input_with_instructions, - tools: &tools_json, - tool_choice: "auto", - parallel_tool_calls: request_family.supports_parallel_tool_calls, - reasoning, - text: text_template.clone(), - store: self.provider.is_azure_responses_endpoint(), - stream: true, - include, - service_tier: match self.config.service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - }, - prompt_cache_key: Some(session_id_str.clone()), - }; - - let mut payload_json = serde_json::to_value(&payload)?; - if let Some(model_value) = payload_json.get_mut("model") { - *model_value = serde_json::Value::String(model_slug.to_string()); - } - if self.provider.is_azure_responses_endpoint() { - attach_item_ids(&mut payload_json, &input_with_instructions); - } - if let Some(openrouter_cfg) = self.provider.openrouter_config() { - if let Some(obj) = payload_json.as_object_mut() { - if let Some(provider) = &openrouter_cfg.provider { - obj.insert("provider".to_string(), serde_json::to_value(provider)?); - } - if let Some(route) = &openrouter_cfg.route { - obj.insert("route".to_string(), route.clone()); - } - for (key, value) in &openrouter_cfg.extra { - obj.entry(key.clone()).or_insert(value.clone()); - } - } - } - - let base_auth = auth_manager.as_ref().and_then(|m| m.auth()); - let auth = self.provider.effective_auth(&base_auth).await?; - let endpoint = self.provider.get_full_url(&auth); - - let url = reqwest::Url::parse(&endpoint).map_err(|err| { - CodexErr::Stream( - format!("[ws] invalid URL: {err}"), - None, - Some(request_id.clone()), - ) - })?; - - let ws_endpoint = match url.scheme() { - "http" => endpoint.replacen("http://", "ws://", 1), - "https" => endpoint.replacen("https://", "wss://", 1), - _ => endpoint.clone(), - }; - let mut req_builder = self - .provider - .create_request_builder_for_url_with_auth( - &self.client, - &auth, - reqwest::Method::GET, - url, - ) - .await?; - req_builder = self.apply_requested_model_headers(req_builder, request_model); - - let has_beta_header = req_builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); - - if !has_beta_header { - let beta_value = if self.provider.is_public_openai_responses_endpoint() { - RESPONSES_BETA_HEADER_V1 - } else { - RESPONSES_BETA_HEADER_EXPERIMENTAL - }; - req_builder = req_builder.header("OpenAI-Beta", beta_value); - } - - req_builder = attach_openai_subagent_header(req_builder); - req_builder = attach_codex_beta_features_header(req_builder, &self.config); - let turn_state = { - let session = self.websocket_session.lock().await; - Arc::clone(&session.turn_state) - }; - if let Some(state) = turn_state.get() { - req_builder = req_builder.header(X_CODEX_TURN_STATE_HEADER, state); - } - req_builder = req_builder - .header("conversation_id", session_id_str.clone()) - .header("session_id", session_id_str.clone()) - .header("thread_id", session_id_str.clone()); - if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { - req_builder = req_builder.header(X_CODEX_WINDOW_ID_HEADER, window_id); - } - - if let Some(auth) = auth.as_ref() - && auth.mode.is_chatgpt() - && let Some(account_id) = auth.get_account_id() - { - req_builder = req_builder.header("chatgpt-account-id", account_id); - } - - let header_snapshot = req_builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map(|req| header_map_to_json(req.headers())); - - if request_id.is_empty() { - if let Ok(logger) = self.debug_logger.lock() { - request_id = logger - .start_request_log(&endpoint, &payload_json, header_snapshot.as_ref(), log_tag) - .unwrap_or_default(); - } - } - - let ws_headers = req_builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map(|req| req.headers().clone()) - .unwrap_or_else(HeaderMap::new); - - let mut ws_request = ws_endpoint - .into_client_request() - .map_err(|err| { - CodexErr::Stream( - format!("[ws] failed to build request: {err}"), - None, - Some(request_id.clone()), - ) - })?; - ws_request.headers_mut().extend(ws_headers); - // The Responses API websocket wire requires its own beta token (distinct from - // `responses=v1` / `responses=experimental`). - ws_request.headers_mut().insert( - reqwest::header::HeaderName::from_static("openai-beta"), - HeaderValue::from_static(match ws_version { - ResponsesWebsocketVersion::V2 => RESPONSES_WEBSOCKETS_BETA_HEADER_V2, - ResponsesWebsocketVersion::V1 => RESPONSES_WEBSOCKETS_BETA_HEADER_V1, - }), + let client_setup = self.client.current_client_setup().await?; + let request_auth_context = AuthRequestTelemetryContext::new( + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + pending_retry, ); - - let current_snapshot = - responses_request_snapshot(&payload_json, &input_with_instructions); - let (input_override, previous_response_id) = if warmup { - (None, None) - } else { - let session = self.websocket_session.lock().await; - match (&session.last_request, &session.last_response_id) { - (Some(previous), Some(response_id)) => { - match incremental_input_for_websocket_request( - previous, - ¤t_snapshot, - ) { - Some(input) - if !input.is_empty() || session.last_response_from_warmup => - { - (Some(input), Some(response_id.clone())) - } - None => (None, None), - _ => (None, None), - } - } - _ => (None, None), - } - }; - let ws_payload_text = build_responses_websocket_payload( - &payload_json, - input_override, - previous_response_id, - warmup.then_some(false), + let compression = self.responses_request_compression(client_setup.auth.as_ref()); + + let options = self.build_responses_options(turn_metadata_header, compression); + let request = self.client.build_responses_request( + &client_setup.api_provider, + prompt, + model_info, + effort, + summary, + service_tier.clone(), )?; - - let (tx_event, rx_event) = mpsc::channel::>(1600); - let _ = tx_event - .send(Ok(ResponseEvent::ContextLedger(context_ledger.clone()))) - .await; - let mut session = self.websocket_session.lock().await; - if session.connection.is_none() { - let connect = timeout( - self.provider.websocket_connect_timeout(), - tokio_tungstenite::connect_async(ws_request), - ) - .await; - match connect { - Ok(Ok((ws_stream, response))) => { - let response_headers = header_map_to_json(response.headers()); - if tx_event - .send(Ok(ResponseEvent::ResponseHeaders(response_headers))) - .await - .is_err() - { - debug!("receiver dropped response headers event"); - } - - if let Some(value) = response - .headers() - .get(X_CODEX_TURN_STATE_HEADER) - .and_then(|value| value.to_str().ok()) - { - if let Some(existing) = session.turn_state.get() - && existing != value - { - warn!( - existing, - new = value, - "received new x-codex-turn-state during websocket connect" - ); - let refreshed = Arc::new(OnceLock::new()); - let _ = refreshed.set(value.to_string()); - session.turn_state = refreshed; - } else { - let _ = session.turn_state.set(value.to_string()); - } - } - - if let Some(snapshot) = parse_rate_limit_snapshot(response.headers()) { - debug!( - "rate limit headers:\n{}", - format_rate_limit_headers(response.headers()) - ); - if tx_event - .send(Ok(ResponseEvent::RateLimits(snapshot))) - .await - .is_err() - { - debug!("receiver dropped rate limit snapshot event"); - } - } - - let models_etag = response - .headers() - .get("X-Models-Etag") - .and_then(|value| value.to_str().ok()) - .map(ToString::to_string); - if let Some(etag) = models_etag { - if tx_event - .send(Ok(ResponseEvent::ModelsEtag(etag))) - .await - .is_err() - { - debug!("receiver dropped models etag event"); - } - } - - if response.headers().contains_key("x-reasoning-included") { - if tx_event - .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) - .await - .is_err() - { - debug!("receiver dropped server reasoning included event"); - } - } - - session.connection = Some(ws_stream); - } - Ok(Err(err)) => { - drop(session); - if websocket_connect_is_upgrade_required(&err) { - self.websockets_disabled.store(true, Ordering::Relaxed); - warn!("responses websocket upgrade required; falling back to HTTP responses transport"); - return self.stream_responses(prompt, log_tag).await; - } - - let err = CodexErr::Stream( - format!("[ws] failed to connect: {err}"), - None, - Some(request_id.clone()), - ); - if (attempt as u64) < max_retries { - tokio::time::sleep(backoff(attempt as u64)).await; - continue; - } - self.websockets_disabled.store(true, Ordering::Relaxed); - return Err(err); - } - Err(_) => { - drop(session); - let err = CodexErr::Stream( - format!( - "[ws] timed out connecting after {} ms", - self.provider.websocket_connect_timeout().as_millis() - ), - None, - Some(request_id.clone()), - ); - if (attempt as u64) < max_retries { - tokio::time::sleep(backoff(attempt as u64)).await; - continue; - } - self.websockets_disabled.store(true, Ordering::Relaxed); - return Err(err); - } - } - } - - let Some(ws_stream) = session.connection.as_mut() else { - return Err(CodexErr::Stream( - "[ws] websocket connection is closed".to_string(), - None, - Some(request_id.clone()), - )); - }; - if let Err(err) = ws_stream.send(Message::Text(ws_payload_text)).await { - session.connection = None; - session.last_request = None; - session.last_response_id = None; - session.last_response_from_warmup = false; - let err = CodexErr::Stream( - format!("[ws] failed to send websocket request: {err}"), - None, - Some(request_id.clone()), - ); - if (attempt as u64) < max_retries { - drop(session); - tokio::time::sleep(backoff(attempt as u64)).await; - continue; - } - self.websockets_disabled.store(true, Ordering::Relaxed); - return Err(err); - } - drop(session); - - // Keep websocket ingress bounded so a slow downstream consumer - // cannot cause unbounded buffering and memory growth. The reader - // exits on the terminal response event and leaves the connection in - // the session for the next chained request. - let (tx_bytes, rx_bytes) = - mpsc::channel::>(RESPONSES_WEBSOCKET_INGRESS_BUFFER); - let request_id_for_ws = request_id.clone(); - let websocket_session = Arc::clone(&self.websocket_session); - let (reader_ready_tx, reader_ready_rx) = oneshot::channel(); - tokio::spawn(async move { - let mut session = websocket_session.lock().await; - let _ = reader_ready_tx.send(()); - let Some(ws_stream) = session.connection.as_mut() else { - let _ = tx_bytes - .send(Err(CodexErr::Stream( - "[ws] websocket connection is closed".to_string(), - None, - Some(request_id_for_ws.clone()), - ))) - .await; - return; - }; - - loop { - let Some(next) = ws_stream.next().await else { - session.connection = None; - session.last_request = None; - session.last_response_id = None; - session.last_response_from_warmup = false; - break; - }; - match next { - Ok(Message::Text(text)) => { - if let Some(error) = parse_wrapped_websocket_error_event(&text) - .and_then(map_wrapped_websocket_error_event) - { - session.connection = None; - session.last_request = None; - session.last_response_id = None; - session.last_response_from_warmup = false; - let _ = tx_bytes.send(Err(error)).await; - break; - } - - let terminal_response_id = terminal_response_id_from_websocket_event(&text); - let chunk = format!("data: {text}\n\n"); - if tx_bytes.send(Ok(Bytes::from(chunk))).await.is_err() { - break; - } - if let Some(response_id) = terminal_response_id { - match response_id { - Some(response_id) if !response_id.is_empty() => { - session.last_request = Some(current_snapshot); - session.last_response_id = Some(response_id); - session.last_response_from_warmup = warmup; - } - _ => { - session.last_request = None; - session.last_response_id = None; - session.last_response_from_warmup = false; - } - } - break; - } - } - Ok(Message::Ping(payload)) => { - if ws_stream.send(Message::Pong(payload)).await.is_err() { - session.connection = None; - break; - } - } - Ok(Message::Pong(_)) => {} - Ok(Message::Close(_)) => { - session.connection = None; - session.last_request = None; - session.last_response_id = None; - session.last_response_from_warmup = false; - break; - } - Ok(Message::Binary(_)) => { - session.connection = None; - session.last_request = None; - session.last_response_id = None; - session.last_response_from_warmup = false; - let _ = tx_bytes - .send(Err(CodexErr::Stream( - "[ws] unexpected binary websocket event".to_string(), - None, - Some(request_id_for_ws.clone()), - ))) - .await; - break; - } - Ok(_) => {} - Err(err) => { - session.connection = None; - session.last_request = None; - session.last_response_id = None; - session.last_response_from_warmup = false; - let _ = tx_bytes - .send(Err(CodexErr::Stream( - format!("[ws] websocket error: {err}"), - None, - Some(request_id_for_ws.clone()), - ))) - .await; - break; - } - } - } - }); - let _ = reader_ready_rx.await; - - let stream = ReceiverStream::new(rx_bytes); - let debug_logger = Arc::clone(&self.debug_logger); - let request_id_clone = request_id.clone(); - let otel_event_manager = self.otel_event_manager.clone(); - let stream_idle_timeout = self.provider.stream_idle_timeout(); - tokio::spawn(async move { - process_sse( - stream, - tx_event, - stream_idle_timeout, - debug_logger, - request_id_clone, - otel_event_manager, - Arc::new(RwLock::new(StreamCheckpoint::default())), - ) - .await; - }); - - return Ok(ResponseStream { rx_event }); - } - } - - /// Implementation for the OpenAI *Responses* experimental API. - async fn stream_responses(&self, prompt: &Prompt, log_tag: Option<&str>) -> Result { - if let Some(path) = &*CODEX_RS_SSE_FIXTURE { - // short circuit for tests - warn!(path, "Streaming from fixture"); - return stream_from_fixture(path, self.provider.clone(), self.otel_event_manager.clone()) - .await; - } - - let auth_manager = self.auth_manager.clone(); - - let auth_mode = auth_manager - .as_ref() - .and_then(|m| m.auth()) - .as_ref() - .map(|a| a.mode); - - // Use non-stored turns on all paths for stability. - let store = false; - let turn_state: Arc> = Arc::new(OnceLock::new()); - - let request_model = prompt - .model_override - .as_deref() - .unwrap_or(self.config.model.as_str()); - let effective_effort = clamp_reasoning_effort_for_model(request_model, self.effort); - let request_family = prompt - .model_family_override - .clone() - .or_else(|| find_family_for_model(request_model)) - .unwrap_or_else(|| self.config.model_family.clone()); - - let full_instructions = prompt.get_full_instructions(&request_family); - let mut tools_json = create_tools_json_for_responses_api(&prompt.tools)?; - if matches!(effective_effort, ReasoningEffortConfig::Minimal) { - tools_json.retain(|tool| { - tool.get("type") - .and_then(|value| value.as_str()) - .map(|tool_type| tool_type != "web_search") - .unwrap_or(true) - }); - } - - let mut input_with_instructions = prompt.get_formatted_input(); - rewrite_image_generation_calls_for_input(&mut input_with_instructions); - replace_image_payloads_for_model(&mut input_with_instructions, request_model); - let context_ledger = - prompt.context_ledger_for_request(&request_family, &input_with_instructions, &tools_json); - debug!( - target: "code_core::context_ledger", - summary = %context_ledger.compact_summary(), - entries = ?context_ledger.entries(), - "assembled context ledger for responses request" - ); - - // Build `text` parameter with conditional verbosity and optional format. - // - Omit entirely for ChatGPT auth unless a `text.format` or output schema is present. - // - Only include `text.verbosity` for GPT-5 family models; warn and ignore otherwise. - // - When a structured `format` is present, still include `verbosity` so GPT-5 can honor it. - let want_format = prompt.text_format.clone().or_else(|| { - prompt.output_schema.as_ref().map(|schema| crate::client_common::TextFormat { - r#type: "json_schema".to_string(), - name: Some("code_output_schema".to_string()), - strict: Some(true), - schema: Some(schema.clone()), - }) - }); - - let effective_verbosity = clamp_text_verbosity_for_model(request_model, self.verbosity); - - let verbosity = match &request_family.family { - family if family == "gpt-5" || family == "gpt-5.1" => Some(effective_verbosity), - _ => None, - }; - - let text_template = match (auth_mode, want_format, verbosity) { - (Some(mode), None, _) if mode.is_chatgpt() => None, - (_, Some(fmt), _) => Some(crate::client_common::Text { - verbosity: effective_verbosity.into(), - format: Some(fmt), - }), - (_, None, Some(_)) => Some(crate::client_common::Text { - verbosity: effective_verbosity.into(), - format: None, - }), - (_, None, None) => None, - }; - - // In general, we want to explicitly send `store: false` when using the Responses API, - // but in practice, the Azure Responses API rejects `store: false`: - // - // - If store = false and id is sent an error is thrown that ID is not found - // - If store = false and id is not sent an error is thrown that ID is required - // - // For Azure, we send `store: true` and preserve reasoning item IDs. - let azure_workaround = self.provider.is_azure_responses_endpoint(); - - let model_slug = request_model; - - let session_id = prompt - .session_id_override - .unwrap_or(self.session_id); - let session_id_str = session_id.to_string(); - - let mut attempt = 0; - let max_retries = self.provider.request_max_retries(); - let mut request_id = String::new(); - let mut rate_limit_switch_state = crate::account_switching::RateLimitSwitchState::default(); - - // Compute endpoint with the latest available auth (may be None at this point). - let endpoint = self - .provider - .get_full_url(&auth_manager.as_ref().and_then(|m| m.auth())); - - loop { - attempt += 1; - - let reasoning = self.current_reasoning_param(&request_family, effective_effort); - // Request encrypted COT if we are not storing responses, - // otherwise reasoning items will be referenced by ID - let include: Vec = if !store && reasoning.is_some() { - vec!["reasoning.encrypted_content".to_string()] - } else { - Vec::new() - }; - - let text = text_template.clone(); - - let payload = ResponsesApiRequest { - model: model_slug, - instructions: &full_instructions, - input: &input_with_instructions, - tools: &tools_json, - tool_choice: "auto", - parallel_tool_calls: request_family.supports_parallel_tool_calls, - reasoning, - text, - store: azure_workaround, - stream: true, - include, - service_tier: match self.config.service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - }, - // Use a stable per-process cache key (session id). With store=false this is inert. - prompt_cache_key: Some(session_id_str.clone()), + let mut ws_payload = ResponseCreateWsRequest { + client_metadata: response_create_client_metadata( + Some(self.client.build_ws_client_metadata(turn_metadata_header)), + request_trace.as_ref(), + ), + ..ResponseCreateWsRequest::from(&request) }; - - let mut payload_json = serde_json::to_value(&payload)?; - if let Some(model_value) = payload_json.get_mut("model") { - *model_value = serde_json::Value::String(model_slug.to_string()); - } - if azure_workaround { - attach_item_ids(&mut payload_json, &input_with_instructions); - } - if let Some(openrouter_cfg) = self.provider.openrouter_config() { - if let Some(obj) = payload_json.as_object_mut() { - if let Some(provider) = &openrouter_cfg.provider { - obj.insert( - "provider".to_string(), - serde_json::to_value(provider)? - ); - } - if let Some(route) = &openrouter_cfg.route { - obj.insert("route".to_string(), route.clone()); - } - for (key, value) in &openrouter_cfg.extra { - obj.entry(key.clone()).or_insert(value.clone()); - } - } - } - let payload_body = serde_json::to_string(&payload_json)?; - - let mut auth_refresh_error: Option = None; - - // Always fetch the latest auth in case a prior attempt refreshed the token. - let base_auth = auth_manager.as_ref().and_then(|m| m.auth()); - let auth = self.provider.effective_auth(&base_auth).await?; - - trace!( - "POST to {}: {}", - self.provider.get_full_url(&auth), - payload_body.as_str() - ); - - let mut req_builder = self - .provider - .create_request_builder_with_auth(&self.client, &auth) - .await?; - req_builder = self.apply_requested_model_headers(req_builder, request_model); - - let has_beta_header = req_builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); - - if !has_beta_header { - let beta_value = if self.provider.is_public_openai_responses_endpoint() { - RESPONSES_BETA_HEADER_V1 - } else { - RESPONSES_BETA_HEADER_EXPERIMENTAL - }; - req_builder = req_builder.header("OpenAI-Beta", beta_value); - } - - req_builder = attach_openai_subagent_header(req_builder); - req_builder = attach_codex_beta_features_header(req_builder, &self.config); - if let Some(state) = turn_state.get() { - req_builder = req_builder.header(X_CODEX_TURN_STATE_HEADER, state); - } - - req_builder = req_builder - // Send `conversation_id`/`session_id` so the server can hit the prompt-cache. - .header("conversation_id", session_id_str.clone()) - .header("session_id", session_id_str.clone()) - .header("thread_id", session_id_str.clone()) - .header(reqwest::header::ACCEPT, "text/event-stream") - .json(&payload_json); - if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { - req_builder = req_builder.header(X_CODEX_WINDOW_ID_HEADER, window_id); - } - - if let Some(auth) = auth.as_ref() - && auth.mode.is_chatgpt() - && let Some(account_id) = auth.get_account_id() + if warmup { + ws_payload.generate = Some(false); + } + + match self + .websocket_connection(WebsocketConnectParams { + session_telemetry, + api_provider: client_setup.api_provider, + api_auth: client_setup.api_auth, + turn_metadata_header, + options: &options, + auth_context: request_auth_context, + request_route_telemetry: RequestRouteTelemetry::for_endpoint( + RESPONSES_ENDPOINT, + ), + }) + .await { - req_builder = req_builder.header("chatgpt-account-id", account_id); - } - - if request_id.is_empty() { - let endpoint_for_log = self.provider.get_full_url(&auth); - let header_snapshot = req_builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map(|req| header_map_to_json(req.headers())); - - if let Ok(logger) = self.debug_logger.lock() { - request_id = logger - .start_request_log( - &endpoint_for_log, - &payload_json, - header_snapshot.as_ref(), - log_tag, + Ok(_) => {} + Err(ApiError::Transport(TransportError::Http { status, .. })) + if status == StatusCode::UPGRADE_REQUIRED => + { + return Ok(WebsocketStreamOutcome::FallbackToHttp); + } + Err(ApiError::Transport( + unauthorized_transport @ TransportError::Http { status, .. }, + )) if status == StatusCode::UNAUTHORIZED => { + pending_retry = PendingUnauthorizedRetry::from_recovery( + handle_unauthorized( + unauthorized_transport, + &mut auth_recovery, + session_telemetry, ) - .unwrap_or_default(); + .await?, + ); + continue; } + Err(err) => return Err(map_api_error(err)), } - let res = if let Some(otel) = self.otel_event_manager.as_ref() { - otel.log_request(attempt, || req_builder.send()).await + let ws_request = self.prepare_websocket_request(ws_payload, &request); + self.websocket_session.last_request = Some(request); + let inference_trace_attempt = if warmup { + // Prewarm sends `generate=false`; it is connection setup, not a + // model inference attempt that should appear in rollout traces. + InferenceTraceAttempt::disabled() } else { - req_builder.send().await + inference_trace.start_attempt() }; - if let Ok(resp) = &res { - trace!( - "Response status: {}, request-id: {}", - resp.status(), - resp.headers() - .get("x-request-id") - .map(|v| v.to_str().unwrap_or_default()) - .unwrap_or_default() - ); - } - - match res { - Ok(resp) if resp.status().is_success() => { - if let Some(value) = resp - .headers() - .get(X_CODEX_TURN_STATE_HEADER) - .and_then(|value| value.to_str().ok()) - { - if let Some(existing) = turn_state.get() - && existing != value - { - warn!( - existing, - new = value, - "received unexpected x-codex-turn-state during responses request" - ); - } else { - let _ = turn_state.set(value.to_string()); - } - } - - // Log successful response initiation - if let Ok(logger) = self.debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "stream_initiated", - &serde_json::json!({ - "status": "success", - "status_code": resp.status().as_u16(), - "x_request_id": resp.headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default(), - "headers": header_map_to_json(resp.headers()), - }), - ); - } - let (tx_event, rx_event) = mpsc::channel::>(1600); - let _ = tx_event - .send(Ok(ResponseEvent::ContextLedger(context_ledger.clone()))) - .await; - - let response_headers = header_map_to_json(resp.headers()); - if tx_event - .send(Ok(ResponseEvent::ResponseHeaders(response_headers))) - .await - .is_err() - { - debug!("receiver dropped response headers event"); - } - - if let Some(snapshot) = parse_rate_limit_snapshot(resp.headers()) { - debug!( - "rate limit headers:\n{}", - format_rate_limit_headers(resp.headers()) - ); - - if tx_event - .send(Ok(ResponseEvent::RateLimits(snapshot))) - .await - .is_err() - { - debug!("receiver dropped rate limit snapshot event"); - } - } - - let models_etag = resp - .headers() - .get("X-Models-Etag") - .and_then(|value| value.to_str().ok()) - .map(ToString::to_string); - if let Some(etag) = models_etag { - if tx_event - .send(Ok(ResponseEvent::ModelsEtag(etag))) - .await - .is_err() - { - debug!("receiver dropped models etag event"); - } - } - - // spawn task to process SSE - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); - let debug_logger = Arc::clone(&self.debug_logger); - let request_id_clone = request_id.clone(); - let otel_event_manager = self.otel_event_manager.clone(); - tokio::spawn(process_sse( - stream, - tx_event, - self.provider.stream_idle_timeout(), - debug_logger, - request_id_clone, - otel_event_manager, - Arc::new(RwLock::new(StreamCheckpoint::default())), - )); - - return Ok(ResponseStream { rx_event }); - } - Ok(res) => { - let status = res.status(); - let headers = res.headers().clone(); - if let Some(value) = headers - .get(X_CODEX_TURN_STATE_HEADER) - .and_then(|value| value.to_str().ok()) - { - if let Some(existing) = turn_state.get() - && existing != value - { - warn!( - existing, - new = value, - "received unexpected x-codex-turn-state during responses request" - ); - } else { - let _ = turn_state.set(value.to_string()); - } - } - // Capture x-request-id up-front in case we consume the response body later. - let x_request_id = headers - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - let now = Utc::now(); - - // Pull out Retry‑After header if present. - let retry_after_hint = headers - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .and_then(|raw| parse_retry_after_header(raw, now)); - - if status == StatusCode::UNAUTHORIZED { - if self.provider.has_command_auth() { - self.provider.invalidate_cached_auth_token(); - } else if let Some(manager) = auth_manager.as_ref() { - match manager.refresh_token_classified().await { - Ok(Some(_)) => {} - Ok(None) => { - auth_refresh_error = Some(RefreshTokenError::permanent( - AUTH_REQUIRED_MESSAGE, - )); - } - Err(err) => { - auth_refresh_error = Some(err); - } - } - } else if auth.is_none() { - auth_refresh_error = Some(RefreshTokenError::permanent( - "Authentication manager unavailable; please log in again.", - )); - } - } - - // Read the response body once for diagnostics across error branches. - let body_text = res.text().await.unwrap_or_default(); - let body = serde_json::from_str::(&body_text).ok(); - - if status == StatusCode::TOO_MANY_REQUESTS { - if let Some(model) = headers - .get(MODEL_CAP_MODEL_HEADER) - .and_then(|value| value.to_str().ok()) - .map(str::to_string) - { - let reset_after_seconds = headers - .get(MODEL_CAP_RESET_AFTER_HEADER) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()); - return Err(CodexErr::ModelCap(ModelCapError { - model, - reset_after_seconds, - })); - } - } - - if status == StatusCode::TOO_MANY_REQUESTS - && self.config.auto_switch_accounts_on_rate_limit - && auth_manager.is_some() - && auth::read_code_api_key_from_env().is_none() - { - let current_account_id = auth - .as_ref() - .and_then(|current| current.get_account_id()) - .or_else(|| { - auth_accounts::get_active_account_id(self.code_home()) - .ok() - .flatten() - }); - if let Some(current_account_id) = current_account_id { - let mut retry_after_delay = retry_after_hint.clone(); - if retry_after_delay.is_none() { - if let Some(ErrorResponse { ref error }) = body { - retry_after_delay = try_parse_retry_after(error, now); - } - } - - let current_auth_mode = auth - .as_ref() - .map(|a| a.mode) - .unwrap_or(AuthMode::ApiKey); - - let switch_reason = match body - .as_ref() - .and_then(|err| err.error.r#type.as_deref()) - { - Some("usage_limit_reached") => "usage_limit_reached", - Some("usage_not_included") => "usage_not_included", - _ => "http_429", - }; - - let (blocked_until, should_record_usage_limit) = match body.as_ref() { - Some(ErrorResponse { error }) - if error.r#type.as_deref() == Some("usage_limit_reached") => - { - ( - error - .resets_in_seconds - .map(|seconds| now + ChronoDuration::seconds(seconds as i64)), - true, - ) - } - _ => (retry_after_delay.as_ref().map(|info| info.resume_at), false), - }; - - rate_limit_switch_state.mark_limited( - ¤t_account_id, - current_auth_mode, - blocked_until, - ); - - if let Ok(Some(next_account_id)) = - crate::account_switching::select_next_account_id( - self.code_home(), - &rate_limit_switch_state, - self.config.api_key_fallback_on_all_accounts_limited, - now, - Some(current_account_id.as_str()), - ) - { - if should_record_usage_limit { - let plan_type = body - .as_ref() - .and_then(|err| err.error.plan_type.as_deref()) - .map(|s| s.to_string()); - let resets_in_seconds = - body.as_ref().and_then(|err| err.error.resets_in_seconds); - let reached_type = body - .as_ref() - .and_then(|err| err.error.rate_limit_reached_type); - let code_home = self.code_home().to_path_buf(); - let account_id = current_account_id.clone(); - tokio::task::spawn_blocking(move || { - let observed_at = Utc::now(); - if let Err(err) = account_usage::record_usage_limit_hint_with_type( - &code_home, - &account_id, - plan_type.as_deref(), - resets_in_seconds, - observed_at, - reached_type, - ) { - tracing::warn!("Failed to persist usage limit hint: {err}"); - } - }); - } - - tracing::info!( - from_account_id = %current_account_id, - to_account_id = %next_account_id, - reason = switch_reason, - "rate limit hit; auto-switching active account" - ); - - if let Ok(logger) = self.debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "account_switch", - &serde_json::json!({ - "reason": switch_reason, - "from_account_id": current_account_id.clone(), - "to_account_id": next_account_id.clone(), - "status": status.as_u16(), - }), - ); - } - - if let Err(err) = - auth::activate_account(self.code_home(), &next_account_id) - { - tracing::warn!( - from_account_id = %current_account_id, - to_account_id = %next_account_id, - error = %err, - "failed to activate account after rate limit" - ); - } else { - if let Some(manager) = auth_manager.as_ref() { - manager.reload(); - } - attempt = 0; - continue; - } - } - } - } - - if status == StatusCode::BAD_REQUEST { - if let Some(ErrorResponse { ref error }) = body { - if !self.reasoning_summary_disabled.load(Ordering::Relaxed) - && is_reasoning_summary_rejected(error) - { - self.disable_reasoning_summary(); - - if let Ok(logger) = self.debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "reasoning_summary_disabled", - &serde_json::json!({ - "status": status.as_u16(), - "message": error.message.clone(), - "code": error.code.clone(), - "param": error.param.clone(), - }), - ); - } - - // Retry immediately with reasoning summaries removed. - attempt = 0; - continue; - } - } - } - - // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx - // errors. When we bubble early with only the HTTP status the caller sees an opaque - // "unexpected status 400 Bad Request" which makes debugging nearly impossible. - // Instead, read (and include) the response text so higher layers and users see the - // exact error message (e.g. "Unknown parameter: 'input[0].metadata'"). The body is - // small and this branch only runs on error paths so the extra allocation is - // negligible. - if !(status == StatusCode::TOO_MANY_REQUESTS - || status == StatusCode::UNAUTHORIZED - || status.is_server_error()) - { - // Log error response - if let Ok(logger) = self.debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "error", - &serde_json::json!({ - "status": status.as_u16(), - "headers": header_map_to_json(&headers), - "body": body_text - }), - ); - let _ = logger.end_request_log(&request_id); - } - return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError { - status, - body: body_text, - request_id: None, - })); - } - - if let Some(ErrorResponse { ref error }) = body { - if is_quota_exceeded_http_error(status, error) { - return Err(CodexErr::QuotaExceeded); - } - } - - if status == StatusCode::UNAUTHORIZED { - if let Some(error) = - map_unauthorized_outcome(auth.is_some(), auth_refresh_error.as_ref()) - { - return Err(error); - } - } - - if status == StatusCode::TOO_MANY_REQUESTS { - if let Some(ErrorResponse { ref error }) = body { - if error.r#type.as_deref() == Some("usage_limit_reached") { - // Prefer the plan_type provided in the error message if present - // because it's more up to date than the one encoded in the auth - // token. - let plan_type = error - .plan_type - .clone() - .or_else(|| auth.and_then(|a| a.get_plan_type())); - let resets_in_seconds = error.resets_in_seconds; - return Err(CodexErr::UsageLimitReached(UsageLimitReachedError { - plan_type, - resets_in_seconds, - rate_limit_reached_type: error.rate_limit_reached_type, - })); - } else if error.r#type.as_deref() == Some("usage_not_included") { - return Err(CodexErr::UsageNotIncluded); - } - } - } - - if attempt > max_retries { - // On final attempt, surface rich diagnostics for server errors. - // On final attempt, surface rich diagnostics for server errors. - if status.is_server_error() { - let (message, body_excerpt) = - match serde_json::from_str::(&body_text) { - Ok(ErrorResponse { error }) => { - let msg = error - .message - .unwrap_or_else(|| "server error".to_string()); - (msg, None) - } - Err(_) => { - let mut excerpt = body_text; - const MAX: usize = 600; - if excerpt.len() > MAX { - excerpt.truncate(MAX); - } - ( - "server error".to_string(), - if excerpt.is_empty() { - None - } else { - Some(excerpt) - }, - ) - } - }; - - // Build a single-line, actionable message for the UI and logs. - let mut msg = format!("server error {status}: {message}"); - if let Some(id) = &x_request_id { - msg.push_str(&format!(" (request-id: {id})")); - } - if let Some(excerpt) = &body_excerpt { - msg.push_str(&format!(" | body: {excerpt}")); - } - - // Log detailed context to the debug logger and close the request log. - if let Ok(logger) = self.debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "server_error_on_retry_limit", - &serde_json::json!({ - "status": status.as_u16(), - "x_request_id": x_request_id, - "message": message, - "body_excerpt": body_excerpt, - }), - ); - let _ = logger.end_request_log(&request_id); - } - - return Err(CodexErr::ServerError(msg)); - } - - return Err(CodexErr::RetryLimit(RetryLimitReachedError { - status, - request_id: None, - retryable: status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS, - })); - } - - let mut retry_after_delay = retry_after_hint; - if retry_after_delay.is_none() { - if let Some(ErrorResponse { ref error }) = body { - retry_after_delay = try_parse_retry_after(error, now); - } - } - - let delay = retry_after_delay - .as_ref() - .map(|info| info.delay) - .unwrap_or_else(|| backoff(attempt)); - tokio::time::sleep(delay).await; - } - Err(e) => { - let is_connectivity = e.is_connect() || e.is_timeout() || e.is_request(); - if attempt > max_retries { - // Log network error before surfacing. - if let Ok(logger) = self.debug_logger.lock() { - let _ = logger.log_error(&endpoint, &format!("Network error: {}", e), log_tag); - } - if is_connectivity { - let req_id = (!request_id.is_empty()).then(|| request_id.clone()); - return Err(CodexErr::Stream( - format!("[transport] network unavailable: {e}"), - None, - req_id, - )); - } - return Err(e.into()); - } - let delay = backoff(attempt); - tokio::time::sleep(delay).await; - } - } - } - } - - pub fn get_provider(&self) -> ModelProviderInfo { - self.provider.clone() - } - - /// Returns the currently configured model slug. - #[allow(dead_code)] - pub fn get_model(&self) -> String { - self.config.model.clone() - } - - pub fn model_explicit(&self) -> bool { - self.config.model_explicit - } - - pub fn model_personality(&self) -> Option { - self.config.model_personality - } - - /// Returns the currently configured model family. - #[allow(dead_code)] - pub fn get_model_family(&self) -> ModelFamily { - self.config.model_family.clone() - } - - #[allow(dead_code)] - pub fn get_model_context_window(&self) -> Option { - self.config.model_context_window - } - - #[allow(dead_code)] - pub fn get_auth_manager(&self) -> Option> { - self.auth_manager.clone() - } - - pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result> { - if prompt.input.is_empty() { - return Ok(Vec::new()); - } - - let auth_manager = self.auth_manager.clone(); - let mut rate_limit_switch_state = crate::account_switching::RateLimitSwitchState::default(); - - let model_slug = prompt - .model_override - .as_deref() - .unwrap_or(self.config.model.as_str()); - let family = prompt - .model_family_override - .clone() - .or_else(|| find_family_for_model(model_slug)) - .unwrap_or_else(|| self.config.model_family.clone()); - let session_id = prompt.session_id_override.unwrap_or(self.session_id); - let session_id_str = session_id.to_string(); - let instructions = prompt.get_full_instructions(&family).into_owned(); - let mut request_id = String::new(); - - loop { - let base_auth = auth_manager.as_ref().and_then(|m| m.auth()); - let auth = self.provider.effective_auth(&base_auth).await?; - let service_tier = if auth - .as_ref() - .is_some_and(|auth| auth.mode == AuthMode::ApiKey) - { - None - } else { - match self.config.service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - } - }; - let payload = CompactHistoryRequest { - model: model_slug, - input: &prompt.input, - instructions: instructions.clone(), - service_tier, - prompt_cache_key: Some(session_id_str.as_str()), - }; - let payload_json = serde_json::to_value(&payload)?; - let mut request = self - .provider - .create_compact_request_builder_with_auth(&self.client, &auth) - .await?; - request = self.apply_requested_model_headers(request, model_slug); - - // Ensure Responses API beta header is present for compact calls. Mirror the - // streaming path: use the public "responses=v1" header for the public OpenAI - // endpoint and fall back to "responses=experimental" for other providers. - let has_beta_header = request - .try_clone() - .and_then(|builder| builder.build().ok()) - .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); - - if !has_beta_header { - let beta_value = if self.provider.is_public_openai_responses_endpoint() { - RESPONSES_BETA_HEADER_V1 - } else { - RESPONSES_BETA_HEADER_EXPERIMENTAL - }; - request = request.header("OpenAI-Beta", beta_value); - } - - request = attach_openai_subagent_header(request); - request = attach_codex_beta_features_header(request, &self.config); - if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { - request = request.header(X_CODEX_WINDOW_ID_HEADER, window_id); - } - - request = request - .header("conversation_id", session_id_str.clone()) - .header("session_id", session_id_str.clone()) - .header("thread_id", session_id_str.clone()); - - if let Some(auth) = auth.as_ref() - && auth.mode.is_chatgpt() - && let Some(account_id) = auth.get_account_id() - { - request = request.header("chatgpt-account-id", account_id); - } - - request = request.json(&payload); - - let header_snapshot = request - .try_clone() - .and_then(|builder| builder.build().ok()) - .map(|req| header_map_to_json(req.headers())); - - if request_id.is_empty() { - if let Ok(logger) = self.debug_logger.lock() { - let endpoint = self - .provider - .get_compact_url(&auth) - .unwrap_or_else(|| self.provider.get_full_url(&auth)); - request_id = logger - .start_request_log( - &endpoint, - &payload_json, - header_snapshot.as_ref(), - Some("compact_remote"), - ) - .unwrap_or_default(); - } - } - - let response = request.send().await?; - let status = response.status(); - let body = response.text().await?; - - if status == StatusCode::TOO_MANY_REQUESTS - && self.config.auto_switch_accounts_on_rate_limit - && auth_manager.is_some() - && auth::read_code_api_key_from_env().is_none() - { - let now = Utc::now(); - let current_account_id = auth - .as_ref() - .and_then(|current| current.get_account_id()) - .or_else(|| { - auth_accounts::get_active_account_id(self.code_home()) - .ok() - .flatten() - }); - if let Some(current_account_id) = current_account_id { - let current_auth_mode = auth - .as_ref() - .map(|a| a.mode) - .unwrap_or(AuthMode::ApiKey); - rate_limit_switch_state.mark_limited( - ¤t_account_id, - current_auth_mode, - None, - ); - if let Ok(Some(next_account_id)) = - crate::account_switching::select_next_account_id( - self.code_home(), - &rate_limit_switch_state, - self.config.api_key_fallback_on_all_accounts_limited, - now, - Some(current_account_id.as_str()), - ) - { - tracing::info!( - from_account_id = %current_account_id, - to_account_id = %next_account_id, - "rate limit hit during compact; auto-switching active account" - ); - if let Err(err) = auth::activate_account(self.code_home(), &next_account_id) { - tracing::warn!( - from_account_id = %current_account_id, - to_account_id = %next_account_id, - error = %err, - "failed to activate account after rate limit during compact" - ); - } else { - if let Some(manager) = auth_manager.as_ref() { - manager.reload(); - } - continue; - } - } - } - } - - if let Ok(logger) = self.debug_logger.lock() { - let response_body: serde_json::Value = serde_json::from_str(&body) - .unwrap_or_else(|_| serde_json::json!({ "raw": body })); - let _ = logger.append_response_event( - &request_id, - "compact_response", - &serde_json::json!({ - "status_code": status.as_u16(), - "body": response_body, - }), - ); - let _ = logger.end_request_log(&request_id); - } - - if !status.is_success() { - return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError { - status, - body, - request_id: None, - })); - } - - let CompactHistoryResponse { output } = serde_json::from_str(&body)?; - return Ok(output); - } - } -} - -fn attach_codex_beta_features_header( - builder: reqwest::RequestBuilder, - config: &Config, -) -> reqwest::RequestBuilder { - let Some(value) = codex_beta_features_header_value(config) else { - return builder; - }; - - let has_header = builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map_or(false, |req| req.headers().contains_key("x-codex-beta-features")); - if has_header { - return builder; - } - - builder.header("x-codex-beta-features", value) -} - -fn parse_wrapped_websocket_error_event(payload: &str) -> Option { - let event: WrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?; - if event.kind != "error" { - return None; - } - Some(event) -} - -fn map_wrapped_websocket_error_event(event: WrappedWebsocketErrorEvent) -> Option { - let status = match event.status.and_then(|value| StatusCode::from_u16(value).ok()) { - Some(status) => status, - None => { - if let Some(error) = event.error { - let message = error - .message - .unwrap_or_else(|| "websocket returned an error event".to_string()); - return Some(CodexErr::Stream(message, None, None)); - } - return Some(CodexErr::Stream( - "websocket returned an error event".to_string(), - None, - None, - )); - } - }; - if status.is_success() { - return None; - } - - let body = if let Some(error) = event.error { - if status == StatusCode::TOO_MANY_REQUESTS { - if error.r#type.as_deref() == Some("usage_limit_reached") { - return Some(CodexErr::UsageLimitReached(UsageLimitReachedError { - plan_type: error.plan_type, - resets_in_seconds: error.resets_in_seconds, - rate_limit_reached_type: error.rate_limit_reached_type, - })); - } - - if error.r#type.as_deref() == Some("usage_not_included") { - return Some(CodexErr::UsageNotIncluded); - } - } - - if is_quota_exceeded_error(&error) { - return Some(CodexErr::QuotaExceeded); - } - - if is_server_overloaded_error(&error) { - return Some(CodexErr::ServerOverloaded); - } - - serde_json::json!({ - "error": { - "type": error.r#type, - "code": error.code, - "param": error.param, - "message": error.message, - "plan_type": error.plan_type, - "resets_in_seconds": error.resets_in_seconds, - } - }) - .to_string() - } else { - serde_json::json!({ - "error": { - "message": "websocket returned an error event" - } - }) - .to_string() - }; - - Some(CodexErr::UnexpectedStatus(UnexpectedResponseError { - status, - body, - request_id: None, - })) -} - -fn websocket_connect_is_upgrade_required(error: &WsError) -> bool { - matches!( - error, - WsError::Http(response) - if response.status().as_u16() == 426 - ) -} - -fn codex_beta_features_header_value(config: &Config) -> Option { - let mut enabled: Vec<&'static str> = Vec::new(); - - if config.skills_enabled { - enabled.push("skills"); - } - if config.tools_web_search_request { - enabled.push("web_search_request"); - } - - let value = enabled.join(","); - if value.is_empty() { - return None; - } - - HeaderValue::from_str(value.as_str()).ok() -} - -fn attach_openai_subagent_header(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - let Some(value) = openai_subagent_header_value() else { - return builder; - }; - - let has_header = builder - .try_clone() - .and_then(|builder| builder.build().ok()) - .map_or(false, |req| req.headers().contains_key("x-openai-subagent")); - if has_header { - return builder; - } - - builder.header("x-openai-subagent", value) -} - -fn openai_subagent_header_value() -> Option { - let subagent = std::env::var(CODE_OPENAI_SUBAGENT_ENV).ok()?; - let subagent = subagent.trim(); - if subagent.is_empty() { - return None; - } - HeaderValue::from_str(subagent).ok() -} - -fn clamp_text_verbosity_for_model( - model: &str, - requested: TextVerbosityConfig, -) -> TextVerbosityConfig { - let allowed = supported_text_verbosity_for_model(model); - if allowed.iter().any(|v| v == &requested) { - return requested; - } - - if let Some(medium) = allowed.iter().find(|v| matches!(v, TextVerbosityConfig::Medium)) { - tracing::debug!( - model, - requested = ?requested, - fallback = ?medium, - "text verbosity clamped to supported value for model", - ); - return *medium; - } - - let fallback = *allowed.first().unwrap_or(&TextVerbosityConfig::Medium); - tracing::debug!( - model, - requested = ?requested, - fallback = ?fallback, - "text verbosity clamped to first supported value for model", - ); - fallback -} - -fn supported_text_verbosity_for_model(model: &str) -> &'static [TextVerbosityConfig] { - if model.eq_ignore_ascii_case("gpt-5.1-codex-max") { - return &[TextVerbosityConfig::Medium]; - } - - const ALL: &[TextVerbosityConfig] = &[TextVerbosityConfig::Low, TextVerbosityConfig::Medium, TextVerbosityConfig::High]; - ALL -} - -#[derive(Debug, Deserialize, Serialize)] -struct SseEvent { - #[serde(rename = "type")] - kind: String, - response: Option, - item: Option, - delta: Option, - // Present on delta events from the Responses API; used to correlate - // streaming chunks with the final OutputItemDone. - item_id: Option, - // Optional ordering metadata from the Responses API; used to filter - // duplicates and out‑of‑order reasoning deltas. - sequence_number: Option, - output_index: Option, - content_index: Option, - summary_index: Option, -} - -#[derive(Debug, Deserialize)] -struct ResponseCompleted { - id: String, - usage: Option, -} - -#[derive(Debug, Deserialize)] -struct ResponseDone { - id: Option, - usage: Option, -} - -#[derive(Debug, Deserialize)] -struct ResponseCompletedUsage { - input_tokens: u64, - input_tokens_details: Option, - output_tokens: u64, - output_tokens_details: Option, - total_tokens: u64, -} - -impl From for TokenUsage { - fn from(val: ResponseCompletedUsage) -> Self { - let input_tokens_details = val.input_tokens_details; - TokenUsage { - input_tokens: val.input_tokens, - cached_input_tokens: input_tokens_details - .as_ref() - .map(|d| d.cached_tokens) - .unwrap_or(0), - cached_input_tokens_reported: input_tokens_details.is_some(), - output_tokens: val.output_tokens, - reasoning_output_tokens: val - .output_tokens_details - .map(|d| d.reasoning_tokens) - .unwrap_or(0), - total_tokens: val.total_tokens, - } - } -} - -#[derive(Debug, Deserialize)] -struct ResponseCompletedInputTokensDetails { - cached_tokens: u64, -} - -#[derive(Debug, Deserialize)] -struct ResponseCompletedOutputTokensDetails { - reasoning_tokens: u64, -} - -fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { - let Some(input_value) = payload_json.get_mut("input") else { - return; - }; - let serde_json::Value::Array(items) = input_value else { - return; - }; - - for (value, item) in items.iter_mut().zip(original_items.iter()) { - if let ResponseItem::Reasoning { id, .. } - | ResponseItem::Message { id: Some(id), .. } - | ResponseItem::WebSearchCall { id: Some(id), .. } - | ResponseItem::ImageGenerationCall { id, .. } - | ResponseItem::FunctionCall { id: Some(id), .. } - | ResponseItem::LocalShellCall { id: Some(id), .. } - | ResponseItem::CustomToolCall { id: Some(id), .. } = item - { - if id.is_empty() { - continue; - } - - if let Some(obj) = value.as_object_mut() { - obj.insert("id".to_string(), Value::String(id.clone())); - } - } - } -} - -fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { - let primary_used_percent = parse_header_f64(headers, "x-codex-primary-used-percent")?; - let secondary_used_percent = parse_header_f64(headers, "x-codex-secondary-used-percent")?; - let primary_to_secondary_ratio_percent = - parse_header_f64(headers, "x-codex-primary-over-secondary-limit-percent")?; - let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?; - let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?; - let primary_reset_after_seconds = - parse_header_u64(headers, "x-codex-primary-reset-after-seconds"); - let secondary_reset_after_seconds = - parse_header_u64(headers, "x-codex-secondary-reset-after-seconds"); - - Some(RateLimitSnapshotEvent { - primary_used_percent, - secondary_used_percent, - primary_to_secondary_ratio_percent, - primary_window_minutes, - secondary_window_minutes, - primary_reset_after_seconds, - secondary_reset_after_seconds, - rate_limit_reached_type: None, - }) -} - -fn format_rate_limit_headers(headers: &HeaderMap) -> String { - let mut pairs: Vec = headers - .iter() - .map(|(name, value)| { - let value_str = value.to_str().unwrap_or(""); - format!("{}: {}", name, value_str) - }) - .collect(); - pairs.sort(); - pairs.join("\n") -} - -fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { - parse_header_str(headers, name)? - .parse::() - .ok() - .filter(|v| v.is_finite()) -} - -fn parse_header_u64(headers: &HeaderMap, name: &str) -> Option { - parse_header_str(headers, name)?.parse::().ok() -} - -fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { - headers.get(name)?.to_str().ok() -} - -fn parse_retry_after_header(value: &str, now: DateTime) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return None; - } - let normalized = trimmed - .trim_matches(|c: char| matches!(c, '"' | '\'' | '<' | '>')) - .trim(); - if normalized.is_empty() { - return None; - } - - if let Ok(secs) = normalized.parse::() { - return Some(RetryAfter::from_duration(Duration::from_secs(secs), now)); - } - if let Ok(float_secs) = normalized.parse::() { - if !float_secs.is_sign_negative() { - return Some(RetryAfter::from_duration(Duration::from_secs_f64(float_secs), now)); - } - } - if let Ok(system_time) = parse_http_date(normalized) { - let resume_at: DateTime = system_time.into(); - return Some(RetryAfter::from_resume_at(resume_at, now)); - } - if let Ok(dt) = DateTime::parse_from_rfc3339(normalized) { - return Some(RetryAfter::from_resume_at(dt.with_timezone(&Utc), now)); - } - if let Ok(dt) = DateTime::parse_from_rfc2822(normalized) { - return Some(RetryAfter::from_resume_at(dt.with_timezone(&Utc), now)); - } - if let Ok(dt) = DateTime::parse_from_str(normalized, "%a, %d %b %Y %H:%M:%S %z") { - return Some(RetryAfter::from_resume_at(dt.with_timezone(&Utc), now)); - } - - None -} - -fn header_map_to_json(headers: &HeaderMap) -> Value { - let mut ordered: BTreeMap> = BTreeMap::new(); - for (name, value) in headers.iter() { - let entry = ordered.entry(name.as_str().to_string()).or_default(); - entry.push(value.to_str().unwrap_or_default().to_string()); - } - - serde_json::to_value(ordered).unwrap_or(Value::Null) -} - -async fn emit_completed_event( - completed: ResponseCompleted, - tx_event: &mpsc::Sender>, - otel_event_manager: Option<&OtelEventManager>, - debug_logger: &Arc>, - request_id: &str, -) { - let ResponseCompleted { id, usage } = completed; - if let (Some(usage), Some(manager)) = (&usage, otel_event_manager) { - manager.sse_event_completed( - usage.input_tokens, - usage.output_tokens, - usage.input_tokens_details.as_ref().map(|d| d.cached_tokens), - usage.output_tokens_details.as_ref().map(|d| d.reasoning_tokens), - usage.total_tokens, - ); - } - - let event = ResponseEvent::Completed { - response_id: id, - token_usage: usage.map(Into::into), - }; - let _ = tx_event.send(Ok(event)).await; - - if let Ok(logger) = debug_logger.lock() { - let _ = logger.end_request_log(request_id); - } -} - -async fn process_sse( - stream: S, - tx_event: mpsc::Sender>, - idle_timeout: Duration, - debug_logger: Arc>, - request_id: String, - otel_event_manager: Option, - checkpoint: Arc>, -) where - S: Stream> + Unpin, -{ - let mut stream = stream.eventsource(); - - // If the stream stays completely silent for an extended period treat it as disconnected. - // The response id returned from the "complete" message. - let mut response_completed: Option = None; - let mut response_error: Option = None; - // Track the current item_id to include with delta events - let mut current_item_id: Option = None; - - // Monotonic sequence guards to drop duplicate/out‑of‑order deltas. - // Keys are item_id strings. - use std::collections::HashMap; - // Track last sequence_number per (item_id, output_index[, content_index]) - // Default indices to 0 when absent for robustness across providers. - let mut last_seq_reasoning_summary: HashMap<(String, u32, u32), u64> = HashMap::new(); - let mut last_seq_reasoning_content: HashMap<(String, u32, u32), u64> = HashMap::new(); - // Best-effort duplicate text guard when sequence_number is unavailable. - let mut last_text_reasoning_summary: HashMap<(String, u32, u32), String> = HashMap::new(); - let mut last_text_reasoning_content: HashMap<(String, u32, u32), String> = HashMap::new(); - let mut global_last_seq: Option = checkpoint.read().ok().and_then(|c| c.last_sequence); - - loop { - let next_event = if let Some(manager) = otel_event_manager.as_ref() { - manager - .log_sse_event(|| timeout(idle_timeout, stream.next())) + inference_trace_attempt.record_started(&ws_request); + let websocket_connection = + self.websocket_session.connection.as_ref().ok_or_else(|| { + map_api_error(ApiError::Stream( + "websocket connection is unavailable".to_string(), + )) + })?; + let stream_result = websocket_connection + .stream_request(ws_request, self.websocket_session.connection_reused()) .await - } else { - timeout(idle_timeout, stream.next()).await - }; - - let sse = match next_event { - Ok(Some(Ok(sse))) => sse, - Ok(Some(Err(e))) => { - debug!("SSE Error: {e:#}"); - let event = CodexErr::Stream( - format!("[transport] {e}"), - None, - Some(request_id.clone()), - ); - let _ = tx_event.send(Err(event)).await; - return; - } - Ok(None) => { - match response_completed { - Some(completed) => { - emit_completed_event( - completed, - &tx_event, - otel_event_manager.as_ref(), - &debug_logger, - &request_id, - ) - .await; - } - None => { - let error = response_error.unwrap_or(CodexErr::Stream( - "stream closed before response.completed".into(), - None, - Some(request_id.clone()), - )); - if let Some(manager) = otel_event_manager.as_ref() { - manager.see_event_completed_failed(&error); - } - let _ = tx_event.send(Err(error)).await; - } - } - // Mark the request log as complete - if let Ok(logger) = debug_logger.lock() { - let _ = logger.end_request_log(&request_id); - } - return; - } - Err(_) => { - let _ = tx_event - .send(Err(CodexErr::Stream( - "[idle] timeout waiting for SSE".into(), - None, - Some(request_id.clone()), - ))) - .await; - return; - } - }; - - let raw = sse.data.clone(); - trace!("SSE event: {}", raw); - - // Log the raw SSE event data - if let Ok(logger) = debug_logger.lock() { - if let Ok(json_value) = serde_json::from_str::(&sse.data) { - let _ = logger.append_response_event(&request_id, "sse_event", &json_value); - } - } - - let event: SseEvent = match serde_json::from_str(&sse.data) { - Ok(event) => event, - Err(e) => { - // Log parse error with data excerpt, and record it in the debug logger as well. - let mut excerpt = sse.data.clone(); - const MAX: usize = 600; - if excerpt.len() > MAX { - excerpt.truncate(MAX); - } - debug!("Failed to parse SSE event: {e}, data: {excerpt}"); - if let Ok(logger) = debug_logger.lock() { - let _ = logger.append_response_event( - &request_id, - "sse_parse_error", - &serde_json::json!({ - "error": e.to_string(), - "data_excerpt": excerpt, - }), - ); - } - continue; - } - }; - - if let Some(seq) = event.sequence_number { - if let Some(last) = global_last_seq { - if seq <= last { - continue; - } - } - global_last_seq = Some(seq); - if let Ok(mut guard) = checkpoint.write() { - guard.last_sequence = Some(seq); - } - } - - match event.kind.as_str() { - // Individual output item finalised. Forward immediately so the - // rest of the agent can stream assistant text/functions *live* - // instead of waiting for the final `response.completed` envelope. - // - // IMPORTANT: We used to ignore these events and forward the - // duplicated `output` array embedded in the `response.completed` - // payload. That produced two concrete issues: - // 1. No real‑time streaming – the user only saw output after the - // entire turn had finished, which broke the "typing" UX and - // made long‑running turns look stalled. - // 2. Duplicate `function_call_output` items – both the - // individual *and* the completed array were forwarded, which - // confused the backend and triggered 400 - // "previous_response_not_found" errors because the duplicated - // IDs did not match the incremental turn chain. - // - // The fix is to forward the incremental events *as they come* and - // drop the duplicated list inside `response.completed`. - "response.output_item.done" => { - let Some(item_val) = event.item else { continue }; - // Special-case: web_search_call completion -> synthesize a completion event - if item_val - .get("type") - .and_then(|v| v.as_str()) - .is_some_and(|s| s == "web_search_call") - { - let call_id = item_val - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let query = item_val - .get("action") - .and_then(|a| a.get("query")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let ev = ResponseEvent::WebSearchCallCompleted { call_id, query }; - if tx_event.send(Ok(ev)).await.is_err() { - return; - } - } - let Ok(item) = serde_json::from_value::(item_val.clone()) else { - debug!("failed to parse ResponseItem from output_item.done"); - continue; - }; - - // Extract item_id if present - if let Some(id) = item_val.get("id").and_then(|v| v.as_str()) { - current_item_id = Some(id.to_string()); - } else { - // Check within the parsed item structure - match &item { - ResponseItem::Message { id, .. } - | ResponseItem::FunctionCall { id, .. } - | ResponseItem::LocalShellCall { id, .. } => { - if let Some(item_id) = id { - current_item_id = Some(item_id.clone()); - } - } - ResponseItem::Reasoning { id, .. } => { - current_item_id = Some(id.clone()); - } - _ => {} - } - } - - let event = ResponseEvent::OutputItemDone { item, sequence_number: event.sequence_number, output_index: event.output_index }; - if tx_event.send(Ok(event)).await.is_err() { - return; - } - } - "response.output_text.delta" => { - if let Some(delta) = event.delta { - // Prefer the explicit item_id from the SSE event; fall back to last seen. - if let Some(ref id) = event.item_id { - current_item_id = Some(id.clone()); - } - tracing::debug!("sse.delta output_text id={:?} len={}", current_item_id, delta.len()); - let ev = ResponseEvent::OutputTextDelta { - delta, - item_id: event.item_id.or_else(|| current_item_id.clone()), - sequence_number: event.sequence_number, - output_index: event.output_index, - }; - if tx_event.send(Ok(ev)).await.is_err() { - return; - } - } - } - "response.reasoning_summary_text.delta" => { - if let Some(delta) = event.delta { - if let Some(ref id) = event.item_id { - current_item_id = Some(id.clone()); - } - // Compose key using item_id + output_index - let out_idx: u32 = event.output_index.unwrap_or(0); - let sum_idx: u32 = event.summary_index.unwrap_or(0); - if let Some(ref id) = current_item_id { - // Drop duplicates/out‑of‑order by sequence_number when available - if let Some(sn) = event.sequence_number { - let last = last_seq_reasoning_summary.entry((id.clone(), out_idx, sum_idx)).or_insert(0); - if *last >= sn { continue; } - *last = sn; - } else { - // Best-effort: drop exact duplicate text for same key when seq is missing - let key = (id.clone(), out_idx, sum_idx); - if last_text_reasoning_summary.get(&key).map_or(false, |prev| prev == &delta) { - continue; - } - last_text_reasoning_summary.insert(key, delta.clone()); - } - } - tracing::debug!( - "sse.delta reasoning_summary id={:?} out_idx={} sum_idx={} len={} seq={:?}", - current_item_id, out_idx, sum_idx, - delta.len(), - event.sequence_number - ); - let ev = ResponseEvent::ReasoningSummaryDelta { - delta, - item_id: event.item_id.or_else(|| current_item_id.clone()), - sequence_number: event.sequence_number, - output_index: event.output_index, - summary_index: event.summary_index, - }; - if tx_event.send(Ok(ev)).await.is_err() { - return; - } - } - } - "response.reasoning_text.delta" => { - if let Some(delta) = event.delta { - if let Some(ref id) = event.item_id { - current_item_id = Some(id.clone()); - } - // Compose key using item_id + output_index + content_index - let out_idx: u32 = event.output_index.unwrap_or(0); - let content_idx: u32 = event.content_index.unwrap_or(0); - if let Some(ref id) = current_item_id { - // Drop duplicates/out‑of‑order by sequence_number when available - if let Some(sn) = event.sequence_number { - let last = last_seq_reasoning_content.entry((id.clone(), out_idx, content_idx)).or_insert(0); - if *last >= sn { continue; } - *last = sn; - } else { - // Best-effort: drop exact duplicate text for same key when seq is missing - let key = (id.clone(), out_idx, content_idx); - if last_text_reasoning_content.get(&key).map_or(false, |prev| prev == &delta) { - continue; - } - last_text_reasoning_content.insert(key, delta.clone()); - } - } - tracing::debug!( - "sse.delta reasoning_content id={:?} out_idx={} content_idx={} len={} seq={:?}", - current_item_id, out_idx, content_idx, - delta.len(), - event.sequence_number - ); - let ev = ResponseEvent::ReasoningContentDelta { - delta, - item_id: event.item_id.or_else(|| current_item_id.clone()), - sequence_number: event.sequence_number, - output_index: event.output_index, - content_index: event.content_index, - }; - if tx_event.send(Ok(ev)).await.is_err() { - return; - } - } - } - "response.created" => { - if let Some(response) = event.response { - let response_id = response - .get("id") - .and_then(Value::as_str) - .map(ToString::to_string); - let response_model = response - .get("model") - .and_then(Value::as_str) - .map(ToString::to_string); - let _ = tx_event - .send(Ok(ResponseEvent::Created { - response_id, - response_model, - })) - .await; - } - } - "response.failed" => { - if let Some(resp_val) = event.response { - response_error = Some(CodexErr::Stream( - "response.failed event received".to_string(), - None, - Some(request_id.clone()), - )); - - let error = resp_val.get("error"); - - if let Some(error) = error { - match serde_json::from_value::(error.clone()) { - Ok(error) => { - if error.r#type.as_deref() == Some("usage_limit_reached") { - response_error = Some(CodexErr::UsageLimitReached( - UsageLimitReachedError { - plan_type: error.plan_type, - resets_in_seconds: error.resets_in_seconds, - rate_limit_reached_type: error.rate_limit_reached_type, - }, - )); - } else if error.r#type.as_deref() == Some("usage_not_included") { - response_error = Some(CodexErr::UsageNotIncluded); - } else if is_quota_exceeded_error(&error) { - response_error = Some(CodexErr::QuotaExceeded); - } else if is_server_overloaded_error(&error) { - response_error = Some(CodexErr::ServerOverloaded); - } else { - let retry_after = try_parse_retry_after(&error, Utc::now()); - let message = error.message.unwrap_or_default(); - response_error = Some(CodexErr::Stream( - message, - retry_after, - Some(request_id.clone()), - )); - } - } - Err(e) => { - debug!("failed to parse ErrorResponse: {e}"); - } - } - } - - if let Some(error) = response_error.take() { - if let Some(manager) = otel_event_manager.as_ref() { - manager.see_event_completed_failed(&error); - } - let _ = tx_event.send(Err(error)).await; - if let Ok(logger) = debug_logger.lock() { - let _ = logger.end_request_log(&request_id); - } - return; - } - } - } - "response.incomplete" => { - let reason = event.response.as_ref().and_then(|response| { - response - .get("incomplete_details") - .and_then(|details| details.get("reason")) - .and_then(Value::as_str) - }); - let reason = reason.unwrap_or("unknown"); - let message = format!("Incomplete response returned, reason: {reason}"); - let event = CodexErr::Stream(message, None, Some(request_id.clone())); - let _ = tx_event.send(Err(event)).await; - return; - } - // Final response completed – includes array of output items & id - "response.completed" => { - if let Some(resp_val) = event.response { - match serde_json::from_value::(resp_val) { - Ok(r) => { - response_completed = Some(r); - } - Err(e) => { - debug!("failed to parse ResponseCompleted: {e}"); - continue; - } - }; - - if let Some(completed) = response_completed.take() { - emit_completed_event( - completed, - &tx_event, - otel_event_manager.as_ref(), - &debug_logger, - &request_id, - ) - .await; - return; - } - }; - } - "response.done" => { - if let Some(resp_val) = event.response { - match serde_json::from_value::(resp_val) { - Ok(r) => { - response_completed = Some(ResponseCompleted { - id: r.id.unwrap_or_default(), - usage: r.usage, - }); - } - Err(e) => { - debug!("failed to parse ResponseDone: {e}"); - continue; - } - }; - } else { - response_completed = Some(ResponseCompleted { - id: String::new(), - usage: None, - }); - } - - if let Some(completed) = response_completed.take() { - emit_completed_event( - completed, - &tx_event, - otel_event_manager.as_ref(), - &debug_logger, - &request_id, - ) - .await; - return; - } - } - "response.content_part.done" - | "response.function_call_arguments.delta" - | "response.custom_tool_call_input.delta" - | "response.custom_tool_call_input.done" // also emitted as response.output_item.done - | "response.in_progress" - | "response.output_item.added" - | "response.output_text.done" => { - if event.kind == "response.output_item.added" { - if let Some(item) = event.item.as_ref() { - // Detect web_search_call begin and forward a synthetic event upstream. - if let Some(ty) = item.get("type").and_then(|v| v.as_str()) { - if ty == "web_search_call" { - let call_id = item - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let ev = ResponseEvent::WebSearchCallBegin { call_id }; - if tx_event.send(Ok(ev)).await.is_err() { - return; - } - } - } - } - } - } - "response.reasoning_summary_part.added" => { - // Boundary between reasoning summary sections (e.g., titles). - let event = ResponseEvent::ReasoningSummaryPartAdded; - if tx_event.send(Ok(event)).await.is_err() { - return; - } - } - "response.reasoning_summary_text.done" => {} - _ => {} - } - } -} - -/// used in tests to stream from a text SSE file -async fn stream_from_fixture( - path: impl AsRef, - provider: ModelProviderInfo, - otel_event_manager: Option, -) -> Result { - let (tx_event, rx_event) = mpsc::channel::>(1600); - let f = std::fs::File::open(path.as_ref())?; - let lines = std::io::BufReader::new(f).lines(); - - // insert \n\n after each line for proper SSE parsing - let mut content = String::new(); - for line in lines { - content.push_str(&line?); - content.push_str("\n\n"); - } - - let rdr = std::io::Cursor::new(content); - let stream = ReaderStream::new(rdr).map_err(CodexErr::Io); - // Create a dummy debug logger for testing - let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap())); - tokio::spawn(process_sse( - stream, - tx_event, - provider.stream_idle_timeout(), - debug_logger, - String::new(), // Empty request_id for test fixture - otel_event_manager, - Arc::new(RwLock::new(StreamCheckpoint::default())), - )); - Ok(ResponseStream { rx_event }) -} - -// Note: legacy helpers for parsing Retry-After headers and rate-limit messages -// were removed during merge cleanup. If needed in the future, pick them from -// upstream and integrate with our error handling path. - -#[cfg(test)] -mod tests { - use super::*; - use crate::model_provider_info::{ModelProviderInfo, WireApi}; - use std::collections::HashMap; - use serde_json::json; - use tokio::sync::mpsc; - use tokio_test::io::Builder as IoBuilder; - use tokio_util::io::ReaderStream; - use chrono::{Duration as ChronoDuration, TimeZone, Utc}; - - // ──────────────────────────── - // Helpers - // ──────────────────────────── - - fn response_text_item(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![code_protocol::models::ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - #[test] - fn response_completed_usage_marks_cached_input_telemetry_availability() { - let without_details: TokenUsage = ResponseCompletedUsage { - input_tokens: 100, - input_tokens_details: None, - output_tokens: 10, - output_tokens_details: None, - total_tokens: 110, - } - .into(); - assert_eq!(without_details.cached_input_tokens, 0); - assert!(!without_details.cached_input_tokens_reported); - assert_eq!(without_details.cache_hit_rate(), None); - - let with_details: TokenUsage = ResponseCompletedUsage { - input_tokens: 100, - input_tokens_details: Some(ResponseCompletedInputTokensDetails { cached_tokens: 0 }), - output_tokens: 10, - output_tokens_details: None, - total_tokens: 110, - } - .into(); - assert_eq!(with_details.cached_input_tokens, 0); - assert!(with_details.cached_input_tokens_reported); - assert_eq!(with_details.cache_hit_rate(), Some(0)); - } - - #[test] - fn websocket_incremental_input_requires_matching_request_prefix() { - let first = response_text_item("one"); - let second = response_text_item("two"); - let payload = json!({ - "model": "gpt-5.5", - "instructions": "be useful", - "input": [first.clone()], - "stream": true - }); - let previous = responses_request_snapshot(&payload, std::slice::from_ref(&first)); - let current_payload = json!({ - "model": "gpt-5.5", - "instructions": "be useful", - "input": [first.clone(), second.clone()], - "stream": true - }); - let current = - responses_request_snapshot(¤t_payload, &[first.clone(), second.clone()]); - - assert_eq!( - incremental_input_for_websocket_request(&previous, ¤t), - Some(vec![second.clone()]) - ); - - let changed_payload = json!({ - "model": "gpt-5.5", - "instructions": "be terse", - "input": [first, second], - "stream": true - }); - let changed = responses_request_snapshot(&changed_payload, ¤t.input); - assert_eq!( - incremental_input_for_websocket_request(&previous, &changed), - None - ); - } - - #[test] - fn websocket_payload_adds_generate_and_previous_response_fields() { - let delta = response_text_item("follow up"); - let payload = json!({ - "model": "gpt-5.5", - "instructions": "be useful", - "input": [], - "stream": true, - "prompt_cache_key": "session-1" - }); - - let warmup = build_responses_websocket_payload(&payload, None, None, Some(false)) - .expect("warmup payload"); - let warmup_json: Value = serde_json::from_str(&warmup).expect("warmup json"); - assert_eq!(warmup_json["type"], "response.create"); - assert_eq!(warmup_json["generate"], false); - assert!(warmup_json.get("previous_response_id").is_none()); - - let chained = build_responses_websocket_payload( - &payload, - Some(vec![delta.clone()]), - Some("resp_previous".to_string()), - None, - ) - .expect("chained payload"); - let chained_json: Value = serde_json::from_str(&chained).expect("chained json"); - assert_eq!(chained_json["previous_response_id"], "resp_previous"); - assert_eq!( - chained_json["input"], - serde_json::to_value(vec![delta]).expect("delta input json") - ); - assert!(chained_json.get("generate").is_none()); - } - - #[test] - fn unauthorized_outcome_returns_permanent_error_for_permanent_refresh_failure() { - let err = RefreshTokenError::permanent("token revoked"); - let outcome = map_unauthorized_outcome(true, Some(&err)) - .expect("should produce CodexErr"); - match outcome { - CodexErr::AuthRefreshPermanent(msg) => { - assert!( - msg.contains("token revoked"), - "unexpected message: {}", - msg - ); - } - other => panic!("unexpected outcome: {:?}", other), - } - } - - #[test] - fn unauthorized_outcome_requires_login_without_auth() { - let outcome = map_unauthorized_outcome(false, None) - .expect("should require login"); - match outcome { - CodexErr::AuthRefreshPermanent(msg) => { - assert_eq!(msg, AUTH_REQUIRED_MESSAGE); - } - other => panic!("unexpected outcome: {:?}", other), - } - } - - #[test] - fn unauthorized_outcome_allows_retry_for_transient_refresh_error() { - let err = RefreshTokenError::transient("server busy"); - assert!(map_unauthorized_outcome(true, Some(&err)).is_none()); - } - - #[tokio::test] - async fn responses_request_uses_beta_header_for_public_openai() { - let provider = ModelProviderInfo { - name: "openai".to_string(), - base_url: Some("https://api.openai.com/v1".to_string()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: None, - stream_idle_timeout_ms: None, - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("client"); - - let mut builder = provider - .create_request_builder(&client, &None) - .await - .expect("builder"); - let has_beta = builder - .try_clone() - .and_then(|b| b.build().ok()) - .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); - if !has_beta { - builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_V1); - } - let request = builder - .try_clone() - .expect("clone request builder") - .build() - .expect("build request"); - - let header_value = request - .headers() - .get("OpenAI-Beta") - .expect("OpenAI-Beta header present"); - assert_eq!(header_value, RESPONSES_BETA_HEADER_V1); - } - - #[tokio::test] - async fn responses_request_uses_experimental_for_backend() { - let provider = ModelProviderInfo { - name: "backend".to_string(), - base_url: Some("https://chatgpt.com/backend-api/codex".to_string()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: None, - stream_idle_timeout_ms: None, - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("client"); - - let mut builder = provider - .create_request_builder(&client, &None) - .await - .expect("builder"); - let has_beta = builder - .try_clone() - .and_then(|b| b.build().ok()) - .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); - if !has_beta { - builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_EXPERIMENTAL); - } - let request = builder - .try_clone() - .expect("clone request builder") - .build() - .expect("build request"); - - let header_value = request - .headers() - .get("OpenAI-Beta") - .expect("OpenAI-Beta header present"); - assert_eq!(header_value, RESPONSES_BETA_HEADER_EXPERIMENTAL); - } - - #[tokio::test] - async fn responses_request_respects_preexisting_beta_header() { - let mut headers = HashMap::new(); - headers.insert("OpenAI-Beta".to_string(), "custom".to_string()); - let provider = ModelProviderInfo { - name: "custom".to_string(), - base_url: Some("https://api.openai.com/v1".to_string()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some(headers), - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: None, - stream_idle_timeout_ms: None, - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("client"); - - let request = provider - .create_request_builder(&client, &None) - .await - .expect("builder") - .try_clone() - .expect("clone request builder") - .build() - .expect("build request"); - - let header_value = request - .headers() - .get("OpenAI-Beta") - .expect("OpenAI-Beta header present"); - assert_eq!(header_value, "custom"); - } - - /// Runs the SSE parser on pre-chunked byte slices and returns every event - /// (including any final `Err` from a stream-closure check). - async fn collect_events( - chunks: &[&[u8]], - provider: ModelProviderInfo, - ) -> Vec> { - let mut builder = IoBuilder::new(); - for chunk in chunks { - builder.read(chunk); - } - - let reader = builder.build(); - let stream = ReaderStream::new(reader).map_err(CodexErr::Io); - let (tx, mut rx) = mpsc::channel::>(16); - let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap())); - let checkpoint = Arc::new(RwLock::new(StreamCheckpoint::default())); - tokio::spawn(process_sse( - stream, - tx, - provider.stream_idle_timeout(), - debug_logger, - String::new(), - None, - checkpoint, - )); - - let mut events = Vec::new(); - while let Some(ev) = rx.recv().await { - events.push(ev); + .map_err(|err| { + let response_debug_context = + extract_response_debug_context_from_api_error(&err); + let err = map_api_error(err); + inference_trace_attempt.record_failed( + &err, + response_debug_context.request_id.as_deref(), + /*output_items*/ &[], + ); + err + })?; + let (stream, last_request_rx) = map_response_stream( + stream_result, + session_telemetry.clone(), + inference_trace_attempt, + ); + self.websocket_session.last_response_rx = Some(last_request_rx); + return Ok(WebsocketStreamOutcome::Stream(stream)); } - events } - /// Builds an in-memory SSE stream from JSON fixtures and returns only the - /// successfully parsed events (panics on internal channel errors). - async fn run_sse( - events: Vec, - provider: ModelProviderInfo, - ) -> Vec { - let mut body = String::new(); - for e in events { - let kind = e - .get("type") - .and_then(|v| v.as_str()) - .expect("fixture event missing type"); - if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { - body.push_str(&format!("event: {kind}\n\n")); - } else { - body.push_str(&format!("event: {kind}\ndata: {e}\n\n")); - } - } - - let (tx, mut rx) = mpsc::channel::>(8); - let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io); - let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap())); - let checkpoint = Arc::new(RwLock::new(StreamCheckpoint::default())); - tokio::spawn(process_sse( - stream, - tx, - provider.stream_idle_timeout(), - debug_logger, - String::new(), - None, - checkpoint, + /// Builds request and SSE telemetry for streaming API calls. + fn build_streaming_telemetry( + session_telemetry: &SessionTelemetry, + auth_context: AuthRequestTelemetryContext, + request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, + ) -> (Arc, Arc) { + let telemetry = Arc::new(ApiTelemetry::new( + session_telemetry.clone(), + auth_context, + request_route_telemetry, + auth_env_telemetry, )); - - let mut out = Vec::new(); - while let Some(ev) = rx.recv().await { - out.push(ev.expect("channel closed")); - } - out + let request_telemetry: Arc = telemetry.clone(); + let sse_telemetry: Arc = telemetry; + (request_telemetry, sse_telemetry) + } + + /// Builds telemetry for the Responses API WebSocket transport. + fn build_websocket_telemetry( + session_telemetry: &SessionTelemetry, + auth_context: AuthRequestTelemetryContext, + request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, + ) -> Arc { + let telemetry = Arc::new(ApiTelemetry::new( + session_telemetry.clone(), + auth_context, + request_route_telemetry, + auth_env_telemetry, + )); + let websocket_telemetry: Arc = telemetry; + websocket_telemetry } - // ──────────────────────────── - // Tests from `implement-test-for-responses-api-sse-parser` - // ──────────────────────────── - - #[tokio::test] - async fn parses_items_and_completed() { - let item1 = json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello"}] - } - }) - .to_string(); - - let item2 = json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "World"}] - } - }) - .to_string(); - - let completed = json!({ - "type": "response.completed", - "response": { "id": "resp1" } - }) - .to_string(); - - let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n"); - let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n"); - let sse3 = format!("event: response.completed\ndata: {completed}\n\n"); - - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let events = collect_events( - &[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()], - provider, - ) - .await; - - assert_eq!(events.len(), 3); - - matches!( - &events[0], - Ok(ResponseEvent::OutputItemDone { - item: ResponseItem::Message { role, .. }, - .. - }) if role == "assistant" - ); - - matches!( - &events[1], - Ok(ResponseEvent::OutputItemDone { - item: ResponseItem::Message { role, .. }, - .. - }) if role == "assistant" - ); - - match &events[2] { - Ok(ResponseEvent::Completed { - response_id, - token_usage, - }) => { - assert_eq!(response_id, "resp1"); - assert!(token_usage.is_none()); - } - other => panic!("unexpected third event: {other:?}"), + #[allow(clippy::too_many_arguments)] + pub async fn prewarm_websocket( + &mut self, + prompt: &Prompt, + model_info: &ModelInfo, + session_telemetry: &SessionTelemetry, + effort: Option, + summary: ReasoningSummaryConfig, + service_tier: Option, + turn_metadata_header: Option<&str>, + ) -> Result<()> { + if !self.client.responses_websocket_enabled() { + return Ok(()); } - } - - #[tokio::test] - async fn error_when_missing_completed() { - let item1 = json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello"}] - } - }) - .to_string(); - - let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let events = collect_events(&[sse1.as_bytes()], provider).await; - - assert_eq!(events.len(), 2); - - matches!( - events[0], - Ok(ResponseEvent::OutputItemDone { .. }) - ); - - match &events[1] { - Err(CodexErr::Stream(msg, _, _)) => { - assert_eq!(msg, "stream closed before response.completed") - } - other => panic!("unexpected second event: {other:?}"), + if self.websocket_session.last_request.is_some() { + return Ok(()); } - } - #[tokio::test] - async fn response_done_emits_completed() { - let done = json!({ - "type": "response.done", - "response": { - "id": "resp_done_1", - "usage": { - "input_tokens": 1, - "input_tokens_details": null, - "output_tokens": 2, - "output_tokens_details": null, - "total_tokens": 3 + let disabled_trace = InferenceTraceContext::disabled(); + match self + .stream_responses_websocket( + prompt, + model_info, + session_telemetry, + effort, + summary, + service_tier, + turn_metadata_header, + /*warmup*/ true, + current_span_w3c_trace_context(), + &disabled_trace, + ) + .await + { + Ok(WebsocketStreamOutcome::Stream(mut stream)) => { + // Wait for the v2 warmup request to complete before sending the first turn request. + while let Some(event) = stream.next().await { + match event { + Ok(ResponseEvent::Completed { .. }) => break, + Err(err) => return Err(err), + _ => {} + } } + Ok(()) } - }) - .to_string(); - - let sse1 = format!("event: response.done\ndata: {done}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let events = collect_events(&[sse1.as_bytes()], provider).await; - - assert_eq!(events.len(), 1); - match &events[0] { - Ok(ResponseEvent::Completed { - response_id, - token_usage, - }) => { - assert_eq!(response_id, "resp_done_1"); - assert!(token_usage.is_some()); + Ok(WebsocketStreamOutcome::FallbackToHttp) => { + self.try_switch_fallback_transport(session_telemetry, model_info); + Ok(()) } - other => panic!("unexpected done event: {other:?}"), + Err(err) => Err(err), } } - #[tokio::test] - async fn response_completed_does_not_wait_for_stream_close() { - let completed = json!({ - "type": "response.completed", - "response": { - "id": "resp_ws_1", - "usage": { - "input_tokens": 1, - "input_tokens_details": null, - "output_tokens": 2, - "output_tokens_details": null, - "total_tokens": 3 + #[allow(clippy::too_many_arguments)] + /// Streams a single model request within the current turn. + /// + /// The caller is responsible for passing per-turn settings explicitly (model selection, + /// reasoning settings, telemetry context, and turn metadata). This method will prefer the + /// Responses WebSocket transport when the provider supports it and it remains healthy, and will + /// fall back to the HTTP Responses API transport otherwise. The trace context may be enabled or + /// disabled, but is always explicit so transport paths do not need separate trace/no-trace + /// branches. + pub async fn stream( + &mut self, + prompt: &Prompt, + model_info: &ModelInfo, + session_telemetry: &SessionTelemetry, + effort: Option, + summary: ReasoningSummaryConfig, + service_tier: Option, + turn_metadata_header: Option<&str>, + inference_trace: &InferenceTraceContext, + ) -> Result { + let wire_api = self.client.state.provider.info().wire_api; + match wire_api { + WireApi::Responses => { + if self.client.responses_websocket_enabled() { + let request_trace = current_span_w3c_trace_context(); + match self + .stream_responses_websocket( + prompt, + model_info, + session_telemetry, + effort, + summary, + service_tier.clone(), + turn_metadata_header, + /*warmup*/ false, + request_trace, + inference_trace, + ) + .await? + { + WebsocketStreamOutcome::Stream(stream) => return Ok(stream), + WebsocketStreamOutcome::FallbackToHttp => { + self.try_switch_fallback_transport(session_telemetry, model_info); + } + } } - } - }) - .to_string(); - - let sse = format!("event: response.completed\ndata: {completed}\n\n"); - let (tx_bytes, rx_bytes) = mpsc::channel::>(4); - tx_bytes - .send(Ok(Bytes::from(sse))) - .await - .expect("seed response.completed chunk"); - let stream = ReceiverStream::new(rx_bytes); - let (tx, mut rx) = mpsc::channel::>(8); - let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap())); - let checkpoint = Arc::new(RwLock::new(StreamCheckpoint::default())); - - tokio::spawn(process_sse( - stream, - tx, - Duration::from_secs(60), - debug_logger, - String::new(), - None, - checkpoint, - )); - - // Keep sender alive so the stream does not terminate on EOF. - let _keep_stream_open = tx_bytes; - let first = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("parser should emit completion without waiting for EOF") - .expect("completion event"); - match first { - Ok(ResponseEvent::Completed { response_id, .. }) => { - assert_eq!(response_id, "resp_ws_1"); + self.stream_responses_api( + prompt, + model_info, + session_telemetry, + effort, + summary, + service_tier, + turn_metadata_header, + inference_trace, + ) + .await } - other => panic!("unexpected first event: {other:?}"), } - - let second = tokio::time::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("channel should close after completion"); - assert!(second.is_none()); } - #[tokio::test] - async fn error_when_error_event() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_689bcf18d7f08194bf3440ba62fe05d803fee0cdac429894","object":"response","created_at":1755041560,"status":"failed","background":false,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."}, "usage":null,"user":null,"metadata":{}}}"#; - - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; + /// Permanently disables WebSockets for this Codex session and resets WebSocket state. + /// + /// This is used after exhausting the provider retry budget, to force subsequent requests onto + /// the HTTP transport. + /// + /// Returns `true` if this call activated fallback, or `false` if fallback was already active. + pub(crate) fn try_switch_fallback_transport( + &mut self, + session_telemetry: &SessionTelemetry, + model_info: &ModelInfo, + ) -> bool { + let activated = self + .client + .force_http_fallback(session_telemetry, model_info); + self.websocket_session = WebsocketSession::default(); + activated + } +} - let events = collect_events(&[sse1.as_bytes()], provider).await; +/// Parses per-turn metadata into an HTTP header value. +/// +/// Invalid values are treated as absent so callers can compare and propagate +/// metadata with the same sanitization path used when constructing headers. +fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option { + turn_metadata_header.and_then(|value| HeaderValue::from_str(value).ok()) +} - assert_eq!(events.len(), 1); +/// Builds the extra headers attached to Responses API requests. +/// +/// These headers implement Codex-specific conventions: +/// +/// - `x-codex-beta-features`: comma-separated beta feature keys enabled for the session. +/// - `x-codex-turn-state`: sticky routing token captured earlier in the turn. +/// - `x-codex-turn-metadata`: optional per-turn metadata for observability. +fn build_responses_headers( + beta_features_header: Option<&str>, + turn_state: Option<&Arc>>, + turn_metadata_header: Option<&HeaderValue>, +) -> ApiHeaderMap { + let mut headers = ApiHeaderMap::new(); + if let Some(value) = beta_features_header + && !value.is_empty() + && let Ok(header_value) = HeaderValue::from_str(value) + { + headers.insert("x-codex-beta-features", header_value); + } + if let Some(turn_state) = turn_state + && let Some(state) = turn_state.get() + && let Ok(header_value) = HeaderValue::from_str(state) + { + headers.insert(X_CODEX_TURN_STATE_HEADER, header_value); + } + if let Some(header_value) = turn_metadata_header { + headers.insert(X_CODEX_TURN_METADATA_HEADER, header_value.clone()); + } + headers +} - match &events[0] { - Err(CodexErr::Stream(msg, Some(retry), _)) => { - assert_eq!( - msg, - "Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more." - ); - assert_eq!(retry.delay, Duration::from_secs_f64(11.054)); - } - other => panic!("unexpected second event: {other:?}"), +fn subagent_header_value(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(subagent_source) => match subagent_source { + SubAgentSource::Review => Some("review".to_string()), + SubAgentSource::Compact => Some("compact".to_string()), + SubAgentSource::MemoryConsolidation => Some("memory_consolidation".to_string()), + SubAgentSource::ThreadSpawn { .. } => Some("collab_spawn".to_string()), + SubAgentSource::Other(label) => Some(label.clone()), + }, + SessionSource::Internal(InternalSessionSource::MemoryConsolidation) => { + Some("memory_consolidation".to_string()) } + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, } +} - // ──────────────────────────── - // Table-driven test from `main` - // ──────────────────────────── +fn parent_thread_id_header_value(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) => Some(parent_thread_id.to_string()), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Internal(_) + | SessionSource::SubAgent(_) + | SessionSource::Unknown => None, + } +} - /// Verifies that the adapter produces the right `ResponseEvent` for a - /// variety of incoming `type` values. - #[tokio::test] - async fn table_driven_event_kinds() { - struct TestCase { - name: &'static str, - event: serde_json::Value, - expect_first: fn(&ResponseEvent) -> bool, - expected_len: usize, - } +const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600; +const STREAM_DROPPED_REASON: &str = "response stream dropped before provider terminal event"; + +fn map_response_stream( + api_stream: codex_api::ResponseStream, + session_telemetry: SessionTelemetry, + inference_trace_attempt: InferenceTraceAttempt, +) -> (ResponseStream, oneshot::Receiver) { + let codex_api::ResponseStream { + rx_event, + upstream_request_id, + } = api_stream; + let api_stream = codex_api::ResponseStream { + rx_event, + upstream_request_id: None, + }; + map_response_events( + upstream_request_id, + api_stream, + session_telemetry, + inference_trace_attempt, + ) +} - fn is_created(ev: &ResponseEvent) -> bool { - matches!(ev, ResponseEvent::Created { .. }) - } - fn is_output(ev: &ResponseEvent) -> bool { - matches!(ev, ResponseEvent::OutputItemDone { .. }) - } - fn is_completed(ev: &ResponseEvent) -> bool { - matches!(ev, ResponseEvent::Completed { .. }) +fn map_response_events( + upstream_request_id: Option, + api_stream: S, + session_telemetry: SessionTelemetry, + inference_trace_attempt: InferenceTraceAttempt, +) -> (ResponseStream, oneshot::Receiver) +where + S: futures::Stream> + + Unpin + + Send + + 'static, +{ + let (tx_event, rx_event) = + mpsc::channel::>(RESPONSE_STREAM_CHANNEL_CAPACITY); + let (tx_last_response, rx_last_response) = oneshot::channel::(); + let consumer_dropped = CancellationToken::new(); + let consumer_dropped_for_stream = consumer_dropped.clone(); + + tokio::spawn(async move { + let mut logged_error = false; + let mut tx_last_response = Some(tx_last_response); + let mut items_added: Vec = Vec::new(); + let mut api_stream = api_stream; + let upstream_request_id = upstream_request_id.as_deref(); + if let Some(upstream_request_id) = upstream_request_id { + feedback_tags!(last_model_request_id = upstream_request_id); } - - let completed = json!({ - "type": "response.completed", - "response": { - "id": "c", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - }); - - let cases = vec![ - TestCase { - name: "created", - event: json!({"type": "response.created", "response": {}}), - expect_first: is_created, - expected_len: 2, - }, - TestCase { - name: "output_item.done", - event: json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "content": [ - {"type": "output_text", "text": "hi"} - ] - } - }), - expect_first: is_output, - expected_len: 2, - }, - TestCase { - name: "unknown", - event: json!({"type": "response.new_tool_event"}), - expect_first: is_completed, - expected_len: 1, - }, - ]; - - for case in cases { - let mut evs = vec![case.event]; - evs.push(completed.clone()); - - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, + loop { + let event = tokio::select! { + _ = consumer_dropped.cancelled() => { + inference_trace_attempt.record_cancelled( + STREAM_DROPPED_REASON, + upstream_request_id, + &items_added, + ); + return; + } + event = api_stream.next() => event, }; - - let out = run_sse(evs, provider).await; - assert_eq!(out.len(), case.expected_len, "case {}", case.name); - assert!( - (case.expect_first)(&out[0]), - "first event mismatch in case {}", - case.name - ); + let Some(event) = event else { + break; + }; + match event { + Ok(ResponseEvent::OutputItemDone(item)) => { + items_added.push(item.clone()); + if tx_event + .send(Ok(ResponseEvent::OutputItemDone(item))) + .await + .is_err() + { + inference_trace_attempt.record_cancelled( + STREAM_DROPPED_REASON, + upstream_request_id, + &items_added, + ); + return; + } + } + Ok(ResponseEvent::Completed { + response_id, + token_usage, + end_turn, + }) => { + feedback_tags!(last_model_response_id = &response_id); + if let Some(usage) = &token_usage { + session_telemetry.sse_event_completed( + usage.input_tokens, + usage.output_tokens, + Some(usage.cached_input_tokens), + Some(usage.reasoning_output_tokens), + usage.total_tokens, + ); + } + inference_trace_attempt.record_completed( + &response_id, + upstream_request_id, + &token_usage, + &items_added, + ); + if let Some(sender) = tx_last_response.take() { + let _ = sender.send(LastResponse { + response_id: response_id.clone(), + items_added: std::mem::take(&mut items_added), + }); + } + if tx_event + .send(Ok(ResponseEvent::Completed { + response_id, + token_usage, + end_turn, + })) + .await + .is_err() + { + return; + } + } + Ok(event) => { + if tx_event.send(Ok(event)).await.is_err() { + inference_trace_attempt.record_cancelled( + STREAM_DROPPED_REASON, + upstream_request_id, + &items_added, + ); + return; + } + } + Err(err) => { + let response_debug_context = + extract_response_debug_context_from_api_error(&err); + let upstream_request_id = + upstream_request_id.or(response_debug_context.request_id.as_deref()); + if let Some(upstream_request_id) = upstream_request_id { + feedback_tags!(last_model_request_id = upstream_request_id); + } + let mapped = map_api_error(err); + inference_trace_attempt.record_failed( + &mapped, + upstream_request_id, + &items_added, + ); + if !logged_error { + session_telemetry.see_event_completed_failed(&mapped); + logged_error = true; + } + if tx_event.send(Err(mapped)).await.is_err() { + return; + } + } + } } - } - - fn fixed_now() -> DateTime { - Utc.with_ymd_and_hms(2025, 11, 7, 12, 0, 0).unwrap() - } - - #[test] - fn test_try_parse_retry_after_ms() { - let now = fixed_now(); - let err = Error { - r#type: None, - message: Some("Rate limit reached for gpt-5.1 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), - code: Some("rate_limit_exceeded".to_string()), - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - - let retry_after = try_parse_retry_after(&err, now).expect("retry"); - assert_eq!(retry_after.delay, Duration::from_millis(28)); - assert!(retry_after.resume_at >= now); - } - - #[test] - fn test_try_parse_retry_after_seconds() { - let now = fixed_now(); - let err = Error { - r#type: None, - message: Some("Rate limit reached for gpt-5.1 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), - code: Some("rate_limit_exceeded".to_string()), - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - let retry_after = try_parse_retry_after(&err, now).expect("retry"); - assert_eq!(retry_after.delay, Duration::from_secs_f64(1.898)); - } - - #[test] - fn test_try_parse_retry_after_azure() { - let now = fixed_now(); - let err = Error { - r#type: None, - message: Some("Rate limit exceeded. Retry after 35 seconds.".to_string()), - code: Some("rate_limit_exceeded".to_string()), - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - let retry_after = try_parse_retry_after(&err, now).expect("retry"); - assert_eq!(retry_after.delay, Duration::from_secs(35)); - } - - #[test] - fn test_try_parse_retry_after_none_when_missing() { - let now = fixed_now(); - let err = Error { - r#type: None, - message: Some("Some other error".to_string()), - code: None, - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - - assert!(try_parse_retry_after(&err, now).is_none()); - } - - #[test] - fn parse_retry_after_header_parses_seconds() { - let now = fixed_now(); - let retry = parse_retry_after_header("42", now).expect("header"); - assert_eq!(retry.delay, Duration::from_secs(42)); - assert_eq!(retry.resume_at, now + ChronoDuration::seconds(42)); - } - - #[test] - fn parse_retry_after_header_parses_rfc7231_date() { - let now = Utc.with_ymd_and_hms(1994, 11, 15, 8, 0, 0).unwrap(); - let retry = parse_retry_after_header("Tue, 15 Nov 1994 08:12:31 GMT", now).expect("header"); - assert_eq!( - retry.resume_at, - Utc.with_ymd_and_hms(1994, 11, 15, 8, 12, 31).unwrap() + inference_trace_attempt.record_failed( + "stream closed before response.completed", + upstream_request_id, + &items_added, ); - } + }); + + ( + ResponseStream { + rx_event, + consumer_dropped: consumer_dropped_for_stream, + }, + rx_last_response, + ) +} - #[test] - fn parse_retry_after_header_clamps_past_date() { - let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); - let retry = parse_retry_after_header("Tue, 15 Nov 1994 08:12:31 GMT", now).expect("header"); - assert_eq!(retry.delay, Duration::ZERO); - assert_eq!(retry.resume_at, now); - } +/// Handles a 401 response by optionally refreshing ChatGPT tokens once. +/// +/// When refresh succeeds, the caller should retry the API call; otherwise +/// the mapped `CodexErr` is returned to the caller. +#[derive(Clone, Copy, Debug)] +struct UnauthorizedRecoveryExecution { + mode: &'static str, + phase: &'static str, +} - #[test] - fn parse_retry_after_header_strips_wrappers() { - let now = fixed_now(); - let retry = parse_retry_after_header(" \"17\" ", now).expect("header"); - assert_eq!(retry.delay, Duration::from_secs(17)); - } +#[derive(Clone, Copy, Debug, Default)] +struct PendingUnauthorizedRetry { + retry_after_unauthorized: bool, + recovery_mode: Option<&'static str>, + recovery_phase: Option<&'static str>, +} - #[test] - fn retry_after_prefers_header_over_body_hint() { - let now = fixed_now(); - let header_retry = parse_retry_after_header("5", now); - let mut chosen = header_retry.clone(); - if chosen.is_none() { - let err = Error { - r#type: None, - message: Some( - "Rate limit reached for gpt-5.1. Please try again in 30 seconds.".to_string(), - ), - code: Some("rate_limit_exceeded".to_string()), - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - chosen = try_parse_retry_after(&err, now); +impl PendingUnauthorizedRetry { + fn from_recovery(recovery: UnauthorizedRecoveryExecution) -> Self { + Self { + retry_after_unauthorized: true, + recovery_mode: Some(recovery.mode), + recovery_phase: Some(recovery.phase), } - let retry = chosen.expect("retry"); - assert_eq!(retry.delay, Duration::from_secs(5)); - } - - #[test] - fn parse_retry_after_header_handles_timezones() { - let now = Utc.with_ymd_and_hms(2025, 3, 9, 5, 0, 0).unwrap(); - let retry = parse_retry_after_header("Sun, 09 Mar 2025 01:30:00 -0500", now).expect("header"); - assert_eq!( - retry.resume_at, - Utc.with_ymd_and_hms(2025, 3, 9, 6, 30, 0).unwrap() - ); } +} - #[test] - fn quota_error_detected_for_common_statuses() { - let error = Error { - r#type: Some("invalid_request_error".to_string()), - message: Some("You exceeded your current quota".to_string()), - code: Some("insufficient_quota".to_string()), - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; +#[derive(Clone, Copy, Debug, Default)] +struct AuthRequestTelemetryContext { + auth_mode: Option<&'static str>, + auth_header_attached: bool, + auth_header_name: Option<&'static str>, + retry_after_unauthorized: bool, + recovery_mode: Option<&'static str>, + recovery_phase: Option<&'static str>, +} - for status in [ - StatusCode::BAD_REQUEST, - StatusCode::FORBIDDEN, - StatusCode::TOO_MANY_REQUESTS, - ] { - assert!(is_quota_exceeded_http_error(status, &error), "status {status} should be fatal"); +impl AuthRequestTelemetryContext { + fn new( + auth_mode: Option, + api_auth: &dyn AuthProvider, + retry: PendingUnauthorizedRetry, + ) -> Self { + let auth_telemetry = auth_header_telemetry(api_auth); + Self { + auth_mode: auth_mode.map(|mode| match mode { + AuthMode::ApiKey => "ApiKey", + AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => { + "Chatgpt" + } + }), + auth_header_attached: auth_telemetry.attached, + auth_header_name: auth_telemetry.name, + retry_after_unauthorized: retry.retry_after_unauthorized, + recovery_mode: retry.recovery_mode, + recovery_phase: retry.recovery_phase, } - - assert!( - !is_quota_exceeded_http_error(StatusCode::INTERNAL_SERVER_ERROR, &error), - "server errors should not map to quota handling" - ); - } - - #[test] - fn malformed_quota_body_is_ignored() { - let error = Error { - r#type: Some("invalid_request_error".to_string()), - message: Some("missing code".to_string()), - code: None, - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - - assert!(!is_quota_exceeded_http_error(StatusCode::BAD_REQUEST, &error)); } +} - #[test] - fn reasoning_summary_rejection_is_detected() { - let error_with_param = Error { - r#type: Some("invalid_request_error".to_string()), - message: Some("Your organization must be verified to generate reasoning summaries.".to_string()), - code: Some("unsupported_value".to_string()), - param: Some("reasoning.summary".to_string()), - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - - assert!(is_reasoning_summary_rejected(&error_with_param)); - - let error_by_message = Error { - r#type: Some("invalid_request_error".to_string()), - message: Some("Your organization must be verified to generate reasoning summaries. If you just verified, it can take up to 15 minutes for access to propagate.".to_string()), - code: Some("unsupported_value".to_string()), - param: None, - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, - }; - - assert!(is_reasoning_summary_rejected(&error_by_message)); +struct WebsocketConnectParams<'a> { + session_telemetry: &'a SessionTelemetry, + api_provider: codex_api::Provider, + api_auth: SharedAuthProvider, + turn_metadata_header: Option<&'a str>, + options: &'a ApiResponsesOptions, + auth_context: AuthRequestTelemetryContext, + request_route_telemetry: RequestRouteTelemetry, +} - // An error with param="reasoning.summary" but a different error code - // (e.g., rate_limit_exceeded) should NOT be treated as a rejection. - let rate_limit_error = Error { - r#type: Some("rate_limit_error".to_string()), - message: Some("Rate limit reached for reasoning.summary requests.".to_string()), - code: Some("rate_limit_exceeded".to_string()), - param: Some("reasoning.summary".to_string()), - plan_type: None, - resets_in_seconds: None, - rate_limit_reached_type: None, +async fn handle_unauthorized( + transport: TransportError, + auth_recovery: &mut Option, + session_telemetry: &SessionTelemetry, +) -> Result { + let debug = extract_response_debug_context(&transport); + if let Some(recovery) = auth_recovery + && recovery.has_next() + { + let mode = recovery.mode_name(); + let phase = recovery.step_name(); + return match recovery.next().await { + Ok(step_result) => { + session_telemetry.record_auth_recovery( + mode, + phase, + "recovery_succeeded", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + /*recovery_reason*/ None, + step_result.auth_state_changed(), + ); + emit_feedback_auth_recovery_tags( + mode, + phase, + "recovery_succeeded", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + ); + Ok(UnauthorizedRecoveryExecution { mode, phase }) + } + Err(RefreshTokenError::Permanent(failed)) => { + session_telemetry.record_auth_recovery( + mode, + phase, + "recovery_failed_permanent", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + /*recovery_reason*/ None, + /*auth_state_changed*/ None, + ); + emit_feedback_auth_recovery_tags( + mode, + phase, + "recovery_failed_permanent", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + ); + Err(CodexErr::RefreshTokenFailed(failed)) + } + Err(RefreshTokenError::Transient(other)) => { + session_telemetry.record_auth_recovery( + mode, + phase, + "recovery_failed_transient", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + /*recovery_reason*/ None, + /*auth_state_changed*/ None, + ); + emit_feedback_auth_recovery_tags( + mode, + phase, + "recovery_failed_transient", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + ); + Err(CodexErr::Io(other)) + } }; - - assert!(!is_reasoning_summary_rejected(&rate_limit_error)); } - #[tokio::test] - async fn quota_exceeded_error_is_fatal() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details."},"incomplete_details":null}}"#; - - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; + let (mode, phase, recovery_reason) = match auth_recovery.as_ref() { + Some(recovery) => ( + recovery.mode_name(), + recovery.step_name(), + Some(recovery.unavailable_reason()), + ), + None => ("none", "none", Some("auth_manager_missing")), + }; + session_telemetry.record_auth_recovery( + mode, + phase, + "recovery_not_run", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + recovery_reason, + /*auth_state_changed*/ None, + ); + emit_feedback_auth_recovery_tags( + mode, + phase, + "recovery_not_run", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + ); - let events = collect_events(&[sse1.as_bytes()], provider).await; + Err(map_api_error(ApiError::Transport(transport))) +} - assert_eq!(events.len(), 1); - match &events[0] { - Err(CodexErr::QuotaExceeded) => {} - other => panic!("unexpected quota event: {other:?}"), - } +fn api_error_http_status(error: &ApiError) -> Option { + match error { + ApiError::Transport(TransportError::Http { status, .. }) => Some(status.as_u16()), + _ => None, } +} - #[tokio::test] - async fn response_failed_usage_limit_maps_to_typed_error() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_limit","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"type":"usage_limit_reached","message":"You've hit your usage limit.","plan_type":"pro","resets_in_seconds":120},"incomplete_details":null}}"#; - - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let events = collect_events(&[sse1.as_bytes()], provider).await; +struct ApiTelemetry { + session_telemetry: SessionTelemetry, + auth_context: AuthRequestTelemetryContext, + request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, +} - assert_eq!(events.len(), 1); - match &events[0] { - Err(CodexErr::UsageLimitReached(err)) => { - assert_eq!(err.plan_type.as_deref(), Some("pro")); - assert_eq!(err.resets_in_seconds, Some(120)); - } - other => panic!("unexpected usage-limit event: {other:?}"), +impl ApiTelemetry { + fn new( + session_telemetry: SessionTelemetry, + auth_context: AuthRequestTelemetryContext, + request_route_telemetry: RequestRouteTelemetry, + auth_env_telemetry: AuthEnvTelemetry, + ) -> Self { + Self { + session_telemetry, + auth_context, + request_route_telemetry, + auth_env_telemetry, } } +} - #[tokio::test] - async fn response_failed_usage_not_included_maps_to_typed_error() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_not_included","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"type":"usage_not_included","message":"Usage is not included for this model."},"incomplete_details":null}}"#; - - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let events = collect_events(&[sse1.as_bytes()], provider).await; - - assert_eq!(events.len(), 1); - match &events[0] { - Err(CodexErr::UsageNotIncluded) => {} - other => panic!("unexpected usage-not-included event: {other:?}"), - } +impl RequestTelemetry for ApiTelemetry { + fn on_request( + &self, + attempt: u64, + status: Option, + error: Option<&TransportError>, + duration: Duration, + ) { + let error_message = error.map(telemetry_transport_error_message); + let status = status.map(|s| s.as_u16()); + let debug = error + .map(extract_response_debug_context) + .unwrap_or_default(); + self.session_telemetry.record_api_request( + attempt, + status, + error_message.as_deref(), + duration, + self.auth_context.auth_header_attached, + self.auth_context.auth_header_name, + self.auth_context.retry_after_unauthorized, + self.auth_context.recovery_mode, + self.auth_context.recovery_phase, + self.request_route_telemetry.endpoint, + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + ); + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: self.request_route_telemetry.endpoint, + auth_header_attached: self.auth_context.auth_header_attached, + auth_header_name: self.auth_context.auth_header_name, + auth_mode: self.auth_context.auth_mode, + auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized), + auth_recovery_mode: self.auth_context.recovery_mode, + auth_recovery_phase: self.auth_context.recovery_phase, + auth_connection_reused: None, + auth_request_id: debug.request_id.as_deref(), + auth_cf_ray: debug.cf_ray.as_deref(), + auth_error: debug.auth_error.as_deref(), + auth_error_code: debug.auth_error_code.as_deref(), + auth_recovery_followup_success: self + .auth_context + .retry_after_unauthorized + .then_some(error.is_none()), + auth_recovery_followup_status: self + .auth_context + .retry_after_unauthorized + .then_some(status) + .flatten(), + }, + &self.auth_env_telemetry, + ); } +} - #[tokio::test] - async fn server_overloaded_error_is_typed() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_slow_down","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"slow_down","message":"Server is overloaded. Please retry shortly."},"incomplete_details":null}}"#; - - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let events = collect_events(&[sse1.as_bytes()], provider).await; - - assert_eq!(events.len(), 1); - match &events[0] { - Err(CodexErr::ServerOverloaded) => {} - other => panic!("unexpected overloaded event: {other:?}"), - } +impl SseTelemetry for ApiTelemetry { + fn on_sse_poll( + &self, + result: &std::result::Result< + Option>>, + tokio::time::error::Elapsed, + >, + duration: Duration, + ) { + self.session_telemetry.log_sse_event(result, duration); } +} - #[tokio::test] - async fn response_incomplete_surfaces_stream_error_reason() { - let raw_incomplete = r#"{"type":"response.incomplete","sequence_number":4,"response":{"id":"resp_incomplete","object":"response","created_at":1759771626,"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}"#; - - let sse1 = format!("event: response.incomplete\ndata: {raw_incomplete}\n\n"); - let provider = ModelProviderInfo { - name: "test".to_string(), - base_url: Some("https://test.com".to_string()), - env_key: Some("TEST_API_KEY".to_string()), - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(1000), - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - openrouter: None, - }; - - let events = collect_events(&[sse1.as_bytes()], provider).await; - - assert_eq!(events.len(), 1); - match &events[0] { - Err(CodexErr::Stream(message, None, _)) => { - assert_eq!( - message, - "Incomplete response returned, reason: max_output_tokens" - ); - } - other => panic!("unexpected incomplete event: {other:?}"), - } +impl WebsocketTelemetry for ApiTelemetry { + fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>, connection_reused: bool) { + let error_message = error.map(telemetry_api_error_message); + let status = error.and_then(api_error_http_status); + let debug = error + .map(extract_response_debug_context_from_api_error) + .unwrap_or_default(); + self.session_telemetry.record_websocket_request( + duration, + error_message.as_deref(), + connection_reused, + ); + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: self.request_route_telemetry.endpoint, + auth_header_attached: self.auth_context.auth_header_attached, + auth_header_name: self.auth_context.auth_header_name, + auth_mode: self.auth_context.auth_mode, + auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized), + auth_recovery_mode: self.auth_context.recovery_mode, + auth_recovery_phase: self.auth_context.recovery_phase, + auth_connection_reused: Some(connection_reused), + auth_request_id: debug.request_id.as_deref(), + auth_cf_ray: debug.cf_ray.as_deref(), + auth_error: debug.auth_error.as_deref(), + auth_error_code: debug.auth_error_code.as_deref(), + auth_recovery_followup_success: self + .auth_context + .retry_after_unauthorized + .then_some(error.is_none()), + auth_recovery_followup_status: self + .auth_context + .retry_after_unauthorized + .then_some(status) + .flatten(), + }, + &self.auth_env_telemetry, + ); } - #[test] - fn websocket_error_without_status_surfaces_stream_message() { - let payload = r#"{"type":"error","error":{"type":"invalid_request_error","message":"The requested model 'gpt-5.3-codex-spark' does not exist."}}"#; - let wrapped = parse_wrapped_websocket_error_event(payload) - .expect("wrapped websocket error should parse"); - let mapped = - map_wrapped_websocket_error_event(wrapped).expect("error should map without status"); - match mapped { - CodexErr::Stream(message, None, None) => { - assert_eq!( - message, - "The requested model 'gpt-5.3-codex-spark' does not exist." - ); - } - other => panic!("unexpected mapped websocket error: {other:?}"), - } + fn on_ws_event( + &self, + result: &std::result::Result>, ApiError>, + duration: Duration, + ) { + self.session_telemetry + .record_websocket_event(result, duration); } } + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/code-rs/core/src/client_common.rs b/code-rs/core/src/client_common.rs index e4522d3ecfb..efe2670652b 100644 --- a/code-rs/core/src/client_common.rs +++ b/code-rs/core/src/client_common.rs @@ -1,918 +1,184 @@ -use crate::agent_defaults::model_guide_markdown; -use crate::config_types::ReasoningEffort as ReasoningEffortConfig; -use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; -use crate::config_types::TextVerbosity as TextVerbosityConfig; -use crate::context_ledger::ContextLedger; -use crate::context_ledger::ContextPersistence; -use crate::context_ledger::ContextSourceKind; -use crate::context_ledger::response_item_bytes; -use crate::environment_context::EnvironmentContext; -use crate::error::Result; -use crate::model_family::ModelFamily; -use crate::openai_tools::OpenAiTool; -use crate::protocol::RateLimitSnapshotEvent; -use crate::protocol::TokenUsage; -use crate::truncate::truncate_middle; -use crate::user_instructions::UserInstructions; -use code_protocol::models::ContentItem; -use code_protocol::models::FunctionCallOutputContentItem; -use code_protocol::models::ResponseItem; +pub use codex_api::ResponseEvent; +use codex_config::types::Personality; +use codex_protocol::error::Result; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::ResponseItem; +use codex_tools::ToolSpec; use futures::Stream; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::Value; -use std::borrow::Cow; -use std::collections::hash_map::DefaultHasher; -use std::hash::Hash; -use std::hash::Hasher; -use std::ops::Deref; +use std::collections::HashSet; use std::pin::Pin; use std::task::Context; use std::task::Poll; use tokio::sync::mpsc; -use uuid::Uuid; - -/// Additional prompt for Code. Can not edit Codex instructions. -const PROMPT_CODER_TEMPLATE: &str = include_str!("../prompt_coder.md"); -static BASE_MODEL_DESCRIPTIONS: Lazy = Lazy::new(|| model_guide_markdown()); -static DEFAULT_DEVELOPER_PROMPT: Lazy = Lazy::new(|| { - PROMPT_CODER_TEMPLATE.replace("{MODEL_DESCRIPTIONS}", &BASE_MODEL_DESCRIPTIONS) -}); - -/// wraps environment context message in a tag for the model to parse more easily. -const ENVIRONMENT_CONTEXT_START: &str = "\n\n"; -const ENVIRONMENT_CONTEXT_END: &str = "\n\n"; +use tokio_util::sync::CancellationToken; /// Review thread system prompt. Edit `core/src/review_prompt.md` to customize. -#[allow(dead_code)] pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md"); +// Centralized templates for review-related user messages +pub const REVIEW_EXIT_SUCCESS_TMPL: &str = include_str!("../templates/review/exit_success.xml"); +pub const REVIEW_EXIT_INTERRUPTED_TMPL: &str = + include_str!("../templates/review/exit_interrupted.xml"); + /// API request payload for a single model turn #[derive(Debug, Clone)] pub struct Prompt { /// Conversation context input items. pub input: Vec, - /// Insertion point for request-only context within `input`. Items before - /// this index are stable history; items at and after it are the live turn. - pub volatile_context_insert_at: Option, - - /// Request-only context items to render at `volatile_context_insert_at`. - pub volatile_context_items: Vec, - - /// Whether to store response on server side (disable_response_storage = !store). - pub store: bool, - - /// Model instructions that are appended to the base instructions. - pub user_instructions: Option, - - /// A list of key-value pairs that will be added as a developer message - /// for the model to use - pub(crate) environment_context: Option, - /// Tools available to the model, including additional tools sourced from /// external MCP servers. - pub(crate) tools: Vec, + pub(crate) tools: Vec, - /// Status items to be added at the end of the input - /// These are generated fresh for each request (screenshots, system status) - pub status_items: Vec, - - /// Optional override for the built-in BASE_INSTRUCTIONS. - pub base_instructions_override: Option, - - /// Whether to prepend the default developer instructions block. - pub include_additional_instructions: bool, - - /// Additional developer messages to insert immediately after the default - /// fork instructions but before any environment or user context. - pub prepend_developer_messages: Vec, + /// Whether parallel tool calls are permitted for this prompt. + pub(crate) parallel_tool_calls: bool, - /// Optional `text.format` for structured outputs (used by side-channel requests). - pub text_format: Option, + pub base_instructions: BaseInstructions, - /// Optional per-request model slug override. - pub model_override: Option, + /// Optionally specify the personality of the model. + pub personality: Option, - /// Optional per-request model family override matching `model_override`. - pub model_family_override: Option, /// Optional the output schema for the model's response. pub output_schema: Option, - /// Optional tag used to route debug logs into helper-specific directories. - pub log_tag: Option, - /// Optional override for session/conversation identifiers used for caching. - pub session_id_override: Option, - /// Optional override for the model guide placeholder in the developer prompt. - pub model_descriptions: Option, + /// Whether the Responses API should strictly validate `output_schema`. + pub output_schema_strict: bool, } impl Default for Prompt { fn default() -> Self { Self { input: Vec::new(), - volatile_context_insert_at: None, - volatile_context_items: Vec::new(), - store: false, - user_instructions: None, - environment_context: None, tools: Vec::new(), - status_items: Vec::new(), - base_instructions_override: None, - include_additional_instructions: true, - prepend_developer_messages: Vec::new(), - text_format: None, - model_override: None, - model_family_override: None, + parallel_tool_calls: false, + base_instructions: BaseInstructions::default(), + personality: None, output_schema: None, - log_tag: None, - session_id_override: None, - model_descriptions: None, + output_schema_strict: true, } } } impl Prompt { - pub(crate) fn get_full_instructions<'a>(&'a self, model: &'a ModelFamily) -> Cow<'a, str> { - let effective_model = self.model_family_override.as_ref().unwrap_or(model); - Cow::Borrowed( - self.base_instructions_override - .as_deref() - .unwrap_or(effective_model.base_instructions.deref()), - ) - } - - pub fn set_log_tag>(&mut self, tag: S) { - self.log_tag = Some(tag.into()); - } - - fn additional_instructions(&self) -> Cow<'_, str> { - if let Some(custom) = &self.model_descriptions { - Cow::Owned(PROMPT_CODER_TEMPLATE.replace("{MODEL_DESCRIPTIONS}", custom)) - } else { - Cow::Borrowed(DEFAULT_DEVELOPER_PROMPT.deref()) - } - } - - fn get_formatted_user_instructions(&self) -> Option { - let instructions = self.user_instructions.as_ref()?; - let directory = self - .environment_context - .as_ref() - .and_then(|ctx| ctx.cwd.as_ref()) - .map(|cwd| cwd.to_string_lossy().into_owned()) - .unwrap_or_default(); - Some( - UserInstructions { - directory, - text: instructions.clone(), - } - .into(), - ) - } - - fn get_formatted_environment_context(&self) -> Option { - self.environment_context.as_ref().map(|ec| { - let ec_str = serde_json::to_string_pretty(ec).unwrap_or_else(|_| format!("{:?}", ec)); - format!("{ENVIRONMENT_CONTEXT_START}{ec_str}{ENVIRONMENT_CONTEXT_END}") - }) - } - pub(crate) fn get_formatted_input(&self) -> Vec { - self.get_formatted_input_with_ledger().0 - } - - pub(crate) fn get_formatted_input_with_ledger(&self) -> (Vec, ContextLedger) { - let mut input_with_instructions = Vec::with_capacity( - self.input.len() + self.volatile_context_items.len() + self.status_items.len() + 3, - ); - let mut ledger = ContextLedger::default(); - let mut seen_tool_outputs = std::collections::HashSet::new(); - let volatile_context_insert_at = self - .volatile_context_insert_at - .unwrap_or(self.input.len()) - .min(self.input.len()); - if self.include_additional_instructions { - let developer_text = self.additional_instructions().into_owned(); - ledger.push( - ContextSourceKind::DeveloperInstructions, - ContextPersistence::Contextual, - "default developer instructions", - 1, - developer_text.len(), - Some("developer:default".to_string()), - ); - input_with_instructions.push(ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { text: developer_text }], end_turn: None, phase: None}); - if let Some(ui) = self.get_formatted_user_instructions() { - let has_user_instructions = self.input.iter().any(|item| { - matches!(item, ResponseItem::Message { role, content, .. } - if role == "user" && UserInstructions::is_user_instructions(content)) - }); - if !has_user_instructions { - add_response_item_to_ledger(&mut ledger, &ui); - input_with_instructions.push(ui); - } - } - } - add_input_items_to_prompt( - &mut input_with_instructions, - &mut ledger, - &mut seen_tool_outputs, - self.input[..volatile_context_insert_at].iter(), - ); - add_input_items_to_prompt( - &mut input_with_instructions, - &mut ledger, - &mut seen_tool_outputs, - self.volatile_context_items.iter(), - ); - if self.include_additional_instructions { - if let Some(ec) = self.get_formatted_environment_context() { - let has_environment_context = self.input.iter().any(|item| { - matches!(item, ResponseItem::Message { role, content, .. } - if role == "user" - && content.iter().any(|c| matches!(c, - ContentItem::InputText { text } if text.contains(ENVIRONMENT_CONTEXT_START.trim()) - ))) - }); - if !has_environment_context { - ledger.push( - ContextSourceKind::EnvironmentContext, - ContextPersistence::GeneratedPerAttempt, - "environment context", - 1, - ec.len(), - Some("environment_context".to_string()), - ); - input_with_instructions.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: ec }], end_turn: None, phase: None}); - } - } - for message in &self.prepend_developer_messages { - let trimmed = message.trim(); - if trimmed.is_empty() { - continue; - } - ledger.push( - classify_prepend_developer_message(trimmed), - ContextPersistence::RequestOnly, - "prepended developer message", - 1, - trimmed.len(), - duplicate_key_for_prepend_developer_message(trimmed), - ); - input_with_instructions.push(ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: trimmed.to_string(), - }], end_turn: None, phase: None}); - } - } - add_input_items_to_prompt( - &mut input_with_instructions, - &mut ledger, - &mut seen_tool_outputs, - self.input[volatile_context_insert_at..].iter(), - ); - - // Add status items at the end so they're fresh for each request - for item in &self.status_items { - ledger.push( - classify_status_item(item), - ContextPersistence::GeneratedPerAttempt, - "status item", - 1, - response_item_bytes(item), - duplicate_key_for_status_item(item), - ); - input_with_instructions.push(item.clone()); - } - - // Limit screenshots to maximum 5 (keep first and last 4) - limit_screenshots_in_input(&mut input_with_instructions); - - (input_with_instructions, ledger) - } - - pub(crate) fn context_ledger_for_request( - &self, - model: &ModelFamily, - input: &[ResponseItem], - tools_json: &[serde_json::Value], - ) -> ContextLedger { - let mut ledger = self.context_ledger_for_formatted_input(input); - let full_instructions = self.get_full_instructions(model); - ledger.push( - ContextSourceKind::BaseInstructions, - ContextPersistence::Contextual, - "base instructions", - 1, - full_instructions.len(), - Some("base_instructions".to_string()), - ); - - let tools_bytes = tools_json - .iter() - .map(|tool| serde_json::to_string(tool).map(|s| s.len()).unwrap_or(0)) - .sum::(); - ledger.push( - ContextSourceKind::ToolSchema, - ContextPersistence::Contextual, - "tool schemas", - tools_json.len(), - tools_bytes, - Some("tool_schemas".to_string()), - ); - ledger - } - - fn context_ledger_for_formatted_input(&self, input: &[ResponseItem]) -> ContextLedger { - let mut ledger = ContextLedger::default(); - for item in input { - if self.status_items.iter().any(|status_item| status_item == item) { - ledger.push( - classify_status_item(item), - ContextPersistence::GeneratedPerAttempt, - "status item", - 1, - response_item_bytes(item), - duplicate_key_for_status_item(item), - ); - continue; - } - add_response_item_to_ledger(&mut ledger, item); - } - ledger - } - - pub fn set_tools(&mut self, tools: Vec) { - self.tools = tools; - } - - /// Creates a formatted user instructions message from a string - #[allow(dead_code)] - pub(crate) fn format_user_instructions_message(ui: &str) -> ResponseItem { - UserInstructions { - directory: String::new(), - text: ui.to_string(), - } - .into() - } -} - -fn add_input_items_to_prompt<'a, I>( - input_with_instructions: &mut Vec, - ledger: &mut ContextLedger, - seen_tool_outputs: &mut std::collections::HashSet, - items: I, -) where - I: IntoIterator, -{ - // Deduplicate function call outputs before adding to input. - for item in items { - if classify_input_item(item) == ContextSourceKind::ToolOutput - && let Some(duplicate_key) = duplicate_key_for_input_item(item) - && !seen_tool_outputs.insert(duplicate_key.clone()) - { - tracing::debug!("Filtering duplicate tool output from input: {duplicate_key}"); - continue; + let mut input = self.input.clone(); + + // when using the *Freeform* apply_patch tool specifically, tool outputs + // should be structured text, not json. Do NOT reserialize when using + // the Function tool - note that this differs from the check above for + // instructions. We declare the result as a named variable for clarity. + let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool { + ToolSpec::Freeform(f) => f.name == "apply_patch", + _ => false, + }); + if is_freeform_apply_patch_tool_present { + reserialize_shell_outputs(&mut input); } - add_response_item_to_ledger(ledger, item); - input_with_instructions.push(item.clone()); - } -} -fn classify_prepend_developer_message(text: &str) -> ContextSourceKind { - if text.contains(" Option { - if classify_prepend_developer_message(text) == ContextSourceKind::AutoReviewLedger { - Some("developer:prepend:auto_review_ledger".to_string()) - } else { - Some(format!("developer:prepend:{:016x}", stable_hash(text))) - } -} +fn reserialize_shell_outputs(items: &mut [ResponseItem]) { + let mut shell_call_ids: HashSet = HashSet::new(); -fn add_response_item_to_ledger(ledger: &mut ContextLedger, item: &ResponseItem) { - if let ResponseItem::Message { role, content, .. } = item { - if role == "user" && UserInstructions::is_user_instructions(content) { - add_user_instructions_to_ledger(ledger, content); - return; - } - } - - ledger.push( - classify_input_item(item), - persistence_for_input_item(item), - label_for_input_item(item), - 1, - response_item_bytes(item), - duplicate_key_for_input_item(item), - ); -} - -fn add_user_instructions_to_ledger(ledger: &mut ContextLedger, content: &[ContentItem]) { - let text = content_text(content); - let skills_marker = "## Skills"; - if let Some((project_docs, skills_manifest)) = text.split_once(skills_marker) { - ledger.push( - ContextSourceKind::UserInstructions, - ContextPersistence::Contextual, - "user/project instructions", - 1, - project_docs.len(), - Some("user_instructions".to_string()), - ); - ledger.push( - ContextSourceKind::SkillsManifest, - ContextPersistence::Contextual, - "skills manifest", - 1, - skills_marker.len() + skills_manifest.len(), - Some("skills_manifest".to_string()), - ); - } else { - ledger.push( - ContextSourceKind::UserInstructions, - ContextPersistence::Contextual, - "user/project instructions", - 1, - text.len(), - Some("user_instructions".to_string()), - ); - } -} - -fn classify_input_item(item: &ResponseItem) -> ContextSourceKind { - match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - classify_user_message(content) - } - ResponseItem::Message { role, .. } if role == "developer" => { - ContextSourceKind::DeveloperInstructions + items.iter_mut().for_each(|item| match item { + ResponseItem::LocalShellCall { call_id, id, .. } => { + if let Some(identifier) = call_id.clone().or_else(|| id.clone()) { + shell_call_ids.insert(identifier); + } } - ResponseItem::Message { .. } => ContextSourceKind::ConversationHistory, - ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } => ContextSourceKind::ToolOutput, - ResponseItem::FunctionCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::ImageGenerationCall { .. } => ContextSourceKind::ConversationHistory, - ResponseItem::Reasoning { .. } - | ResponseItem::GhostSnapshot { .. } - | ResponseItem::CompactionSummary { .. } - | ResponseItem::ContextCompaction { .. } => ContextSourceKind::ConversationHistory, - ResponseItem::Other => ContextSourceKind::Unknown, - } -} - -fn classify_user_message(content: &[ContentItem]) -> ContextSourceKind { - let text = content_text(content); - if text.starts_with("\n") { - ContextSourceKind::ExplicitSkill - } else if text.contains(ENVIRONMENT_CONTEXT_START.trim()) { - ContextSourceKind::EnvironmentContext - } else if UserInstructions::is_user_instructions(content) { - if text.contains("### Available skills") { - ContextSourceKind::SkillsManifest - } else { - ContextSourceKind::UserInstructions + ResponseItem::CustomToolCall { + id: _, + status: _, + call_id, + name, + input: _, + } => { + if name == "apply_patch" { + shell_call_ids.insert(call_id.clone()); + } } - } else if content - .iter() - .any(|item| matches!(item, ContentItem::InputImage { .. })) - { - ContextSourceKind::BrowserStatus - } else { - ContextSourceKind::ConversationHistory - } -} - -fn classify_status_item(item: &ResponseItem) -> ContextSourceKind { - match item { - ResponseItem::Message { content, .. } - if content - .iter() - .any(|item| matches!(item, ContentItem::InputImage { .. })) => + ResponseItem::FunctionCall { name, call_id, .. } + if is_shell_tool_name(name) || name == "apply_patch" => { - ContextSourceKind::BrowserStatus + shell_call_ids.insert(call_id.clone()); } - _ => ContextSourceKind::StatusItem, - } -} - -fn persistence_for_input_item(item: &ResponseItem) -> ContextPersistence { - match classify_input_item(item) { - ContextSourceKind::ExplicitSkill => ContextPersistence::RequestOnly, - ContextSourceKind::DeveloperInstructions - | ContextSourceKind::UserInstructions - | ContextSourceKind::SkillsManifest => ContextPersistence::Contextual, - ContextSourceKind::EnvironmentContext | ContextSourceKind::BrowserStatus => { - ContextPersistence::GeneratedPerAttempt + ResponseItem::FunctionCallOutput { + call_id, output, .. } - ContextSourceKind::ToolOutput => ContextPersistence::ToolResult, - _ => ContextPersistence::Persisted, - } -} - -fn label_for_input_item(item: &ResponseItem) -> &'static str { - match classify_input_item(item) { - ContextSourceKind::ExplicitSkill => "explicit skill", - ContextSourceKind::EnvironmentContext => "environment context", - ContextSourceKind::BrowserStatus => "browser/status context", - ContextSourceKind::AutoReviewLedger => "auto review ledger", - ContextSourceKind::ToolOutput => "tool output", - ContextSourceKind::DeveloperInstructions => "developer message", - ContextSourceKind::UserInstructions => "user/project instructions", - ContextSourceKind::SkillsManifest => "skills manifest", - ContextSourceKind::ConversationHistory => "conversation history", - _ => "input item", - } -} - -fn duplicate_key_for_input_item(item: &ResponseItem) -> Option { - match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - let source = classify_user_message(content); - match source { - ContextSourceKind::ExplicitSkill => skill_name_from_content(content) - .map(|name| format!("explicit_skill:{name}")) - .or_else(|| Some(format!("explicit_skill:{:016x}", stable_hash(&content_text(content))))), - ContextSourceKind::EnvironmentContext => Some("environment_context".to_string()), - ContextSourceKind::UserInstructions | ContextSourceKind::SkillsManifest => { - Some("user_instructions".to_string()) - } - _ => None, + | ResponseItem::CustomToolCallOutput { + call_id, output, .. + } => { + if shell_call_ids.remove(call_id) + && let Some(structured) = output + .text_content() + .and_then(parse_structured_shell_output) + { + output.body = FunctionCallOutputBody::Text(structured); } } - ResponseItem::FunctionCallOutput { call_id, .. } - | ResponseItem::CustomToolCallOutput { call_id, .. } => Some(format!("tool_output:{call_id}")), - ResponseItem::ToolSearchOutput { call_id, .. } => { - call_id.as_ref().map(|call_id| format!("tool_output:{call_id}")) - } - _ => None, - } -} - -fn duplicate_key_for_status_item(item: &ResponseItem) -> Option { - match classify_status_item(item) { - ContextSourceKind::BrowserStatus => Some("browser_status".to_string()), - ContextSourceKind::StatusItem => Some(format!("status:{:016x}", stable_hash(&format!("{item:?}")))), - _ => None, - } -} - -fn content_text(content: &[ContentItem]) -> String { - content - .iter() - .filter_map(|item| match item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - Some(text.as_str()) - } - ContentItem::InputImage { .. } => None, - }) - .collect::>() - .join("\n") + _ => {} + }) } -fn skill_name_from_content(content: &[ContentItem]) -> Option { - let text = content_text(content); - text.lines() - .find_map(|line| line.strip_prefix("")?.strip_suffix("")) - .map(ToOwned::to_owned) +fn is_shell_tool_name(name: &str) -> bool { + matches!(name, "shell" | "container.exec") } -fn stable_hash(value: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - value.hash(&mut hasher); - hasher.finish() +#[derive(Deserialize)] +struct ExecOutputJson { + output: String, + metadata: ExecOutputMetadataJson, } -#[derive(Debug)] -pub enum ResponseEvent { - ContextLedger(ContextLedger), - Created { - response_id: Option, - response_model: Option, - }, - ResponseHeaders(serde_json::Value), - OutputItemDone { item: ResponseItem, sequence_number: Option, output_index: Option }, - /// Indicates that the server will include reasoning content on this stream. - /// - /// Some providers expose this as a handshake header on websocket streams. - ServerReasoningIncluded(bool), - Completed { - response_id: String, - token_usage: Option, - }, - OutputTextDelta { - delta: String, - item_id: Option, - sequence_number: Option, - output_index: Option, - }, - ReasoningSummaryDelta { - delta: String, - item_id: Option, - sequence_number: Option, - output_index: Option, - summary_index: Option, - }, - ReasoningContentDelta { - delta: String, - item_id: Option, - sequence_number: Option, - output_index: Option, - content_index: Option, - }, - ReasoningSummaryPartAdded, - WebSearchCallBegin { - call_id: String, - }, - WebSearchCallCompleted { - call_id: String, - query: Option, - }, - RateLimits(RateLimitSnapshotEvent), - ModelsEtag(String), +#[derive(Deserialize)] +struct ExecOutputMetadataJson { + exit_code: i32, + duration_seconds: f32, } -#[derive(Debug, Serialize)] -pub(crate) struct Reasoning { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) summary: Option, +fn parse_structured_shell_output(raw: &str) -> Option { + let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?; + Some(build_structured_output(&parsed)) } -/// Text configuration for verbosity/format in OpenAI API responses. -#[derive(Debug, Clone)] -pub(crate) struct Text { - pub(crate) verbosity: OpenAiTextVerbosity, - pub(crate) format: Option, -} +fn build_structured_output(parsed: &ExecOutputJson) -> String { + let mut sections = Vec::new(); + sections.push(format!("Exit code: {}", parsed.metadata.exit_code)); + sections.push(format!( + "Wall time: {} seconds", + parsed.metadata.duration_seconds + )); -impl serde::Serialize for Text { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(None)?; - map.serialize_entry("verbosity", &self.verbosity)?; - if let Some(fmt) = &self.format { - map.serialize_entry("format", fmt)?; - } - map.end() + let mut output = parsed.output.clone(); + if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) { + sections.push(format!("Total output lines: {total_lines}")); + output = stripped.to_string(); } -} -/// OpenAI text verbosity level for serialization. -#[derive(Debug, Serialize, Default, Clone, Copy)] -#[serde(rename_all = "lowercase")] -pub(crate) enum OpenAiTextVerbosity { - Low, - #[default] - Medium, - High, -} + sections.push("Output:".to_string()); + sections.push(output); -impl From for OpenAiTextVerbosity { - fn from(verbosity: TextVerbosityConfig) -> Self { - match verbosity { - TextVerbosityConfig::Low => OpenAiTextVerbosity::Low, - TextVerbosityConfig::Medium => OpenAiTextVerbosity::Medium, - TextVerbosityConfig::High => OpenAiTextVerbosity::High, - } - } + sections.join("\n") } -/// Optional structured output format for `text.format` in the Responses API. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct TextFormat { - #[serde(rename = "type")] - pub r#type: String, // e.g. "json_schema" - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub strict: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub schema: Option, +fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { + let after_prefix = output.strip_prefix("Total output lines: ")?; + let (total_segment, remainder) = after_prefix.split_once('\n')?; + let total_lines = total_segment.parse::().ok()?; + let remainder = remainder.strip_prefix('\n').unwrap_or(remainder); + Some((remainder, total_lines)) } -/// Limits the number of screenshots in the input to a maximum of 5. -/// Keeps the first screenshot and the last 4 screenshots. -/// Replaces removed screenshots with a placeholder message. -fn limit_screenshots_in_input(input: &mut Vec) { - // Find all screenshot positions - let mut screenshot_positions = Vec::new(); - - for (idx, item) in input.iter().enumerate() { - if let ResponseItem::Message { content, .. } = item { - let has_screenshot = content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })); - if has_screenshot { - screenshot_positions.push(idx); - } - } - } - - // If we have 5 or fewer screenshots, no action needed - if screenshot_positions.len() <= 5 { - return; - } - - // Determine which screenshots to keep - let mut positions_to_keep = std::collections::HashSet::new(); - - // Keep the first screenshot - if let Some(&first) = screenshot_positions.first() { - positions_to_keep.insert(first); - } - - // Keep the last 4 screenshots - let last_four_start = screenshot_positions.len().saturating_sub(4); - for &pos in &screenshot_positions[last_four_start..] { - positions_to_keep.insert(pos); - } - - // Replace screenshots that should be removed - for &pos in &screenshot_positions { - if !positions_to_keep.contains(&pos) { - if let Some(ResponseItem::Message { content, .. }) = input.get_mut(pos) { - // Replace image content with placeholder message - let mut new_content = Vec::new(); - for item in content.iter() { - match item { - ContentItem::InputImage { .. } => { - new_content.push(ContentItem::InputText { - text: "[screenshot no longer available]".to_string(), - }); - } - other => new_content.push(other.clone()), - } - } - *content = new_content; - } - } - } - - tracing::debug!( - "Limited screenshots from {} to {} (kept first and last 4)", - screenshot_positions.len(), - positions_to_keep.len() - ); -} - -const SPARK_IMAGE_PLACEHOLDER: &str = - "[image omitted: selected -spark model does not support image inputs]"; -const IMAGE_GENERATION_REVISED_PROMPT_MAX_BYTES: usize = 8 * 1024; - -/// Replace `input_image` payloads with text placeholders for models that are -/// known not to accept image inputs. -pub(crate) fn replace_image_payloads_for_model(input: &mut Vec, model_slug: &str) { - if !model_slug.to_ascii_lowercase().contains("-spark") { - return; - } - - for item in input.iter_mut() { - match item { - ResponseItem::Message { content, .. } => { - for content_item in content.iter_mut() { - if matches!(content_item, ContentItem::InputImage { .. }) { - *content_item = ContentItem::InputText { - text: SPARK_IMAGE_PLACEHOLDER.to_string(), - }; - } - } - } - ResponseItem::FunctionCallOutput { output, .. } => { - if let Some(content_items) = output.content_items_mut() { - for output_item in content_items.iter_mut() { - if matches!(output_item, FunctionCallOutputContentItem::InputImage { .. }) { - *output_item = FunctionCallOutputContentItem::InputText { - text: SPARK_IMAGE_PLACEHOLDER.to_string(), - }; - } - } - } - } - _ => {} - } - } -} - -/// Replace upstream `image_generation_call` output items with bounded text when -/// we replay stateless history. -pub(crate) fn rewrite_image_generation_calls_for_input(input: &mut Vec) { - let original_items = std::mem::take(input); - *input = original_items - .into_iter() - .map(|item| match item { - ResponseItem::ImageGenerationCall { - status, - revised_prompt, - result, - .. - } => { - let bytes = result.len(); - let mut text = format!( - "[image generation result omitted from conversation replay; status={status}; {bytes} bytes]" - ); - if let Some(revised_prompt) = revised_prompt { - text.push_str("\nRevised prompt: "); - text.push_str( - &truncate_middle( - &revised_prompt, - IMAGE_GENERATION_REVISED_PROMPT_MAX_BYTES, - ) - .0, - ); - } - - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { text }], - end_turn: None, - phase: None, - } - } - _ => item, - }) - .collect(); -} - -/// Request object that is serialized as JSON and POST'ed when using the -/// Responses API. -#[derive(Debug, Serialize)] -pub(crate) struct ResponsesApiRequest<'a> { - pub(crate) model: &'a str, - pub(crate) instructions: &'a str, - // TODO(mbolin): ResponseItem::Other should not be serialized. Currently, - // we code defensively to avoid this case, but perhaps we should use a - // separate enum for serialization. - pub(crate) input: &'a Vec, - pub(crate) tools: &'a [serde_json::Value], - pub(crate) tool_choice: &'static str, - pub(crate) parallel_tool_calls: bool, - pub(crate) reasoning: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - /// true when using the Responses API. - pub(crate) store: bool, - pub(crate) stream: bool, - pub(crate) include: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) service_tier: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) prompt_cache_key: Option, -} - -pub(crate) fn create_reasoning_param_for_request( - model_family: &ModelFamily, - effort: Option, - summary: ReasoningSummaryConfig, -) -> Option { - if !model_family.supports_reasoning_summaries { - return None; - } - - let summary = match summary { - ReasoningSummaryConfig::Auto => model_family.default_reasoning_summary, - other => other, - }; - - let summary = if summary == ReasoningSummaryConfig::None { - None - } else { - Some(summary) - }; - - Some(Reasoning { effort, summary }) -} - -// Removed legacy TextControls helper; use `Text` with `OpenAiTextVerbosity` instead. - pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, + /// Signals the mapper task that the consumer stopped polling before the + /// provider stream reached its own terminal event. + pub(crate) consumer_dropped: CancellationToken, } impl Stream for ResponseStream { @@ -923,829 +189,12 @@ impl Stream for ResponseStream { } } -#[cfg(test)] -mod tests { - use crate::model_family::find_family_for_model; - use crate::context_ledger::ContextPersistence; - use crate::context_ledger::ContextSourceKind; - use code_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; - use pretty_assertions::assert_eq; - - use super::*; - - fn message(role: &str, text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: role.to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - #[test] - fn context_ledger_classifies_request_sources() { - let model_family = find_family_for_model("gpt-5.1").expect("model family"); - let prompt = Prompt { - input: vec![ - message("user", "hello"), - message( - "user", - "\nmanual-skill\nskills/manual/SKILL.md\nbody\n", - ), - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: code_protocol::models::FunctionCallOutputPayload::from_text( - "tool output".to_string(), - ), - }, - ], - user_instructions: Some( - "project guidance\n\n## Skills\n### Available skills\n- demo: Demo skill".to_string(), - ), - status_items: vec![message("user", "status marker")], - include_additional_instructions: false, - ..Prompt::default() - }; - - let mut formatted = prompt.get_formatted_input(); - rewrite_image_generation_calls_for_input(&mut formatted); - replace_image_payloads_for_model(&mut formatted, "gpt-5.1"); - let tools = vec![serde_json::json!({ - "type": "function", - "name": "demo_tool", - "parameters": {} - })]; - let ledger = prompt.context_ledger_for_request(&model_family, &formatted, &tools); - - assert!(ledger.total_bytes() > 0); - assert!(ledger.total_estimated_tokens() > 0); - assert!(ledger.entries().iter().any(|entry| { - entry.source == ContextSourceKind::BaseInstructions - && entry.persistence == ContextPersistence::Contextual - })); - assert!(ledger.entries().iter().any(|entry| { - entry.source == ContextSourceKind::ExplicitSkill - && entry.persistence == ContextPersistence::RequestOnly - && entry.duplicate_key.as_deref() == Some("explicit_skill:manual-skill") - })); - assert!(ledger.entries().iter().any(|entry| { - entry.source == ContextSourceKind::ToolOutput - && entry.persistence == ContextPersistence::ToolResult - && entry.duplicate_key.as_deref() == Some("tool_output:call-1") - })); - assert!(ledger.entries().iter().any(|entry| { - entry.source == ContextSourceKind::StatusItem - && entry.persistence == ContextPersistence::GeneratedPerAttempt - })); - assert!(ledger.entries().iter().any(|entry| { - entry.source == ContextSourceKind::ToolSchema - && entry.persistence == ContextPersistence::Contextual - })); - } - - #[test] - fn context_ledger_sees_project_doc_skills_manifest() { - let prompt = Prompt { - user_instructions: Some( - "project guidance\n\n## Skills\n### Available skills\n- demo: Demo skill".to_string(), - ), - include_additional_instructions: true, - ..Prompt::default() - }; - - let (formatted, ledger) = prompt.get_formatted_input_with_ledger(); - - assert!(formatted.iter().any(|item| match item { - ResponseItem::Message { content, .. } => { - content.iter().any(|content| matches!(content, - ContentItem::InputText { text } if text.contains("### Available skills") - )) - } - _ => false, - })); - assert!(ledger.entries().iter().any(|entry| { - entry.source == ContextSourceKind::UserInstructions - && entry.duplicate_key.as_deref() == Some("user_instructions") - })); - assert!(ledger.entries().iter().any(|entry| { - entry.source == ContextSourceKind::SkillsManifest - && entry.duplicate_key.as_deref() == Some("skills_manifest") - })); - } - - #[test] - fn stable_project_instructions_precede_volatile_context() { - let prompt = Prompt { - user_instructions: Some( - "project guidance\n\n## Skills\n### Available skills\n- demo: Demo skill".to_string(), - ), - prepend_developer_messages: vec![ - "fresh run state" - .to_string(), - ], - environment_context: Some(EnvironmentContext::new( - Some("/workspace/project".into()), - None, - None, - None, - )), - include_additional_instructions: true, - ..Prompt::default() - }; - - let (formatted, ledger) = prompt.get_formatted_input_with_ledger(); - - let item_kinds = formatted - .iter() - .map(classify_input_item) - .collect::>(); - let user_instructions_pos = item_kinds - .iter() - .position(|kind| { - matches!( - kind, - ContextSourceKind::UserInstructions | ContextSourceKind::SkillsManifest - ) - }) - .expect("stable user/project instructions item"); - let volatile_developer_pos = formatted - .iter() - .position(|item| match item { - ResponseItem::Message { content, .. } => content.iter().any(|content| { - matches!(content, - ContentItem::InputText { text } if text.contains(" false, - }) - .expect("prepended auto-review developer item"); - let environment_pos = item_kinds - .iter() - .position(|kind| *kind == ContextSourceKind::EnvironmentContext) - .expect("environment context item"); - - assert!( - user_instructions_pos < volatile_developer_pos, - "stable project instructions should precede volatile prepended developer context" - ); - assert!( - user_instructions_pos < environment_pos, - "stable project instructions should precede generated environment context" - ); - - let ledger_kinds = ledger - .entries() - .iter() - .map(|entry| entry.source) - .collect::>(); - let skills_pos = ledger_kinds - .iter() - .position(|kind| *kind == ContextSourceKind::SkillsManifest) - .expect("skills manifest ledger entry"); - let auto_review_pos = ledger_kinds - .iter() - .position(|kind| *kind == ContextSourceKind::AutoReviewLedger) - .expect("auto review ledger entry"); - - assert!( - skills_pos < auto_review_pos, - "stable skills manifest should precede volatile auto-review ledger" - ); - } - - #[test] - fn volatile_context_does_not_break_static_prefix() { - fn formatted_text(prompt: &Prompt) -> String { - prompt - .get_formatted_input() - .iter() - .map(|item| match item { - ResponseItem::Message { role, content, .. } => { - let text = content - .iter() - .filter_map(|content| match content { - ContentItem::InputText { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - format!("{role}:{text}") - } - _ => format!("{item:?}"), - }) - .collect::>() - .join("\n---\n") - } - - fn prompt_with(ledger: &str, cwd: &str) -> Prompt { - Prompt { - user_instructions: Some( - "project guidance\n\n## Skills\n### Available skills\n- demo: Demo skill".to_string(), - ), - prepend_developer_messages: vec![format!( - "{ledger}" - )], - environment_context: Some(EnvironmentContext::new( - Some(cwd.into()), - None, - None, - None, - )), - include_additional_instructions: true, - ..Prompt::default() - } - } - - let first = formatted_text(&prompt_with( - "first volatile state", - "/Users/me/.code/working/code/branches/feature-one", - )); - let second = formatted_text(&prompt_with( - "second volatile state", - "/Users/me/.code/working/code/branches/feature-two", - )); - let common_prefix_len = first - .bytes() - .zip(second.bytes()) - .take_while(|(left, right)| left == right) - .count(); - let static_marker_end = first - .find("### Available skills") - .expect("skills marker") - + "### Available skills".len(); - - assert!( - common_prefix_len >= static_marker_end, - "volatile context should not appear before the static AGENTS/skills prefix" - ); - } - - #[test] - fn managed_worktree_subdirectories_do_not_break_static_prefix() { - fn rendered_user_instructions(prompt: &Prompt) -> String { - prompt - .get_formatted_input() - .iter() - .find_map(|item| match item { - ResponseItem::Message { content, .. } => content.iter().find_map(|content| { - match content { - ContentItem::InputText { text } - if text.starts_with("# AGENTS.md instructions for ") => - { - Some(text.clone()) - } - _ => None, - } - }), - _ => None, - }) - .expect("user instructions") - } - - fn prompt_with(cwd: &str) -> Prompt { - Prompt { - user_instructions: Some( - "project guidance\n\n## Skills\n### Available skills\n- demo: Demo skill".to_string(), - ), - environment_context: Some(EnvironmentContext::new( - Some(cwd.into()), - None, - None, - None, - )), - include_additional_instructions: true, - ..Prompt::default() - } - } - - let first = rendered_user_instructions(&prompt_with( - "/Users/me/.code/working/code/branches/feature-one/code-rs/core", - )); - let second = rendered_user_instructions(&prompt_with( - "/Users/me/.code/working/code/branches/feature-two/code-rs/core", - )); - - assert_eq!(first, second); - assert!(first.starts_with( - "# AGENTS.md instructions for /.code/working/code/branches//code-rs/core" - )); - } - - #[test] - fn volatile_context_sits_between_history_and_current_turn() { - fn formatted_text(prompt: &Prompt) -> String { - prompt - .get_formatted_input() - .iter() - .map(|item| match item { - ResponseItem::Message { role, content, .. } => { - let text = content - .iter() - .filter_map(|content| match content { - ContentItem::InputText { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - format!("{role}:{text}") - } - _ => format!("{item:?}"), - }) - .collect::>() - .join("\n---\n") - } - - let prompt = Prompt { - input: vec![ - message("user", "old stable user turn"), - message("user", "live current turn"), - ], - volatile_context_insert_at: Some(1), - user_instructions: Some( - "project guidance\n\n## Skills\n### Available skills\n- demo: Demo skill".to_string(), - ), - prepend_developer_messages: vec![ - "fresh run state" - .to_string(), - ], - environment_context: Some(EnvironmentContext::new( - Some("/workspace/project".into()), - None, - None, - None, - )), - include_additional_instructions: true, - ..Prompt::default() - }; - - let rendered = formatted_text(&prompt); - let skills_pos = rendered.find("### Available skills").expect("skills"); - let history_pos = rendered - .find("old stable user turn") - .expect("stable history"); - let ledger_pos = rendered.find("").expect("environment"); - let current_pos = rendered.find("live current turn").expect("current turn"); - - assert!(skills_pos < history_pos); - assert!(history_pos < env_pos); - assert!(env_pos < ledger_pos); - assert!(ledger_pos < current_pos); - } - - #[test] - fn volatile_context_items_sit_between_history_and_current_turn() { - let prompt = Prompt { - input: vec![ - message("user", "old stable user turn"), - message("user", "live current turn"), - ], - volatile_context_insert_at: Some(1), - volatile_context_items: vec![message("user", "timeline env delta")], - include_additional_instructions: false, - ..Prompt::default() - }; - - let rendered = prompt - .get_formatted_input() - .iter() - .map(|item| match item { - ResponseItem::Message { role, content, .. } => { - let text = content - .iter() - .filter_map(|content| match content { - ContentItem::InputText { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - format!("{role}:{text}") - } - _ => format!("{item:?}"), - }) - .collect::>() - .join("\n---\n"); - - let history_pos = rendered - .find("old stable user turn") - .expect("stable history"); - let timeline_pos = rendered.find("timeline env delta").expect("timeline"); - let current_pos = rendered.find("live current turn").expect("current turn"); - - assert!(history_pos < timeline_pos); - assert!(timeline_pos < current_pos); - } - - #[test] - fn replace_image_payloads_for_spark_model_rewrites_images() { - let mut input = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputText { - text: "Please inspect this".to_string(), - }, - ContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - }, - ], - end_turn: None, - phase: None, - }, - ResponseItem::FunctionCallOutput { - call_id: "call_1".to_string(), - output: code_protocol::models::FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,BBB".to_string(), - detail: None, - }, - ]), - }, - ]; - - replace_image_payloads_for_model(&mut input, "gpt-5.3-codex-spark"); - - assert!(matches!( - &input[0], - ResponseItem::Message { content, .. } - if matches!( - content.get(1), - Some(ContentItem::InputText { text }) if text == SPARK_IMAGE_PLACEHOLDER - ) - )); - - assert!(matches!( - &input[1], - ResponseItem::FunctionCallOutput { output, .. } - if matches!( - output.content_items().and_then(|items| items.first()), - Some(FunctionCallOutputContentItem::InputText { text }) - if text == SPARK_IMAGE_PLACEHOLDER - ) - )); - } - - #[test] - fn replace_image_payloads_for_non_spark_model_keeps_images() { - let mut input = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - }], - end_turn: None, - phase: None, - }]; - - replace_image_payloads_for_model(&mut input, "gpt-5.3-codex"); - - assert!(matches!( - &input[0], - ResponseItem::Message { content, .. } - if matches!(content.first(), Some(ContentItem::InputImage { .. })) - )); - } - - #[test] - fn rewrite_image_generation_calls_for_input_converts_to_bounded_assistant_message() { - let mut input = vec![ResponseItem::ImageGenerationCall { - id: "ig_1".to_string(), - status: "completed".to_string(), - revised_prompt: Some("a tidy diagram".to_string()), - result: "Zm9v".to_string(), - }]; - - rewrite_image_generation_calls_for_input(&mut input); - - assert_eq!(input.len(), 1); - assert!(matches!( - &input[0], - ResponseItem::Message { role, content, .. } - if role == "assistant" - && matches!( - content.first(), - Some(ContentItem::OutputText { text }) - if text.contains("image generation result omitted") - && text.contains("status=completed") - && text.contains("4 bytes") - && text.contains("a tidy diagram") - && !text.contains("Zm9v") - ) - )); - } - - #[test] - fn old_image_generation_results_are_not_replayed_as_full_images() { - let mut input = vec![ - ResponseItem::ImageGenerationCall { - id: "ig_old".to_string(), - status: "completed".to_string(), - revised_prompt: None, - result: "A".repeat(64 * 1024), - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "continue".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - - rewrite_image_generation_calls_for_input(&mut input); - - assert!(matches!( - &input[0], - ResponseItem::Message { content, .. } - if matches!( - content.first(), - Some(ContentItem::OutputText { text }) - if text.contains("image generation result omitted") - && text.contains("65536 bytes") - && !text.contains("data:image") - && !text.contains(&"A".repeat(1024)) - ) - )); - } - - struct InstructionsTestCase { - pub slug: &'static str, - pub expects_apply_patch_instructions: bool, - } - #[test] - fn get_full_instructions_no_user_content() { - let prompt = Prompt { - ..Default::default() - }; - let test_cases = vec![ - InstructionsTestCase { - slug: "gpt-3.5", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4.1", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4o", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-5.1", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "codex-mini-latest", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-oss:120b", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex", - expects_apply_patch_instructions: false, - }, - ]; - for test_case in test_cases { - let model_family = find_family_for_model(test_case.slug).expect("known model slug"); - let full = prompt.get_full_instructions(&model_family); - assert_eq!(full, model_family.base_instructions); - if test_case.expects_apply_patch_instructions { - assert!( - full.contains(APPLY_PATCH_TOOL_INSTRUCTIONS), - "expected apply_patch instructions for {}", - test_case.slug - ); - } else { - assert!( - !full.contains(APPLY_PATCH_TOOL_INSTRUCTIONS), - "did not expect apply_patch instructions for {}", - test_case.slug - ); - } - } - } - - #[test] - fn volatile_context_follows_history_and_precedes_current_turn() { - use std::path::PathBuf; - - let mut prompt = Prompt::default(); - prompt.input.push(message("user", "stable history")); - prompt.input.push(message("user", "current turn")); - prompt.volatile_context_insert_at = Some(1); - prompt.environment_context = Some(EnvironmentContext::new( - Some(PathBuf::from("/workspace")), - None, - None, - None, - )); - let coordinator_text = "Coordinator guidance"; - prompt - .prepend_developer_messages - .push(coordinator_text.to_string()); - - let formatted = prompt.get_formatted_input(); - let rendered = formatted - .iter() - .map(|item| match item { - ResponseItem::Message { role, content, .. } => { - let text = content - .iter() - .filter_map(|content| match content { - ContentItem::InputText { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - format!("{role}:{text}") - } - _ => format!("{item:?}"), - }) - .collect::>() - .join("\n---\n"); - - let history_pos = rendered.find("stable history").expect("history"); - let env_pos = rendered.find("").expect("environment"); - let coordinator_pos = rendered.find(coordinator_text).expect("developer context"); - let current_pos = rendered.find("current turn").expect("current turn"); - - assert!(history_pos < env_pos); - assert!(env_pos < coordinator_pos); - assert!(coordinator_pos < current_pos); - } - - #[test] - fn duplicate_tool_outputs_are_filtered_across_prompt_split() { - let prompt = Prompt { - input: vec![ResponseItem::CustomToolCallOutput { - call_id: "call-1".to_string(), - name: None, - output: code_protocol::models::FunctionCallOutputPayload::from_text( - "first".to_string(), - ), - }], - volatile_context_insert_at: Some(1), - status_items: vec![ResponseItem::CustomToolCallOutput { - call_id: "call-status".to_string(), - name: None, - output: code_protocol::models::FunctionCallOutputPayload::from_text( - "status output is not part of split dedupe".to_string(), - ), - }], - ..Prompt::default() - }; - let mut prompt_with_duplicate = prompt.clone(); - prompt_with_duplicate.input.push(ResponseItem::CustomToolCallOutput { - call_id: "call-1".to_string(), - name: None, - output: code_protocol::models::FunctionCallOutputPayload::from_text( - "duplicate".to_string(), - ), - }); - - let formatted = prompt_with_duplicate.get_formatted_input(); - let matching_outputs = formatted - .iter() - .filter(|item| { - matches!(item, ResponseItem::CustomToolCallOutput { call_id, .. } if call_id == "call-1") - }) - .count(); - - assert_eq!(matching_outputs, 1); - } - - #[test] - fn auto_review_ledger_developer_message_has_distinct_context_source() { - let mut prompt = Prompt::default(); - prompt.prepend_developer_messages.push( - "\nrun id=abc status=Reviewing\n" - .to_string(), - ); - - let (_input, ledger) = prompt.get_formatted_input_with_ledger(); - let entry = ledger - .entries() - .iter() - .find(|entry| entry.source == ContextSourceKind::AutoReviewLedger) - .expect("auto review ledger entry"); - assert_eq!(entry.label, "prepended developer message"); - assert_eq!(entry.persistence, ContextPersistence::RequestOnly); - assert_eq!(entry.duplicate_key.as_deref(), Some("developer:prepend:auto_review_ledger")); - } - - #[test] - fn serializes_text_verbosity_when_set() { - let input: Vec = vec![]; - let tools: Vec = vec![]; - let req = ResponsesApiRequest { - model: "gpt-5.1", - instructions: "i", - input: &input, - tools: &tools, - tool_choice: "auto", - parallel_tool_calls: false, - reasoning: None, - store: false, - stream: true, - include: vec![], - service_tier: None, - prompt_cache_key: None, - text: Some(Text { verbosity: OpenAiTextVerbosity::Low, format: None }), - }; - - let v = serde_json::to_value(&req).expect("json"); - assert_eq!( - v.get("text") - .and_then(|t| t.get("verbosity")) - .and_then(|s| s.as_str()), - Some("low") - ); - } - - #[test] - fn serializes_text_schema_with_strict_format() { - let input: Vec = vec![]; - let tools: Vec = vec![]; - let schema = serde_json::json!({ - "type": "object", - "properties": { - "answer": {"type": "string"} - }, - "required": ["answer"], - }); - let req = ResponsesApiRequest { - model: "gpt-5.1", - instructions: "i", - input: &input, - tools: &tools, - tool_choice: "auto", - parallel_tool_calls: false, - reasoning: None, - store: false, - stream: true, - include: vec![], - service_tier: None, - prompt_cache_key: None, - text: Some(Text { - verbosity: OpenAiTextVerbosity::Medium, - format: Some(TextFormat { - r#type: "json_schema".to_string(), - name: Some("code_output_schema".to_string()), - strict: Some(true), - schema: Some(schema.clone()), - }), - }), - }; - - let v = serde_json::to_value(&req).expect("json"); - let text = v.get("text").expect("text field"); - assert_eq!( - text.get("verbosity").and_then(|v| v.as_str()), - Some("medium") - ); - let format = text.get("format").expect("format field"); - - assert_eq!( - format.get("name"), - Some(&serde_json::Value::String("code_output_schema".into())) - ); - assert_eq!( - format.get("type"), - Some(&serde_json::Value::String("json_schema".into())) - ); - assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true))); - assert_eq!(format.get("schema"), Some(&schema)); - } - - #[test] - fn omits_text_when_not_set() { - let input: Vec = vec![]; - let tools: Vec = vec![]; - let req = ResponsesApiRequest { - model: "gpt-5.1", - instructions: "i", - input: &input, - tools: &tools, - tool_choice: "auto", - parallel_tool_calls: false, - reasoning: None, - store: false, - stream: true, - include: vec![], - service_tier: None, - prompt_cache_key: None, - text: None, - }; - - let v = serde_json::to_value(&req).expect("json"); - assert!(v.get("text").is_none()); +impl Drop for ResponseStream { + fn drop(&mut self) { + self.consumer_dropped.cancel(); } } + +#[cfg(test)] +#[path = "client_common_tests.rs"] +mod tests; diff --git a/code-rs/core/src/client_common_tests.rs b/code-rs/core/src/client_common_tests.rs new file mode 100644 index 00000000000..f67e4f1fd8b --- /dev/null +++ b/code-rs/core/src/client_common_tests.rs @@ -0,0 +1,230 @@ +use codex_api::OpenAiVerbosity; +use codex_api::ResponsesApiRequest; +use codex_api::TextControls; +use codex_api::create_text_param_for_request; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::FunctionCallOutputPayload; +use pretty_assertions::assert_eq; + +use super::*; + +#[test] +fn serializes_text_verbosity_when_set() { + let input: Vec = vec![]; + let tools: Vec = vec![]; + let req = ResponsesApiRequest { + model: "gpt-5.4".to_string(), + instructions: "i".to_string(), + input, + tools, + tool_choice: "auto".to_string(), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: vec![], + prompt_cache_key: None, + service_tier: None, + text: Some(TextControls { + verbosity: Some(OpenAiVerbosity::Low), + format: None, + }), + client_metadata: None, + }; + + let v = serde_json::to_value(&req).expect("json"); + assert_eq!( + v.get("text") + .and_then(|t| t.get("verbosity")) + .and_then(|s| s.as_str()), + Some("low") + ); +} + +#[test] +fn serializes_text_schema_with_strict_format() { + let input: Vec = vec![]; + let tools: Vec = vec![]; + let schema = serde_json::json!({ + "type": "object", + "properties": { + "answer": {"type": "string"} + }, + "required": ["answer"], + }); + let text_controls = create_text_param_for_request( + /*verbosity*/ None, + &Some(schema.clone()), + /*output_schema_strict*/ true, + ) + .expect("text controls"); + + let req = ResponsesApiRequest { + model: "gpt-5.4".to_string(), + instructions: "i".to_string(), + input, + tools, + tool_choice: "auto".to_string(), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: vec![], + prompt_cache_key: None, + service_tier: None, + text: Some(text_controls), + client_metadata: None, + }; + + let v = serde_json::to_value(&req).expect("json"); + let text = v.get("text").expect("text field"); + assert!(text.get("verbosity").is_none()); + let format = text.get("format").expect("format field"); + + assert_eq!( + format.get("name"), + Some(&serde_json::Value::String("codex_output_schema".into())) + ); + assert_eq!( + format.get("type"), + Some(&serde_json::Value::String("json_schema".into())) + ); + assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true))); + assert_eq!(format.get("schema"), Some(&schema)); +} + +#[test] +fn serializes_text_schema_with_non_strict_format() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "answer": {"type": "string"}, + "rationale": {"type": "string"} + }, + "required": ["answer"], + "additionalProperties": false + }); + let text_controls = create_text_param_for_request( + /*verbosity*/ None, + &Some(schema.clone()), + /*output_schema_strict*/ false, + ) + .expect("text controls"); + + let format = text_controls.format.expect("format field"); + assert!(!format.strict); + assert_eq!(format.schema, schema); +} + +#[test] +fn omits_text_when_not_set() { + let input: Vec = vec![]; + let tools: Vec = vec![]; + let req = ResponsesApiRequest { + model: "gpt-5.4".to_string(), + instructions: "i".to_string(), + input, + tools, + tool_choice: "auto".to_string(), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: vec![], + prompt_cache_key: None, + service_tier: None, + text: None, + client_metadata: None, + }; + + let v = serde_json::to_value(&req).expect("json"); + assert!(v.get("text").is_none()); +} + +#[test] +fn serializes_flex_service_tier_when_set() { + let req = ResponsesApiRequest { + model: "gpt-5.4".to_string(), + instructions: "i".to_string(), + input: vec![], + tools: vec![], + tool_choice: "auto".to_string(), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: vec![], + prompt_cache_key: None, + service_tier: Some(ServiceTier::Flex.to_string()), + text: None, + client_metadata: None, + }; + + let v = serde_json::to_value(&req).expect("json"); + assert_eq!( + v.get("service_tier").and_then(|tier| tier.as_str()), + Some("flex") + ); +} + +#[test] +fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { + let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#; + let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello"; + let mut items = vec![ + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text(raw_output.to_string()), + }, + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-2".to_string(), + name: "apply_patch".to_string(), + input: "*** Begin Patch".to_string(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "call-2".to_string(), + name: None, + output: FunctionCallOutputPayload::from_text(raw_output.to_string()), + }, + ]; + + reserialize_shell_outputs(&mut items); + + assert_eq!( + items, + vec![ + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text(expected_output.to_string()), + }, + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-2".to_string(), + name: "apply_patch".to_string(), + input: "*** Begin Patch".to_string(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "call-2".to_string(), + name: None, + output: FunctionCallOutputPayload::from_text(expected_output.to_string()), + }, + ] + ); +} diff --git a/code-rs/core/src/client_tests.rs b/code-rs/core/src/client_tests.rs new file mode 100644 index 00000000000..2ba65d7c453 --- /dev/null +++ b/code-rs/core/src/client_tests.rs @@ -0,0 +1,468 @@ +use super::AuthRequestTelemetryContext; +use super::ModelClient; +use super::PendingUnauthorizedRetry; +use super::UnauthorizedRecoveryExecution; +use super::X_CODEX_INSTALLATION_ID_HEADER; +use super::X_CODEX_PARENT_THREAD_ID_HEADER; +use super::X_CODEX_TURN_METADATA_HEADER; +use super::X_CODEX_WINDOW_ID_HEADER; +use super::X_OPENAI_SUBAGENT_HEADER; +use codex_api::ApiError; +use codex_api::ResponseEvent; +use codex_app_server_protocol::AuthMode; +use codex_model_provider::BearerAuthProvider; +use codex_model_provider_info::WireApi; +use codex_model_provider_info::create_oss_provider_with_base_url; +use codex_otel::SessionTelemetry; +use codex_protocol::ThreadId; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::protocol::InternalSessionSource; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_rollout_trace::ExecutionStatus; +use codex_rollout_trace::InferenceTraceAttempt; +use codex_rollout_trace::InferenceTraceContext; +use codex_rollout_trace::RawTraceEventPayload; +use codex_rollout_trace::RolloutTrace; +use codex_rollout_trace::TraceWriter; +use codex_rollout_trace::replay_bundle; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::Mutex; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; +use tempfile::TempDir; +use tokio::sync::Notify; +use tracing::Event; +use tracing::Subscriber; +use tracing::field::Visit; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context as LayerContext; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::util::SubscriberInitExt; + +fn test_model_client(session_source: SessionSource) -> ModelClient { + let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); + let thread_id = ThreadId::new(); + ModelClient::new( + /*auth_manager*/ None, + thread_id.into(), + thread_id, + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), + provider, + session_source, + /*model_verbosity*/ None, + /*enable_request_compression*/ false, + /*include_timing_metrics*/ false, + /*beta_features_header*/ None, + ) +} + +fn test_model_info() -> ModelInfo { + serde_json::from_value(json!({ + "slug": "gpt-test", + "display_name": "gpt-test", + "description": "desc", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + {"effort": "medium", "description": "medium"} + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 1, + "upgrade": null, + "base_instructions": "base instructions", + "model_messages": null, + "supports_reasoning_summaries": false, + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "truncation_policy": {"mode": "bytes", "limit": 10000}, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, + "context_window": 272000, + "auto_compact_token_limit": null, + "experimental_supported_tools": [] + })) + .expect("deserialize test model info") +} + +fn test_session_telemetry() -> SessionTelemetry { + SessionTelemetry::new( + ThreadId::new(), + "gpt-test", + "gpt-test", + /*account_id*/ None, + /*account_email*/ None, + /*auth_mode*/ None, + "test-originator".to_string(), + /*log_user_prompts*/ false, + "test-terminal".to_string(), + SessionSource::Cli, + ) +} + +#[derive(Default)] +struct TagCollectorVisitor { + tags: BTreeMap, +} + +impl Visit for TagCollectorVisitor { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.tags + .insert(field.name().to_string(), value.to_string()); + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.tags + .insert(field.name().to_string(), format!("{value:?}")); + } +} + +#[derive(Clone)] +struct TagCollectorLayer { + tags: Arc>>, +} + +impl Layer for TagCollectorLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { + if event.metadata().target() != "feedback_tags" { + return; + } + let mut visitor = TagCollectorVisitor::default(); + event.record(&mut visitor); + self.tags.lock().unwrap().extend(visitor.tags); + } +} + +fn started_inference_attempt(temp: &TempDir) -> anyhow::Result { + let writer = Arc::new(TraceWriter::create( + temp.path(), + "trace-1".to_string(), + "rollout-1".to_string(), + "thread-root".to_string(), + )?); + writer.append(RawTraceEventPayload::ThreadStarted { + thread_id: "thread-root".to_string(), + agent_path: "/root".to_string(), + metadata_payload: None, + })?; + writer.append(RawTraceEventPayload::CodexTurnStarted { + codex_turn_id: "turn-1".to_string(), + thread_id: "thread-root".to_string(), + })?; + + let inference_trace = InferenceTraceContext::enabled( + writer, + "thread-root".to_string(), + "turn-1".to_string(), + "gpt-test".to_string(), + "test-provider".to_string(), + ); + let attempt = inference_trace.start_attempt(); + attempt.record_started(&json!({ + "model": "gpt-test", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}] + }], + })); + Ok(attempt) +} + +fn output_message(id: &str, text: &str) -> ResponseItem { + ResponseItem::Message { + id: Some(id.to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + phase: None, + } +} + +async fn replay_until_cancelled(temp: &TempDir) -> anyhow::Result { + let mut rollout = replay_bundle(temp.path())?; + for _ in 0..50 { + let inference = rollout + .inference_calls + .values() + .next() + .expect("inference should be reduced"); + if inference.execution.status == ExecutionStatus::Cancelled { + return Ok(rollout); + } + tokio::time::sleep(Duration::from_millis(10)).await; + rollout = replay_bundle(temp.path())?; + } + Ok(rollout) +} + +struct NotifyAfterEventStream { + events: VecDeque, + yielded: usize, + notify_after: usize, + notify: Arc, +} + +impl futures::Stream for NotifyAfterEventStream { + type Item = std::result::Result; + + fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + let Some(event) = self.events.pop_front() else { + return Poll::Pending; + }; + self.yielded += 1; + if self.yielded == self.notify_after { + self.notify.notify_one(); + } + Poll::Ready(Some(Ok(event))) + } +} + +#[test] +fn build_subagent_headers_sets_other_subagent_label() { + let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other( + "memory_consolidation".to_string(), + ))); + let headers = client.build_subagent_headers(); + let value = headers + .get(X_OPENAI_SUBAGENT_HEADER) + .and_then(|value| value.to_str().ok()); + assert_eq!(value, Some("memory_consolidation")); +} + +#[test] +fn build_subagent_headers_sets_internal_memory_consolidation_label() { + let client = test_model_client(SessionSource::Internal( + InternalSessionSource::MemoryConsolidation, + )); + let headers = client.build_subagent_headers(); + let value = headers + .get(X_OPENAI_SUBAGENT_HEADER) + .and_then(|value| value.to_str().ok()); + assert_eq!(value, Some("memory_consolidation")); +} + +#[test] +fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { + let parent_thread_id = ThreadId::new(); + let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: None, + })); + + client.advance_window_generation(); + + let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); + let thread_id = client.state.thread_id; + assert_eq!( + client_metadata, + std::collections::HashMap::from([ + ( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + "11111111-1111-4111-8111-111111111111".to_string(), + ), + ( + X_CODEX_WINDOW_ID_HEADER.to_string(), + format!("{thread_id}:1"), + ), + ( + X_OPENAI_SUBAGENT_HEADER.to_string(), + "collab_spawn".to_string(), + ), + ( + X_CODEX_PARENT_THREAD_ID_HEADER.to_string(), + parent_thread_id.to_string(), + ), + ( + X_CODEX_TURN_METADATA_HEADER.to_string(), + r#"{"turn_id":"turn-123"}"#.to_string(), + ), + ]) + ); +} + +#[tokio::test] +async fn summarize_memories_returns_empty_for_empty_input() { + let client = test_model_client(SessionSource::Cli); + let model_info = test_model_info(); + let session_telemetry = test_session_telemetry(); + + let output = client + .summarize_memories( + Vec::new(), + &model_info, + /*effort*/ None, + &session_telemetry, + ) + .await + .expect("empty summarize request should succeed"); + assert_eq!(output.len(), 0); +} + +#[tokio::test] +async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let attempt = started_inference_attempt(&temp)?; + + // The provider has produced one complete output item, but no terminal + // response.completed event. The harness has enough information to keep this + // item in history, so the trace should preserve it when the stream is + // abandoned. + let item = output_message("msg-1", "partial answer"); + let api_stream = futures::stream::iter([Ok(ResponseEvent::OutputItemDone(item))]) + .chain(futures::stream::pending()); + let (mut stream, _) = super::map_response_events( + /*upstream_request_id*/ None, + api_stream, + test_session_telemetry(), + attempt, + ); + + let observed = stream + .next() + .await + .expect("mapped stream should yield output item")?; + assert!(matches!(observed, ResponseEvent::OutputItemDone(_))); + + // Dropping the consumer is how turn interruption/preemption stops polling + // the provider stream. The mapper task observes that drop asynchronously + // and records cancellation using the output items it has already seen. + drop(stream); + + // Cancellation is recorded by the mapper task after Drop wakes it, so the + // replay may need a short wait before the terminal event appears on disk. + let rollout = replay_until_cancelled(&temp).await?; + let inference = rollout + .inference_calls + .values() + .next() + .expect("inference should be reduced"); + + assert_eq!(inference.execution.status, ExecutionStatus::Cancelled); + assert_eq!(inference.response_item_ids.len(), 1); + assert_eq!(rollout.raw_payloads.len(), 2); + + Ok(()) +} + +#[tokio::test] +async fn response_stream_records_last_model_feedback_ids() { + let tags = Arc::new(Mutex::new(BTreeMap::new())); + let _guard = tracing_subscriber::registry() + .with(TagCollectorLayer { tags: tags.clone() }) + .set_default(); + + let api_stream = futures::stream::iter([ + Ok(ResponseEvent::Created), + Ok(ResponseEvent::Completed { + response_id: "resp-123".to_string(), + token_usage: None, + end_turn: Some(true), + }), + ]); + let (mut stream, _) = super::map_response_events( + Some("req-123".to_string()), + api_stream, + test_session_telemetry(), + InferenceTraceAttempt::disabled(), + ); + + while stream.next().await.is_some() {} + + let tags = tags.lock().unwrap().clone(); + assert_eq!( + tags.get("last_model_request_id").map(String::as_str), + Some("\"req-123\"") + ); + assert_eq!( + tags.get("last_model_response_id").map(String::as_str), + Some("\"resp-123\"") + ); +} + +#[tokio::test] +async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() +-> anyhow::Result<()> { + let temp = TempDir::new()?; + let attempt = started_inference_attempt(&temp)?; + let backpressured_item_yielded = Arc::new(Notify::new()); + let mut events = VecDeque::new(); + for _ in 0..super::RESPONSE_STREAM_CHANNEL_CAPACITY { + events.push_back(ResponseEvent::Created); + } + events.push_back(ResponseEvent::OutputItemDone(output_message( + "msg-1", + "partial answer", + ))); + let api_stream = NotifyAfterEventStream { + events, + yielded: 0, + notify_after: super::RESPONSE_STREAM_CHANNEL_CAPACITY + 1, + notify: Arc::clone(&backpressured_item_yielded), + }; + + let (stream, _) = super::map_response_events( + /*upstream_request_id*/ None, + api_stream, + test_session_telemetry(), + attempt, + ); + + // Fill the mapper channel with non-terminal events, then yield one output + // item. The mapper has observed that item and is blocked trying to send it + // downstream, so dropping the consumer covers the send-failure path rather + // than the `consumer_dropped` select branch. + backpressured_item_yielded.notified().await; + drop(stream); + + let rollout = replay_until_cancelled(&temp).await?; + let inference = rollout + .inference_calls + .values() + .next() + .expect("inference should be reduced"); + + assert_eq!(inference.execution.status, ExecutionStatus::Cancelled); + assert_eq!(inference.response_item_ids.len(), 1); + assert_eq!(rollout.raw_payloads.len(), 2); + + Ok(()) +} + +#[test] +fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() { + let auth_context = AuthRequestTelemetryContext::new( + Some(AuthMode::Chatgpt), + &BearerAuthProvider::for_test(Some("access-token"), Some("workspace-123")), + PendingUnauthorizedRetry::from_recovery(UnauthorizedRecoveryExecution { + mode: "managed", + phase: "refresh_token", + }), + ); + + assert_eq!(auth_context.auth_mode, Some("Chatgpt")); + assert!(auth_context.auth_header_attached); + assert_eq!(auth_context.auth_header_name, Some("authorization")); + assert!(auth_context.retry_after_unauthorized); + assert_eq!(auth_context.recovery_mode, Some("managed")); + assert_eq!(auth_context.recovery_phase, Some("refresh_token")); +} diff --git a/code-rs/core/src/code_conversation.rs b/code-rs/core/src/code_conversation.rs deleted file mode 100644 index d3b00046fc0..00000000000 --- a/code-rs/core/src/code_conversation.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::codex::Codex; -use crate::error::Result as CodexResult; -use crate::protocol::Event; -use crate::protocol::Op; -use crate::protocol::Submission; - -pub struct CodexConversation { - codex: Codex, -} - -/// Conduit for the bidirectional stream of messages that compose a conversation -/// in Codex. -impl CodexConversation { - pub(crate) fn new(codex: Codex) -> Self { - Self { codex } - } - - pub async fn submit(&self, op: Op) -> CodexResult { - self.codex.submit(op).await - } - - /// Use sparingly: this is intended to be removed soon. - pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { - self.codex.submit_with_id(sub).await - } - - pub async fn next_event(&self) -> CodexResult { - self.codex.next_event().await - } -} diff --git a/code-rs/core/src/codex.rs b/code-rs/core/src/codex.rs deleted file mode 100644 index 7ed6b445078..00000000000 --- a/code-rs/core/src/codex.rs +++ /dev/null @@ -1,1328 +0,0 @@ -// Poisoned mutex should fail the program -#![allow(clippy::unwrap_used)] - -use std::borrow::Cow; -use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::VecDeque; -use std::path::Component; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::RwLock; -use std::sync::Weak; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::{Duration, Instant}; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -use async_channel::Receiver; -use async_channel::Sender; -use base64::Engine; -use code_apply_patch::ApplyPatchAction; -use code_apply_patch::MaybeApplyPatchVerified; -use crate::bridge_client::spawn_bridge_listener; -use code_browser::BrowserConfig as CodexBrowserConfig; -use code_browser::BrowserManager; -use code_otel::otel_event_manager::{ - OtelEventManager, - ToolDecisionSource, - TurnLatencyPayload, - TurnLatencyPhase, -}; -use code_protocol::config_types::ReasoningEffort as ProtoReasoningEffort; -use code_protocol::config_types::ReasoningSummary as ProtoReasoningSummary; -use code_utils_absolute_path::AbsolutePathBuf as ProtoAbsolutePathBuf; -use code_protocol::protocol::AskForApproval as ProtoAskForApproval; -use code_protocol::protocol::RejectConfig as ProtoRejectConfig; -use code_protocol::protocol::ReviewDecision as ProtoReviewDecision; -use code_protocol::protocol::SandboxPolicy as ProtoSandboxPolicy; -use code_protocol::protocol::BROWSER_SNAPSHOT_OPEN_TAG; -use code_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; -use code_protocol::protocol::ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG; -use code_protocol::protocol::ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG; -use code_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; -use crate::config_types::ReasoningEffort as ReasoningEffortConfig; -use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; -use crate::config_types::ClientTools; -// unused: AuthManager -// unused: ConversationHistoryResponseEvent -use code_protocol::protocol::TurnAbortReason; -use code_protocol::protocol::TurnAbortedEvent; -use futures::prelude::*; -use code_protocol::mcp::CallToolResult; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tokio::sync::oneshot; -use tokio::task::AbortHandle; -use tracing::debug; -use tracing::error; -use tracing::info; -use tracing::trace; -use tracing::warn; -use uuid::Uuid; -use crate::EnvironmentContextEmission; -use crate::AuthManager; -use crate::CodexAuth; -use crate::agent_tool::AgentStatusUpdatePayload; -use crate::remote_models::RemoteModelsManager; -use crate::split_command_and_args; -use crate::git_worktree; -use crate::protocol::ApprovedCommandMatchKind; -use crate::protocol::WebSearchBeginEvent; -use crate::protocol::WebSearchCompleteEvent; -use crate::account_usage; -use crate::auth_accounts; -use crate::agent_defaults::{ - agent_model_spec, - default_agent_configs, - enabled_agent_model_specs_for_auth, - filter_agent_model_names_for_auth, -}; -use code_protocol::models::WebSearchAction; -use code_protocol::protocol::RolloutItem; -use shlex::split as shlex_split; -use shlex::try_join as shlex_try_join; -use chrono::Local; -use chrono::Utc; - -pub mod compact; -pub mod compact_remote; -mod events; -mod exec; -mod session; -mod streaming; - -pub use session::ApprovedCommandPattern; -pub(crate) use session::{Session, ToolCallCtx}; -use self::compact::{build_compacted_history, collect_compaction_snippets}; -use self::compact_remote::run_inline_remote_auto_compact_task; -use self::streaming::{add_pending_screenshot, capture_browser_screenshot, submission_loop}; - -/// Initial submission ID for session configuration -pub(crate) const INITIAL_SUBMIT_ID: &str = ""; -const HOOK_OUTPUT_LIMIT: usize = 2048; -const PENDING_ONLY_SENTINEL: &str = "__code_pending_only__"; -const POST_TURN_PENDING_ONLY_SENTINEL: &str = "__code_post_turn_pending_only__"; -const MIN_SHELL_TIMEOUT_MS: u64 = 30 * 60 * 1000; - -#[derive(Clone, Default)] -struct ConfirmGuardRuntime { - patterns: Vec, -} - -#[derive(Clone)] -struct ConfirmGuardPatternRuntime { - regex: regex_lite::Regex, - message: Option, - raw: String, -} - -impl ConfirmGuardRuntime { - fn from_config(config: &crate::config_types::ConfirmGuardConfig) -> Self { - let mut patterns = Vec::new(); - for pattern in &config.patterns { - match regex_lite::Regex::new(&pattern.regex) { - Ok(regex) => patterns.push(ConfirmGuardPatternRuntime { - regex, - message: pattern.message.clone(), - raw: pattern.regex.clone(), - }), - Err(err) => { - tracing::warn!("Skipping confirm guard pattern `{}`: {err}", pattern.regex); - } - } - } - Self { patterns } - } - - fn matched_pattern(&self, input: &str) -> Option<&ConfirmGuardPatternRuntime> { - self.patterns.iter().find(|pat| pat.regex.is_match(input)) - } - - fn is_empty(&self) -> bool { - self.patterns.is_empty() - } -} - -impl ConfirmGuardPatternRuntime { - fn guidance(&self, original_label: &str, original_value: &str, suggested: &str) -> String { - let header = self - .message - .clone() - .unwrap_or_else(|| { - format!( - "Blocked command matching confirm guard pattern `{}`. Resend with 'confirm:' if you intend to proceed.", - self.raw - ) - }); - format!("{header}\n\n{original_label}: {original_value}\nresend_exact_argv: {suggested}") - } -} - -fn to_proto_reasoning_effort(effort: ReasoningEffortConfig) -> ProtoReasoningEffort { - match effort { - ReasoningEffortConfig::Minimal => ProtoReasoningEffort::Minimal, - ReasoningEffortConfig::Low => ProtoReasoningEffort::Low, - ReasoningEffortConfig::Medium => ProtoReasoningEffort::Medium, - ReasoningEffortConfig::High => ProtoReasoningEffort::High, - ReasoningEffortConfig::XHigh => ProtoReasoningEffort::XHigh, - ReasoningEffortConfig::None => ProtoReasoningEffort::Minimal, - } -} - -fn to_proto_reasoning_summary(summary: ReasoningSummaryConfig) -> ProtoReasoningSummary { - match summary { - ReasoningSummaryConfig::Auto => ProtoReasoningSummary::Auto, - ReasoningSummaryConfig::Concise => ProtoReasoningSummary::Concise, - ReasoningSummaryConfig::Detailed => ProtoReasoningSummary::Detailed, - ReasoningSummaryConfig::None => ProtoReasoningSummary::None, - } -} - -fn to_proto_approval_policy(policy: AskForApproval) -> ProtoAskForApproval { - match policy { - AskForApproval::UnlessTrusted => ProtoAskForApproval::UnlessTrusted, - AskForApproval::OnFailure => ProtoAskForApproval::OnFailure, - AskForApproval::OnRequest => ProtoAskForApproval::OnRequest, - AskForApproval::Reject(config) => ProtoAskForApproval::Reject(ProtoRejectConfig { - sandbox_approval: config.sandbox_approval, - rules: config.rules, - skill_approval: config.skill_approval, - request_permissions: config.request_permissions, - mcp_elicitations: config.mcp_elicitations, - }), - AskForApproval::Never => ProtoAskForApproval::Never, - } -} - -fn to_proto_sandbox_policy(policy: SandboxPolicy) -> ProtoSandboxPolicy { - match policy { - SandboxPolicy::DangerFullAccess => ProtoSandboxPolicy::DangerFullAccess, - SandboxPolicy::ReadOnly => ProtoSandboxPolicy::ReadOnly, - SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - allow_git_writes, - } => { - let writable_roots = writable_roots - .into_iter() - .filter_map(|root| match ProtoAbsolutePathBuf::from_absolute_path(&root) { - Ok(abs) => Some(abs), - Err(err) => { - warn!( - "Ignoring invalid writable root {} for sandbox policy: {err}", - root.display() - ); - None - } - }) - .collect(); - ProtoSandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - allow_git_writes, - } - } - } -} - -fn to_proto_review_decision(decision: ReviewDecision) -> ProtoReviewDecision { - match decision { - ReviewDecision::Approved => ProtoReviewDecision::Approved, - ReviewDecision::ApprovedForSession => ProtoReviewDecision::ApprovedForSession, - ReviewDecision::Denied => ProtoReviewDecision::Denied, - ReviewDecision::Abort => ProtoReviewDecision::Abort, - } -} - -#[allow(dead_code)] -trait MutexExt { - fn lock_unchecked(&self) -> std::sync::MutexGuard<'_, T>; -} - -#[allow(dead_code)] -impl MutexExt for Mutex { - fn lock_unchecked(&self) -> std::sync::MutexGuard<'_, T> { - #[expect(clippy::expect_used)] - self.lock().expect("poisoned lock") - } -} - -#[derive(Clone)] -pub(crate) struct TurnContext { - pub(crate) client: ModelClient, - pub(crate) cwd: PathBuf, - pub(crate) base_instructions: Option, - pub(crate) user_instructions: Option, - pub(crate) demo_developer_message: Option, - pub(crate) active_session_model_notice: Option, - pub(crate) compact_prompt_override: Option, - pub(crate) approval_policy: AskForApproval, - pub(crate) sandbox_policy: SandboxPolicy, - pub(crate) shell_environment_policy: ShellEnvironmentPolicy, - pub(crate) is_review_mode: bool, - pub(crate) text_format_override: Option, - pub(crate) final_output_json_schema: Option, -} - -/// Gather ephemeral, per-turn context that should not be persisted to history. -/// Combines environment info and (when enabled) a live browser snapshot and status. -struct EphemeralJar { - items: Vec, -} - -impl EphemeralJar { - fn new() -> Self { - Self { items: Vec::new() } - } - - fn into_items(self) -> Vec { - self.items - } -} - -/// Convert a vector of core `InputItem`s into a single `ResponseInputItem` -/// suitable for sending to the model. Handles images (local and pre‑encoded) -/// and our fork's ephemeral image variant by inlining a brief metadata marker -/// followed by the image as a data URL. -fn response_input_from_core_items(items: Vec) -> ResponseInputItem { - let mut content_items = Vec::new(); - - for item in items { - match item { - InputItem::Text { text } => { - content_items.push(ContentItem::InputText { text }); - } - InputItem::Image { image_url } => { - content_items.push(ContentItem::InputImage { image_url }); - } - InputItem::LocalImage { path } => match std::fs::read(&path) { - Ok(bytes) => { - let mime = mime_guess::from_path(&path) - .first() - .map(|m| m.essence_str().to_owned()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - content_items.push(ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - }); - } - Err(err) => { - tracing::warn!( - "Skipping image {} – could not read file: {}", - path.display(), - err - ); - } - }, - InputItem::EphemeralImage { path, metadata } => { - tracing::info!( - "Processing ephemeral image: {} with metadata: {:?}", - path.display(), - metadata - ); - - if let Some(meta) = metadata { - content_items.push(ContentItem::InputText { - text: format!("[EPHEMERAL:{}]", meta), - }); - } - - match std::fs::read(&path) { - Ok(bytes) => { - let mime = mime_guess::from_path(&path) - .first() - .map(|m| m.essence_str().to_owned()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - tracing::info!("Created ephemeral image data URL with mime: {}", mime); - content_items.push(ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - }); - } - Err(err) => { - tracing::error!( - "Failed to read ephemeral image {} – {}", - path.display(), - err - ); - } - } - } - } - } - - ResponseInputItem::Message { - role: "user".to_string(), - content: content_items, - } -} - -fn selected_skill_messages_from_input( - items: &[InputItem], - skills: &[crate::skills::SkillMetadata], -) -> Vec { - let requested = selected_skill_names_from_input(items); - if requested.is_empty() { - return Vec::new(); - } - - let mut messages = Vec::new(); - for skill in skills { - if requested - .iter() - .any(|requested_name| requested_name.eq_ignore_ascii_case(&skill.name)) - { - messages.push( - SkillInstructions { - name: skill.name.clone(), - path: skill.path.to_string_lossy().replace('\\', "/"), - contents: skill.content.clone(), - } - .into(), - ); - } - } - messages -} - -fn selected_skill_names_from_input(items: &[InputItem]) -> Vec { - let mut names = Vec::new(); - for item in items { - let InputItem::Text { text } = item else { - continue; - }; - for token in text.split_whitespace() { - let Some(name) = token.strip_prefix('$') else { - continue; - }; - let name = name - .trim_matches(|ch: char| { - !(ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) - }) - .to_string(); - if !name.is_empty() && !names.iter().any(|existing| existing == &name) { - names.push(name); - } - } - } - names -} - -fn convert_call_tool_result_to_function_call_output_payload( - result: &Result, -) -> FunctionCallOutputPayload { - match result { - Ok(ok) => FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text( - serde_json::to_string(ok) - .unwrap_or_else(|e| format!("JSON serialization error: {e}")), - ), - success: Some(true), - }, - Err(e) => FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("err: {e:?}")), - success: Some(false), - }, - } -} - -fn get_git_branch(cwd: &std::path::Path) -> Option { - let head_path = cwd.join(".git/HEAD"); - if let Ok(contents) = std::fs::read_to_string(&head_path) { - if let Some(rest) = contents.trim().strip_prefix("ref: ") { - if let Some(branch) = rest.trim().rsplit('/').next() { - return Some(branch.to_string()); - } - } - } - None -} - -fn maybe_update_from_model_info( - field: &mut Option, - old_default: Option, - new_default: Option, -) { - if field.is_none() { - if let Some(new_val) = new_default { - *field = Some(new_val); - } - return; - } - - if let (Some(current), Some(old_val)) = (*field, old_default) { - if current == old_val { - *field = new_default; - } - } -} - -#[derive(Clone, Debug)] -struct RunTimeBudget { - deadline: Instant, - total: Duration, - next_nudge_at: Instant, -} - -impl RunTimeBudget { - fn new(deadline: Instant, total: Duration) -> Self { - let half = total / 2; - let next_nudge_at = deadline.checked_sub(half).unwrap_or(deadline); - Self { - deadline, - total, - next_nudge_at, - } - } - - fn maybe_nudge(&mut self, now: Instant) -> Option { - if now < self.next_nudge_at { - return None; - } - - let remaining = self.deadline.saturating_duration_since(now); - let elapsed = self.total.saturating_sub(remaining); - - if elapsed < (self.total / 2) { - // Avoid time pressure early. - let half = self.total / 2; - self.next_nudge_at = self.deadline.checked_sub(half).unwrap_or(self.deadline); - return None; - } - - let guidance = if remaining <= Duration::from_secs(30) { - "Time is nearly up: stop exploring; take the simplest safe path and do one cheap verification before finishing." - } else if remaining <= Duration::from_secs(120) { - "Time is tight: parallelize any remaining scouting/verification (batch tool calls) and finish with the cheapest proof." - } else { - "Past 50% of the time budget: start converging; parallelize remaining scouting/verification and avoid detours." - }; - - self.next_nudge_at = now + next_budget_nudge_interval(remaining); - - let total_secs = self.total.as_secs(); - let elapsed_secs = elapsed.as_secs(); - let remaining_secs = remaining.as_secs(); - Some(format!( - "== System Status ==\n [automatic message added by system]\n\n time_budget: {total_secs}s\n elapsed: {elapsed_secs}s\n remaining: {remaining_secs}s\n\n Guidance: {guidance}" - )) - } -} - -fn next_budget_nudge_interval(remaining: Duration) -> Duration { - if remaining >= Duration::from_secs(30 * 60) { - Duration::from_secs(5 * 60) - } else if remaining >= Duration::from_secs(10 * 60) { - Duration::from_secs(2 * 60) - } else if remaining >= Duration::from_secs(5 * 60) { - Duration::from_secs(60) - } else if remaining >= Duration::from_secs(2 * 60) { - Duration::from_secs(30) - } else if remaining >= Duration::from_secs(60) { - Duration::from_secs(15) - } else if remaining >= Duration::from_secs(30) { - Duration::from_secs(10) - } else if remaining >= Duration::from_secs(10) { - Duration::from_secs(5) - } else { - Duration::from_secs(2) - } -} - -fn maybe_time_budget_status_item(sess: &Session) -> Option { - let mut guard = sess.time_budget.lock().unwrap(); - let budget = guard.as_mut()?; - let text = budget.maybe_nudge(Instant::now())?; - Some(ResponseItem::Message { - id: Some(format!("run-budget-{}", sess.id)), - role: "user".to_string(), - content: vec![ContentItem::InputText { text }], end_turn: None, phase: None}) -} - -async fn build_turn_status_items(sess: &Session) -> Vec { - if sess.env_ctx_v2 { - build_turn_status_items_v2(sess).await - } else { - build_turn_status_items_legacy(sess).await - } -} - -async fn build_turn_status_items_legacy(sess: &Session) -> Vec { - let mut jar = EphemeralJar::new(); - - // Collect environment context - let cwd = sess.cwd.to_string_lossy().to_string(); - let branch = get_git_branch(&sess.cwd).unwrap_or_else(|| "unknown".to_string()); - let reasoning_effort = sess.client.get_reasoning_effort(); - - // Build current system status (UI-only; not persisted) - let mut current_status = format!( - r#"== System Status == - [automatic message added by system] - - cwd: {cwd} - branch: {branch} - reasoning: {reasoning_effort:?}"# - ); - - // Prepare browser context + optional screenshot - let mut screenshot_content: Option = None; - let mut include_screenshot = false; - - if let Some(browser_manager) = code_browser::global::get_browser_manager().await { - if browser_manager.is_enabled().await { - if let Some((_, idle_timeout)) = browser_manager.idle_elapsed_past_timeout().await { - let idle_text = format!( - "Browser idle (timeout {:?}); screenshot capture paused until browser_* tools run again.", - idle_timeout - ); - current_status.push_str("\n"); - current_status.push_str(&idle_text); - } else { - // Get current URL and browser info - let url = browser_manager - .get_current_url() - .await - .unwrap_or_else(|| "unknown".to_string()); - - // Try to get a tab title if available - let title = match browser_manager.get_or_create_page().await { - Ok(page) => page.get_title().await, - Err(_) => None, - }; - - // Get browser type description - let browser_type = browser_manager.get_browser_type().await; - - // Get viewport dimensions - let (viewport_width, viewport_height) = browser_manager.get_viewport_size().await; - let viewport_info = format!(" | Viewport: {}x{}", viewport_width, viewport_height); - - // Get cursor position - let cursor_info = match browser_manager.get_cursor_position().await { - Ok((x, y)) => format!( - " | Mouse position: ({:.0}, {:.0}) [shown as a blue cursor in the screenshot]", - x, y - ), - Err(_) => String::new(), - }; - - // Try to capture screenshot and compare with last one - let screenshot_status = match capture_browser_screenshot(sess).await { - Ok((screenshot_path, _url)) => { - // Always update the UI with the latest screenshot, even if unchanged for LLM payload - // This ensures the user sees that a fresh capture occurred each turn. - add_pending_screenshot(sess, screenshot_path.clone(), url.clone()); - // Check if screenshot has changed using image hashing - let mut last_screenshot_info = sess.last_screenshot_info.lock().unwrap(); - - // Compute hash for current screenshot - let current_hash = - crate::image_comparison::compute_image_hash(&screenshot_path).ok(); - - let should_include_screenshot = if let ( - Some((_last_path, last_phash, last_dhash)), - Some((cur_phash, cur_dhash)), - ) = - (last_screenshot_info.as_ref(), current_hash.as_ref()) - { - // Compare hashes to see if screenshots are similar - let similar = crate::image_comparison::are_hashes_similar( - last_phash, last_dhash, cur_phash, cur_dhash, - ); - - if !similar { - // Screenshot has changed, include it - *last_screenshot_info = Some(( - screenshot_path.clone(), - cur_phash.clone(), - cur_dhash.clone(), - )); - true - } else { - // Screenshot unchanged - false - } - } else { - // No previous screenshot or hash computation failed, include it - if let Some((phash, dhash)) = current_hash { - *last_screenshot_info = Some((screenshot_path.clone(), phash, dhash)); - } - true - }; - - if should_include_screenshot { - if let Ok(bytes) = std::fs::read(&screenshot_path) { - let mime = mime_guess::from_path(&screenshot_path) - .first() - .map(|m| m.to_string()) - .unwrap_or_else(|| "image/png".to_string()); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - screenshot_content = Some(ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - }); - include_screenshot = true; - "" - } else { - " [Screenshot file read failed]" - } - } else { - " [Screenshot unchanged]" - } - } - Err(err_msg) => { - // Include error message so LLM knows screenshot failed - format!(" [Screenshot unavailable: {}]", err_msg).leak() - } - }; - - let status_line = if let Some(t) = title { - format!( - "Browser url: {} — {} ({}){}{}{}. You can interact with it using browser_* tools.", - url, t, browser_type, viewport_info, cursor_info, screenshot_status - ) - } else { - format!( - "Browser url: {} ({}){}{}{}. You can interact with it using browser_* tools.", - url, browser_type, viewport_info, cursor_info, screenshot_status - ) - }; - current_status.push_str("\n"); - current_status.push_str(&status_line); - } - } - } - - // Check if system status has changed - let mut last_status = sess.last_system_status.lock().unwrap(); - let status_changed = last_status.as_ref() != Some(¤t_status); - - if status_changed { - // Update last status - *last_status = Some(current_status.clone()); - } - - // Only include items if something has changed or is new - let mut content: Vec = Vec::new(); - - if status_changed { - content.push(ContentItem::InputText { - text: current_status, - }); - } - - if include_screenshot { - if let Some(image) = screenshot_content { - content.push(image); - } - } - - if !content.is_empty() { - jar.items.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content, end_turn: None, phase: None}); - } - - if let Some(item) = maybe_time_budget_status_item(sess) { - jar.items.push(item); - } - - jar.into_items() -} - -async fn build_turn_status_items_v2(sess: &Session) -> Vec { - let mut items = Vec::new(); - - let env_context = EnvironmentContext::new( - Some(sess.cwd.clone()), - Some(sess.approval_policy), - Some(sess.sandbox_policy.clone()), - Some(sess.user_shell.clone()), - ); - - if let Some(mut env_items) = sess.maybe_emit_env_ctx_messages( - &env_context, - get_git_branch(&sess.cwd), - Some(format!("{:?}", sess.client.get_reasoning_effort())), - ) { - items.append(&mut env_items); - } - - if let Some(item) = maybe_time_budget_status_item(sess) { - items.push(item); - } - - if let Some(browser_manager) = code_browser::global::get_browser_manager().await { - if browser_manager.is_enabled().await { - let browser_stream_id = { - let mut state = sess.state.lock().unwrap(); - state - .context_stream_ids - .browser_stream_id(sess.id) - }; - - if let Some((_, timeout)) = browser_manager.idle_elapsed_past_timeout().await { - let idle_text = format!( - "Browser idle (timeout {:?}); screenshot capture paused until browser_* tools run again.", - timeout - ); - items.push(ResponseItem::Message { - id: Some(browser_stream_id), - role: "user".to_string(), - content: vec![ContentItem::InputText { text: idle_text }], end_turn: None, phase: None}); - return items; - } else { - let url = browser_manager - .get_current_url() - .await - .unwrap_or_else(|| "unknown".to_string()); - - let title = match browser_manager.get_or_create_page().await { - Ok(page) => page.get_title().await, - Err(_) => None, - }; - - let browser_type = browser_manager.get_browser_type().await.to_string(); - let (viewport_width, viewport_height) = browser_manager.get_viewport_size().await; - let cursor_position = browser_manager.get_cursor_position().await.ok(); - - let mut metadata = HashMap::new(); - metadata.insert("browser_type".to_string(), browser_type.clone()); - if let Some((x, y)) = cursor_position { - metadata.insert("cursor_position".to_string(), format!("{:.0},{:.0}", x, y)); - } - - let viewport = if viewport_width > 0 && viewport_height > 0 { - Some(ViewportDimensions { - width: viewport_width as u32, - height: viewport_height as u32, - }) - } else { - None - }; - - let mut screenshot_path = None; - - match capture_browser_screenshot(sess).await { - Ok((path, _)) => { - add_pending_screenshot(sess, path.clone(), url.clone()); - let current_hash = crate::image_comparison::compute_image_hash(&path).ok(); - let mut last_info = sess.last_screenshot_info.lock().unwrap(); - let include_screenshot = should_include_browser_screenshot( - &mut last_info, - &path, - current_hash, - ); - drop(last_info); - if include_screenshot { - screenshot_path = Some(path); - } - } - Err(err_msg) => { - trace!("env_ctx_v2: screenshot capture failed: {}", err_msg); - } - } - - if let Some(path) = screenshot_path { - let captured_at = OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()); - - let mut snapshot = BrowserSnapshot::new(url.clone(), captured_at); - snapshot.title = title.clone(); - snapshot.viewport = viewport; - if !metadata.is_empty() { - snapshot.metadata = Some(metadata); - } - - match snapshot.to_response_item_with_id(Some(&browser_stream_id)) { - Ok(item) => items.push(item), - Err(err) => warn!("env_ctx_v2: failed to serialize browser_snapshot JSON: {err}"), - } - - if *crate::flags::CTX_UI { - sess.emit_browser_snapshot_event(&browser_stream_id, &snapshot); - } - - match std::fs::read(&path) { - Ok(bytes) => { - let mime = mime_guess::from_path(&path) - .first() - .map(|m| m.to_string()) - .unwrap_or_else(|| "image/png".to_string()); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - items.push(ResponseItem::Message { - id: Some(browser_stream_id), - role: "user".to_string(), - content: vec![ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - }], end_turn: None, phase: None}); - } - Err(err) => warn!( - "env_ctx_v2: failed to read screenshot file {}: {err}", - path.display() - ), - } - } - } - } - } - - items -} - -fn should_include_browser_screenshot( - last_info: &mut Option<(PathBuf, Vec, Vec)>, - path: &PathBuf, - current_hash: Option<(Vec, Vec)>, -) -> bool { - if let Some((cur_phash, cur_dhash)) = current_hash { - if let Some((_, last_phash, last_dhash)) = last_info.as_ref() { - if crate::image_comparison::are_hashes_similar( - last_phash, - last_dhash, - &cur_phash, - &cur_dhash, - ) { - return false; - } - } - *last_info = Some((path.clone(), cur_phash, cur_dhash)); - true - } else { - *last_info = Some((path.clone(), Vec::new(), Vec::new())); - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codex::streaming::{process_rollout_env_item, TimelineReplayContext}; - use crate::skills::SkillMetadata; - use crate::skills::model::SkillPolicy; - use crate::skills::model::SkillScope; - use code_protocol::models::ContentItem; - use pretty_assertions::assert_eq; - - #[test] - fn screenshot_dedup_tracks_changes() { - let mut last = None; - let path = PathBuf::from("/tmp/a.png"); - let hash_one = (vec![0xAAu8; 32], vec![0x55u8; 32]); - let hash_two = (vec![0xABu8; 32], vec![0x56u8; 32]); - - assert!(should_include_browser_screenshot(&mut last, &path, Some(hash_one.clone()))); - assert!(!should_include_browser_screenshot(&mut last, &path, Some(hash_one.clone()))); - assert!(should_include_browser_screenshot(&mut last, &path, Some(hash_two))); - } - - #[test] - fn selected_skill_messages_include_explicit_dollar_skill_once() { - let skills = vec![SkillMetadata { - name: "manual-skill".to_string(), - description: "Manual skill".to_string(), - short_description: None, - path: PathBuf::from("/tmp/manual-skill/SKILL.md"), - scope: SkillScope::User, - content: "manual body".to_string(), - resources: Vec::new(), - policy: Some(SkillPolicy { - allow_implicit_invocation: Some(false), - command_policies: Vec::new(), - }), - commands: Vec::new(), - workflow_defaults: Vec::new(), - }]; - let input = vec![InputItem::Text { - text: "Please use $manual-skill for this turn, then reply.".to_string(), - }]; - - let messages = selected_skill_messages_from_input(&input, &skills); - - assert_eq!(messages.len(), 1); - let ResponseItem::Message { role, content, .. } = &messages[0] else { - panic!("expected skill message"); - }; - assert_eq!(role, "user"); - let [ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected skill text"); - }; - assert!(text.contains("manual-skill")); - assert!(text.contains("manual body")); - } - - fn make_snapshot(cwd: &str) -> EnvironmentContextSnapshot { - EnvironmentContextSnapshot { - version: EnvironmentContextSnapshot::VERSION, - cwd: Some(cwd.to_string()), - approval_policy: None, - sandbox_mode: None, - network_access: None, - writable_roots: Vec::new(), - operating_system: None, - common_tools: Vec::new(), - shell: None, - git_branch: Some("main".to_string()), - reasoning_effort: None, - } - } - - #[test] - fn timeline_rehydrate_round_trip() { - let baseline = make_snapshot("/repo"); - let delta_snapshot = make_snapshot("/repo-updated"); - let delta = delta_snapshot.diff_from(&baseline); - - let baseline_item = baseline - .to_response_item() - .expect("serialize baseline snapshot"); - let delta_item = delta - .to_response_item() - .expect("serialize delta snapshot"); - - let mut ctx = TimelineReplayContext::default(); - process_rollout_env_item(&mut ctx, &baseline_item); - process_rollout_env_item(&mut ctx, &delta_item); - - assert!(ctx.timeline.baseline().is_some()); - assert_eq!(ctx.timeline.delta_count(), 1); - assert_eq!(ctx.next_sequence, 2); - assert!(ctx.last_snapshot.is_some()); - } - - #[test] - fn timeline_rehydrate_legacy_baseline() { - let legacy_item = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "== System Status ==\n cwd: /legacy\n branch: main".to_string(), - }], end_turn: None, phase: None}; - - let mut ctx = TimelineReplayContext::default(); - process_rollout_env_item(&mut ctx, &legacy_item); - - assert!(ctx.timeline.is_empty()); - assert!(ctx.legacy_baseline.is_some()); - } - - #[test] - fn timeline_rehydrate_delta_gap_triggers_reset() { - let baseline = make_snapshot("/repo"); - let baseline_item = baseline - .to_response_item() - .expect("serialize baseline snapshot"); - - let mut ctx = TimelineReplayContext::default(); - process_rollout_env_item(&mut ctx, &baseline_item); - - let mut delta = make_snapshot("/other").diff_from(&baseline); - delta.base_fingerprint = "mismatch".to_string(); - let delta_item = delta - .to_response_item() - .expect("serialize delta snapshot"); - - process_rollout_env_item(&mut ctx, &delta_item); - - assert!(ctx.timeline.is_empty()); - assert!(ctx.last_snapshot.is_none()); - assert_eq!(ctx.next_sequence, 1); - } -} -use crate::agent_tool::AGENT_MANAGER; -use crate::agent_tool::AgentStatus; -use crate::agent_tool::AgentToolRequest; -use crate::agent_defaults::model_guide_markdown_with_custom; -use crate::agent_tool::CancelAgentParams; -use crate::agent_tool::CheckAgentStatusParams; -use crate::agent_tool::GetAgentResultParams; -use crate::agent_tool::ListAgentsParams; -use crate::agent_tool::normalize_agent_name; -use crate::agent_tool::RunAgentParams; -use crate::agent_tool::WaitForAgentParams; -use crate::apply_patch::convert_apply_patch_to_protocol; -use crate::apply_patch::get_writable_roots; -use crate::apply_patch::{self, ApplyPatchResult}; -use crate::bridge_client::{ - get_effective_subscription, persist_workspace_subscription, send_bridge_control, - set_session_subscription, set_workspace_subscription, -}; -use crate::client::ModelClient; -use crate::client_common::{Prompt, ResponseEvent, TextFormat, REVIEW_PROMPT}; -use crate::context_timeline::ContextTimeline; -use crate::environment_context::{ - BrowserSnapshot, - EnvironmentContext, - EnvironmentContextDelta, - EnvironmentContextSnapshot, - EnvironmentContextTracker, - ViewportDimensions, -}; -use crate::user_instructions::SkillInstructions; -use crate::user_instructions::UserInstructions; -use crate::user_instructions::is_skill_instructions_message; -use crate::config::{persist_model_selection, Config}; -use crate::timeboxed_exec_guidance::{ - AUTO_EXEC_TIMEBOXED_CLI_GUIDANCE, - AUTO_EXEC_TIMEBOXED_REVIEW_GUIDANCE, -}; -use crate::config_types::ProjectHookEvent; -use crate::config_types::ShellEnvironmentPolicy; -use crate::conversation_history::ConversationHistory; -use crate::error::{CodexErr, RetryAfter}; -use crate::error::Result as CodexResult; -use crate::error::SandboxErr; -use crate::error::get_error_message_ui; -use crate::exec::ExecParams; -use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; -use crate::exec::StdoutStream; -use crate::exec::StreamOutput; -use crate::exec::EXEC_CAPTURE_MAX_BYTES; -use crate::exec::process_exec_tool_call; -use crate::review_format::format_review_findings_block; -use crate::exec_env::create_env; -use crate::mcp_connection_manager::McpConnectionManager; -use crate::mcp_tool_call::handle_mcp_tool_call; -use crate::model_family::{derive_default_model_family, find_family_for_model}; -use code_protocol::models::ContentItem; -use code_protocol::models::FunctionCallOutputPayload; -use code_protocol::models::LocalShellAction; -use code_protocol::models::ReasoningItemContent; -use code_protocol::models::ReasoningItemReasoningSummary; -use code_protocol::models::ResponseInputItem; -use code_protocol::models::ResponseItem; -use code_protocol::models::SandboxPermissions; -use crate::openai_tools::ToolsConfig; -use crate::openai_tools::get_openai_tools; -use crate::slash_commands::get_enabled_agents; -use crate::dry_run_guard::{analyze_command, DryRunAnalysis, DryRunDisposition, DryRunGuardState}; -use crate::parse_command::parse_command; -use crate::plan_tool::handle_update_plan; -use crate::project_doc::get_user_instructions; -use crate::project_features::{ProjectCommand, ProjectHook, ProjectHooks}; -use crate::protocol::AgentMessageDeltaEvent; -use crate::protocol::AgentMessageEvent; -use crate::protocol::AgentReasoningDeltaEvent; -use crate::protocol::AgentReasoningEvent; -use crate::protocol::AgentSourceKind; -use crate::protocol::AgentReasoningRawContentDeltaEvent; -use crate::protocol::AgentReasoningRawContentEvent; -use crate::protocol::AgentReasoningSectionBreakEvent; -use crate::protocol::AgentStatusUpdateEvent; -use crate::protocol::ApplyPatchApprovalRequestEvent; -use crate::protocol::AskForApproval; -use crate::protocol::BackgroundEventEvent; -use crate::protocol::BrowserScreenshotUpdateEvent; -use crate::protocol::ErrorEvent; -use crate::protocol::Event; -use crate::protocol::EventMsg; -use crate::protocol::ExitedReviewModeEvent; -use crate::protocol::ReviewSnapshotInfo; -use crate::protocol::ListCustomPromptsResponseEvent; -use crate::protocol::ListSkillsResponseEvent; -use crate::protocol::{BrowserSnapshotEvent, EnvironmentContextDeltaEvent, EnvironmentContextFullEvent}; -use crate::protocol::ExecApprovalRequestEvent; -use crate::protocol::ExecCommandBeginEvent; -use crate::protocol::ExecCommandEndEvent; -use crate::protocol::FileChange; -use crate::protocol::InputItem; -use crate::protocol::Op; -use crate::protocol::PatchApplyBeginEvent; -use crate::protocol::PatchApplyEndEvent; -use crate::protocol::RateLimitSnapshotEvent; -use crate::protocol::TokenCountEvent; -use crate::protocol::TokenUsage; -use crate::protocol::TokenUsageInfo; -use crate::protocol::ReviewDecision; -use crate::protocol::ValidationGroup; -use crate::protocol::ReviewOutputEvent; -use crate::protocol::ReviewRequest; -use crate::protocol::SandboxPolicy; -use crate::protocol::SessionConfiguredEvent; -use crate::protocol::Submission; -use crate::protocol::TaskCompleteEvent; -use crate::skills::loader::load_skills; -use std::sync::OnceLock; -use tokio::sync::Notify; -use crate::protocol::TurnDiffEvent; -use crate::rollout::RolloutRecorder; -use crate::safety::SafetyCheck; -use crate::safety::assess_command_safety; -use crate::safety::assess_safety_for_untrusted_command; -use crate::shell; -use crate::turn_diff_tracker::TurnDiffTracker; -use crate::user_notification::UserNotification; -use crate::util::{backoff, wait_for_connectivity}; -use code_protocol::protocol::SessionSource; -use crate::rollout::recorder::SessionStateSnapshot; -use serde_json::Value; -use crate::exec_command::ExecSessionManager; - -/// The high-level interface to the Codex system. -/// It operates as a queue pair where you send submissions and receive events. -pub struct Codex { - next_id: AtomicU64, - tx_sub: Sender, - rx_event: Receiver, -} - -static ANY_BG_NOTIFY: OnceLock> = OnceLock::new(); - -/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], -/// the submission id for the initial `ConfigureSession` request and the -/// unique session id. -pub struct CodexSpawnOk { - pub codex: Codex, - pub init_id: String, - pub session_id: Uuid, -} - -impl Codex { - /// Spawn a new [`Codex`] and initialize the session. - pub async fn spawn(config: Config, auth: Option) -> CodexResult { - let auth_manager = auth.map(crate::AuthManager::from_auth_for_testing); - Self::spawn_with_auth_manager(config, auth_manager).await - } - - pub async fn spawn_with_auth_manager( - config: Config, - auth_manager: Option>, - ) -> CodexResult { - Self::spawn_with_auth_manager_and_source(config, auth_manager, SessionSource::Cli).await - } - - pub async fn spawn_with_auth_manager_and_source( - config: Config, - auth_manager: Option>, - session_source: SessionSource, - ) -> CodexResult { - // experimental resume path (undocumented) - let resume_path = config.experimental_resume.clone(); - info!("resume_path: {resume_path:?}"); - // Use an unbounded submission queue to avoid any possibility of back‑pressure - // between the TUI submit worker and the core loop during interrupts/cancels. - let (tx_sub, rx_sub) = async_channel::unbounded(); - let (tx_event, rx_event) = async_channel::unbounded(); - - let configure_session = Op::ConfigureSession { - provider: config.model_provider.clone(), - model: config.model.clone(), - model_explicit: config.model_explicit, - model_reasoning_effort: config.model_reasoning_effort, - preferred_model_reasoning_effort: config.preferred_model_reasoning_effort, - model_reasoning_summary: config.model_reasoning_summary, - model_text_verbosity: config.model_text_verbosity, - service_tier: config.service_tier, - context_mode: config.context_mode, - model_context_window: config.model_context_window, - model_auto_compact_token_limit: config.model_auto_compact_token_limit, - user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), - approval_policy: config.approval_policy, - sandbox_policy: config.sandbox_policy.clone(), - disable_response_storage: config.disable_response_storage, - notify: config.notify.clone(), - cwd: config.cwd.clone(), - resume_path: resume_path.clone(), - demo_developer_message: config.demo_developer_message.clone(), - dynamic_tools: config.dynamic_tools.clone(), - }; - - if config.auto_switch_accounts_on_rate_limit - && crate::auth::read_code_api_key_from_env().is_none() - && resume_path.is_none() - && matches!(&session_source, SessionSource::Cli) - { - match crate::account_switching::switch_active_account_to_preferred_for_new_session( - &config.code_home, - Utc::now(), - ) { - Ok(Some(account_id)) => { - info!( - to_account_id = %account_id, - reason = "new_session_preferred_reset", - "auto-switching active account for new session" - ); - if let Some(auth_manager) = auth_manager.as_ref() { - auth_manager.reload(); - } - } - Ok(None) => {} - Err(err) => { - warn!( - error = %err, - "failed to auto-select preferred account for new session" - ); - } - } - } - - let config = Arc::new(config); - - // Generate a unique ID for the lifetime of this Codex session. - let session_id = Uuid::new_v4(); - - // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop( - session_id, - config, - auth_manager, - session_source, - rx_sub, - tx_event, - )); - let codex = Codex { - next_id: AtomicU64::new(0), - tx_sub, - rx_event, - }; - let _ = ANY_BG_NOTIFY.set(std::sync::Arc::new(Notify::new())); - let init_id = codex.submit(configure_session).await?; - - Ok(CodexSpawnOk { - codex, - init_id, - session_id, - }) - } - - /// Submit the `op` wrapped in a `Submission` with a unique ID. - pub async fn submit(&self, op: Op) -> CodexResult { - let id = self - .next_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) - .to_string(); - let sub = Submission { id: id.clone(), op }; - self.submit_with_id(sub).await?; - Ok(id) - } - - /// Use sparingly: prefer `submit()` so Codex is responsible for generating - /// unique IDs for each submission. - pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { - self.tx_sub - .send(sub) - .await - .map_err(|_| CodexErr::InternalAgentDied)?; - Ok(()) - } - - pub async fn next_event(&self) -> CodexResult { - let event = self - .rx_event - .recv() - .await - .map_err(|_| CodexErr::InternalAgentDied)?; - Ok(event) - } -} diff --git a/code-rs/core/src/codex/compact.rs b/code-rs/core/src/codex/compact.rs deleted file mode 100644 index cb4546eebb5..00000000000 --- a/code-rs/core/src/codex/compact.rs +++ /dev/null @@ -1,1210 +0,0 @@ -use std::collections::HashSet; -use std::sync::Arc; - -use super::streaming::AgentTask; -use super::Session; -use super::compact_remote; -use super::TurnContext; -use super::streaming::get_last_assistant_message_from_turn; -use crate::Prompt; -use crate::client_common::ResponseEvent; -use crate::environment_context::EnvironmentContext; -use crate::error::CodexErr; -use crate::error::RetryAfter; -use crate::error::Result as CodexResult; -use crate::protocol::AgentMessageEvent; -use crate::protocol::ErrorEvent; -use crate::protocol::EventMsg; -use code_protocol::protocol::CompactionCheckpointWarningEvent; -use crate::protocol::InputItem; -use crate::protocol::TaskCompleteEvent; -use crate::truncate::truncate_middle; -use crate::util::backoff; -use askama::Template; -use code_protocol::models::ContentItem; -use code_protocol::models::FunctionCallOutputPayload; -use code_protocol::models::ResponseInputItem; -use code_protocol::models::ResponseItem; -use code_protocol::protocol::CompactedItem; -use code_protocol::protocol::InputMessageKind; -use code_protocol::protocol::RolloutItem; -use base64::Engine; -use chrono::Utc; -use futures::prelude::*; -use std::time::Duration; - -pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md"); -pub const COMPACTION_CHECKPOINT_MESSAGE: &str = "History checkpoint: earlier conversation compacted."; -const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -const COMPACT_TEXT_CONTENT_MAX_BYTES: usize = 8 * 1024; -const COMPACT_TOOL_ARGS_MAX_BYTES: usize = 4 * 1024; -const COMPACT_TOOL_OUTPUT_MAX_BYTES: usize = 4 * 1024; -const COMPACT_IMAGE_URL_MAX_BYTES: usize = 512; -const MAX_COMPACTION_SNIPPETS: usize = 12; -const COMPACT_STREAM_TIMEOUT: Duration = Duration::from_secs(120); -const MAX_COMPACT_CONTEXT_OVERFLOW_TRIMS: usize = 32; -const MAX_COMPACT_USAGE_LIMIT_RETRIES: usize = 2; -const COMPACTION_EMERGENCY_MESSAGE: &str = "⚠️ Compaction failed: The conversation history is too large to compact within the model's context limits. The history has been reset to prevent further errors. Please start a new session or manually reduce context by clearing history."; - -/// Determine whether to use remote compaction (ChatGPT-based) or local compaction. -/// -/// Upstream codex-rs checks if auth mode is ChatGPT and RemoteCompaction feature is enabled. -/// In code-rs, remote compaction infrastructure is not yet implemented, so this always -/// returns false (always use local compaction). -/// -/// TODO: Once ChatGPT auth and remote compaction are implemented, update this to: -/// ``` -/// session -/// .services -/// .auth_manager -/// .auth() -/// .is_some_and(|auth| auth.mode == AuthMode::ChatGPT) -/// && session.enabled(Feature::RemoteCompaction).await -/// ``` -pub(super) async fn should_use_remote_compact_task(session: &Session) -> bool { - session - .client - .get_auth_manager() - .and_then(|manager| manager.auth()) - .is_some_and(|auth| auth.mode.is_chatgpt()) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CompactionSnippet { - pub role: String, - pub text: String, -} - -#[derive(Template)] -#[template(path = "compact/history_bridge.md", escape = "none")] -struct HistoryBridgeTemplate<'a> { - snippets: &'a [CompactionSnippet], - summary_text: &'a str, -} - -pub fn collect_compaction_snippets(items: &[ResponseItem]) -> Vec { - let mut snippets = Vec::new(); - let mut total_bytes = 0usize; - - for item in items.iter().rev() { - if let ResponseItem::Message { role, content, .. } = item { - if role != "user" && role != "assistant" { - continue; - } - let Some(text) = content_items_to_text(content) else { - continue; - }; - if role == "user" && is_session_prefix_message(&text) { - continue; - } - let truncated = truncate_for_compact(text, COMPACT_TEXT_CONTENT_MAX_BYTES); - if truncated.trim().is_empty() { - continue; - } - if snippets.len() >= MAX_COMPACTION_SNIPPETS { - break; - } - let snippet_len = truncated.len(); - if !snippets.is_empty() && total_bytes + snippet_len > COMPACT_USER_MESSAGE_MAX_TOKENS * 4 { - break; - } - total_bytes += snippet_len; - snippets.push(CompactionSnippet { - role: role.clone(), - text: truncated, - }); - } - } - - snippets.reverse(); - snippets -} - -pub fn render_compaction_summary(snippets: &[CompactionSnippet], summary_text: &str) -> String { - let normalized_summary = if summary_text.trim().is_empty() { - "(no summary available)".to_string() - } else { - summary_text.to_string() - }; - - HistoryBridgeTemplate { - snippets, - summary_text: normalized_summary.as_str(), - } - .render() - .unwrap_or(normalized_summary) -} - -pub fn make_compaction_summary_message( - snippets: &[CompactionSnippet], - summary_text: &str, -) -> ResponseItem { - let text = render_compaction_summary(snippets, summary_text); - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text }], end_turn: None, phase: None} -} - -/// Resolve the compaction prompt text based on an optional override. -/// -/// Empty strings are treated as missing so we always fall back to the embedded -/// template instead of sending a blank developer message. -pub fn resolve_compact_prompt_text(override_prompt: Option<&str>) -> String { - if let Some(text) = override_prompt { - if !text.trim().is_empty() { - return text.to_string(); - } - } - SUMMARIZATION_PROMPT.to_string() -} - -pub(super) fn spawn_compact_task( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, -) { - let task = AgentTask::compact(sess.clone(), turn_context, sub_id, input); - // set_task is synchronous in our fork - sess.set_task(task); -} - -pub(super) async fn run_inline_auto_compact_task( - sess: Arc, - turn_context: Arc, -) -> Vec { - let sub_id = sess.next_internal_sub_id(); - let prompt_text = resolve_compact_prompt_text(turn_context.compact_prompt_override.as_deref()); - let input = vec![InputItem::Text { text: prompt_text.clone() }]; - run_compact_task_inner_inline(sess, turn_context, sub_id, input).await -} - -pub(super) async fn run_compact_task( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, -) { - let start_event = sess.make_event(&sub_id, EventMsg::TaskStarted); - sess.send_event(start_event).await; - let compaction_result = if should_use_remote_compact_task(&sess).await { - compact_remote::run_remote_compact_task( - Arc::clone(&sess), - Arc::clone(&turn_context), - sub_id.clone(), - input, - ) - .await - } else { - perform_compaction( - sess.clone(), - turn_context, - sub_id.clone(), - input, - true, - ) - .await - }; - - let _ = compaction_result; - let event = sess.make_event( - &sub_id, - EventMsg::TaskComplete(TaskCompleteEvent { - last_agent_message: None, - }), - ); - sess.send_event(event).await; -} - -pub(super) async fn apply_emergency_compaction_fallback( - sess: &Arc, - turn_context: &TurnContext, - sub_id: &str, - reason: &str, -) -> Vec { - let message = if reason.trim().is_empty() { - COMPACTION_EMERGENCY_MESSAGE.to_string() - } else { - format!("{reason} {COMPACTION_EMERGENCY_MESSAGE}") - }; - - let event = sess.make_event( - sub_id, - EventMsg::Error(ErrorEvent { - message: message.clone(), - }), - ); - sess.send_event(event).await; - - let initial_context = sess.build_initial_context(turn_context); - let emergency_history = build_emergency_compacted_history(initial_context, &message); - sess.replace_history(emergency_history.clone()); - { - let mut state = sess.state.lock().unwrap(); - state.token_usage_info = None; - } - - emergency_history -} - -/// Perform compaction as a background task that updates session history in-place. -pub(super) async fn perform_compaction( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - remove_task_on_completion: bool, -) -> CodexResult<()> { - // Convert core InputItem -> ResponseInputItem using the same logic as the main turn flow - let initial_input_for_turn: ResponseInputItem = response_input_from_core_items(input); - let mut turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]); - - turn_input = sanitize_items_for_compact(turn_input); - - let max_retries = turn_context.client.get_provider().stream_max_retries(); - let mut retries = 0; - let mut truncated_count = 0usize; - let mut usage_limit_retries = 0usize; - - // Do not persist a TurnContext rollout item here; inline compaction is a - // background maintenance task and should not affect rollout reconstruction. - - loop { - prune_orphan_tool_outputs(&mut turn_input); - - let mut prompt = Prompt::default(); - prompt.input = turn_input.clone(); - prompt.store = !sess.disable_response_storage; - prompt.user_instructions = turn_context.user_instructions.clone(); - prompt.environment_context = Some(EnvironmentContext::new( - Some(turn_context.cwd.clone()), - Some(turn_context.approval_policy), - Some(turn_context.sandbox_policy.clone()), - Some(sess.user_shell.clone()), - )); - prompt.model_descriptions = sess.model_descriptions.clone(); - prompt.log_tag = Some("codex/compact".to_string()); - - match drain_to_completed(&sess, turn_context.as_ref(), &prompt).await { - Ok(()) => { - if truncated_count > 0 { - tracing::warn!( - "Context window exceeded during compact; trimmed {truncated_count} item(s) from prompt" - ); - } - break; - } - Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), - Err(CodexErr::UsageLimitReached(limit_err)) => { - if usage_limit_retries >= MAX_COMPACT_USAGE_LIMIT_RETRIES { - tracing::error!( - "Compaction aborted after {} usage-limit retries", - MAX_COMPACT_USAGE_LIMIT_RETRIES - ); - let reason = "Compaction hit persistent usage limits and cannot continue."; - let _ = apply_emergency_compaction_fallback( - &sess, - turn_context.as_ref(), - &sub_id, - reason, - ) - .await; - return Err(CodexErr::UsageLimitReached(limit_err)); - } - usage_limit_retries = usage_limit_retries.saturating_add(1); - let now = Utc::now(); - let retry_after = limit_err - .retry_after(now) - .unwrap_or_else(|| RetryAfter::from_duration(Duration::from_secs(5 * 60), now)); - let mut message = format!("{limit_err} Auto-retrying"); - message.push('…'); - sess.notify_stream_error(&sub_id, message).await; - tokio::time::sleep(retry_after.delay).await; - retries = 0; - continue; - } - Err(e) if is_context_overflow_error(&e) => { - if turn_input.len() > 1 && truncated_count < MAX_COMPACT_CONTEXT_OVERFLOW_TRIMS { - tracing::warn!( - "Context window exceeded while compacting; dropping oldest item ({} remaining)", - turn_input.len().saturating_sub(1) - ); - turn_input.remove(0); - truncated_count = truncated_count.saturating_add(1); - retries = 0; - usage_limit_retries = 0; - continue; - } - - let reason = if truncated_count >= MAX_COMPACT_CONTEXT_OVERFLOW_TRIMS { - format!( - "Compaction trimmed {} items but still exceeded the context window.", - truncated_count - ) - } else { - "Compaction failed: context overflow even with minimal input.".to_string() - }; - tracing::error!("{reason}"); - let _ = apply_emergency_compaction_fallback( - &sess, - turn_context.as_ref(), - &sub_id, - &reason, - ) - .await; - - return Err(e); - } - Err(e) => { - if retries < max_retries { - retries += 1; - let delay = backoff(retries); - sess - .notify_stream_error( - &sub_id, - format!( - "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" - ), - ) - .await; - tokio::time::sleep(delay).await; - continue; - } else { - let event = sess.make_event( - &sub_id, - EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), - ); - sess.send_event(event).await; - return Err(e); - } - } - } - } - - if remove_task_on_completion { - sess.remove_task(&sub_id); - } - - // Snapshot history and compute a compacted version - let history_snapshot = { - let state = sess.state.lock().unwrap(); - state.history.contents() - }; - let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); - let snippets = collect_compaction_snippets(&history_snapshot); - let initial_context = sess.build_initial_context(turn_context.as_ref()); - let new_history = build_compacted_history(initial_context, &snippets, &summary_text); - - // Replace session history in-place using the canonical helper so any future - // state bookkeeping stays centralized. - sess.replace_history(new_history); - - send_compaction_checkpoint_warning(&sess, &sub_id).await; - - let rollout_item = RolloutItem::Compacted(CompactedItem { - message: summary_text.clone(), - replacement_history: None, - }); - sess.persist_rollout_items(&[rollout_item]).await; - - let display_message = if summary_text.trim().is_empty() { - "Compact task completed.".to_string() - } else { - summary_text.clone() - }; - let event = sess.make_event( - &sub_id, - EventMsg::AgentMessage(AgentMessageEvent { - message: display_message, - }), - ); - sess.send_event(event).await; - Ok(()) -} - -/// Run compaction inline, update the session history in-place, and return the rebuilt compact history. -async fn run_compact_task_inner_inline( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, -) -> Vec { - // Convert core InputItem -> ResponseInputItem and build prompt - let initial_input_for_turn: ResponseInputItem = response_input_from_core_items(input); - let mut turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]); - - turn_input = sanitize_items_for_compact(turn_input); - - let max_retries = turn_context.client.get_provider().stream_max_retries(); - let mut retries = 0; - let mut truncated_count = 0usize; - let mut usage_limit_retries = 0usize; - loop { - let mut prompt = Prompt::default(); - prompt.input = turn_input.clone(); - prompt.store = !sess.disable_response_storage; - prompt.user_instructions = turn_context.user_instructions.clone(); - prompt.environment_context = Some(EnvironmentContext::new( - Some(turn_context.cwd.clone()), - Some(turn_context.approval_policy), - Some(turn_context.sandbox_policy.clone()), - Some(sess.user_shell.clone()), - )); - prompt.model_descriptions = sess.model_descriptions.clone(); - prompt.log_tag = Some("codex/compact".to_string()); - - match drain_to_completed(&sess, turn_context.as_ref(), &prompt).await { - Ok(()) => { - if truncated_count > 0 { - tracing::warn!( - "Context window exceeded during inline compact; trimmed {truncated_count} item(s) from prompt" - ); - } - break; - } - Err(CodexErr::Interrupted) => return Vec::new(), - Err(CodexErr::UsageLimitReached(limit_err)) => { - if usage_limit_retries >= MAX_COMPACT_USAGE_LIMIT_RETRIES { - tracing::error!( - "Inline compaction aborted after {} usage-limit retries", - MAX_COMPACT_USAGE_LIMIT_RETRIES - ); - let reason = "Compaction hit persistent usage limits and cannot continue."; - return apply_emergency_compaction_fallback( - &sess, - turn_context.as_ref(), - &sub_id, - reason, - ) - .await; - } - usage_limit_retries = usage_limit_retries.saturating_add(1); - let now = Utc::now(); - let retry_after = limit_err - .retry_after(now) - .unwrap_or_else(|| RetryAfter::from_duration(Duration::from_secs(5 * 60), now)); - let mut message = format!("{limit_err} Auto-retrying"); - message.push('…'); - sess.notify_stream_error(&sub_id, message).await; - tokio::time::sleep(retry_after.delay).await; - retries = 0; - continue; - } - Err(e) if is_context_overflow_error(&e) => { - if turn_input.len() > 1 && truncated_count < MAX_COMPACT_CONTEXT_OVERFLOW_TRIMS { - tracing::warn!( - "Context window exceeded while compacting; dropping oldest item ({} remaining)", - turn_input.len().saturating_sub(1) - ); - turn_input.remove(0); - truncated_count = truncated_count.saturating_add(1); - retries = 0; - usage_limit_retries = 0; - continue; - } - - let reason = if truncated_count >= MAX_COMPACT_CONTEXT_OVERFLOW_TRIMS { - format!( - "Compaction trimmed {} items but still exceeded the context window.", - truncated_count - ) - } else { - "Compaction failed: context overflow even with minimal input.".to_string() - }; - tracing::error!("{reason}"); - - return apply_emergency_compaction_fallback( - &sess, - turn_context.as_ref(), - &sub_id, - &reason, - ) - .await; - } - Err(e) => { - if retries < max_retries { - retries += 1; - let delay = backoff(retries); - sess - .notify_stream_error( - &sub_id, - format!( - "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" - ), - ) - .await; - tokio::time::sleep(delay).await; - continue; - } else { - let event = sess.make_event( - &sub_id, - EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), - ); - sess.send_event(event).await; - return Vec::new(); - } - } - } - } - - let history_snapshot = { - let state = sess.state.lock().unwrap(); - state.history.contents() - }; - let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); - let snippets = collect_compaction_snippets(&history_snapshot); - let initial_context = sess.build_initial_context(turn_context.as_ref()); - let new_history = build_compacted_history(initial_context, &snippets, &summary_text); - - sess.replace_history(new_history.clone()); - { - let mut state = sess.state.lock().unwrap(); - state.token_usage_info = None; - } - - send_compaction_checkpoint_warning(&sess, &sub_id).await; - - let rollout_item = RolloutItem::Compacted(CompactedItem { - message: summary_text.clone(), - replacement_history: None, - }); - sess.persist_rollout_items(&[rollout_item]).await; - - let display_message = if summary_text.trim().is_empty() { - "Compact task completed.".to_string() - } else { - summary_text.clone() - }; - let event = sess.make_event( - &sub_id, - EventMsg::AgentMessage(AgentMessageEvent { - message: display_message, - }), - ); - sess.send_event(event).await; - - new_history -} - -pub fn content_items_to_text(content: &[ContentItem]) -> Option { - let mut pieces = Vec::new(); - for item in content { - match item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - if !text.is_empty() { - pieces.push(text.as_str()); - } - } - ContentItem::InputImage { .. } => {} - } - } - if pieces.is_empty() { - None - } else { - Some(pieces.join("\n")) - } -} - -fn truncate_for_compact(text: String, max_bytes: usize) -> String { - if text.len() <= max_bytes { - return text; - } - truncate_middle(&text, max_bytes).0 -} - -fn looks_like_context_overflow(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("context_length_exceeded") - || lower.contains("context length exceeded") - || lower.contains("context window") - && (lower.contains("exceed") - || lower.contains("exceeded") - || lower.contains("full") - || lower.contains("too long")) - || lower.contains("maximum context length") - || lower.contains("exceeds the context window") -} - -pub(super) fn is_context_overflow_error(err: &CodexErr) -> bool { - match err { - CodexErr::UnexpectedStatus(resp) => looks_like_context_overflow(&resp.body), - CodexErr::Stream(msg, _, _) => looks_like_context_overflow(msg), - _ => false, - } -} - -pub fn sanitize_items_for_compact(items: Vec) -> Vec { - items - .into_iter() - .filter_map(|item| match item { - ResponseItem::Message { - id, - role, - content, - .. - } => { - let mut filtered_content = Vec::with_capacity(content.len()); - for content_item in content { - match content_item { - ContentItem::InputText { text } => { - filtered_content.push(ContentItem::InputText { - text: truncate_for_compact(text, COMPACT_TEXT_CONTENT_MAX_BYTES), - }); - } - ContentItem::OutputText { text } => { - filtered_content.push(ContentItem::OutputText { - text: truncate_for_compact(text, COMPACT_TEXT_CONTENT_MAX_BYTES), - }); - } - ContentItem::InputImage { image_url } => { - if image_url.starts_with("data:") - || image_url.len() > COMPACT_IMAGE_URL_MAX_BYTES - { - let bytes = image_url.len(); - filtered_content.push(ContentItem::InputText { - text: format!( - "(image omitted for compaction; {bytes} bytes)", - ), - }); - } else { - filtered_content.push(ContentItem::InputImage { image_url }); - } - } - } - } - if filtered_content.is_empty() { - None - } else { - Some(ResponseItem::Message { - id, - role, - content: filtered_content, end_turn: None, phase: None}) - } - } - ResponseItem::FunctionCall { - id, - name, - namespace, - arguments, - call_id, - } => { - let arguments = truncate_for_compact(arguments, COMPACT_TOOL_ARGS_MAX_BYTES); - Some(ResponseItem::FunctionCall { - id, - name, - namespace, - arguments, - call_id, - }) - } - ResponseItem::FunctionCallOutput { call_id, output } => { - let success = output.success; - let content = truncate_for_compact(output.to_string(), COMPACT_TOOL_OUTPUT_MAX_BYTES); - let mut output = FunctionCallOutputPayload::from_text(content); - output.success = success; - Some(ResponseItem::FunctionCallOutput { - call_id, - output, - }) - } - ResponseItem::CustomToolCall { - id, - status, - call_id, - name, - input, - } => { - let input = truncate_for_compact(input, COMPACT_TOOL_ARGS_MAX_BYTES); - Some(ResponseItem::CustomToolCall { - id, - status, - call_id, - name, - input, - }) - } - ResponseItem::CustomToolCallOutput { - call_id, - name, - output, - } => { - let output = truncate_for_compact(output.to_string(), COMPACT_TOOL_OUTPUT_MAX_BYTES); - let output = FunctionCallOutputPayload::from_text(output); - Some(ResponseItem::CustomToolCallOutput { - call_id, - name, - output, - }) - } - ResponseItem::Reasoning { id, summary, .. } => Some(ResponseItem::Reasoning { - id, - summary, - content: None, - encrypted_content: None, - }), - other => Some(other), - }) - .collect() -} - -/// Remove tool outputs that no longer have a matching tool call in the -/// conversation slice. This can happen if earlier items are trimmed for -/// context overflow, leaving orphaned outputs that will be rejected by the -/// compact endpoint. -pub fn prune_orphan_tool_outputs(items: &mut Vec) -> usize { - let mut seen_calls: HashSet = HashSet::new(); - - for item in items.iter() { - match item { - ResponseItem::FunctionCall { call_id, .. } - | ResponseItem::ToolSearchCall { - call_id: Some(call_id), - .. - } - | ResponseItem::CustomToolCall { call_id, .. } => { - seen_calls.insert(call_id.clone()); - } - ResponseItem::LocalShellCall { - id, - call_id, - .. - } => { - if let Some(call_id) = call_id { - seen_calls.insert(call_id.clone()); - } - if let Some(id) = id { - // Chat Completions flow sets only `id`; outputs use that value as call_id. - seen_calls.insert(id.clone()); - } - } - _ => {} - } - } - - let before = items.len(); - items.retain(|item| match item { - ResponseItem::FunctionCallOutput { call_id, .. } - | ResponseItem::ToolSearchOutput { - call_id: Some(call_id), - .. - } - | ResponseItem::CustomToolCallOutput { call_id, .. } => seen_calls.contains(call_id), - _ => true, - }); - - let removed = before.saturating_sub(items.len()); - if removed > 0 { - tracing::warn!("Dropping {removed} orphaned tool output(s) during compaction"); - } - - removed -} - -fn compaction_checkpoint_warning_event() -> EventMsg { - EventMsg::CompactionCheckpointWarning(CompactionCheckpointWarningEvent { - message: COMPACTION_CHECKPOINT_MESSAGE.to_string(), - }) -} - -pub(super) async fn send_compaction_checkpoint_warning(sess: &Arc, sub_id: &str) { - let event = sess.make_event(sub_id, compaction_checkpoint_warning_event()); - sess.send_event(event).await; -} - -#[cfg(test)] -pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { - collect_compaction_snippets(items) - .into_iter() - .filter(|snippet| snippet.role == "user") - .map(|snippet| snippet.text) - .collect() -} - -pub fn is_session_prefix_message(text: &str) -> bool { - matches!( - InputMessageKind::from(("user", text)), - InputMessageKind::UserInstructions | InputMessageKind::EnvironmentContext - ) -} - -pub(crate) fn build_compacted_history( - initial_context: Vec, - snippets: &[CompactionSnippet], - summary_text: &str, -) -> Vec { - let mut history = initial_context; - history.push(make_compaction_summary_message(snippets, summary_text)); - history -} - -/// Build an emergency fallback history when compaction fails catastrophically. -/// This returns just the initial context plus a warning message, ensuring the -/// session can continue without hitting infinite retry loops. -pub(crate) fn build_emergency_compacted_history( - initial_context: Vec, - warning_message: &str, -) -> Vec { - let mut history = initial_context; - history.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: warning_message.to_string(), - }], end_turn: None, phase: None}); - history -} - -async fn drain_to_completed( - sess: &Session, - turn_context: &TurnContext, - prompt: &Prompt, -) -> CodexResult<()> { - let mut stream = turn_context.client.clone().stream(prompt).await?; - let result = tokio::time::timeout(COMPACT_STREAM_TIMEOUT, async { - loop { - let maybe_event = stream.next().await; - let Some(event) = maybe_event else { - return Err(CodexErr::Stream( - "stream closed before response.completed".into(), - None, - None, - )); - }; - match event { - Ok(ResponseEvent::OutputItemDone { item, .. }) => { - let mut state = sess.state.lock().unwrap(); - state.history.record_items(std::slice::from_ref(&item)); - } - Ok(ResponseEvent::Completed { .. }) => { - return Ok(()); - } - Ok(_) => continue, - Err(e) => return Err(e), - } - } - }) - .await; - - match result { - Ok(res) => res, - Err(_) => Err(CodexErr::Stream( - "compaction stream timed out".into(), - None, - None, - )), - } -} - -// Helper copied from codex.rs (private there): convert core InputItem -> ResponseInputItem -pub(super) fn response_input_from_core_items(items: Vec) -> ResponseInputItem { - let mut content_items = Vec::new(); - - for item in items { - match item { - InputItem::Text { text } => { - content_items.push(ContentItem::InputText { text }); - } - InputItem::Image { image_url } => { - content_items.push(ContentItem::InputImage { image_url }); - } - InputItem::LocalImage { path } => match std::fs::read(&path) { - Ok(bytes) => { - let mime = mime_guess::from_path(&path) - .first() - .map(|m| m.essence_str().to_owned()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - content_items.push(ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - }); - } - Err(err) => { - tracing::warn!( - "Skipping image {} – could not read file: {}", - path.display(), - err - ); - } - }, - InputItem::EphemeralImage { path, metadata } => { - if let Some(meta) = metadata { - content_items.push(ContentItem::InputText { - text: format!("[EPHEMERAL:{}]", meta), - }); - } - match std::fs::read(&path) { - Ok(bytes) => { - let mime = mime_guess::from_path(&path) - .first() - .map(|m| m.essence_str().to_owned()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - content_items.push(ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - }); - } - Err(err) => { - tracing::error!( - "Failed to read ephemeral image {} – {}", - path.display(), - err - ); - } - } - } - } - } - - ResponseInputItem::Message { - role: "user".to_string(), - content: content_items, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn resolve_compact_prompt_text_prefers_override() { - let text = resolve_compact_prompt_text(Some("custom prompt")); - assert_eq!(text, "custom prompt"); - } - - #[test] - fn resolve_compact_prompt_text_falls_back_on_blank() { - let text = resolve_compact_prompt_text(Some(" \n\t")); - assert_eq!(text, SUMMARIZATION_PROMPT); - } - - #[test] - fn content_items_to_text_joins_non_empty_segments() { - let items = vec![ - ContentItem::InputText { - text: "hello".to_string(), - }, - ContentItem::OutputText { - text: String::new(), - }, - ContentItem::OutputText { - text: "world".to_string(), - }, - ]; - - let joined = content_items_to_text(&items); - - assert_eq!(Some("hello\nworld".to_string()), joined); - } - - #[test] - fn content_items_to_text_ignores_image_only_content() { - let items = vec![ContentItem::InputImage { - image_url: "file://image.png".to_string(), - }]; - - let joined = content_items_to_text(&items); - - assert_eq!(None, joined); - } - - #[test] - fn collect_user_messages_extracts_user_text_only() { - let items = vec![ - ResponseItem::Message { - id: Some("assistant".to_string()), - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "ignored".to_string(), - }], end_turn: None, phase: None}, - ResponseItem::Message { - id: Some("user".to_string()), - role: "user".to_string(), - content: vec![ - ContentItem::InputText { - text: "first".to_string(), - }, - ContentItem::OutputText { - text: "second".to_string(), - }, - ], end_turn: None, phase: None}, - ResponseItem::Other, - ]; - - let collected = collect_user_messages(&items); - - assert_eq!(vec!["first\nsecond".to_string()], collected); - } - - #[test] - fn collect_user_messages_filters_session_prefix_entries() { - let items = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for /tmp\n\n\ndo things\n" - .to_string(), - }], end_turn: None, phase: None}, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "cwd=/tmp".to_string(), - }], end_turn: None, phase: None}, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "real user message".to_string(), - }], end_turn: None, phase: None}, - ]; - - let collected = collect_user_messages(&items); - - assert_eq!( - vec![ - "# AGENTS.md instructions for /tmp\n\n\ndo things\n" - .to_string(), - "cwd=/tmp".to_string(), - "real user message".to_string(), - ], - collected - ); - } - - #[test] - fn collect_compaction_snippets_limits_messages() { - let mut items = Vec::new(); - for idx in 0..15 { - items.push(ResponseItem::Message { - id: None, - role: if idx % 2 == 0 { "user".to_string() } else { "assistant".to_string() }, - content: vec![ContentItem::InputText { - text: format!("Message #{idx} {}", "x".repeat(1024)), - }], end_turn: None, phase: None}); - } - - let snippets = collect_compaction_snippets(&items); - assert!(snippets.len() <= MAX_COMPACTION_SNIPPETS); - assert!(snippets.iter().any(|snippet| snippet.role == "user")); - assert!(snippets.last().unwrap().text.contains("Message #14")); - } - - #[test] - fn make_compaction_summary_message_renders_template() { - let snippets = vec![ - CompactionSnippet { - role: "user".to_string(), - text: "Investigate bug".to_string(), - }, - CompactionSnippet { - role: "assistant".to_string(), - text: "Proposed fix".to_string(), - }, - ]; - let message = make_compaction_summary_message(&snippets, "Apply patch to parser"); - let ResponseItem::Message { content, .. } = message else { - panic!("expected message variant"); - }; - let body = content_items_to_text(&content).expect("text body"); - assert!(body.contains("(user) Investigate bug")); - assert!(body.contains("Key takeaways")); - assert!(body.contains("Apply patch to parser")); - } - - #[test] - fn build_compacted_history_truncates_overlong_user_messages() { - // Prepare a very large prior user message so the aggregated - // `user_messages_text` exceeds the truncation threshold used by - // `build_compacted_history` (80k bytes). - let big = "X".repeat(200_000); - let snippet_source = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: big.clone() }], end_turn: None, phase: None}]; - let snippets = collect_compaction_snippets(&snippet_source); - let history = build_compacted_history(Vec::new(), &snippets, "SUMMARY"); - - // Expect exactly one bridge message added to history (plus any initial context we provided, which is none). - assert_eq!(history.len(), 1); - - // Extract the text content of the bridge message. - let bridge_text = match &history[0] { - ResponseItem::Message { role, content, .. } if role == "user" => { - content_items_to_text(content).unwrap_or_default() - } - other => panic!("unexpected item in history: {other:?}"), - }; - - assert!(bridge_text.contains("Key takeaways")); - assert!(bridge_text.contains("SUMMARY")); - assert!(bridge_text.len() < big.len()); - } - - #[test] - fn build_emergency_compacted_history_creates_minimal_history() { - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for /tmp\n\n\ntest\n" - .to_string(), - }], end_turn: None, phase: None}, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "cwd=/tmp".to_string(), - }], end_turn: None, phase: None}, - ]; - - let warning = "Emergency fallback"; - let history = build_emergency_compacted_history(initial_context.clone(), warning); - - assert_eq!(history.len(), initial_context.len() + 1); - - if let ResponseItem::Message { role, content, .. } = &history[history.len() - 1] { - assert_eq!(role, "user"); - let text = content_items_to_text(content).unwrap(); - assert_eq!(text, warning); - } else { - panic!("Expected warning message"); - } - - assert_eq!(history[0], initial_context[0]); - assert_eq!(history[1], initial_context[1]); - } - - #[test] - fn build_emergency_compacted_history_with_empty_context() { - let warning = "Emergency fallback"; - let history = build_emergency_compacted_history(Vec::new(), warning); - - assert_eq!(history.len(), 1); - - if let ResponseItem::Message { role, content, .. } = &history[0] { - assert_eq!(role, "user"); - let text = content_items_to_text(content).unwrap(); - assert_eq!(text, warning); - } else { - panic!("Expected warning message"); - } - } - - #[test] - fn compaction_checkpoint_warning_event_has_copy() { - match compaction_checkpoint_warning_event() { - EventMsg::CompactionCheckpointWarning(payload) => { - assert!(payload.message.contains("checkpoint")); - } - other => panic!("unexpected variant: {other:?}"), - } - } -} diff --git a/code-rs/core/src/codex/compact_remote.rs b/code-rs/core/src/codex/compact_remote.rs deleted file mode 100644 index 310a7941ea1..00000000000 --- a/code-rs/core/src/codex/compact_remote.rs +++ /dev/null @@ -1,292 +0,0 @@ -use std::sync::Arc; - -use super::compact::{ - apply_emergency_compaction_fallback, - is_context_overflow_error, - prune_orphan_tool_outputs, - response_input_from_core_items, - sanitize_items_for_compact, - send_compaction_checkpoint_warning, -}; -use super::Session; -use super::TurnContext; -use crate::Prompt; -use crate::error::CodexErr; -use crate::error::Result as CodexResult; -use crate::error::RetryAfter; -use crate::protocol::AgentMessageEvent; -use crate::protocol::ErrorEvent; -use crate::protocol::EventMsg; -use crate::protocol::InputItem; -use crate::util::backoff; -use code_protocol::models::ResponseInputItem; -use code_protocol::models::ResponseItem; -use code_protocol::protocol::CompactedItem; -use code_protocol::protocol::RolloutItem; -use std::time::Duration; - -const MAX_REMOTE_COMPACT_CONTEXT_OVERFLOW_RETRIES: usize = 1; -const REMOTE_COMPACT_OVERFLOW_RECENT_ITEM_LIMIT: usize = 64; -const MAX_REMOTE_COMPACT_USAGE_LIMIT_RETRIES: usize = 2; - -pub(super) async fn run_inline_remote_auto_compact_task( - sess: Arc, - turn_context: Arc, - extra_input: Vec, -) -> Vec { - let sub_id = sess.next_internal_sub_id(); - match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input).await { - Ok(history) => history, - Err(err) => { - let event = sess.make_event( - &sub_id, - EventMsg::Error(ErrorEvent { - message: format!("remote compact failed: {err}"), - }), - ); - sess.send_event(event).await; - Vec::new() - } - } -} - -pub(super) async fn run_remote_compact_task( - sess: Arc, - turn_context: Arc, - sub_id: String, - extra_input: Vec, -) -> CodexResult<()> { - match run_remote_compact_task_inner(&sess, &turn_context, &sub_id, extra_input).await { - Ok(_history) => { - // Mirror local compaction behaviour: clear the running task when the - // compaction finished successfully so the UI can unblock. - sess.remove_task(&sub_id); - Ok(()) - } - Err(err) => { - let event = sess.make_event( - &sub_id, - EventMsg::Error(ErrorEvent { - message: err.to_string(), - }), - ); - sess.send_event(event).await; - Err(err) - } - } -} - -async fn run_remote_compact_task_inner( - sess: &Arc, - turn_context: &Arc, - sub_id: &str, - extra_input: Vec, -) -> CodexResult> { - let mut turn_items = sess.turn_input_with_history({ - if extra_input.is_empty() { - Vec::new() - } else { - let response_input: ResponseInputItem = response_input_from_core_items(extra_input); - vec![ResponseItem::from(response_input)] - } - }); - - turn_items = sanitize_items_for_compact(turn_items); - let mut overflow_retries = 0usize; - let mut overflow_trimmed_count = 0usize; - let max_retries = turn_context.client.get_provider().stream_max_retries(); - let mut retries = 0; - let mut usage_limit_retries = 0usize; - let new_history = loop { - prune_orphan_tool_outputs(&mut turn_items); - - let mut prompt = Prompt::default(); - prompt.input = turn_items.clone(); - prompt.base_instructions_override = turn_context.base_instructions.clone(); - prompt.include_additional_instructions = false; - prompt.log_tag = Some("codex/remote-compact".to_string()); - - let _used_fallback_model_metadata = sess.apply_remote_model_overrides(&mut prompt).await; - - match turn_context - .client - .compact_conversation_history(&prompt) - .await - { - Ok(history) => { - if overflow_trimmed_count > 0 { - tracing::warn!( - "Context window exceeded during remote compact; retried after trimming {overflow_trimmed_count} item(s) from prompt" - ); - } - break history; - } - Err(err) if is_context_overflow_error(&err) => { - if overflow_retries < MAX_REMOTE_COMPACT_CONTEXT_OVERFLOW_RETRIES { - let removed = trim_remote_compact_input_after_overflow(&mut turn_items); - if removed == 0 { - let reason = "Remote compact failed: context overflow even with minimal input."; - return Ok( - apply_emergency_compaction_fallback( - sess, - turn_context.as_ref(), - sub_id, - reason, - ) - .await, - ); - } - - overflow_retries = overflow_retries.saturating_add(1); - overflow_trimmed_count = overflow_trimmed_count.saturating_add(removed); - tracing::warn!( - "Context window exceeded while remote compacting; trimmed {removed} oldest item(s), retaining {} recent item(s)", - turn_items.len() - ); - retries = 0; - usage_limit_retries = 0; - continue; - } - - let reason = format!( - "Remote compact retried with reduced recent history but still exceeded the context window after trimming {overflow_trimmed_count} item(s)." - ); - return Ok( - apply_emergency_compaction_fallback( - sess, - turn_context.as_ref(), - sub_id, - &reason, - ) - .await, - ); - } - Err(CodexErr::UsageLimitReached(limit_err)) => { - if usage_limit_retries >= MAX_REMOTE_COMPACT_USAGE_LIMIT_RETRIES { - let reason = "Remote compact hit persistent usage limits and cannot continue."; - return Ok( - apply_emergency_compaction_fallback( - sess, - turn_context.as_ref(), - sub_id, - reason, - ) - .await, - ); - } - usage_limit_retries = usage_limit_retries.saturating_add(1); - let now = chrono::Utc::now(); - let retry_after = limit_err - .retry_after(now) - .unwrap_or_else(|| RetryAfter::from_duration(Duration::from_secs(5 * 60), now)); - let mut message = format!("{limit_err} Auto-retrying"); - message.push('…'); - sess.notify_stream_error(sub_id, message).await; - tokio::time::sleep(retry_after.delay).await; - retries = 0; - continue; - } - Err(err) => { - if retries < max_retries { - retries += 1; - let delay = backoff(retries); - sess - .notify_stream_error( - sub_id, - format!( - "remote compact error: {err}; retrying {retries}/{max_retries} in {delay:?}…" - ), - ) - .await; - tokio::time::sleep(delay).await; - continue; - } - - return Err(err); - } - } - }; - - sess.replace_history(new_history.clone()); - { - let mut state = sess.state.lock().unwrap(); - state.token_usage_info = None; - } - - send_compaction_checkpoint_warning(sess, sub_id).await; - - let rollout_item = RolloutItem::Compacted(CompactedItem { - message: "Conversation history compacted.".to_string(), - replacement_history: None, - }); - sess.persist_rollout_items(&[rollout_item]).await; - - let event = sess.make_event( - sub_id, - EventMsg::AgentMessage(AgentMessageEvent { - message: "Compact task completed".to_string(), - }), - ); - sess.send_event(event).await; - - Ok(new_history) -} - -fn trim_remote_compact_input_after_overflow(turn_items: &mut Vec) -> usize { - let before = turn_items.len(); - if before <= 1 { - return 0; - } - - let keep = (before / 2) - .max(1) - .min(REMOTE_COMPACT_OVERFLOW_RECENT_ITEM_LIMIT); - let remove_count = before.saturating_sub(keep); - turn_items.drain(0..remove_count); - remove_count -} - -#[cfg(test)] -mod tests { - use super::*; - use code_protocol::models::ContentItem; - - fn user_item(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - #[test] - fn overflow_retry_trims_to_recent_bounded_history() { - let mut items = (0..200) - .map(|idx| user_item(&format!("item {idx}"))) - .collect::>(); - - let removed = trim_remote_compact_input_after_overflow(&mut items); - - assert_eq!(removed, 136); - assert_eq!(items.len(), 64); - assert!(matches!( - &items[0], - ResponseItem::Message { content, .. } - if matches!(content.first(), Some(ContentItem::InputText { text }) if text == "item 136") - )); - } - - #[test] - fn overflow_retry_does_not_trim_minimal_input() { - let mut items = vec![user_item("current input")]; - - let removed = trim_remote_compact_input_after_overflow(&mut items); - - assert_eq!(removed, 0); - assert_eq!(items.len(), 1); - } -} diff --git a/code-rs/core/src/codex/events.rs b/code-rs/core/src/codex/events.rs deleted file mode 100644 index 26d6f009feb..00000000000 --- a/code-rs/core/src/codex/events.rs +++ /dev/null @@ -1,134 +0,0 @@ -use super::*; -use super::session::MAX_EVENT_SEQ_SUB_IDS; - -impl Session { - pub(crate) async fn send_event(&self, event: Event) { - if let Err(e) = self.tx_event.send(event).await { - error!("failed to send tool call event: {e}"); - } - } - - /// Persist an event into the rollout log if appropriate. - fn persist_event(&self, event: &Event) { - if !crate::rollout::policy::should_persist_event_msg(&event.msg) { - return; - } - let Some(msg) = crate::protocol::event_msg_to_protocol(&event.msg) else { - return; - }; - let recorder = { - let guard = self.rollout.lock().unwrap(); - guard.as_ref().cloned() - }; - if let Some(rec) = recorder { - let order = event - .order - .as_ref() - .map(crate::protocol::order_meta_to_protocol); - let protocol_event = code_protocol::protocol::RecordedEvent { - id: event.id.clone(), - event_seq: event.event_seq, - order, - msg, - }; - tokio::spawn(async move { - if let Err(e) = rec.record_events(&[protocol_event]).await { - warn!("failed to persist rollout event: {e}"); - } - }); - } - } - - /// Create a stamped Event with a per-turn sequence number. - fn stamp_event(&self, sub_id: &str, msg: EventMsg) -> Event { - let mut state = self.state.lock().unwrap(); - if state.event_seq_by_sub_id.len() > MAX_EVENT_SEQ_SUB_IDS { - while state.event_seq_by_sub_id.len() > MAX_EVENT_SEQ_SUB_IDS { - let Some(old_key) = state.event_seq_by_sub_id.keys().next().cloned() else { - break; - }; - state.event_seq_by_sub_id.remove(&old_key); - } - warn!( - cap = MAX_EVENT_SEQ_SUB_IDS, - retained = state.event_seq_by_sub_id.len(), - "trimmed event_seq_by_sub_id map to cap long-session growth" - ); - } - let seq = match msg { - EventMsg::TaskStarted => { - // Reset per-sub_id sequence at the start of a turn. - // We increment request_ordinal per HTTP attempt instead - // (see `begin_http_attempt`). - let e = state - .event_seq_by_sub_id - .entry(sub_id.to_string()) - .or_insert(0); - *e = 0; - 0 - } - _ => { - let e = state - .event_seq_by_sub_id - .entry(sub_id.to_string()) - .or_insert(0); - *e = e.saturating_add(1); - *e - } - }; - Event { - id: sub_id.to_string(), - event_seq: seq, - msg, - order: None, - } - } - - pub(crate) fn make_event(&self, sub_id: &str, msg: EventMsg) -> Event { - let event = self.stamp_event(sub_id, msg); - self.persist_event(&event); - event - } - - /// Same as make_event but allows supplying a provider sequence_number - /// (e.g., Responses API SSE event). We DO NOT overwrite `event_seq` - /// with this hint because `event_seq` must remain monotonic per turn - /// and local to our runtime. Provider ordering is carried via - /// `OrderMeta` when applicable. - pub(super) fn make_event_with_hint( - &self, - sub_id: &str, - msg: EventMsg, - _seq_hint: Option, - ) -> Event { - let event = self.stamp_event(sub_id, msg); - self.persist_event(&event); - event - } - - pub(super) fn make_event_with_order( - &self, - sub_id: &str, - msg: EventMsg, - order: crate::protocol::OrderMeta, - _seq_hint: Option, - ) -> Event { - let mut ev = self.stamp_event(sub_id, msg); - ev.order = Some(order); - self.persist_event(&ev); - ev - } - - // Kept private helpers focused on ctx-based flow to avoid misuse. - - pub(crate) async fn send_ordered_from_ctx(&self, ctx: &ToolCallCtx, msg: EventMsg) { - let order = ctx.order_meta(self.current_request_ordinal()); - let ev = self.make_event_with_order(&ctx.sub_id, msg, order, ctx.seq_hint); - let _ = self.tx_event.send(ev).await; - } - - pub(super) fn current_request_ordinal(&self) -> u64 { - let state = self.state.lock().unwrap(); - state.request_ordinal - } -} diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs deleted file mode 100644 index 7f98da40320..00000000000 --- a/code-rs/core/src/codex/exec.rs +++ /dev/null @@ -1,1209 +0,0 @@ -use super::*; -use super::session::{HookGuard, RunningExecMeta}; -use crate::util::is_shell_like_executable; -use std::ffi::OsString; - -fn synthetic_exec_end_payload(cancelled: bool) -> (i32, String) { - if cancelled { - (130, "Command cancelled by user.".to_string()) - } else { - (130, "Command interrupted before completion.".to_string()) - } -} - -struct ExecDropGuard { - sub_id: String, - call_id: String, - order_meta: crate::protocol::OrderMeta, - tx_event: Sender, - cancel_flag: Arc, - end_emitted: Arc, - session: Weak, - completed: bool, -} - -impl ExecDropGuard { - fn new( - session: Weak, - tx_event: Sender, - sub_id: String, - call_id: String, - order_meta: crate::protocol::OrderMeta, - cancel_flag: Arc, - end_emitted: Arc, - ) -> Self { - Self { - sub_id, - call_id, - order_meta, - tx_event, - cancel_flag, - end_emitted, - session, - completed: false, - } - } - - fn mark_completed(&mut self) { - self.completed = true; - self.end_emitted.store(true, Ordering::Release); - self.remove_from_registry(); - } - - fn remove_from_registry(&self) { - if let Some(session) = self.session.upgrade() { - session.unregister_running_exec(&self.call_id); - } - } -} - -impl Drop for ExecDropGuard { - fn drop(&mut self) { - self.remove_from_registry(); - - if self.completed { - return; - } - - if self.end_emitted.swap(true, Ordering::AcqRel) { - return; - } - - let (exit_code, stderr) = synthetic_exec_end_payload( - self.cancel_flag.load(Ordering::Acquire), - ); - let msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: self.call_id.clone(), - stdout: String::new(), - stderr, - exit_code, - duration: Duration::ZERO, - }); - - if let Some(session) = self.session.upgrade() { - let event = session.make_event_with_order( - &self.sub_id, - msg, - self.order_meta.clone(), - self.order_meta.sequence_number, - ); - let _ = self.tx_event.try_send(event); - } else { - // Fallback: emit directly if session no longer exists. - let event = Event { - id: self.sub_id.clone(), - event_seq: 0, - msg, - order: Some(self.order_meta.clone()), - }; - let _ = self.tx_event.try_send(event); - } - } -} - -#[derive(Clone, Debug)] -pub(crate) struct ExecCommandContext { - pub(crate) sub_id: String, - pub(crate) call_id: String, - pub(crate) command_for_display: Vec, - pub(crate) cwd: PathBuf, - pub(crate) apply_patch: Option, -} - -#[derive(Clone, Debug)] -pub(crate) struct ApplyPatchCommandContext { - pub(crate) user_explicitly_approved_this_action: bool, - pub(crate) changes: HashMap, -} - -fn sanitize_identifier(value: &str) -> String { - let mut slug = String::with_capacity(value.len()); - for ch in value.chars() { - if ch.is_ascii_alphanumeric() { - slug.push(ch.to_ascii_lowercase()); - } else { - slug.push('_'); - } - } - while slug.starts_with('_') { - slug.remove(0); - } - if slug.is_empty() { - slug.push_str("hook"); - } - slug -} - -fn truncate_payload(text: &str, limit: usize) -> String { - let mut iter = text.chars(); - let truncated: String = iter.by_ref().take(limit).collect(); - if iter.next().is_some() { - format!("{truncated}…") - } else { - truncated - } -} - -fn build_exec_hook_payload( - event: ProjectHookEvent, - ctx: &ExecCommandContext, - params: &ExecParams, - output: Option<&ExecToolCallOutput>, -) -> Value { - let base = json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - }); - - match event { - ProjectHookEvent::ToolBefore => base, - ProjectHookEvent::ToolAfter => { - if let Some(out) = output { - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "exit_code": out.exit_code, - "duration_ms": out.duration.as_millis(), - "timed_out": out.timed_out, - "stdout": truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT), - "stderr": truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT), - }) - } else { - base - } - } - ProjectHookEvent::FileBeforeWrite => { - let changes = ctx - .apply_patch - .as_ref() - .and_then(|p| serde_json::to_value(&p.changes).ok()) - .unwrap_or(Value::Null); - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "changes": changes, - }) - } - ProjectHookEvent::FileAfterWrite => { - let changes = ctx - .apply_patch - .as_ref() - .and_then(|p| serde_json::to_value(&p.changes).ok()) - .unwrap_or(Value::Null); - if let Some(out) = output { - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "changes": changes, - "exit_code": out.exit_code, - "duration_ms": out.duration.as_millis(), - "timed_out": out.timed_out, - "stdout": truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT), - "stderr": truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT), - "success": out.exit_code == 0, - }) - } else { - json!({ - "event": event.as_str(), - "call_id": ctx.call_id, - "cwd": ctx.cwd.to_string_lossy(), - "command": params.command, - "timeout_ms": params.timeout_ms, - "changes": changes, - }) - } - } - _ => base, - } -} - -pub struct ExecInvokeArgs<'a> { - pub params: ExecParams, - pub sandbox_type: SandboxType, - pub sandbox_policy: &'a SandboxPolicy, - pub sandbox_cwd: &'a std::path::Path, - pub code_linux_sandbox_exe: &'a Option, - pub stdout_stream: Option, -} - -fn materialize_shell_script(user_shell: &crate::shell::Shell, mut params: ExecParams) -> ExecParams { - if let Some(shell_script) = params.shell_script.take() { - let command = match params.command.as_slice() { - [command] => command.clone(), - _ => shell_script.command, - }; - params.command = user_shell - .shell_script_invocation_or_default(command, shell_script.use_login_shell); - } - params -} - -pub(super) fn maybe_run_with_user_profile(mut params: ExecParams, sess: &Session) -> ExecParams { - maybe_apply_python_runtime_env(&mut params); - - let had_shell_script = params.shell_script.is_some(); - params = materialize_shell_script(&sess.user_shell, params); - - if !had_shell_script && sess.shell_environment_policy.use_profile { - let maybe_command = sess - .user_shell - .format_default_shell_invocation(params.command.clone()); - if let Some(command) = maybe_command { - params.command = command; - } - } - - suppress_bash_job_control(&mut params.command); - - params -} - -fn maybe_apply_python_runtime_env(params: &mut ExecParams) { - if !command_needs_python_runtime(¶ms.command) { - return; - } - - let Some(virtualenv_root) = find_virtualenv_root(¶ms.cwd) else { - return; - }; - let Some(bin_dir) = virtualenv_bin_dir(&virtualenv_root) else { - return; - }; - - params.env.insert( - "PATH".to_string(), - prepend_path_for_env(params.env.get("PATH"), &bin_dir), - ); - params.env.insert( - "VIRTUAL_ENV".to_string(), - virtualenv_root.to_string_lossy().to_string(), - ); -} - -fn command_needs_python_runtime(command: &[String]) -> bool { - let Some(program) = extract_primary_command_token(command) else { - return false; - }; - - let normalized = std::path::Path::new(&program) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(program.as_str()) - .to_ascii_lowercase(); - - normalized == "python" - || normalized == "python3" - || normalized == "python2" - || normalized == "pip" - || normalized == "pip3" - || normalized == "pytest" - || normalized == "mypy" - || normalized == "pyright" - || normalized == "ruff" -} - -fn extract_primary_command_token(command: &[String]) -> Option { - match command { - [program, flag, script] if is_shell_like_executable(program) && (flag == "-lc" || flag == "-c") => { - let tokens = shlex::split(script)?; - first_non_assignment_token(&tokens) - } - _ => first_non_assignment_token(command), - } -} - -fn first_non_assignment_token(tokens: &[T]) -> Option -where - T: AsRef, -{ - tokens - .iter() - .map(AsRef::as_ref) - .find(|token| !is_env_assignment_token(token)) - .map(str::to_string) -} - -fn is_env_assignment_token(token: &str) -> bool { - if token.is_empty() || token.starts_with('-') { - return false; - } - let Some((name, _value)) = token.split_once('=') else { - return false; - }; - !name.is_empty() - && name - .chars() - .all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) -} - -fn find_virtualenv_root(cwd: &Path) -> Option { - for dir in cwd.ancestors() { - for candidate_name in [".venv", "venv"] { - let candidate = dir.join(candidate_name); - if virtualenv_bin_dir(&candidate).is_some() { - return Some(candidate); - } - } - } - None -} - -fn virtualenv_bin_dir(virtualenv_root: &Path) -> Option { - let unix_bin = virtualenv_root.join("bin"); - if unix_bin.is_dir() { - return Some(unix_bin); - } - let windows_bin = virtualenv_root.join("Scripts"); - if windows_bin.is_dir() { - return Some(windows_bin); - } - None -} - -fn prepend_path_for_env(existing_path: Option<&String>, bin_dir: &Path) -> String { - let existing = existing_path - .map(OsString::from) - .or_else(|| std::env::var_os("PATH")); - let mut parts: Vec = Vec::new(); - parts.push(bin_dir.to_path_buf()); - if let Some(existing) = existing { - parts.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(parts) - .unwrap_or_else(|_| bin_dir.as_os_str().to_os_string()) - .to_string_lossy() - .to_string() -} - -fn suppress_bash_job_control(command: &mut [String]) { - let [program, flag, script] = command else { - return; - }; - if !is_bash_executable(program) || flag != "-lc" { - return; - } - - let trimmed = script.trim_start(); - if trimmed.starts_with("set +m") { - return; - } - - let original = script.clone(); - *script = format!("set +m; {original}"); -} - -fn is_bash_executable(token: &str) -> bool { - let trimmed = token.trim_matches('"').trim_matches('\''); - let name = std::path::Path::new(trimmed) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(trimmed) - .to_ascii_lowercase(); - matches!(name.as_str(), "bash" | "bash.exe") -} - -impl Session { - pub(super) async fn on_exec_command_begin( - &self, - turn_diff_tracker: &mut TurnDiffTracker, - exec_command_context: ExecCommandContext, - seq_hint: Option, - output_index: Option, - attempt_req: u64, - ) { - let ExecCommandContext { - sub_id, - call_id, - command_for_display, - cwd, - apply_patch, - } = exec_command_context; - let msg = match apply_patch { - Some(ApplyPatchCommandContext { - user_explicitly_approved_this_action, - changes, - }) => { - turn_diff_tracker.on_patch_begin(&changes); - - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved: !user_explicitly_approved_this_action, - changes, - }) - } - None => EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id, - command: command_for_display.clone(), - cwd, - parsed_cmd: parse_command(&command_for_display), - }), - }; - let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint }; - let event = self.make_event_with_order(&sub_id, msg, order, seq_hint); - let _ = self.tx_event.send(event).await; - } - - async fn on_exec_command_end( - &self, - turn_diff_tracker: &mut TurnDiffTracker, - sub_id: &str, - call_id: &str, - output: &ExecToolCallOutput, - is_apply_patch: bool, - seq_hint: Option, - output_index: Option, - attempt_req: u64, - ) { - let ExecToolCallOutput { - stdout, - stderr, - aggregated_output: _, - duration, - exit_code, - timed_out: _, - } = output; - // Because stdout and stderr could each be up to 100 KiB, we send - // truncated versions. - const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB - let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect(); - let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect(); - // Precompute formatted output if needed in future for logging/pretty UI. - - let msg = if is_apply_patch { - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: call_id.to_string(), - stdout, - stderr, - success: *exit_code == 0, - }) - } else { - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: call_id.to_string(), - stdout, - stderr, - exit_code: *exit_code, - duration: *duration, - }) - }; - let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint }; - let event = self.make_event_with_order(sub_id, msg, order, seq_hint); - let _ = self.tx_event.send(event).await; - - // If this is an apply_patch, after we emit the end patch, emit a second event - // with the full turn diff if there is one. - if is_apply_patch { - let unified_diff = turn_diff_tracker.get_unified_diff(); - if let Ok(Some(unified_diff)) = unified_diff { - let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); - let event = self.make_event(sub_id, msg); - let _ = self.tx_event.send(event).await; - } - } - - } - /// Runs the exec tool call and emits events for the begin and end of the - /// command even on error. - /// - /// Returns the output of the exec tool call. - pub(super) async fn run_exec_with_events<'a>( - &self, - turn_diff_tracker: &mut TurnDiffTracker, - begin_ctx: ExecCommandContext, - exec_args: ExecInvokeArgs<'a>, - seq_hint: Option, - output_index: Option, - attempt_req: u64, - ) -> crate::error::Result { - self - .run_exec_with_events_inner( - turn_diff_tracker, - begin_ctx, - exec_args, - seq_hint, - output_index, - attempt_req, - true, - ) - .await - } - - fn track_running_exec( - &self, - call_id: &str, - sub_id: &str, - order_meta: crate::protocol::OrderMeta, - cancel_flag: Arc, - end_emitted: Arc, - ) { - let mut state = self.state.lock().unwrap(); - state.running_execs.insert( - call_id.to_string(), - RunningExecMeta { - sub_id: sub_id.to_string(), - order_meta, - cancel_flag, - end_emitted, - }, - ); - } - - fn unregister_running_exec(&self, call_id: &str) { - let mut state = self.state.lock().unwrap(); - state.running_execs.remove(call_id); - } - - fn mark_running_exec_as_cancelled(&self, sub_id: &str) { - let state = self.state.lock().unwrap(); - for meta in state.running_execs.values() { - if meta.sub_id == sub_id { - meta.cancel_flag.store(true, Ordering::Release); - } - } - } - - pub(super) fn mark_all_running_execs_as_cancelled(&self) { - let sub_ids: Vec = { - let state = self.state.lock().unwrap(); - state - .running_execs - .values() - .map(|meta| meta.sub_id.clone()) - .collect() - }; - for sub_id in sub_ids { - self.mark_running_exec_as_cancelled(&sub_id); - } - } - - async fn finalize_cancelled_execs(&self, sub_id: &str) { - let mut to_emit = Vec::new(); - { - let mut state = self.state.lock().unwrap(); - let mut remove_keys = Vec::new(); - for (call_id, meta) in state.running_execs.iter() { - if meta.sub_id == sub_id && !meta.end_emitted.load(Ordering::Acquire) { - to_emit.push(( - call_id.clone(), - meta.order_meta.clone(), - meta.cancel_flag.clone(), - meta.end_emitted.clone(), - )); - remove_keys.push(call_id.clone()); - } - } - for key in remove_keys { - state.running_execs.remove(&key); - } - } - - for (call_id, order_meta, cancel_flag, end_emitted) in to_emit { - cancel_flag.store(true, Ordering::Release); - if !end_emitted.swap(true, Ordering::AcqRel) { - let (exit_code, stderr) = synthetic_exec_end_payload(true); - let msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, - stdout: String::new(), - stderr, - exit_code, - duration: Duration::ZERO, - }); - let event = self.make_event_with_order(sub_id, msg, order_meta.clone(), order_meta.sequence_number); - let _ = self.tx_event.send(event).await; - } - } - } - - async fn run_exec_with_events_inner<'a>( - &self, - turn_diff_tracker: &mut TurnDiffTracker, - begin_ctx: ExecCommandContext, - exec_args: ExecInvokeArgs<'a>, - seq_hint: Option, - output_index: Option, - attempt_req: u64, - enable_hooks: bool, - ) -> crate::error::Result { - let is_apply_patch = begin_ctx.apply_patch.is_some(); - let sub_id = begin_ctx.sub_id.clone(); - let call_id = begin_ctx.call_id.clone(); - - let order_for_end = crate::protocol::OrderMeta { - request_ordinal: attempt_req, - output_index, - sequence_number: seq_hint.map(|h| h.saturating_add(1)), - }; - - let cancel_flag = Arc::new(AtomicBool::new(false)); - let end_emitted = Arc::new(AtomicBool::new(false)); - self.track_running_exec(&call_id, &sub_id, order_for_end.clone(), cancel_flag.clone(), end_emitted.clone()); - - let mut exec_guard = ExecDropGuard::new( - self.self_handle.clone(), - self.tx_event.clone(), - sub_id.clone(), - call_id.clone(), - order_for_end.clone(), - cancel_flag, - end_emitted, - ); - - let ExecInvokeArgs { params, sandbox_type, sandbox_policy, sandbox_cwd, code_linux_sandbox_exe, stdout_stream } = exec_args; - let tracking_command = params.command.clone(); - let dry_run_analysis = analyze_command(&tracking_command); - let params = maybe_run_with_user_profile(params, self); - let params_for_hooks = if enable_hooks { - Some(params.clone()) - } else { - None - }; - - if enable_hooks { - if let Some(params_ref) = params_for_hooks.as_ref() { - let before_event = if is_apply_patch { - ProjectHookEvent::FileBeforeWrite - } else { - ProjectHookEvent::ToolBefore - }; - self - .run_hooks_for_exec_event( - turn_diff_tracker, - before_event, - &begin_ctx, - params_ref, - None, - attempt_req, - ) - .await; - } - } - - self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req) - .await; - - let result = process_exec_tool_call(params, sandbox_type, sandbox_policy, sandbox_cwd, code_linux_sandbox_exe, stdout_stream) - .await; - - let output_stderr; - let borrowed: &ExecToolCallOutput = match &result { - Ok(output) => output, - Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => output, - Err(e) => { - output_stderr = ExecToolCallOutput { - exit_code: -1, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(get_error_message_ui(e)), - aggregated_output: StreamOutput::new(get_error_message_ui(e)), - duration: Duration::default(), - timed_out: false, - }; - &output_stderr - } - }; - self.on_exec_command_end( - turn_diff_tracker, - &sub_id, - &call_id, - borrowed, - is_apply_patch, - seq_hint.map(|h| h.saturating_add(1)), - output_index, - attempt_req, - ) - .await; - - exec_guard.mark_completed(); - self.finalize_cancelled_execs(&sub_id).await; - - if enable_hooks { - if let Some(params_ref) = params_for_hooks.as_ref() { - let after_event = if is_apply_patch { - ProjectHookEvent::FileAfterWrite - } else { - ProjectHookEvent::ToolAfter - }; - self - .run_hooks_for_exec_event( - turn_diff_tracker, - after_event, - &begin_ctx, - params_ref, - Some(borrowed), - attempt_req, - ) - .await; - } - } - - if let Some(analysis) = dry_run_analysis.as_ref() { - let mut state = self.state.lock().unwrap(); - state.dry_run_guard.note_execution(analysis); - } - - result - } - - /// Helper that emits a BackgroundEvent with explicit ordering metadata. - pub(crate) async fn notify_background_event_with_order( - &self, - sub_id: &str, - order: crate::protocol::OrderMeta, - message: impl Into, - ) { - let event = self.make_event_with_order( - sub_id, - EventMsg::BackgroundEvent(BackgroundEventEvent { message: message.into() }), - order, - None, - ); - let _ = self.tx_event.send(event).await; - } - - pub(super) async fn notify_stream_error(&self, sub_id: &str, message: impl Into) { - let event = self.make_event( - sub_id, - EventMsg::Error(ErrorEvent { message: message.into() }), - ); - let _ = self.tx_event.send(event).await; - } - - fn resolve_internal_sandbox(&self, with_escalated_permissions: bool) -> SandboxType { - match assess_safety_for_untrusted_command( - self.approval_policy, - &self.sandbox_policy, - with_escalated_permissions, - ) { - SafetyCheck::AutoApprove { sandbox_type, .. } => sandbox_type, - SafetyCheck::AskUser | SafetyCheck::Reject { .. } => { - crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None) - } - } - } - - pub(super) async fn run_hooks_for_exec_event( - &self, - turn_diff_tracker: &mut TurnDiffTracker, - event: ProjectHookEvent, - exec_ctx: &ExecCommandContext, - params: &ExecParams, - output: Option<&ExecToolCallOutput>, - attempt_req: u64, - ) { - if self.project_hooks.is_empty() { - return; - } - let hooks: Vec = self.project_hooks.hooks_for(event).cloned().collect(); - if hooks.is_empty() { - return; - } - let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else { - return; - }; - let payload = build_exec_hook_payload(event, exec_ctx, params, output); - for (idx, hook) in hooks.into_iter().enumerate() { - self - .run_hook_command(turn_diff_tracker, &hook, event, &payload, Some(exec_ctx), attempt_req, idx) - .await; - } - } - - pub(super) async fn run_session_hooks(&self, event: ProjectHookEvent) { - if self.project_hooks.is_empty() { - return; - } - let hooks: Vec = self.project_hooks.hooks_for(event).cloned().collect(); - if hooks.is_empty() { - return; - } - let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else { - return; - }; - let payload = self.build_session_payload(event); - let mut tracker = TurnDiffTracker::new(); - let attempt_req = self.current_request_ordinal(); - for (idx, hook) in hooks.into_iter().enumerate() { - self - .run_hook_command(&mut tracker, &hook, event, &payload, None, attempt_req, idx) - .await; - } - } - - fn build_session_payload(&self, event: ProjectHookEvent) -> Value { - match event { - ProjectHookEvent::SessionStart => json!({ - "event": event.as_str(), - "cwd": self.cwd.to_string_lossy(), - "sandbox_policy": format!("{}", self.sandbox_policy), - "approval_policy": format!("{}", self.approval_policy), - }), - ProjectHookEvent::SessionEnd => json!({ - "event": event.as_str(), - "cwd": self.cwd.to_string_lossy(), - "sandbox_policy": format!("{}", self.sandbox_policy), - "approval_policy": format!("{}", self.approval_policy), - }), - _ => json!({ "event": event.as_str() }), - } - } - - async fn run_hook_command( - &self, - turn_diff_tracker: &mut TurnDiffTracker, - hook: &ProjectHook, - event: ProjectHookEvent, - payload: &Value, - base_ctx: Option<&ExecCommandContext>, - attempt_req: u64, - index: usize, - ) { - let sub_id = base_ctx - .map(|ctx| ctx.sub_id.clone()) - .unwrap_or_else(|| INITIAL_SUBMIT_ID.to_string()); - let base_slug = base_ctx - .map(|ctx| sanitize_identifier(&ctx.call_id)) - .unwrap_or_else(|| event.slug().to_string()); - let call_id = format!("{base_slug}_hook_{}_{}", event.slug(), index + 1); - - let mut env = hook.env.clone(); - env.entry("CODE_HOOK_EVENT".to_string()) - .or_insert_with(|| event.as_str().to_string()); - env.entry("CODE_HOOK_TRIGGER".to_string()) - .or_insert_with(|| event.slug().to_string()); - env.insert("CODE_HOOK_CALL_ID".to_string(), call_id.clone()); - env.insert("CODE_HOOK_SUB_ID".to_string(), sub_id.clone()); - env.insert("CODE_HOOK_INDEX".to_string(), (index + 1).to_string()); - env.insert("CODE_HOOK_PAYLOAD".to_string(), payload.to_string()); - env.entry("CODE_SESSION_CWD".to_string()) - .or_insert_with(|| self.cwd.to_string_lossy().to_string()); - if let Some(name) = &hook.name { - env.entry("CODE_HOOK_NAME".to_string()) - .or_insert_with(|| name.clone()); - } - if let Some(ctx) = base_ctx { - env.entry("CODE_HOOK_SOURCE_CALL_ID".to_string()) - .or_insert_with(|| ctx.call_id.clone()); - } - - let exec_params = ExecParams { - command: hook.command.clone(), - shell_script: None, - cwd: hook.resolved_cwd(self.get_cwd()), - timeout_ms: hook.timeout_ms, - env, - with_escalated_permissions: Some(false), - justification: None, - }; - - let exec_ctx = ExecCommandContext { - sub_id: sub_id.clone(), - call_id: call_id.clone(), - command_for_display: exec_params.command.clone(), - cwd: exec_params.cwd.clone(), - apply_patch: None, - }; - - let sandbox_type = self.resolve_internal_sandbox(false); - let exec_args = ExecInvokeArgs { - params: exec_params, - sandbox_type, - sandbox_policy: &self.sandbox_policy, - sandbox_cwd: self.get_cwd(), - code_linux_sandbox_exe: &self.code_linux_sandbox_exe, - stdout_stream: None, - }; - - if let Err(err) = Box::pin(self.run_exec_with_events_inner( - turn_diff_tracker, - exec_ctx, - exec_args, - None, - None, - attempt_req, - false, - )) - .await - { - let hook_label = hook - .name - .as_deref() - .unwrap_or_else(|| hook.command.first().map(String::as_str).unwrap_or("hook")); - let order = self.next_background_order(&sub_id, attempt_req, None); - self - .notify_background_event_with_order( - &sub_id, - order, - format!("Hook `{}` failed: {}", hook_label, get_error_message_ui(&err)), - ) - .await; - } - } - - fn find_project_command(&self, candidate: &str) -> Option { - self.project_commands - .iter() - .find(|cmd| cmd.matches(candidate)) - .cloned() - } - - pub(super) async fn run_project_command( - &self, - turn_diff_tracker: &mut TurnDiffTracker, - sub_id: &str, - name: &str, - attempt_req: u64, - ) { - let Some(command) = self.find_project_command(name) else { - let order = self.next_background_order(sub_id, attempt_req, None); - self - .notify_background_event_with_order( - sub_id, - order, - format!("Unknown project command `{}`", name.trim()), - ) - .await; - return; - }; - - let mut env = command.env.clone(); - env.entry("CODE_PROJECT_COMMAND_NAME".to_string()) - .or_insert_with(|| command.name.clone()); - if let Some(desc) = &command.description { - env.entry("CODE_PROJECT_COMMAND_DESCRIPTION".to_string()) - .or_insert_with(|| desc.clone()); - } - env.entry("CODE_SESSION_CWD".to_string()) - .or_insert_with(|| self.cwd.to_string_lossy().to_string()); - - let exec_params = ExecParams { - command: command.command.clone(), - shell_script: None, - cwd: command.resolved_cwd(self.get_cwd()), - timeout_ms: command.timeout_ms, - env, - with_escalated_permissions: Some(false), - justification: None, - }; - - let call_id = format!("project_cmd_{}", sanitize_identifier(&command.name)); - let exec_ctx = ExecCommandContext { - sub_id: sub_id.to_string(), - call_id: call_id.clone(), - command_for_display: exec_params.command.clone(), - cwd: exec_params.cwd.clone(), - apply_patch: None, - }; - - let sandbox_type = self.resolve_internal_sandbox(false); - let exec_args = ExecInvokeArgs { - params: exec_params, - sandbox_type, - sandbox_policy: &self.sandbox_policy, - sandbox_cwd: self.get_cwd(), - code_linux_sandbox_exe: &self.code_linux_sandbox_exe, - stdout_stream: None, - }; - - if let Err(err) = self - .run_exec_with_events(turn_diff_tracker, exec_ctx, exec_args, None, None, attempt_req) - .await - { - let order = self.next_background_order(sub_id, attempt_req, None); - self - .notify_background_event_with_order( - sub_id, - order, - format!( - "Project command `{}` failed: {}", - command.name, - get_error_message_ui(&err) - ), - ) - .await; - } - } -} - -#[cfg(test)] -mod tests { - use super::{ - command_needs_python_runtime, extract_primary_command_token, materialize_shell_script, - maybe_apply_python_runtime_env, - }; - use crate::exec::DeferredShellScript; - use crate::exec::ExecParams; - use crate::shell::BashShell; - use crate::shell::Shell; - use std::collections::HashMap; - use std::fs; - use std::path::PathBuf; - use tempfile::TempDir; - - fn base_params(command: &str) -> ExecParams { - ExecParams { - command: vec![command.to_string()], - shell_script: None, - cwd: PathBuf::from("/tmp"), - timeout_ms: None, - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - } - } - - #[test] - fn detects_python_family_commands() { - assert!(command_needs_python_runtime(&["python".to_string(), "script.py".to_string()])); - assert!(command_needs_python_runtime(&["pytest".to_string()])); - assert!(command_needs_python_runtime(&[ - "bash".to_string(), - "-lc".to_string(), - "PYTHONPATH=src python script.py".to_string(), - ])); - assert!(!command_needs_python_runtime(&["node".to_string(), "app.js".to_string()])); - assert!(!command_needs_python_runtime(&[ - "bash".to_string(), - "-lc".to_string(), - "npm test".to_string(), - ])); - } - - #[test] - fn extracts_primary_command_from_shell_script() { - let command = vec!["bash".to_string(), "-lc".to_string(), "FOO=1 pytest -q".to_string()]; - assert_eq!(extract_primary_command_token(&command), Some("pytest".to_string())); - } - - #[test] - fn adds_virtualenv_path_for_python_commands() { - let repo = TempDir::new().expect("tempdir"); - let cwd = repo.path().join("packages/app"); - let venv_bin = cwd.join(".venv/bin"); - fs::create_dir_all(&venv_bin).expect("create venv"); - - let mut params = ExecParams { - command: vec!["python".to_string(), "script.py".to_string()], - shell_script: None, - cwd: cwd.clone(), - timeout_ms: None, - env: HashMap::from([("PATH".to_string(), "/usr/bin".to_string())]), - with_escalated_permissions: Some(false), - justification: None, - }; - - maybe_apply_python_runtime_env(&mut params); - - assert_eq!( - params.env.get("VIRTUAL_ENV").map(String::as_str), - Some(cwd.join(".venv").to_string_lossy().as_ref()) - ); - let path = params.env.get("PATH").expect("PATH set"); - assert!(path.starts_with(venv_bin.to_string_lossy().as_ref())); - } - - #[test] - fn leaves_non_python_commands_unchanged() { - let repo = TempDir::new().expect("tempdir"); - let cwd = repo.path().join("packages/app"); - let venv_bin = cwd.join(".venv/bin"); - fs::create_dir_all(&venv_bin).expect("create venv"); - - let original_env = HashMap::from([("PATH".to_string(), "/usr/bin".to_string())]); - let mut params = ExecParams { - command: vec!["node".to_string(), "app.js".to_string()], - shell_script: None, - cwd, - timeout_ms: None, - env: original_env.clone(), - with_escalated_permissions: Some(false), - justification: None, - }; - - maybe_apply_python_runtime_env(&mut params); - - assert_eq!(params.env, original_env); - } - - #[test] - fn materialize_shell_script_uses_plain_shell_argv_until_exec() { - let shell = Shell::Bash(BashShell { - shell_path: "/bin/bash".to_string(), - bashrc_path: "/home/test/.bashrc".to_string(), - }); - let command = "apply_patch <<'PATCH'\n*** Begin Patch\n*** End Patch\nPATCH"; - let mut params = base_params(command); - params.shell_script = Some(DeferredShellScript { - command: command.to_string(), - use_login_shell: true, - }); - - let materialized = materialize_shell_script(&shell, params); - - assert_eq!( - materialized.command, - vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - command.to_string(), - ] - ); - } - - #[test] - fn materialize_shell_script_honors_login_false() { - let shell = Shell::Bash(BashShell { - shell_path: "/bin/bash".to_string(), - bashrc_path: "/home/test/.bashrc".to_string(), - }); - let mut params = base_params("printf hello"); - params.shell_script = Some(DeferredShellScript { - command: "printf hello".to_string(), - use_login_shell: false, - }); - - let materialized = materialize_shell_script(&shell, params); - - assert_eq!( - materialized.command, - vec![ - "/bin/bash".to_string(), - "-c".to_string(), - "printf hello".to_string(), - ] - ); - } - - #[test] - fn materialize_shell_script_uses_mutated_command_copy() { - let shell = Shell::Bash(BashShell { - shell_path: "/bin/bash".to_string(), - bashrc_path: "/home/test/.bashrc".to_string(), - }); - let mut params = base_params("git reset --hard HEAD"); - params.shell_script = Some(DeferredShellScript { - command: "confirm: git reset --hard HEAD".to_string(), - use_login_shell: true, - }); - - let materialized = materialize_shell_script(&shell, params); - - assert_eq!( - materialized.command, - vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "git reset --hard HEAD".to_string(), - ] - ); - } -} diff --git a/code-rs/core/src/codex/session.rs b/code-rs/core/src/codex/session.rs deleted file mode 100644 index 86a48d61379..00000000000 --- a/code-rs/core/src/codex/session.rs +++ /dev/null @@ -1,2877 +0,0 @@ -use super::*; -use crate::active_sessions::ActiveSessionGuard; -use crate::protocol::TaskOriginKind; -use serde_json::Value; -use crate::util::extract_shell_script; -use code_protocol::dynamic_tools::DynamicToolResponse; -use code_protocol::dynamic_tools::DynamicToolSpec; -use super::streaming::{ - AgentTask, - TRUNCATION_MARKER, - TimelineReplayContext, - debug_history, - ensure_user_dir, - parse_env_delta_from_response, - parse_env_snapshot_from_response, - process_rollout_env_item, - truncate_middle_bytes, - write_agent_file, -}; - -pub(super) const MAX_EVENT_SEQ_SUB_IDS: usize = 1024; -pub(super) const MAX_BACKGROUND_SEQ_SUB_IDS: usize = 1024; -pub(super) const MAX_WAIT_TRACKED_BATCHES: usize = 1024; -pub(super) const MAX_WAIT_TRACKED_AGENT_IDS_PER_BATCH: usize = 2048; -pub(super) const MAX_AGENT_COMPLETION_WAKE_BATCHES: usize = 2048; -pub(super) const MAX_PENDING_MANUAL_COMPACTS: usize = 64; - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ApprovedCommandPattern { - argv: Vec, - kind: ApprovedCommandMatchKind, - semantic_prefix: Option>, -} - -impl ApprovedCommandPattern { - pub(crate) fn new( - argv: Vec, - kind: ApprovedCommandMatchKind, - semantic_prefix: Option>, - ) -> Self { - let semantic_prefix = if matches!(kind, ApprovedCommandMatchKind::Prefix) { - semantic_prefix.or_else(|| Some(argv.clone())) - } else { - None - }; - Self { - argv, - kind, - semantic_prefix, - } - } - - pub(crate) fn matches(&self, command: &[String]) -> bool { - match self.kind { - ApprovedCommandMatchKind::Exact => command == self.argv.as_slice(), - ApprovedCommandMatchKind::Prefix => { - if command.starts_with(&self.argv) { - return true; - } - if let (Some(pattern), Some(candidate)) = ( - self.semantic_prefix.as_ref(), - semantic_tokens(command), - ) { - return candidate.starts_with(pattern); - } - false - } - } - } - - pub fn argv(&self) -> &[String] { &self.argv } - - pub fn kind(&self) -> ApprovedCommandMatchKind { self.kind } -} - -fn semantic_tokens(command: &[String]) -> Option> { - if command.is_empty() { - return None; - } - if let Some((_, script)) = extract_shell_script(command) { - return Some(shlex_split(script).unwrap_or_else(|| vec![script.to_string()])); - } - Some(command.to_vec()) -} - -#[derive(Clone)] -pub(super) struct RunningExecMeta { - pub(super) sub_id: String, - pub(super) order_meta: crate::protocol::OrderMeta, - pub(super) cancel_flag: Arc, - pub(super) end_emitted: Arc, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum WaitInterruptReason { - UserMessage, - SessionAborted, -} - -#[derive(Clone, Default)] -pub(super) struct EnvironmentContextStreamRegistry { - env_stream_id: Option, - browser_stream_id: Option, -} - -impl EnvironmentContextStreamRegistry { - pub(super) fn env_stream_id(&mut self, session_id: Uuid) -> String { - self.env_stream_id - .get_or_insert_with(|| format!("env-context-{}", session_id)) - .clone() - } - - pub(super) fn browser_stream_id(&mut self, session_id: Uuid) -> String { - self.browser_stream_id - .get_or_insert_with(|| format!("browser-context-{}", session_id)) - .clone() - } -} - -#[derive(Default)] -pub(super) struct State { - pub(super) approved_commands: HashSet, - pub(super) current_task: Option, - pub(super) pending_approvals: HashMap>, - pub(super) pending_request_user_input: HashMap>, - pub(super) pending_dynamic_tools: HashMap>, - pub(super) selected_mcp_tools: Vec, - pub(super) pending_input: Vec, - pub(super) pending_post_turn_input: Vec, - pub(super) pending_user_input: Vec, - pub(super) history: ConversationHistory, - /// Tracks which completed agents (by id) have already been returned to the - /// model for a given batch when using `agent` with `action="wait"` and - /// `return_all=false`. - /// This enables sequential waiting behavior across multiple calls. - pub(super) seen_completed_agents_by_batch: HashMap>, - /// FIFO order for `seen_completed_agents_by_batch` so old batches can be - /// evicted under sustained long-session churn. - pub(super) seen_completed_batch_order: VecDeque, - /// Tracks agent batches that already triggered a wake-up after completion. - pub(super) agent_completion_wake_batches: HashSet, - /// FIFO order for wake-batch dedupe keys. - pub(super) agent_completion_wake_order: VecDeque, - /// Scratchpad that buffers streamed items/deltas for the current HTTP attempt - /// so we can seed retries without losing progress. - pub(super) turn_scratchpad: Option, - /// Per-submission monotonic event sequence (resets at TaskStarted) - pub(super) event_seq_by_sub_id: HashMap, - /// Per-submission sequence used when synthesizing background OrderMeta. - pub(super) background_seq_by_sub_id: HashMap, - /// 1-based ordinal of the current HTTP request attempt in this session. - pub(super) request_ordinal: u64, - pub(super) dry_run_guard: DryRunGuardState, - /// Background execs by call_id - pub(super) background_execs: std::collections::HashMap, - /// Active foreground exec calls keyed by call_id (ExecCommandBegin/End lifecycle) - pub(super) running_execs: HashMap, - pub(super) next_internal_sub_id: u64, - pub(super) token_usage_info: Option, - pub(super) latest_rate_limits: Option, - pub(super) last_model_reroute_notice: Option<(String, String)>, - pub(super) pending_manual_compacts: VecDeque, - pub(super) wait_interrupt_epoch: u64, - pub(super) wait_interrupt_reason: Option, - pub(super) shutting_down: bool, - pub(super) context_timeline: ContextTimeline, - pub(super) environment_context_tracker: EnvironmentContextTracker, - pub(super) environment_context_seq: u64, - pub(super) last_environment_snapshot: Option, - pub(super) context_stream_ids: EnvironmentContextStreamRegistry, - pub(super) last_turn_started_at: Option, - pub(super) last_turn_completed_at: Option, - pub(super) last_turn_prompt_counts: Option, - pub(super) active_session_notice_state: ActiveSessionNoticeState, - pub(super) active_session_write_gate_state: ActiveSessionWriteGateState, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(super) struct ActiveSessionNoticeState { - pub(super) context_generation: u64, - pub(super) last_emitted: Option, -} - -impl ActiveSessionNoticeState { - pub(super) fn should_emit(&mut self, fingerprint: &str, submission_id: &str) -> bool { - let should_emit = self.last_emitted.as_ref().is_none_or(|last| { - last.fingerprint != fingerprint - || last.context_generation != self.context_generation - || last.submission_id != submission_id - }); - if should_emit { - self.last_emitted = Some(ActiveSessionNoticeEmission { - fingerprint: fingerprint.to_string(), - context_generation: self.context_generation, - submission_id: submission_id.to_string(), - }); - } - should_emit - } - - pub(super) fn clear(&mut self) { - self.last_emitted = None; - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct ActiveSessionNoticeEmission { - pub(super) fingerprint: String, - pub(super) context_generation: u64, - pub(super) submission_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct ActiveSessionWriteGateState { - pub(super) decision: ActiveSessionWorktreeDecision, -} - -impl ActiveSessionWriteGateState { - pub(super) fn reset_for_pending( - &mut self, - fingerprint: String, - checkout_root: PathBuf, - suggested_worktree_path: PathBuf, - ) { - if self.decision.fingerprint() == Some(fingerprint.as_str()) { - return; - } - self.decision = ActiveSessionWorktreeDecision::AwaitingDecision { - fingerprint, - checkout_root, - suggested_worktree_path, - }; - } - - pub(super) fn clear(&mut self) { - self.decision = ActiveSessionWorktreeDecision::Unset; - } -} - -impl Default for ActiveSessionWriteGateState { - fn default() -> Self { - Self { - decision: ActiveSessionWorktreeDecision::Unset, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) enum ActiveSessionWorktreeDecision { - Unset, - AwaitingDecision { - fingerprint: String, - checkout_root: PathBuf, - suggested_worktree_path: PathBuf, - }, - UseWorktree { - fingerprint: String, - checkout_root: PathBuf, - selected_worktree_path: PathBuf, - }, - StayHere { - fingerprint: String, - checkout_root: PathBuf, - reason: String, - }, -} - -impl ActiveSessionWorktreeDecision { - pub(super) fn fingerprint(&self) -> Option<&str> { - match self { - Self::Unset => None, - Self::AwaitingDecision { fingerprint, .. } - | Self::UseWorktree { fingerprint, .. } - | Self::StayHere { fingerprint, .. } => Some(fingerprint), - } - } -} - -#[derive(Clone, Copy, Default)] -pub(super) struct TurnPromptCounts { - pub(super) input_items: usize, - pub(super) status_items: usize, -} - -#[derive(Clone, Copy, Default)] -pub(super) struct TurnQueueMetrics { - pub(super) pending_input_count: usize, - pub(super) pending_user_input_count: usize, - pub(super) pending_background_execs: usize, - pub(super) running_exec_count: usize, - pub(super) pending_manual_compacts: usize, - pub(super) scratchpad_active: bool, -} - -pub(super) fn capture_turn_queue_metrics(state: &State) -> TurnQueueMetrics { - TurnQueueMetrics { - pending_input_count: state.pending_input.len(), - pending_user_input_count: state.pending_user_input.len(), - pending_background_execs: state.background_execs.len(), - running_exec_count: state.running_execs.len(), - pending_manual_compacts: state.pending_manual_compacts.len(), - scratchpad_active: state.turn_scratchpad.is_some(), - } -} - -pub(super) fn duration_to_millis(duration: Duration) -> u64 { - let ms = duration.as_millis(); - if ms > u128::from(u64::MAX) { - u64::MAX - } else { - ms as u64 - } -} - -#[derive(Clone)] -pub(crate) struct QueuedUserInput { - pub(super) submission_id: String, - pub(super) response_item: ResponseInputItem, - pub(super) core_items: Vec, -} - -pub(super) enum FollowUpTurnAction { - PostTurnPendingInput, - ManualCompact(String), - PendingInput, - QueuedUserInput(QueuedUserInput), -} - -fn take_follow_up_turn_action(state: &mut State) -> Option { - if !state.pending_post_turn_input.is_empty() { - let mut post_turn_input = std::mem::take(&mut state.pending_post_turn_input); - state.pending_input.append(&mut post_turn_input); - return Some(FollowUpTurnAction::PostTurnPendingInput); - } - - if let Some(sub_id) = state.pending_manual_compacts.pop_front() { - return Some(FollowUpTurnAction::ManualCompact(sub_id)); - } - - if !state.pending_input.is_empty() { - return Some(FollowUpTurnAction::PendingInput); - } - - if state.pending_user_input.is_empty() { - None - } else { - Some(FollowUpTurnAction::QueuedUserInput( - state.pending_user_input.remove(0), - )) - } -} - -/// Buffers partial turn progress produced during a single HTTP streaming attempt. -/// This is not recorded to persistent history. It is only used to seed retries -/// when the SSE stream disconnects mid‑turn. -#[derive(Default, Clone, Debug)] -pub(super) struct TurnScratchpad { - /// Output items that reached `response.output_item.done` during this attempt - pub(super) items: Vec, - /// Tool outputs we produced locally in reaction to output items - pub(super) responses: Vec, - /// Last assistant text fragment received via deltas (not yet finalized) - pub(super) partial_assistant_text: String, - /// Last reasoning summary fragment received via deltas (not yet finalized) - pub(super) partial_reasoning_summary: String, -} - -#[derive(Clone)] -pub(super) struct AccountUsageContext { - pub(super) code_home: PathBuf, - pub(super) account_id: String, - pub(super) plan: Option, -} - -pub(super) fn account_usage_context(sess: &Session) -> Option { - let code_home = sess.client.code_home().to_path_buf(); - let account_id = auth_accounts::get_active_account_id(&code_home).ok().flatten()?; - let plan = auth_accounts::find_account(&code_home, &account_id) - .ok() - .flatten() - .and_then(|account| { - account - .tokens - .as_ref() - .and_then(|tokens| tokens.id_token.get_chatgpt_plan_type()) - }); - Some(AccountUsageContext { - code_home, - account_id, - plan, - }) -} - -pub(super) fn spawn_usage_task(task: F) -where - F: FnOnce() + Send + 'static, -{ - let _ = tokio::task::spawn_blocking(task); -} - -pub(super) fn format_retry_eta(retry_after: &RetryAfter) -> Option { - let resume_at = retry_after.resume_at; - let local = resume_at.with_timezone(&Local); - let now = Local::now(); - let formatted = if local.date_naive() == now.date_naive() { - local.format("%-I:%M %p %Z").to_string() - } else { - local.format("%b %-d, %Y %-I:%M %p %Z").to_string() - }; - Some(formatted) -} - -pub(super) fn is_connectivity_error(err: &CodexErr) -> bool { - match err { - CodexErr::Reqwest(e) => e.is_connect() || e.is_timeout() || e.is_request(), - CodexErr::Stream(msg, _, _) => { - let lower = msg.to_ascii_lowercase(); - (msg.starts_with("[transport]") - || lower.contains("transport") - || lower.contains("network") - || lower.contains("connection") - || lower.contains("connectivity") - || lower.contains("timeout")) - && !lower.contains("context window") - && !lower.contains("context length") - && !lower.contains("usage limit") - && !lower.contains("usage_not_included") - && !lower.contains("quota exceeded") - } - _ => false, - } -} - -#[cfg(test)] -mod tests { - use super::is_connectivity_error; - use super::{ - ActiveSessionNoticeState, - ActiveSessionWorktreeDecision, - ApprovedCommandMatchKind, - ApprovedCommandPattern, - FollowUpTurnAction, - QueuedUserInput, - State, - take_follow_up_turn_action, - }; - use crate::error::CodexErr; - use code_protocol::models::{ContentItem, ResponseInputItem}; - use std::path::PathBuf; - - fn developer_input(text: &str) -> ResponseInputItem { - ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - } - } - - #[test] - fn context_overflow_transport_stream_is_not_connectivity() { - let err = CodexErr::Stream( - "[transport] Transport error: stream disconnected before completion: Your input exceeds the context window of this model. Please adjust your input and try again.".to_string(), - None, - None, - ); - - assert!(!is_connectivity_error(&err)); - } - - #[test] - fn network_stream_is_connectivity() { - let err = CodexErr::Stream( - "[transport] network timeout while connecting to api".to_string(), - None, - None, - ); - - assert!(is_connectivity_error(&err)); - } - - #[test] - fn follow_up_turn_action_prioritizes_post_turn_input() { - let mut state = State::default(); - state.pending_post_turn_input.push(developer_input("post-turn")); - state.pending_manual_compacts.push_back("compact-1".to_string()); - state.pending_input.push(developer_input("pending")); - state.pending_user_input.push(QueuedUserInput { - submission_id: "queued-1".to_string(), - response_item: developer_input("queued"), - core_items: vec![], - }); - - let action = take_follow_up_turn_action(&mut state).expect("follow-up action"); - assert!(matches!(action, FollowUpTurnAction::PostTurnPendingInput)); - assert!(state.pending_post_turn_input.is_empty()); - assert_eq!(state.pending_input.len(), 2); - assert_eq!(state.pending_manual_compacts.len(), 1); - assert_eq!(state.pending_user_input.len(), 1); - } - - #[test] - fn follow_up_turn_action_preserves_queued_user_input_after_post_turn_input() { - let mut state = State::default(); - state.pending_post_turn_input.push(developer_input("post-turn")); - state.pending_user_input.push(QueuedUserInput { - submission_id: "queued-1".to_string(), - response_item: developer_input("queued"), - core_items: vec![], - }); - - let first = take_follow_up_turn_action(&mut state).expect("post-turn action"); - assert!(matches!(first, FollowUpTurnAction::PostTurnPendingInput)); - assert_eq!(state.pending_user_input.len(), 1); - - state.pending_input.clear(); - - let second = take_follow_up_turn_action(&mut state).expect("queued user action"); - assert!(matches!(second, FollowUpTurnAction::QueuedUserInput(_))); - assert!(state.pending_user_input.is_empty()); - } - - #[test] - fn approved_command_prefix_matches_raw_shell_script_tokens() { - let pattern = ApprovedCommandPattern::new( - vec!["git".to_string(), "status".to_string()], - ApprovedCommandMatchKind::Prefix, - None, - ); - - assert!(pattern.matches(&["git status --short".to_string()])); - } - - #[test] - fn active_session_notice_state_emits_once_per_generation() { - let mut state = ActiveSessionNoticeState::default(); - assert!(state.should_emit("pid-1", "turn-1")); - assert!(!state.should_emit("pid-1", "turn-1")); - assert!(state.should_emit("pid-1", "turn-2")); - assert!(!state.should_emit("pid-1", "turn-2")); - - state.context_generation = state.context_generation.saturating_add(1); - assert!(state.should_emit("pid-1", "turn-2")); - assert!(state.should_emit("pid-2", "turn-2")); - - state.clear(); - assert!(state.should_emit("pid-2", "turn-2")); - } - - #[test] - fn partial_clone_preserves_active_session_notice_state() { - let mut state = State::default(); - assert!(state - .active_session_notice_state - .should_emit("pid-1", "turn-1")); - state.active_session_write_gate_state.reset_for_pending( - "pid-1".to_string(), - PathBuf::from("/repo"), - PathBuf::from("/worktrees/repo/task"), - ); - - let mut cloned = state.partial_clone(); - - assert!(!cloned - .active_session_notice_state - .should_emit("pid-1", "turn-1")); - assert!(cloned - .active_session_notice_state - .should_emit("pid-1", "turn-2")); - assert_eq!( - cloned.active_session_write_gate_state.decision, - ActiveSessionWorktreeDecision::AwaitingDecision { - fingerprint: "pid-1".to_string(), - checkout_root: PathBuf::from("/repo"), - suggested_worktree_path: PathBuf::from("/worktrees/repo/task"), - } - ); - } -} - -#[derive(Debug)] -pub(super) struct BackgroundExecState { - pub(super) notify: std::sync::Arc, - pub(super) result_cell: std::sync::Arc>>, - pub(super) tail_buf: Option>>>, - pub(super) cmd_display: String, - pub(super) suppress_event: std::sync::Arc, - pub(super) task_handle: Option>, - pub(super) order_meta_for_end: crate::protocol::OrderMeta, - pub(super) sub_id: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct TaskLifecycleInfo { - pub(super) origin: TaskOriginKind, - pub(super) visible_to_user: bool, -} - -/// Context for an initialized model agent -/// -/// A session has at most 1 running agent at a time, and can be interrupted by user input. -pub(crate) struct Session { - pub(super) id: Uuid, - pub(super) client: ModelClient, - pub(super) remote_models_manager: Option>, - pub(super) tx_event: Sender, - - /// The session's current working directory. All relative paths provided by - /// the model as well as sandbox policies are resolved against this path - /// instead of `std::env::current_dir()`. - pub(super) cwd: PathBuf, - pub(super) base_instructions: Option, - pub(super) user_instructions: Option, - pub(super) demo_developer_message: Option, - pub(super) active_session_model_notice: Option, - pub(super) compact_prompt_override: Option, - pub(super) approval_policy: AskForApproval, - pub(super) sandbox_policy: SandboxPolicy, - pub(super) shell_environment_policy: ShellEnvironmentPolicy, - pub(super) _writable_roots: Vec, - pub(super) disable_response_storage: bool, - pub(super) tools_config: ToolsConfig, - pub(super) dynamic_tools: Vec, - pub(super) skills: Vec, - pub(super) skill_command_policies: crate::skills::command_policy::SkillCommandPolicyRuntime, - - /// Manager for external MCP servers/tools. - pub(super) mcp_connection_manager: McpConnectionManager, - pub(super) client_tools: Option, - #[allow(dead_code)] - pub(super) session_manager: ExecSessionManager, - - /// Configuration for available agent models - pub(super) agents: Vec, - - /// Maximum allowed nesting depth for agent-spawned agent runs. - pub(super) subagent_max_depth: i32, - - /// Default reasoning effort for spawned agents and model calls in this session - pub(super) model_reasoning_effort: ReasoningEffortConfig, - - /// External notifier command (will be passed as args to exec()). When - /// `None` this feature is disabled. - pub(super) notify: Option>, - - /// Optional rollout recorder for persisting the conversation transcript so - /// sessions can be replayed or inspected later. - pub(super) rollout: Mutex>, - pub(super) state: Mutex, - pub(super) code_linux_sandbox_exe: Option, - pub(super) user_shell: shell::Shell, - pub(super) show_raw_agent_reasoning: bool, - /// Pending browser screenshots to include in the next model request - #[allow(dead_code)] - pub(super) pending_browser_screenshots: Mutex>, - /// Track the last system status to detect changes - pub(super) last_system_status: Mutex>, - /// Track the last screenshot path and hash to detect changes - pub(super) last_screenshot_info: Mutex, Vec)>>, // (path, phash, dhash) - pub(super) time_budget: Mutex>, - pub(super) confirm_guard: ConfirmGuardRuntime, - pub(super) project_hooks: ProjectHooks, - pub(super) project_commands: Vec, - pub(super) tool_output_max_bytes: usize, - pub(super) hook_guard: AtomicBool, - pub(super) github: Arc>, - pub(super) validation: Arc>, - pub(super) self_handle: Weak, - pub(super) active_review: Mutex>, - pub(super) next_turn_text_format: Mutex>, - pub(super) env_ctx_v2: bool, - pub(super) retention_config: crate::config_types::RetentionConfig, - pub(super) model_descriptions: Option, - pub(super) _active_session_guard: Option, -} -pub(super) struct HookGuard<'a> { - flag: &'a AtomicBool, -} - -impl<'a> HookGuard<'a> { - pub(super) fn try_acquire(flag: &'a AtomicBool) -> Option { - flag - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .ok() - .map(|_| Self { flag }) - } -} - -impl Drop for HookGuard<'_> { - fn drop(&mut self) { - self.flag.store(false, Ordering::SeqCst); - } -} - -#[derive(Debug, Clone)] -pub(crate) struct ToolCallCtx { - pub sub_id: String, - pub call_id: String, - pub seq_hint: Option, - pub output_index: Option, -} - -impl ToolCallCtx { - pub fn new(sub_id: String, call_id: String, seq_hint: Option, output_index: Option) -> Self { - Self { sub_id, call_id, seq_hint, output_index } - } - - pub fn order_meta(&self, req_ordinal: u64) -> crate::protocol::OrderMeta { - crate::protocol::OrderMeta { request_ordinal: req_ordinal, output_index: self.output_index, sequence_number: self.seq_hint } - } -} - -impl Session { - pub(crate) fn client(&self) -> &crate::ModelClient { - &self.client - } - - pub(crate) fn remote_models_manager(&self) -> Option<&Arc> { - self.remote_models_manager.as_ref() - } - - #[allow(dead_code)] - pub(crate) fn get_writable_roots(&self) -> &[PathBuf] { - &self._writable_roots - } - - pub(crate) fn get_approval_policy(&self) -> AskForApproval { - self.approval_policy - } - - pub(crate) fn is_dynamic_tool(&self, namespace: Option<&str>, name: &str) -> bool { - self.dynamic_tools - .iter() - .any(|tool| tool.name == name && tool.namespace.as_deref() == namespace) - } - - fn next_background_sequence(&self, sub_id: &str) -> u64 { - let mut state = self.state.lock().unwrap(); - if state.background_seq_by_sub_id.len() > MAX_BACKGROUND_SEQ_SUB_IDS { - trim_sub_id_sequence_map( - &mut state.background_seq_by_sub_id, - MAX_BACKGROUND_SEQ_SUB_IDS, - "background_seq_by_sub_id", - ); - } - let entry = state - .background_seq_by_sub_id - .entry(sub_id.to_string()) - .or_insert(0); - let current = *entry; - *entry = entry.saturating_add(1); - current - } - - pub(crate) fn next_background_order( - &self, - sub_id: &str, - req_ordinal: u64, - output_index: Option, - ) -> crate::protocol::OrderMeta { - let normalized_req = if req_ordinal == 0 { 1 } else { req_ordinal }; - let sequence = self.next_background_sequence(sub_id); - let stored_output_index = output_index.unwrap_or(i32::MAX as u32); - crate::protocol::OrderMeta { - request_ordinal: normalized_req, - output_index: Some(stored_output_index), - sequence_number: Some(sequence), - } - } - - pub(crate) fn background_order_for_ctx( - &self, - ctx: &ToolCallCtx, - req_ordinal: u64, - ) -> crate::protocol::OrderMeta { - let base_output = ctx.output_index.unwrap_or(i32::MAX as u32); - self.next_background_order(&ctx.sub_id, req_ordinal, Some(base_output)) - } - - pub(crate) fn get_cwd(&self) -> &Path { - &self.cwd - } - - pub(super) async fn apply_remote_model_overrides(&self, prompt: &mut Prompt) -> bool { - let configured_model = self.client.get_model(); - - if prompt.model_override.is_none() { - if !self.client.model_explicit() { - let auth_mode = self - .client - .get_auth_manager() - .as_ref() - .and_then(|mgr| mgr.auth()) - .map(|auth| auth.mode); - - let default_model_slug = if auth_mode.is_some_and(code_app_server_protocol::AuthMode::is_chatgpt) { - crate::config::GPT_5_CODEX_MEDIUM_MODEL - } else { - crate::config::OPENAI_DEFAULT_MODEL - }; - - if let Some(remote) = self.remote_models_manager.as_ref() - && configured_model.eq_ignore_ascii_case(default_model_slug) - && let Some(default_model) = remote.default_model_slug(auth_mode).await - { - prompt.model_override = Some(default_model); - } - } - - if prompt.model_override.is_none() { - prompt.model_override = Some(configured_model.clone()); - } - } - - let mut used_fallback_model_metadata = false; - if prompt.model_family_override.is_none() { - let model_slug = prompt - .model_override - .as_deref() - .unwrap_or(configured_model.as_str()); - let base_family = if let Some(family) = find_family_for_model(model_slug) { - family - } else { - used_fallback_model_metadata = true; - derive_default_model_family(model_slug) - }; - let personality = self.client.model_personality(); - - let family = if let Some(remote) = self.remote_models_manager.as_ref() { - if remote.has_model_slug(model_slug).await { - used_fallback_model_metadata = false; - } - remote - .apply_remote_overrides_with_personality( - model_slug, - base_family, - personality, - ) - .await - } else { - base_family - }; - prompt.model_family_override = Some(family); - } - used_fallback_model_metadata - } - - pub(crate) async fn record_bridge_event(&self, text: String) { - let message = ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], end_turn: None, phase: None}; - self.record_conversation_items(&[message]).await; - } - - pub(crate) fn get_sandbox_policy(&self) -> &SandboxPolicy { - &self.sandbox_policy - } - - pub(crate) fn session_uuid(&self) -> Uuid { - self.id - } - - pub(crate) fn get_github_config(&self) -> Arc> { - Arc::clone(&self.github) - } - - pub(crate) fn validation_config(&self) -> Arc> { - Arc::clone(&self.validation) - } - - pub(crate) fn client_tools(&self) -> Option<&ClientTools> { - self.client_tools.as_ref() - } - - pub(super) fn set_active_review(&self, review_request: ReviewRequest) { - let mut guard = self.active_review.lock().unwrap(); - *guard = Some(review_request); - } - - pub(super) fn take_active_review(&self) -> Option { - self.active_review.lock().unwrap().take() - } - - pub(crate) fn mcp_connection_manager(&self) -> &McpConnectionManager { - &self.mcp_connection_manager - } - - pub(crate) async fn shutdown_mcp_clients(&self) { - self.mcp_connection_manager.shutdown_all().await; - } - - pub(crate) fn update_validation_tool(&self, name: &str, enable: bool) { - if name == "actionlint" { - if let Ok(mut github) = self.github.write() { - github.actionlint_on_patch = enable; - } - return; - } - - if let Ok(mut cfg) = self.validation.write() { - let tools = &mut cfg.tools; - match name { - "shellcheck" => tools.shellcheck = Some(enable), - "markdownlint" => tools.markdownlint = Some(enable), - "hadolint" => tools.hadolint = Some(enable), - "yamllint" => tools.yamllint = Some(enable), - "cargo-check" => tools.cargo_check = Some(enable), - "shfmt" => tools.shfmt = Some(enable), - "prettier" => tools.prettier = Some(enable), - _ => {} - } - } - } - - pub(crate) fn update_validation_group(&self, group: ValidationGroup, enable: bool) { - if let Ok(mut cfg) = self.validation.write() { - match group { - ValidationGroup::Functional => cfg.groups.functional = enable, - ValidationGroup::Stylistic => cfg.groups.stylistic = enable, - } - } - } - - pub(super) fn resolve_path(&self, path: Option) -> PathBuf { - path.as_ref() - .map(PathBuf::from) - .map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p)) - } - - pub(crate) async fn maybe_parse_apply_patch_verified( - &self, - argv: &[String], - cwd: &Path, - ) -> MaybeApplyPatchVerified { - // Upstream parser no longer needs a filesystem; it is pure and sync. - let _ = self.client_tools.as_ref(); - code_apply_patch::maybe_parse_apply_patch_verified(argv, cwd) - } - - // ──────────────────────────── - // Scratchpad helpers - // ──────────────────────────── - pub(super) fn begin_attempt_scratchpad(&self) { - let mut state = self.state.lock().unwrap(); - state.turn_scratchpad = Some(TurnScratchpad::default()); - } - - /// Bump the per-session HTTP request attempt ordinal so `OrderMeta` - /// reflects the correct provider request index for this attempt. - pub(super) fn begin_http_attempt(&self) { - let mut state = self.state.lock().unwrap(); - state.request_ordinal = state.request_ordinal.saturating_add(1); - } - - pub(super) fn turn_latency_request_scheduled(&self, attempt_req: u64, prompt: &Prompt) { - let now = Instant::now(); - let gap_and_metrics = { - let mut state = self.state.lock().unwrap(); - let gap = state - .last_turn_completed_at - .map(|prev| now.saturating_duration_since(prev)); - state.last_turn_started_at = Some(now); - state.last_turn_prompt_counts = Some(TurnPromptCounts { - input_items: prompt.input.len(), - status_items: prompt.status_items.len(), - }); - let metrics = capture_turn_queue_metrics(&state); - (gap, metrics) - }; - - let pending_browser_screenshots = self.pending_browser_screenshots.lock().unwrap().len(); - let (gap, metrics) = gap_and_metrics; - let payload = TurnLatencyPayload { - phase: TurnLatencyPhase::RequestScheduled, - attempt: attempt_req, - gap_ms: gap.map(duration_to_millis), - duration_ms: None, - pending_input_count: metrics.pending_input_count as u64, - pending_user_input_count: metrics.pending_user_input_count as u64, - pending_background_execs: metrics.pending_background_execs as u64, - running_exec_count: metrics.running_exec_count as u64, - pending_manual_compacts: metrics.pending_manual_compacts as u64, - pending_browser_screenshots: pending_browser_screenshots as u64, - scratchpad_active: metrics.scratchpad_active, - prompt_input_count: Some(prompt.input.len() as u64), - prompt_status_count: Some(prompt.status_items.len() as u64), - output_item_count: None, - token_usage_input_tokens: None, - token_usage_cached_input_tokens: None, - token_usage_output_tokens: None, - token_usage_reasoning_output_tokens: None, - token_usage_total_tokens: None, - note: None, - }; - self.emit_turn_latency(payload); - } - - pub(super) fn turn_latency_request_completed( - &self, - attempt_req: u64, - output_item_count: usize, - token_usage: Option<&TokenUsage>, - ) { - let now = Instant::now(); - let (duration, prompt_counts, metrics) = { - let mut state = self.state.lock().unwrap(); - let duration = state - .last_turn_started_at - .map(|start| now.saturating_duration_since(start)); - state.last_turn_started_at = None; - state.last_turn_completed_at = Some(now); - let prompt_counts = state.last_turn_prompt_counts.take(); - let metrics = capture_turn_queue_metrics(&state); - (duration, prompt_counts, metrics) - }; - - let pending_browser_screenshots = self.pending_browser_screenshots.lock().unwrap().len(); - let (token_usage_input_tokens, token_usage_cached_input_tokens, token_usage_output_tokens, token_usage_reasoning_output_tokens, token_usage_total_tokens) = - match token_usage { - Some(usage) => ( - Some(usage.input_tokens), - Some(usage.cached_input_tokens), - Some(usage.output_tokens), - Some(usage.reasoning_output_tokens), - Some(usage.total_tokens), - ), - None => (None, None, None, None, None), - }; - let payload = TurnLatencyPayload { - phase: TurnLatencyPhase::RequestCompleted, - attempt: attempt_req, - gap_ms: None, - duration_ms: duration.map(duration_to_millis), - pending_input_count: metrics.pending_input_count as u64, - pending_user_input_count: metrics.pending_user_input_count as u64, - pending_background_execs: metrics.pending_background_execs as u64, - running_exec_count: metrics.running_exec_count as u64, - pending_manual_compacts: metrics.pending_manual_compacts as u64, - pending_browser_screenshots: pending_browser_screenshots as u64, - scratchpad_active: metrics.scratchpad_active, - prompt_input_count: prompt_counts.map(|counts| counts.input_items as u64), - prompt_status_count: prompt_counts.map(|counts| counts.status_items as u64), - output_item_count: Some(output_item_count as u64), - token_usage_input_tokens, - token_usage_cached_input_tokens, - token_usage_output_tokens, - token_usage_reasoning_output_tokens, - token_usage_total_tokens, - note: None, - }; - self.emit_turn_latency(payload); - } - - pub(super) fn turn_latency_request_failed(&self, attempt_req: u64, note: Option) { - let now = Instant::now(); - let (duration, prompt_counts, metrics) = { - let mut state = self.state.lock().unwrap(); - let duration = state - .last_turn_started_at - .map(|start| now.saturating_duration_since(start)); - state.last_turn_started_at = None; - let prompt_counts = state.last_turn_prompt_counts.take(); - let metrics = capture_turn_queue_metrics(&state); - (duration, prompt_counts, metrics) - }; - - let pending_browser_screenshots = self.pending_browser_screenshots.lock().unwrap().len(); - let payload = TurnLatencyPayload { - phase: TurnLatencyPhase::RequestFailed, - attempt: attempt_req, - gap_ms: None, - duration_ms: duration.map(duration_to_millis), - pending_input_count: metrics.pending_input_count as u64, - pending_user_input_count: metrics.pending_user_input_count as u64, - pending_background_execs: metrics.pending_background_execs as u64, - running_exec_count: metrics.running_exec_count as u64, - pending_manual_compacts: metrics.pending_manual_compacts as u64, - pending_browser_screenshots: pending_browser_screenshots as u64, - scratchpad_active: metrics.scratchpad_active, - prompt_input_count: prompt_counts.map(|counts| counts.input_items as u64), - prompt_status_count: prompt_counts.map(|counts| counts.status_items as u64), - output_item_count: None, - token_usage_input_tokens: None, - token_usage_cached_input_tokens: None, - token_usage_output_tokens: None, - token_usage_reasoning_output_tokens: None, - token_usage_total_tokens: None, - note, - }; - self.emit_turn_latency(payload); - } - - fn emit_turn_latency(&self, payload: TurnLatencyPayload) { - if let Some(otel) = self.client.get_otel_event_manager() { - otel.turn_latency_event(payload.clone()); - } - self.client.log_turn_latency_debug(&payload); - } - - pub(super) fn scratchpad_push( - &self, - item: &ResponseItem, - response: &Option, - sub_id: &str, - ) { - let mut state = self.state.lock().unwrap(); - if let Some(sp) = &mut state.turn_scratchpad { - sp.items.push(item.clone()); - if let Some(r) = response { - let mut truncated = r.clone(); - self.enforce_user_message_limits(sub_id, &mut truncated); - sp.responses.push(truncated); - } - } - } - - pub(super) fn scratchpad_add_text_delta(&self, delta: &str) { - let mut state = self.state.lock().unwrap(); - if let Some(sp) = &mut state.turn_scratchpad { - sp.partial_assistant_text.push_str(delta); - // Keep memory bounded (ensure UTF-8 char boundary when trimming) - if sp.partial_assistant_text.len() > 4000 { - let mut drain_up_to = sp.partial_assistant_text.len() - 4000; - while !sp.partial_assistant_text.is_char_boundary(drain_up_to) { - drain_up_to -= 1; - } - sp.partial_assistant_text.drain(..drain_up_to); - } - } - } - - pub(super) fn scratchpad_add_reasoning_delta(&self, delta: &str) { - let mut state = self.state.lock().unwrap(); - if let Some(sp) = &mut state.turn_scratchpad { - sp.partial_reasoning_summary.push_str(delta); - if sp.partial_reasoning_summary.len() > 4000 { - let mut drain_up_to = sp.partial_reasoning_summary.len() - 4000; - while !sp.partial_reasoning_summary.is_char_boundary(drain_up_to) { - drain_up_to -= 1; - } - sp.partial_reasoning_summary.drain(..drain_up_to); - } - } - } - - pub(super) fn scratchpad_clear_partial_message(&self) { - let mut state = self.state.lock().unwrap(); - if let Some(sp) = &mut state.turn_scratchpad { - sp.partial_assistant_text.clear(); - } - } - - pub(super) fn take_scratchpad(&self) -> Option { - let mut state = self.state.lock().unwrap(); - state.turn_scratchpad.take() - } - - pub(super) fn clear_scratchpad(&self) { - let mut state = self.state.lock().unwrap(); - state.turn_scratchpad = None; - } -} - -fn trim_sub_id_sequence_map( - map: &mut HashMap, - cap: usize, - label: &str, -) { - while map.len() > cap { - let Some(key) = map.keys().next().cloned() else { - break; - }; - map.remove(&key); - } - warn!( - label, - cap, - retained = map.len(), - "trimmed long-lived sequence map to cap memory growth" - ); -} -impl Session { - pub(super) fn set_task(&self, agent: AgentTask) { - let mut state = self.state.lock().unwrap(); - if let Some(current_task) = state.current_task.take() { - current_task.abort(TurnAbortReason::Replaced); - } - state.current_task = Some(agent); - } - - pub(super) async fn start_internal_pending_only_turn( - self: &Arc, - sentinel: &str, - origin: TaskOriginKind, - visible_to_user: bool, - ) -> bool { - let should_start = { - let state = self.state.lock().unwrap(); - state.current_task.is_none() - }; - - if !should_start { - return false; - } - - self.cleanup_old_status_items().await; - let turn_context = self.make_turn_context(); - let sub_id = self.next_internal_sub_id(); - let sentinel_input = vec![InputItem::Text { - text: sentinel.to_string(), - }]; - let agent = AgentTask::spawn( - Arc::clone(self), - turn_context, - sub_id, - sentinel_input, - origin, - visible_to_user, - ); - self.set_task(agent); - true - } - - pub async fn start_pending_only_turn_if_idle(self: &Arc) -> bool { - self.start_internal_pending_only_turn( - PENDING_ONLY_SENTINEL, - TaskOriginKind::PendingInput, - false, - ) - .await - } - - pub async fn start_post_turn_pending_only_turn_if_idle(self: &Arc) -> bool { - let should_start = { - let mut state = self.state.lock().unwrap(); - if state.current_task.is_some() || state.pending_post_turn_input.is_empty() { - false - } else { - let mut post_turn_input = std::mem::take(&mut state.pending_post_turn_input); - state.pending_input.append(&mut post_turn_input); - true - } - }; - - if !should_start { - return false; - } - - self.start_internal_pending_only_turn( - POST_TURN_PENDING_ONLY_SENTINEL, - TaskOriginKind::PostTurn, - false, - ) - .await - } - - pub fn replace_history(&self, items: Vec) { - let mut state = self.state.lock().unwrap(); - state.history.replace(items); - state.active_session_notice_state.context_generation = state - .active_session_notice_state - .context_generation - .saturating_add(1); - } - - pub(super) fn active_session_notice_should_emit( - &self, - fingerprint: &str, - submission_id: &str, - ) -> bool { - let mut state = self.state.lock().unwrap(); - state - .active_session_notice_state - .should_emit(fingerprint, submission_id) - } - - pub(super) fn active_session_notice_clear(&self) { - self.state - .lock() - .unwrap() - .active_session_notice_state - .clear(); - } - - pub(super) fn active_session_write_gate_set_pending( - &self, - notice: &crate::active_sessions::ActiveSessionModelNotice, - ) { - self.state - .lock() - .unwrap() - .active_session_write_gate_state - .reset_for_pending( - notice.fingerprint.clone(), - notice.checkout_root.clone(), - notice.suggested_worktree_path.clone(), - ); - } - - pub(super) fn active_session_worktree_decision(&self) -> ActiveSessionWorktreeDecision { - self.state - .lock() - .unwrap() - .active_session_write_gate_state - .decision - .clone() - } - - pub(super) fn set_active_session_worktree_decision( - &self, - decision: ActiveSessionWorktreeDecision, - ) { - self.state - .lock() - .unwrap() - .active_session_write_gate_state - .decision = decision; - } - - pub(super) fn active_session_write_gate_clear(&self) { - self.state - .lock() - .unwrap() - .active_session_write_gate_state - .clear(); - } - - pub fn remove_task(&self, sub_id: &str) { - let mut state = self.state.lock().unwrap(); - if let Some(agent) = &state.current_task { - if agent.sub_id == sub_id { - state.current_task.take(); - } - } - } - - pub fn enqueue_post_turn_item(&self, item: ResponseInputItem) -> bool { - let mut state = self.state.lock().unwrap(); - let should_start_turn = state.current_task.is_none(); - state.pending_post_turn_input.push(item); - should_start_turn - } - - pub(super) fn take_follow_up_turn_action(&self) -> Option { - let mut state = self.state.lock().unwrap(); - take_follow_up_turn_action(&mut state) - } - - pub fn has_running_task(&self) -> bool { - self.state.lock().unwrap().current_task.is_some() - } - - pub(super) fn task_lifecycle(&self, sub_id: &str) -> Option { - let state = self.state.lock().unwrap(); - let task = state.current_task.as_ref()?; - if task.sub_id != sub_id { - return None; - } - Some(TaskLifecycleInfo { - origin: task.origin, - visible_to_user: task.visible_to_user, - }) - } - - pub fn queue_user_input(&self, queued: QueuedUserInput) { - let mut state = self.state.lock().unwrap(); - state.pending_user_input.push(queued); - } - - pub(super) fn notify_wait_interrupted(&self, reason: WaitInterruptReason) { - let mut state = self.state.lock().unwrap(); - state.wait_interrupt_epoch = state.wait_interrupt_epoch.saturating_add(1); - state.wait_interrupt_reason = Some(reason); - } - - pub(super) fn wait_interrupt_snapshot(&self) -> (u64, Option) { - let state = self.state.lock().unwrap(); - (state.wait_interrupt_epoch, state.wait_interrupt_reason) - } - - pub(super) fn merge_mcp_tool_selection(&self, tool_names: Vec) -> Vec { - let mut state = self.state.lock().unwrap(); - for tool_name in tool_names { - if !state.selected_mcp_tools.iter().any(|name| name == &tool_name) { - state.selected_mcp_tools.push(tool_name); - } - } - state.selected_mcp_tools.sort(); - state.selected_mcp_tools.clone() - } - - pub(super) fn set_mcp_tool_selection(&self, tool_names: Vec) { - let mut state = self.state.lock().unwrap(); - state.selected_mcp_tools.clear(); - for tool_name in tool_names { - if !state.selected_mcp_tools.iter().any(|name| name == &tool_name) { - state.selected_mcp_tools.push(tool_name); - } - } - state.selected_mcp_tools.sort(); - } - - pub(super) fn get_mcp_tool_selection(&self) -> Option> { - let state = self.state.lock().unwrap(); - if state.selected_mcp_tools.is_empty() { - None - } else { - Some(state.selected_mcp_tools.clone()) - } - } - - pub(super) fn clear_mcp_tool_selection(&self) { - let mut state = self.state.lock().unwrap(); - state.selected_mcp_tools.clear(); - } - - pub(super) fn enforce_user_message_limits( - &self, - sub_id: &str, - response_item: &mut ResponseInputItem, - ) { - let ResponseInputItem::Message { role, content } = response_item else { - return; - }; - if role != "user" { - return; - } - - let mut aggregated = String::new(); - let mut text_segments: Vec<(usize, usize)> = Vec::new(); - for item in content.iter() { - if let ContentItem::InputText { text } = item { - let start = aggregated.len(); - aggregated.push_str(text); - let end = aggregated.len(); - text_segments.push((start, end)); - } - } - - if text_segments.is_empty() { - return; - } - - let (_, was_truncated, prefix_end, suffix_start) = - truncate_middle_bytes(&aggregated, self.tool_output_max_bytes); - if !was_truncated { - return; - } - - let cwd = self.get_cwd().to_path_buf(); - let safe_sub_id = crate::fs_sanitize::safe_path_component(sub_id, "sub"); - let uuid = Uuid::new_v4(); - let filename = format!("user-message-{safe_sub_id}-{uuid}.txt"); - let file_note = match ensure_user_dir(&cwd) - .and_then(|dir| write_agent_file(&dir, &filename, &aggregated)) - { - Ok(path) => format!("\n\n[Full output saved to: {}]", path.display()), - Err(e) => format!("\n\n[Full output was too large and truncation applied; failed to save file: {e}]") - }; - - let original = std::mem::take(content); - let mut new_content = Vec::with_capacity(original.len()); - let mut segment_iter = text_segments.into_iter(); - let mut marker_inserted = false; - let mut last_text_idx: Option = None; - - for item in original.into_iter() { - match item { - ContentItem::InputText { text } => { - if let Some((seg_start, seg_end)) = segment_iter.next() { - let mut new_text = String::new(); - - if seg_start < prefix_end { - let slice_end = seg_end.min(prefix_end) - seg_start; - if let Some(prefix_slice) = text.get(..slice_end) { - new_text.push_str(prefix_slice); - } - } - - if !marker_inserted && seg_end > prefix_end && seg_start < suffix_start { - new_text.push_str(TRUNCATION_MARKER); - marker_inserted = true; - } - - if seg_end > suffix_start { - let slice_start = seg_start.max(suffix_start) - seg_start; - if let Some(suffix_slice) = text.get(slice_start..) { - new_text.push_str(suffix_slice); - } - } - - new_content.push(ContentItem::InputText { text: new_text }); - last_text_idx = Some(new_content.len() - 1); - } - } - other => new_content.push(other), - } - } - - if !marker_inserted { - if let Some(idx) = last_text_idx { - if let ContentItem::InputText { text } = &mut new_content[idx] { - text.push_str(TRUNCATION_MARKER); - } - } else { - new_content.push(ContentItem::InputText { - text: TRUNCATION_MARKER.to_string(), - }); - last_text_idx = Some(new_content.len() - 1); - } - } - - if let Some(idx) = last_text_idx { - if let ContentItem::InputText { text } = &mut new_content[idx] { - text.push_str(&file_note); - } - } else { - new_content.push(ContentItem::InputText { text: file_note }); - } - - *content = new_content; - } - - /// Enqueue a response item that should be surfaced to the model at the start of the - /// next turn. Returns `true` if no agent is currently running and a new turn should be - /// scheduled immediately. - pub fn enqueue_out_of_turn_item(&self, item: ResponseInputItem) -> bool { - let mut state = self.state.lock().unwrap(); - if state.shutting_down { - return false; - } - let should_start_turn = state.current_task.is_none(); - state.pending_input.push(item); - should_start_turn - } - - pub(super) fn mark_shutting_down(&self) { - let mut state = self.state.lock().unwrap(); - state.shutting_down = true; - } - - pub(super) fn is_shutting_down(&self) -> bool { - self.state.lock().unwrap().shutting_down - } - - pub(crate) fn next_internal_sub_id(&self) -> String { - let mut state = self.state.lock().unwrap(); - let id = state.next_internal_sub_id; - state.next_internal_sub_id = state.next_internal_sub_id.saturating_add(1); - format!("auto-compact-{id}") - } - - /// Sends the given event to the client and swallows the send error, if - /// any, logging it as an error. - pub(super) fn make_turn_context(&self) -> Arc { - self.make_turn_context_with_schema(None) - } - - pub(super) fn make_turn_context_with_schema( - &self, - final_output_json_schema: Option, - ) -> Arc { - Arc::new(TurnContext { - client: self.client.clone(), - cwd: self.cwd.clone(), - base_instructions: self.base_instructions.clone(), - user_instructions: self.user_instructions.clone(), - demo_developer_message: self.demo_developer_message.clone(), - active_session_model_notice: self - .active_session_model_notice - .as_ref() - .map(|notice| notice.message.clone()), - compact_prompt_override: self.compact_prompt_override.clone(), - approval_policy: self.approval_policy, - sandbox_policy: self.sandbox_policy.clone(), - shell_environment_policy: self.shell_environment_policy.clone(), - is_review_mode: false, - text_format_override: self.next_turn_text_format.lock().unwrap().take(), - final_output_json_schema, - }) - } - - pub(super) fn compact_prompt_text(&self) -> String { - crate::codex::compact::resolve_compact_prompt_text( - self.compact_prompt_override.as_deref(), - ) - } - - pub async fn request_command_approval( - &self, - sub_id: String, - call_id: String, - approval_id: Option, - command: Vec, - cwd: PathBuf, - reason: Option, - network_approval_context: Option, - additional_permissions: Option, - ) -> oneshot::Receiver { - let (tx_approve, rx_approve) = oneshot::channel(); - let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); - let event = self.make_event( - &sub_id, - EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - call_id: call_id.clone(), - approval_id, - turn_id: sub_id.clone(), - command, - cwd, - reason, - network_approval_context, - additional_permissions, - }), - ); - let _ = self.tx_event.send(event).await; - { - let mut state = self.state.lock().unwrap(); - // Track pending approval by approval id (or call_id fallback) rather than sub_id - // so parallel approvals in the same turn do not clobber each other. - state.pending_approvals.insert(effective_approval_id, tx_approve); - } - rx_approve - } - - pub async fn request_patch_approval( - &self, - sub_id: String, - call_id: String, - action: &ApplyPatchAction, - reason: Option, - grant_root: Option, - ) -> oneshot::Receiver { - let (tx_approve, rx_approve) = oneshot::channel(); - let event = self.make_event( - &sub_id, - EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: call_id.clone(), - changes: convert_apply_patch_to_protocol(action), - reason, - grant_root, - }), - ); - let _ = self.tx_event.send(event).await; - { - let mut state = self.state.lock().unwrap(); - // Track pending approval by call_id to avoid collisions. - state.pending_approvals.insert(call_id, tx_approve); - } - rx_approve - } - - pub fn notify_approval(&self, call_id: &str, decision: ReviewDecision) { - let mut state = self.state.lock().unwrap(); - if let Some(tx_approve) = state.pending_approvals.remove(call_id) { - let _ = tx_approve.send(decision); - } else { - // If we cannot find a pending approval for this call id, surface a warning - // to aid debugging of stuck approvals. - tracing::warn!("no pending approval found for call_id={}", call_id); - } - } - - pub fn register_pending_user_input( - &self, - turn_id: String, - ) -> std::result::Result, String> { - let (tx, rx) = oneshot::channel(); - let mut state = self.state.lock().unwrap(); - if state.pending_request_user_input.contains_key(&turn_id) { - return Err(format!("request_user_input already pending for turn_id={turn_id}")); - } - state.pending_request_user_input.insert(turn_id, tx); - Ok(rx) - } - - pub fn notify_user_input_response( - &self, - turn_id: &str, - response: crate::protocol::RequestUserInputResponse, - ) { - let pending = { - let mut state = self.state.lock().unwrap(); - state.pending_request_user_input.remove(turn_id) - }; - if let Some(tx) = pending { - let _ = tx.send(response); - } else { - tracing::warn!("no pending request_user_input found for turn_id={turn_id}"); - } - } - - pub fn register_pending_dynamic_tool( - &self, - call_id: String, - ) -> std::result::Result, String> { - let (tx, rx) = oneshot::channel(); - let mut state = self.state.lock().unwrap(); - if state.pending_dynamic_tools.contains_key(&call_id) { - return Err(format!("dynamic tool already pending for call_id={call_id}")); - } - state.pending_dynamic_tools.insert(call_id, tx); - Ok(rx) - } - - pub fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) { - let pending = { - let mut state = self.state.lock().unwrap(); - state.pending_dynamic_tools.remove(call_id) - }; - if let Some(tx) = pending { - let _ = tx.send(response); - } else { - tracing::warn!("no pending dynamic tool found for call_id={call_id}"); - } - } - - pub fn add_approved_command(&self, pattern: ApprovedCommandPattern) { - let mut state = self.state.lock().unwrap(); - state.approved_commands.insert(pattern); - } - - /// Records items to both the rollout and the chat completions/ZDR - /// transcript, if enabled. - pub(super) async fn record_conversation_items(&self, items: &[ResponseItem]) { - debug!("Recording items for conversation: {items:?}"); - self.record_state_snapshot(items).await; - - self.state.lock().unwrap().history.record_items(items); - - } - - /// Clean up old screenshots and system status messages from conversation history - /// This is called when a new user message arrives to keep history manageable - pub(super) async fn cleanup_old_status_items(&self) { - let mut state = self.state.lock().unwrap(); - let current_items = state.history.take_contents(); - - let (items_to_keep, stats) = if self.env_ctx_v2 { - let policy = crate::retention::RetentionPolicy { - max_env_deltas: self.retention_config.max_env_deltas, - max_browser_snapshots: self.retention_config.max_browser_snapshots, - max_total_bytes: self.retention_config.max_total_bytes, - keep_latest_baseline: self.retention_config.keep_latest_baseline, - }; - - let (kept, retention_stats) = - crate::retention::apply_retention_policy_owned(current_items, &policy); - - crate::telemetry::global_telemetry().record_retention(&retention_stats); - - let legacy_stats = CleanupStats { - removed_screenshots: retention_stats.removed_screenshots, - removed_status: retention_stats.removed_status, - removed_env_baselines: retention_stats.removed_env_baselines, - removed_env_deltas: retention_stats.removed_env_deltas, - removed_browser_snapshots: retention_stats.removed_browser_snapshots, - kept_recent_screenshots: retention_stats.kept_recent_screenshots, - kept_env_deltas: retention_stats.kept_env_deltas, - kept_browser_snapshots: retention_stats.kept_browser_snapshots, - }; - - (kept, legacy_stats) - } else { - prune_history_items_owned(current_items) - }; - - state.history.replace_filtered(items_to_keep); - drop(state); - - if stats.any_removed() { - info!( - "Cleaned up history: removed {} old screenshots, {} status messages, {} env baselines, {} env deltas, {} browser snapshots; kept {} recent screenshots, {} env deltas, {} browser snapshots", - stats.removed_screenshots, - stats.removed_status, - stats.removed_env_baselines, - stats.removed_env_deltas, - stats.removed_browser_snapshots, - stats.kept_recent_screenshots, - stats.kept_env_deltas, - stats.kept_browser_snapshots - ); - } - } - - async fn record_state_snapshot(&self, items: &[ResponseItem]) { - let snapshot = { SessionStateSnapshot {} }; - - let recorder = self.clone_rollout_recorder(); - - if let Some(rec) = recorder { - if let Err(e) = rec.record_state(snapshot).await { - error!("failed to record rollout state: {e:#}"); - } - if let Err(e) = rec.record_response_items(items).await { - error!("failed to record rollout items: {e:#}"); - } - } - } - - pub(super) fn clone_rollout_recorder(&self) -> Option { - let guard = self.rollout.lock().unwrap(); - guard.as_ref().cloned() - } - - pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) { - let recorder = { - let guard = self.rollout.lock().unwrap(); - guard.as_ref().cloned() - }; - if let Some(rec) = recorder { - if let Err(e) = rec.record_items(items).await { - error!("failed to record rollout items: {e:#}"); - } - } - } - - /// Build the full turn input by concatenating the current conversation - /// history with additional items for this turn. - /// Browser screenshots are filtered out from history to keep them ephemeral. - pub fn turn_input_with_history(&self, extra: Vec) -> Vec { - let history = self.state.lock().unwrap().history.contents(); - - // Debug: Count function call outputs in history - let fc_output_count = history - .iter() - .filter(|item| matches!(item, ResponseItem::FunctionCallOutput { .. })) - .count(); - if fc_output_count > 0 { - debug!( - "History contains {} FunctionCallOutput items", - fc_output_count - ); - } - - // Count images in extra for debugging (we can't distinguish ephemeral at this level anymore) - let images_in_extra = extra - .iter() - .filter(|item| { - if let ResponseItem::Message { content, .. } = item { - content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })) - } else { - false - } - }) - .count(); - - if images_in_extra > 0 { - tracing::info!( - "Found {} images in current turn's extra items", - images_in_extra - ); - } - - // Helper closure to detect legacy XML environment context items - let is_legacy_env_context = |item: &ResponseItem| -> bool { - if let ResponseItem::Message { role, content, .. } = item { - if role == "user" { - return content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains("") - } else { - false - } - }); - } - } - false - }; - - // Filter out browser screenshots from historical messages - // We identify them by the [EPHEMERAL:...] marker that precedes them - // When env_ctx_v2 is enabled, also suppress legacy XML environment context messages - let filtered_history: Vec = history - .into_iter() - .filter(|item| { - if self.env_ctx_v2 && *crate::flags::CTX_UI && is_legacy_env_context(item) { - tracing::debug!("Suppressing legacy XML environment context item from history"); - return false; - } - true - }) - .map(|item| { - if let ResponseItem::Message { - id, - role, - content, - .. - } = item - { - if role == "user" { - // Filter out ephemeral content from user messages - let mut filtered_content: Vec = Vec::new(); - let mut skip_next_image = false; - - for content_item in content { - match &content_item { - ContentItem::InputText { text } - if text.starts_with("[EPHEMERAL:") => - { - // This is an ephemeral marker, skip it and the next image - skip_next_image = true; - tracing::info!("Filtering out ephemeral marker: {}", text); - } - ContentItem::InputImage { .. } - if skip_next_image => - { - // Skip this image as it follows an ephemeral marker - skip_next_image = false; - tracing::info!("Filtering out ephemeral image from history"); - } - _ => { - // Keep everything else - filtered_content.push(content_item); - } - } - } - - ResponseItem::Message { - id, - role, - content: filtered_content, end_turn: None, phase: None} - } else { - // Keep assistant messages unchanged - ResponseItem::Message { - id, - role, - content, - end_turn: None, - phase: None, - } - } - } else { - item - } - }) - .collect(); - - let filtered_extra = if self.env_ctx_v2 && *crate::flags::CTX_UI { - extra - .into_iter() - .filter(|item| { - parse_env_snapshot_from_response(item).is_none() - && parse_env_delta_from_response(item).is_none() - }) - .collect::>() - } else { - extra - }; - - let mut result = Vec::new(); - result.extend(filtered_history); - result.extend(filtered_extra); - - let current_auth_mode = self - .client - .get_auth_manager() - .and_then(|manager| manager.auth()) - .map(|auth| auth.mode); - let sanitize_encrypted_reasoning = !current_auth_mode.is_some_and(|mode| mode.is_chatgpt()); - - if sanitize_encrypted_reasoning { - let mut stripped = 0usize; - result = result - .into_iter() - .map(|item| match item { - ResponseItem::Reasoning { - id, - summary, - content, - encrypted_content, - } => { - if encrypted_content.is_some() { - stripped += 1; - } - ResponseItem::Reasoning { - id, - summary, - content, - encrypted_content: None, - } - } - other => other, - }) - .collect(); - if stripped > 0 { - debug!( - "Stripped encrypted reasoning from {} history items before sending request", - stripped - ); - } - } - - debug_history("turn_input_with_history", &result); - - // Count total images in result for debugging - let total_images = result - .iter() - .filter(|item| { - if let ResponseItem::Message { content, .. } = item { - content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })) - } else { - false - } - }) - .count(); - - if total_images > 0 { - tracing::info!("Total images being sent to model: {}", total_images); - } - - result - } - - pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { - let mut items = Vec::new(); - if let Some(user_instructions) = turn_context.user_instructions.as_deref() { - items.push( - UserInstructions { - text: user_instructions.to_string(), - directory: turn_context.cwd.to_string_lossy().into_owned(), - } - .into(), - ); - } - - let env_context = EnvironmentContext::new( - Some(turn_context.cwd.clone()), - Some(turn_context.approval_policy), - Some(turn_context.sandbox_policy.clone()), - Some(self.user_shell.clone()), - ); - - if let Some(mut env_ctx_items) = self.maybe_emit_env_ctx_messages( - &env_context, - get_git_branch(&turn_context.cwd), - Some(format!("{:?}", self.client.get_reasoning_effort())), - ) { - items.append(&mut env_ctx_items); - } - - if !self.env_ctx_v2 { - // Legacy XML payload remains so behaviour is unchanged when the feature flag is off. - items.push(ResponseItem::from(env_context)); - } - items - } - - pub(super) fn maybe_emit_env_ctx_messages( - &self, - env_context: &EnvironmentContext, - git_branch: Option, - reasoning_effort: Option, - ) -> Option> { - if !self.env_ctx_v2 { - return None; - } - - let (stream_id, result) = { - let mut state = self.state.lock().unwrap(); - let stream = state.context_stream_ids.env_stream_id(self.id); - let result = match state.environment_context_tracker.emit_response_items( - env_context, - git_branch.clone(), - reasoning_effort.clone(), - Some(stream.as_str()), - ) { - Ok(Some((emission, items))) => { - state.environment_context_seq = emission.sequence(); - state.last_environment_snapshot = Some(emission.snapshot().clone()); - - match &emission { - EnvironmentContextEmission::Full { snapshot, .. } => { - if let Err(err) = state.context_timeline.add_baseline_once(snapshot.clone()) { - tracing::trace!("env_ctx_v2: baseline already set in context timeline: {err}"); - } - match state.context_timeline.record_snapshot(snapshot.clone()) { - Ok(true) => { - crate::telemetry::global_telemetry().record_snapshot_commit(); - } - Ok(false) => { - crate::telemetry::global_telemetry().record_dedup_drop(); - } - Err(err) => { - tracing::trace!("env_ctx_v2: failed to record baseline snapshot: {err}"); - } - } - } - EnvironmentContextEmission::Delta { sequence, delta, snapshot } => { - if state.context_timeline.baseline().is_none() { - if let Err(err) = state.context_timeline.add_baseline_once(snapshot.clone()) { - tracing::warn!("env_ctx_v2: failed to seed baseline before delta: {err}"); - } - } - if let Err(err) = state - .context_timeline - .apply_delta(*sequence, delta.clone()) - { - tracing::warn!("env_ctx_v2: failed to apply delta to timeline: {err}"); - if matches!(err, crate::context_timeline::TimelineError::DeltaSequenceOutOfOrder { .. }) { - crate::telemetry::global_telemetry().record_delta_gap(); - } - } - match state.context_timeline.record_snapshot(snapshot.clone()) { - Ok(true) => { - crate::telemetry::global_telemetry().record_snapshot_commit(); - } - Ok(false) => { - crate::telemetry::global_telemetry().record_dedup_drop(); - } - Err(err) => { - tracing::warn!("env_ctx_v2: failed to record snapshot: {err}"); - } - } - } - } - - Ok(Some((emission, items))) - } - other => other, - }; - (stream, result) - }; - - let (emission, mut items) = match result { - Ok(Some(pair)) => pair, - Ok(None) => return None, - Err(err) => { - warn!("env_ctx_v2: failed to serialize environment_context JSON: {err}"); - if *crate::flags::CTX_UI { - return Some(vec![ResponseItem::from(env_context.clone())]); - } - return None; - } - }; - - let suppress_legacy_status = self.env_ctx_v2 && *crate::flags::CTX_UI; - if suppress_legacy_status { - items.clear(); - } - - let sequence = emission.sequence(); - - let bytes_sent: usize = items - .iter() - .flat_map(|item| match item { - ResponseItem::Message { content, .. } => content.iter(), - _ => [].iter(), - }) - .map(|content| match content { - ContentItem::InputText { text } | ContentItem::OutputText { text } => text.len(), - _ => 0, - }) - .sum(); - - trace!( - "env_ctx_v2: emitted environment_context message (seq={}, bytes={})", - sequence, - bytes_sent - ); - - if *crate::flags::CTX_UI { - self.emit_env_context_event(stream_id.as_str(), &emission); - } - - Some(items) - } - - /// Assemble environment context items from the timeline for prompt input. - pub(super) fn assemble_timeline_prompt_items(&self) -> Option> { - if !self.env_ctx_v2 { - return None; - } - - let (timeline, stream_id, max_deltas) = { - let mut state = self.state.lock().unwrap(); - if state.context_timeline.is_empty() { - return None; - } - let stream_id = state.context_stream_ids.env_stream_id(self.id); - ( - state.context_timeline.clone(), - stream_id, - self.retention_config.max_env_deltas, - ) - }; - - match timeline.assemble_prompt_items(max_deltas, Some(&stream_id)) { - Ok(items) if !items.is_empty() => Some(items), - Ok(_) => None, - Err(err) => { - warn!("env_ctx_v2: failed to assemble timeline prompt items: {err}"); - None - } - } - } - - fn emit_env_context_event( - &self, - stream_id: &str, - emission: &EnvironmentContextEmission, - ) { - use crate::protocol::OrderMeta; - - let sequence = emission.sequence(); - let order = OrderMeta { - request_ordinal: self.current_request_ordinal(), - output_index: None, - sequence_number: Some(sequence), - }; - - let msg = match emission { - EnvironmentContextEmission::Full { snapshot, .. } => { - let Ok(snapshot_json) = serde_json::to_value(snapshot) else { - warn!("env_ctx_v2: failed to serialize environment context snapshot for event"); - return; - }; - EventMsg::EnvironmentContextFull(EnvironmentContextFullEvent { - snapshot: snapshot_json, - sequence: Some(sequence), - }) - } - EnvironmentContextEmission::Delta { delta, .. } => { - let Ok(delta_json) = serde_json::to_value(delta) else { - warn!("env_ctx_v2: failed to serialize environment context delta for event"); - return; - }; - EventMsg::EnvironmentContextDelta(EnvironmentContextDeltaEvent { - delta: delta_json, - sequence: Some(sequence), - base_fingerprint: Some(delta.base_fingerprint.clone()), - }) - } - }; - - let event = self.make_event_with_order(stream_id, msg, order, Some(sequence)); - if let Err(err) = self.tx_event.try_send(event) { - warn!("env_ctx_v2: failed to send environment context event: {err}"); - } - } - - pub(super) fn emit_browser_snapshot_event(&self, stream_id: &str, snapshot: &BrowserSnapshot) { - use crate::protocol::OrderMeta; - - let Ok(snapshot_json) = serde_json::to_value(snapshot) else { - warn!("env_ctx_v2: failed to serialize browser snapshot for event"); - return; - }; - - let order = OrderMeta { - request_ordinal: self.current_request_ordinal(), - output_index: None, - sequence_number: None, - }; - - let msg = EventMsg::BrowserSnapshot(BrowserSnapshotEvent { - snapshot: snapshot_json, - url: Some(snapshot.url.clone()), - captured_at: Some(snapshot.captured_at.clone()), - }); - - let event = self.make_event_with_order(stream_id, msg, order, None); - if let Err(err) = self.tx_event.try_send(event) { - warn!("env_ctx_v2: failed to send browser snapshot event: {err}"); - } - } - - pub(crate) fn reconstruct_history_from_rollout( - &self, - turn_context: &TurnContext, - rollout_items: &[RolloutItem], - ) -> Vec { - let mut history = self.build_initial_context(turn_context); - let mut replay_ctx = TimelineReplayContext::default(); - - for item in rollout_items { - match item { - RolloutItem::ResponseItem(response_item) => { - history.push(response_item.clone()); - process_rollout_env_item(&mut replay_ctx, response_item); - } - RolloutItem::Compacted(compacted) => { - let snippets = collect_compaction_snippets(&history); - history = build_compacted_history( - self.build_initial_context(turn_context), - &snippets, - &compacted.message, - ); - } - RolloutItem::Event(recorded_event) => { - if let code_protocol::protocol::EventMsg::UserMessage(user_msg_event) = &recorded_event.msg { - let response_item = ResponseItem::Message { - id: Some(recorded_event.id.clone()), - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: user_msg_event.message.clone(), - }], end_turn: None, phase: None}; - process_rollout_env_item(&mut replay_ctx, &response_item); - history.push(response_item); - } - } - _ => {} - } - } - - if replay_ctx.timeline.baseline().is_none() { - if let Some(snapshot) = replay_ctx.legacy_baseline.clone() { - if let Err(err) = replay_ctx.timeline.add_baseline_once(snapshot.clone()) { - tracing::warn!("env_ctx_v2: failed to map legacy status to baseline: {err}"); - } - match replay_ctx.timeline.record_snapshot(snapshot.clone()) { - Ok(true) => crate::telemetry::global_telemetry().record_snapshot_commit(), - Ok(false) => crate::telemetry::global_telemetry().record_dedup_drop(), - Err(err) => tracing::warn!("env_ctx_v2: failed to record legacy baseline snapshot: {err}"), - } - replay_ctx.last_snapshot = Some(snapshot); - } - } - - let restored_snapshot = replay_ctx.last_snapshot.clone(); - let next_seq_value = replay_ctx.next_sequence; - { - let mut state = self.state.lock().unwrap(); - state.context_timeline = replay_ctx.timeline.clone(); - state.environment_context_seq = next_seq_value.saturating_sub(1); - state.context_stream_ids = EnvironmentContextStreamRegistry::default(); - - if let Some(snapshot) = restored_snapshot { - state.last_environment_snapshot = Some(snapshot.clone()); - state - .environment_context_tracker - .restore(snapshot, next_seq_value); - } else { - state.last_environment_snapshot = None; - state.environment_context_tracker = EnvironmentContextTracker::new(); - } - } - - history - } - - /// Returns the input if there was no agent running to inject into - pub fn inject_input(&self, input: Vec) -> Result<(), Vec> { - let mut state = self.state.lock().unwrap(); - if let Some(task) = state.current_task.as_ref() { - let mut response = response_input_from_core_items(input); - self.enforce_user_message_limits(&task.sub_id, &mut response); - state.pending_input.push(response); - Ok(()) - } else { - Err(input) - } - } - - pub fn enqueue_manual_compact(&self, sub_id: String) -> bool { - let mut state = self.state.lock().unwrap(); - let was_empty = state.pending_manual_compacts.is_empty(); - while state.pending_manual_compacts.len() >= MAX_PENDING_MANUAL_COMPACTS { - state.pending_manual_compacts.pop_front(); - warn!( - cap = MAX_PENDING_MANUAL_COMPACTS, - "dropped oldest pending manual compact request to cap queue growth" - ); - } - state.pending_manual_compacts.push_back(sub_id); - was_empty - } - - pub fn get_pending_input(&self) -> Vec { - self.get_pending_input_filtered(true) - } - - /// Returns pending input for the current turn. Callers can decide whether - /// queued user inputs should be drained immediately (`drain_user_inputs = true`) - /// or preserved for a later turn—for example, review mode keeps them queued - /// so the primary agent can resume once the review finishes. - pub fn get_pending_input_filtered(&self, drain_user_inputs: bool) -> Vec { - let mut state = self.state.lock().unwrap(); - if state.pending_input.is_empty() - && (drain_user_inputs || state.pending_user_input.is_empty()) - { - Vec::with_capacity(0) - } else { - let mut ret = Vec::new(); - if !state.pending_input.is_empty() { - let mut model_inputs = Vec::new(); - std::mem::swap(&mut model_inputs, &mut state.pending_input); - ret.extend(model_inputs); - } - - if !state.pending_user_input.is_empty() { - if drain_user_inputs { - let mut queued_user_inputs = Vec::new(); - std::mem::swap(&mut queued_user_inputs, &mut state.pending_user_input); - ret.extend( - queued_user_inputs - .into_iter() - .map(|queued| queued.response_item), - ); - } else { - ret.extend( - state - .pending_user_input - .iter() - .map(|queued| queued.response_item.clone()), - ); - } - } - ret - } - } - - pub fn add_pending_input(&self, mut input: ResponseInputItem) { - let mut state = self.state.lock().unwrap(); - if let Some(task) = state.current_task.as_ref() { - self.enforce_user_message_limits(&task.sub_id, &mut input); - } - state.pending_input.push(input); - } - - pub async fn call_tool( - &self, - server: &str, - tool: &str, - arguments: Option, - timeout: Option, - ) -> anyhow::Result { - self.mcp_connection_manager - .call_tool(server, tool, arguments, timeout) - .await - } - - pub(super) fn abort(&self) { - info!("Aborting existing session"); - - self.mark_all_running_execs_as_cancelled(); - - let mut state = self.state.lock().unwrap(); - state.pending_approvals.clear(); - state.pending_request_user_input.clear(); - state.pending_dynamic_tools.clear(); - // Do not clear `pending_input` here. When a user submits a new message - // immediately after an interrupt, it may have been routed to - // `pending_input` by an earlier code path. Clearing it would drop the - // user's message and prevent the next turn from ever starting. - state.turn_scratchpad = None; - // Take current task while holding the lock, then drop the lock BEFORE calling abort - let current = state.current_task.take(); - drop(state); - if let Some(agent) = current { - agent.abort(TurnAbortReason::Interrupted); - } - // Also terminate any running exec sessions (PTY-based) so child processes do not linger. - // Best-effort cleanup for PTY-based exec sessions would go here. The - // PTY implementation already kills processes on session drop; in the - // common LocalShellCall path we also kill processes immediately via - // KillOnDrop in exec.rs. - } - - /// Spawn the configured notifier (if any) with the given JSON payload as - /// the last argument. Failures are logged but otherwise ignored so that - /// notification issues do not interfere with the main workflow. - pub(super) fn maybe_notify(&self, notification: UserNotification) { - let Some(notify_command) = &self.notify else { - return; - }; - - if notify_command.is_empty() { - return; - } - - let Ok(json) = serde_json::to_string(¬ification) else { - error!("failed to serialise notification payload"); - return; - }; - - let mut command = std::process::Command::new(¬ify_command[0]); - if notify_command.len() > 1 { - command.args(¬ify_command[1..]); - } - command.arg(json); - - // Fire-and-forget – we do not wait for completion. - if let Err(e) = crate::spawn::spawn_background_command_with_retry(&mut command) { - warn!("failed to spawn notifier '{}': {e}", notify_command[0]); - } - } -} -#[derive(Debug, Default, PartialEq, Eq)] -pub(super) struct CleanupStats { - pub(super) removed_screenshots: usize, - pub(super) removed_status: usize, - pub(super) removed_env_baselines: usize, - pub(super) removed_env_deltas: usize, - pub(super) removed_browser_snapshots: usize, - pub(super) kept_recent_screenshots: usize, - pub(super) kept_env_deltas: usize, - pub(super) kept_browser_snapshots: usize, -} - -impl CleanupStats { - pub(super) fn any_removed(&self) -> bool { - self.removed_screenshots > 0 - || self.removed_status > 0 - || self.removed_env_baselines > 0 - || self.removed_env_deltas > 0 - || self.removed_browser_snapshots > 0 - } -} - -#[cfg(test)] -pub(crate) fn prune_history_items( - current_items: &[ResponseItem], -) -> (Vec, CleanupStats) { - let mut real_user_messages = Vec::new(); - let mut status_messages = Vec::new(); - let mut env_baselines = Vec::new(); - let mut env_deltas = Vec::new(); - let mut browser_snapshot_messages = Vec::new(); - - const MAX_ENV_DELTAS: usize = 3; - const MAX_BROWSER_SNAPSHOTS: usize = 2; - - for (idx, item) in current_items.iter().enumerate() { - if let ResponseItem::Message { role, content, .. } = item { - if role != "user" { - continue; - } - - let has_status = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains("== System Status ==") - || text.contains("Current working directory:") - || text.contains("Git branch:") - || text.contains(ENVIRONMENT_CONTEXT_OPEN_TAG) - || text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - || text.contains(BROWSER_SNAPSHOT_OPEN_TAG) - } else { - false - } - }); - - let has_screenshot = content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })); - - let has_real_text = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - !text.contains("== System Status ==") - && !text.contains("Current working directory:") - && !text.contains("Git branch:") - && !text.trim().is_empty() - && !text.contains(ENVIRONMENT_CONTEXT_OPEN_TAG) - && !text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - && !text.contains(BROWSER_SNAPSHOT_OPEN_TAG) - } else { - false - } - }); - - let has_env_baseline = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains(ENVIRONMENT_CONTEXT_OPEN_TAG) - && !text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - } else { - false - } - }); - - let has_env_delta = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - } else { - false - } - }); - - let has_browser_snapshot = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains(BROWSER_SNAPSHOT_OPEN_TAG) - } else { - false - } - }); - - if has_real_text && !has_status && !has_screenshot { - real_user_messages.push(idx); - } else if has_status || has_screenshot { - status_messages.push(idx); - } - - if has_env_baseline { - env_baselines.push(idx); - } - if has_env_delta { - env_deltas.push(idx); - } - if has_browser_snapshot { - browser_snapshot_messages.push(idx); - } - } - } - - let mut screenshots_to_keep = std::collections::HashSet::new(); - for &user_idx in real_user_messages.iter().rev().take(2) { - for &status_idx in status_messages.iter() { - if status_idx > user_idx { - if let Some(ResponseItem::Message { content, .. }) = current_items.get(status_idx) - { - if content.iter().any(|c| matches!(c, ContentItem::InputImage { .. })) { - screenshots_to_keep.insert(status_idx); - break; - } - } - } - } - } - - let baseline_to_keep = env_baselines.last().copied(); - let env_deltas_to_keep: std::collections::HashSet = env_deltas - .iter() - .rev() - .take(MAX_ENV_DELTAS) - .copied() - .collect(); - let browser_snapshots_to_keep: std::collections::HashSet = browser_snapshot_messages - .iter() - .rev() - .take(MAX_BROWSER_SNAPSHOTS) - .copied() - .collect(); - - let mut items_to_keep = Vec::new(); - let mut removed_screenshots = 0usize; - let mut removed_status = 0usize; - - for (idx, item) in current_items.iter().enumerate() { - let keep = if status_messages.contains(&idx) { - screenshots_to_keep.contains(&idx) - || browser_snapshots_to_keep.contains(&idx) - || baseline_to_keep == Some(idx) - || env_deltas_to_keep.contains(&idx) - } else { - true - }; - - if keep { - items_to_keep.push(item.clone()); - } else if let ResponseItem::Message { content, .. } = item { - if content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })) - { - removed_screenshots += 1; - } else { - removed_status += 1; - } - } - } - - let stats = CleanupStats { - removed_screenshots, - removed_status, - removed_env_baselines: env_baselines - .len() - .saturating_sub(if baseline_to_keep.is_some() { 1 } else { 0 }), - removed_env_deltas: env_deltas.len().saturating_sub(env_deltas_to_keep.len()), - removed_browser_snapshots: browser_snapshot_messages - .len() - .saturating_sub(browser_snapshots_to_keep.len()), - kept_recent_screenshots: screenshots_to_keep.len(), - kept_env_deltas: env_deltas_to_keep.len(), - kept_browser_snapshots: browser_snapshots_to_keep.len(), - }; - - (items_to_keep, stats) -} - -fn prune_history_items_owned(current_items: Vec) -> (Vec, CleanupStats) { - let mut real_user_messages = Vec::new(); - let mut status_messages = Vec::new(); - let mut env_baselines = Vec::new(); - let mut env_deltas = Vec::new(); - let mut browser_snapshot_messages = Vec::new(); - - const MAX_ENV_DELTAS: usize = 3; - const MAX_BROWSER_SNAPSHOTS: usize = 2; - - for (idx, item) in current_items.iter().enumerate() { - if let ResponseItem::Message { role, content, .. } = item { - if role != "user" { - continue; - } - - let has_status = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains("== System Status ==") - || text.contains("Current working directory:") - || text.contains("Git branch:") - || text.contains(ENVIRONMENT_CONTEXT_OPEN_TAG) - || text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - || text.contains(BROWSER_SNAPSHOT_OPEN_TAG) - } else { - false - } - }); - - let has_screenshot = content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })); - - let has_real_text = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - !text.contains("== System Status ==") - && !text.contains("Current working directory:") - && !text.contains("Git branch:") - && !text.trim().is_empty() - && !text.contains(ENVIRONMENT_CONTEXT_OPEN_TAG) - && !text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - && !text.contains(BROWSER_SNAPSHOT_OPEN_TAG) - } else { - false - } - }); - - let has_env_baseline = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains(ENVIRONMENT_CONTEXT_OPEN_TAG) - && !text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - } else { - false - } - }); - - let has_env_delta = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG) - } else { - false - } - }); - - let has_browser_snapshot = content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains(BROWSER_SNAPSHOT_OPEN_TAG) - } else { - false - } - }); - - if has_real_text && !has_status && !has_screenshot { - real_user_messages.push(idx); - } else if has_status || has_screenshot { - status_messages.push(idx); - } - - if has_env_baseline { - env_baselines.push(idx); - } - if has_env_delta { - env_deltas.push(idx); - } - if has_browser_snapshot { - browser_snapshot_messages.push(idx); - } - } - } - - let mut screenshots_to_keep = std::collections::HashSet::new(); - for &user_idx in real_user_messages.iter().rev().take(2) { - for &status_idx in status_messages.iter() { - if status_idx > user_idx { - if let Some(ResponseItem::Message { content, .. }) = current_items.get(status_idx) - { - if content.iter().any(|c| matches!(c, ContentItem::InputImage { .. })) { - screenshots_to_keep.insert(status_idx); - break; - } - } - } - } - } - - let baseline_to_keep = env_baselines.last().copied(); - let env_deltas_to_keep: std::collections::HashSet = env_deltas - .iter() - .rev() - .take(MAX_ENV_DELTAS) - .copied() - .collect(); - let browser_snapshots_to_keep: std::collections::HashSet = browser_snapshot_messages - .iter() - .rev() - .take(MAX_BROWSER_SNAPSHOTS) - .copied() - .collect(); - - let mut items_to_keep = Vec::new(); - let mut removed_screenshots = 0usize; - let mut removed_status = 0usize; - - for (idx, item) in current_items.into_iter().enumerate() { - let keep = if status_messages.contains(&idx) { - screenshots_to_keep.contains(&idx) - || browser_snapshots_to_keep.contains(&idx) - || baseline_to_keep == Some(idx) - || env_deltas_to_keep.contains(&idx) - } else { - true - }; - - if keep { - items_to_keep.push(item); - } else if let ResponseItem::Message { content, .. } = &item { - if content - .iter() - .any(|c| matches!(c, ContentItem::InputImage { .. })) - { - removed_screenshots += 1; - } else { - removed_status += 1; - } - } - } - - let stats = CleanupStats { - removed_screenshots, - removed_status, - removed_env_baselines: env_baselines - .len() - .saturating_sub(if baseline_to_keep.is_some() { 1 } else { 0 }), - removed_env_deltas: env_deltas.len().saturating_sub(env_deltas_to_keep.len()), - removed_browser_snapshots: browser_snapshot_messages - .len() - .saturating_sub(browser_snapshots_to_keep.len()), - kept_recent_screenshots: screenshots_to_keep.len(), - kept_env_deltas: env_deltas_to_keep.len(), - kept_browser_snapshots: browser_snapshots_to_keep.len(), - }; - - (items_to_keep, stats) -} - -impl Drop for Session { - fn drop(&mut self) { - // Interrupt any running turn when the session is dropped. - self.abort(); - } -} - -impl State { - pub fn partial_clone(&self) -> Self { - Self { - approved_commands: self.approved_commands.clone(), - history: self.history.clone(), - // Preserve request_ordinal so reconfigurations (e.g., /reasoning) - // do not reset provider ordering mid-session. - request_ordinal: self.request_ordinal, - background_seq_by_sub_id: self.background_seq_by_sub_id.clone(), - selected_mcp_tools: self.selected_mcp_tools.clone(), - dry_run_guard: self.dry_run_guard.clone(), - next_internal_sub_id: self.next_internal_sub_id, - context_timeline: self.context_timeline.clone(), - environment_context_tracker: self.environment_context_tracker.clone(), - environment_context_seq: self.environment_context_seq, - last_environment_snapshot: self.last_environment_snapshot.clone(), - context_stream_ids: self.context_stream_ids.clone(), - active_session_notice_state: self.active_session_notice_state.clone(), - active_session_write_gate_state: self.active_session_write_gate_state.clone(), - ..Default::default() - } - } -} diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs deleted file mode 100644 index 56273257181..00000000000 --- a/code-rs/core/src/codex/streaming.rs +++ /dev/null @@ -1,15646 +0,0 @@ -use super::*; -use super::exec::{ - ApplyPatchCommandContext, - ExecCommandContext, - ExecInvokeArgs, - maybe_run_with_user_profile, -}; -use super::session::{ - ActiveSessionWorktreeDecision, - BackgroundExecState, - FollowUpTurnAction, - QueuedUserInput, - State, - WaitInterruptReason, - account_usage_context, - format_retry_eta, - is_connectivity_error, - spawn_usage_task, -}; -use super::session::{ - MAX_AGENT_COMPLETION_WAKE_BATCHES, - MAX_WAIT_TRACKED_AGENT_IDS_PER_BATCH, - MAX_WAIT_TRACKED_BATCHES, -}; -use crate::auth; -use crate::auth_accounts; -use crate::account_switching::RateLimitSwitchState; -use crate::agent_tool::current_agent_spawn_depth; -use crate::agent_tool::external_agent_command_exists; -use crate::protocol::McpListToolsResponseEvent; -use crate::protocol::TaskLifecycleEvent; -use crate::protocol::TaskLifecyclePhase; -use crate::protocol::TaskOriginKind; -use crate::review_store::{ - default_auto_review_detail_max_bytes, hard_auto_review_detail_max_bytes, - AutoReviewLedgerOptions, AutoReviewRunStore, -}; -use code_app_server_protocol::AuthMode as AppAuthMode; -use code_protocol::models::ContentItem; -use code_protocol::models::ResponseItem; -use code_protocol::models::FunctionCallOutputContentItem; -use code_protocol::models::ImageDetail; -use code_protocol::models::FunctionCallOutputPayload; -use code_protocol::models::ShellCommandToolCallParams; -use code_protocol::models::ShellToolCallParams; -use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[derive(Clone, Debug, Eq, PartialEq)] -enum AgentTaskKind { - Regular, - Review, - Compact, -} - -const SEARCH_TOOL_DEVELOPER_INSTRUCTIONS: &str = - include_str!("../../templates/search_tool/developer_instructions.md"); -const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; -const LEGACY_SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25"; -const CODEX_APPS_TOOL_PREFIX: &str = "mcp__codex_apps__"; -const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; -const AUTO_CONTEXT_JUDGE_MIN_TOKENS: u64 = 150_000; -const AUTO_CONTEXT_FORCE_COMPACT_MARGIN_TOKENS: u64 = 20_000; -const AUTO_CONTEXT_ESTIMATED_BYTES_PER_TOKEN: u64 = 4; -const AUTO_CONTEXT_MIN_PROJECTED_TURN_GROWTH_TOKENS: u64 = 24_000; -const AUTO_CONTEXT_MAX_PROJECTED_TURN_GROWTH_TOKENS: u64 = 180_000; -const AUTO_CONTEXT_JUDGE_PRIMARY_MODEL: &str = "gpt-5.5"; -const AUTO_CONTEXT_JUDGE_FALLBACK_MODEL: &str = "codex-mini-latest"; -const AUTO_CONTEXT_JUDGE_DEVELOPER_MESSAGE: &str = concat!( - "You decide whether Code should compact conversation history before the next user turn. ", - "Return strict JSON only that matches the provided schema. ", - "The provided tokens_in_context already includes the new user turn before assistant/tool work begins. ", - "Strongly prefer should_compact_now=false when the new user message is clearly continuing the same thread ", - "and recent context is likely still needed. However, as projected usage approaches or exceeds the standard ", - "usage limit, increase your bias toward compaction even for continuations. If the current turn is likely to go ", - "past the standard usage limit, treat should_compact_now=true as materially more favorable unless doing so ", - "would likely harm correctness or progress. If the current turn is likely to go past the force-compact threshold ", - "or hard 1M context limit, strongly prefer should_compact_now=true. The farther the projected usage goes past ", - "the standard usage limit, the more aggressively you should lean toward compaction. Prefer preserving continuity ", - "only when nearby context appears genuinely essential to finishing the active thread correctly." -); - -#[derive(Clone, Debug, Default)] -struct ImageGenerationTurnMetadata { - requested_model: String, - latest_response_model: Option, - response_headers: Option, -} - -#[derive(serde::Serialize)] -struct ImageGenerationSidecar<'a> { - call_id: &'a str, - status: &'a str, - revised_prompt: Option<&'a str>, - artifact_path: String, - requested_model: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - latest_response_model: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - response_headers: Option<&'a serde_json::Value>, -} - -/// A series of Turns in response to user input. -pub(super) struct AgentTask { - sess: Arc, - pub(super) sub_id: String, - handle: AbortHandle, - kind: AgentTaskKind, - pub(super) origin: TaskOriginKind, - pub(super) visible_to_user: bool, -} - -impl AgentTask { - pub(super) fn spawn( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - origin: TaskOriginKind, - visible_to_user: bool, - ) -> Self { - let handle = { - let sess_clone = Arc::clone(&sess); - let tc_clone = Arc::clone(&turn_context); - let sub_clone = sub_id.clone(); - let origin_clone = origin; - let visible_clone = visible_to_user; - tokio::spawn(async move { - run_agent(sess_clone, tc_clone, sub_clone, input, origin_clone, visible_clone).await; - }) - .abort_handle() - }; - Self { - sess, - sub_id, - handle, - kind: AgentTaskKind::Regular, - origin, - visible_to_user, - } - } - - pub(super) fn compact( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - ) -> Self { - let handle = { - let sess_clone = Arc::clone(&sess); - let tc_clone = Arc::clone(&turn_context); - let sub_clone = sub_id.clone(); - tokio::spawn(async move { - compact::run_compact_task( - sess_clone, - tc_clone, - sub_clone, - input, - ) - .await; - }) - .abort_handle() - }; - Self { - sess, - sub_id, - handle, - kind: AgentTaskKind::Compact, - origin: TaskOriginKind::ManualCompact, - visible_to_user: false, - } - } - - pub(super) fn review( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - ) -> Self { - let handle = { - let sess_clone = Arc::clone(&sess); - let tc_clone = Arc::clone(&turn_context); - let sub_clone = sub_id.clone(); - tokio::spawn(async move { - run_agent( - sess_clone, - tc_clone, - sub_clone, - input, - TaskOriginKind::Review, - false, - ) - .await; - }) - .abort_handle() - }; - Self { - sess, - sub_id, - handle, - kind: AgentTaskKind::Review, - origin: TaskOriginKind::Review, - visible_to_user: false, - } - } - - pub(super) fn abort(self, reason: TurnAbortReason) { - if !self.handle.is_finished() { - self.handle.abort(); - let event = self - .sess - .make_event(&self.sub_id, EventMsg::TurnAborted(TurnAbortedEvent { reason })); - let sess = self.sess.clone(); - let sub_id = self.sub_id.clone(); - let kind = self.kind; - tokio::spawn(async move { - if kind == AgentTaskKind::Review { - exit_review_mode(sess.clone(), sub_id, None).await; - } - sess.send_event(event).await; - }); - } - } -} - -pub(super) async fn submission_loop( - mut session_id: Uuid, - config: Arc, - auth_manager: Option>, - session_source: SessionSource, - rx_sub: Receiver, - tx_event: Sender, -) { - let mut config = config; - let mut sess: Option> = None; - let mut agent_manager_initialized = false; - // shorthand - send an event when there is no active session - let send_no_session_event = |sub_id: String| async { - let event = Event { - id: sub_id, - event_seq: 0, - msg: EventMsg::Error(ErrorEvent { message: "No session initialized, expected 'ConfigureSession' as first Op".to_string() }), - order: None, - }; - tx_event.send(event).await.ok(); - }; - - // To break out of this loop, send Op::Shutdown. - while let Ok(sub) = rx_sub.recv().await { - debug!(?sub, "Submission"); - match sub.op { - Op::Interrupt => { - let sess = match sess.as_ref() { - Some(sess) => sess.clone(), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - tokio::spawn(async move { - sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted); - sess.abort(); - }); - } - Op::CancelAgents { batch_ids, agent_ids } => { - let sess_arc = match sess.as_ref() { - Some(sess) => Arc::clone(sess), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - - let mut manager = AGENT_MANAGER.write().await; - let mut seen_batches: HashSet = HashSet::new(); - let mut seen_agents: HashSet = HashSet::new(); - let mut cancelled = 0usize; - - for batch in batch_ids { - let trimmed = batch.trim(); - if trimmed.is_empty() { - continue; - } - if !seen_batches.insert(trimmed.to_string()) { - continue; - } - cancelled += manager - .cancel_batch_for_session(trimmed, sess_arc.session_uuid()) - .await; - } - - for agent_id in agent_ids { - let trimmed = agent_id.trim(); - if trimmed.is_empty() { - continue; - } - if !seen_agents.insert(trimmed.to_string()) { - continue; - } - if manager - .cancel_agent_for_session(trimmed, sess_arc.session_uuid()) - .await - { - cancelled += 1; - } - } - - drop(manager); - - send_agent_status_update(&sess_arc).await; - - let message = if cancelled == 0 { - "No running agents to cancel.".to_string() - } else { - let suffix = if cancelled == 1 { "" } else { "s" }; - format!("Cancelled {cancelled} running agent{suffix}.") - }; - - let event = sess_arc.make_event( - &sub.id, - EventMsg::AgentMessage(AgentMessageEvent { message }), - ); - sess_arc.send_event(event).await; - } - Op::AddPendingInputDeveloper { text } => { - let sess = match sess.as_ref() { Some(s) => s.clone(), None => { send_no_session_event(sub.id).await; continue; } }; - let dev_msg = ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }] }; - let should_start_turn = sess.enqueue_out_of_turn_item(dev_msg); - if should_start_turn { - sess.cleanup_old_status_items().await; - let turn_context = sess.make_turn_context(); - let sub_id = sess.next_internal_sub_id(); - let sentinel_input = vec![InputItem::Text { - text: PENDING_ONLY_SENTINEL.to_string(), - }]; - let agent = AgentTask::spawn( - Arc::clone(&sess), - turn_context, - sub_id, - sentinel_input, - TaskOriginKind::OutOfTurnDeveloper, - false, - ); - sess.set_task(agent); - } - } - Op::AddPostTurnDeveloperInput { text } => { - let sess = match sess.as_ref() { - Some(s) => s.clone(), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - let dev_msg = ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - }; - let should_start_turn = sess.enqueue_post_turn_item(dev_msg); - if should_start_turn { - sess.start_post_turn_pending_only_turn_if_idle().await; - } - } - Op::ConfigureSession { - provider, - model, - model_explicit, - model_reasoning_effort, - preferred_model_reasoning_effort, - model_reasoning_summary, - model_text_verbosity, - service_tier, - context_mode, - model_context_window, - model_auto_compact_token_limit, - user_instructions: provided_user_instructions, - base_instructions: provided_base_instructions, - approval_policy, - sandbox_policy, - disable_response_storage, - notify, - cwd, - resume_path, - demo_developer_message, - dynamic_tools, - } => { - debug!( - "Configuring session: model={model}; provider={provider:?}; resume={resume_path:?}" - ); - if !cwd.is_absolute() { - let message = format!("cwd is not absolute: {cwd:?}"); - error!(message); - let event = Event { id: sub.id, event_seq: 0, msg: EventMsg::Error(ErrorEvent { message }), order: None }; - if let Err(e) = tx_event.send(event).await { - error!("failed to send error message: {e:?}"); - } - return; - } - let current_config = Arc::clone(&config); - let mut updated_config = (*current_config).clone(); - - let model_changed = !updated_config.model.eq_ignore_ascii_case(&model); - let effort_changed = updated_config.model_reasoning_effort != model_reasoning_effort; - let preferred_effort_changed = preferred_model_reasoning_effort - .as_ref() - .map(|preferred| updated_config.preferred_model_reasoning_effort != Some(*preferred)) - .unwrap_or(false); - - let old_model_family = updated_config.model_family.clone(); - let old_tool_output_max_bytes = updated_config.tool_output_max_bytes; - let old_default_tool_output_max_bytes = old_model_family.tool_output_max_bytes(); - - updated_config.model = model.clone(); - updated_config.model_explicit = model_explicit; - updated_config.model_provider = provider.clone(); - updated_config.model_reasoning_effort = model_reasoning_effort; - if let Some(preferred) = preferred_model_reasoning_effort { - updated_config.preferred_model_reasoning_effort = Some(preferred); - } - updated_config.model_reasoning_summary = model_reasoning_summary; - updated_config.model_text_verbosity = model_text_verbosity; - updated_config.service_tier = service_tier; - updated_config.context_mode = context_mode; - updated_config.model_context_window = model_context_window; - updated_config.model_auto_compact_token_limit = model_auto_compact_token_limit; - updated_config.user_instructions = provided_user_instructions.clone(); - let base_instructions = provided_base_instructions.or_else(|| { - crate::model_family::base_instructions_override_for_personality( - &model, - updated_config.model_personality, - ) - }); - updated_config.base_instructions = base_instructions.clone(); - updated_config.approval_policy = approval_policy; - updated_config.sandbox_policy = sandbox_policy.clone(); - updated_config.disable_response_storage = disable_response_storage; - updated_config.notify = notify.clone(); - updated_config.cwd = cwd.clone(); - updated_config.dynamic_tools = dynamic_tools.clone(); - - updated_config.model_family = find_family_for_model(&updated_config.model) - .unwrap_or_else(|| derive_default_model_family(&updated_config.model)); - - let new_default_tool_output_max_bytes = - updated_config.model_family.tool_output_max_bytes(); - - let old_context_window = old_model_family.context_window; - let new_context_window = updated_config.model_family.context_window; - let old_max_tokens = old_model_family.max_output_tokens; - let new_max_tokens = updated_config.model_family.max_output_tokens; - let old_auto_compact = old_model_family.auto_compact_token_limit(); - let new_auto_compact = updated_config.model_family.auto_compact_token_limit(); - - maybe_update_from_model_info( - &mut updated_config.model_context_window, - old_context_window, - new_context_window, - ); - maybe_update_from_model_info( - &mut updated_config.model_max_output_tokens, - old_max_tokens, - new_max_tokens, - ); - maybe_update_from_model_info( - &mut updated_config.model_auto_compact_token_limit, - old_auto_compact, - new_auto_compact, - ); - - if old_tool_output_max_bytes == old_default_tool_output_max_bytes { - updated_config.tool_output_max_bytes = new_default_tool_output_max_bytes; - } - - let skills_outcome = - updated_config.skills_enabled.then(|| load_skills(&updated_config)); - if let Some(outcome) = &skills_outcome { - for err in &outcome.errors { - warn!("invalid skill {}: {}", err.path.display(), err.message); - } - } - - let computed_user_instructions = get_user_instructions( - &updated_config, - skills_outcome.as_ref().map(|outcome| outcome.skills.as_slice()), - ) - .await; - updated_config.user_instructions = computed_user_instructions.clone(); - - let effective_user_instructions = computed_user_instructions.clone(); - - // Optionally resume an existing rollout. - let mut restored_items: Option> = None; - let mut restored_history_snapshot: Option = None; - let mut resume_notice: Option = None; - let mut rollout_recorder: Option = None; - if let Some(path) = resume_path.as_ref() { - match RolloutRecorder::resume(&updated_config, path).await { - Ok((rec, saved)) => { - session_id = saved.session_id; - if !saved.items.is_empty() { - restored_items = Some(saved.items); - } - if let Some(snapshot) = saved.history_snapshot { - restored_history_snapshot = Some(snapshot); - } - rollout_recorder = Some(rec); - } - Err(e) => { - warn!("failed to resume rollout from {path:?}: {e}"); - resume_notice = Some(format!( - "⚠️ Failed to load previous session from {}: {e}. Starting a new conversation instead.", - path.display() - )); - updated_config.experimental_resume = None; - } - } - } - - let new_config = Arc::new(updated_config); - - if new_config.model_explicit && (model_changed || effort_changed || preferred_effort_changed) { - if let Err(err) = persist_model_selection( - &new_config.code_home, - new_config.active_profile.as_deref(), - &new_config.model, - Some(new_config.model_reasoning_effort), - new_config.preferred_model_reasoning_effort, - ) - .await - { - warn!("failed to persist model selection: {err:#}"); - } - } - - config = Arc::clone(&new_config); - - let rollout_recorder = match rollout_recorder { - Some(rec) => Some(rec), - None => { - match RolloutRecorder::new( - &config, - crate::rollout::recorder::RolloutRecorderParams::new( - code_protocol::mcp_protocol::ConversationId::from(session_id), - effective_user_instructions.clone(), - session_source.clone(), - ), - ) - .await - { - Ok(r) => Some(r), - Err(e) => { - warn!("failed to initialise rollout recorder: {e}"); - None - } - } - } - }; - - // Create debug logger based on config - let debug_logger = match crate::debug_logger::DebugLogger::new(config.debug) { - Ok(logger) => std::sync::Arc::new(std::sync::Mutex::new(logger)), - Err(e) => { - warn!("Failed to create debug logger: {}", e); - // Create a disabled logger as fallback - std::sync::Arc::new(std::sync::Mutex::new( - crate::debug_logger::DebugLogger::new(false).unwrap(), - )) - } - }; - - if config.debug { - if let Ok(logger) = debug_logger.lock() { - if let Err(e) = logger.set_session_usage_file(&session_id) { - warn!("failed to initialise session usage log: {e}"); - } - } - - // SAFETY: setting a process-wide env var is intentional here to - // coordinate sub-agent debug behaviour launched from this session. - unsafe { std::env::set_var("CODE_SUBAGENT_DEBUG", "1"); } - match crate::config::find_code_home() { - Ok(mut debug_root) => { - debug_root.push("debug_logs"); - let mut manager = AGENT_MANAGER.write().await; - manager.set_debug_log_root(Some(debug_root)); - } - Err(err) => { - warn!("failed to resolve debug log root: {err}"); - let mut manager = AGENT_MANAGER.write().await; - manager.set_debug_log_root(None); - } - } - } else { - // SAFETY: removing the coordination flag is safe when debug is off. - unsafe { std::env::remove_var("CODE_SUBAGENT_DEBUG"); } - let mut manager = AGENT_MANAGER.write().await; - manager.set_debug_log_root(None); - } - - let conversation_id = code_protocol::mcp_protocol::ConversationId::from(session_id); - let auth_snapshot = auth_manager.as_ref().and_then(|mgr| mgr.auth()); - let otel_event_manager = { - let manager = OtelEventManager::new( - conversation_id, - config.model.as_str(), - config.model_family.slug.as_str(), - auth_snapshot - .as_ref() - .and_then(|auth| auth.get_account_id()), - auth_snapshot.as_ref().map(|auth| auth.mode), - config.otel.log_user_prompt, - crate::terminal::user_agent(), - ); - manager.conversation_starts( - config.model_provider.name.as_str(), - Some(to_proto_reasoning_effort(model_reasoning_effort)), - to_proto_reasoning_summary(model_reasoning_summary), - config.model_context_window, - config.model_max_output_tokens, - config.model_auto_compact_token_limit, - to_proto_approval_policy(approval_policy), - to_proto_sandbox_policy(sandbox_policy.clone()), - config - .mcp_servers - .keys() - .map(String::as_str) - .collect(), - config.active_profile.clone(), - ); - manager - }; - - // Wrap provided auth (if any) in a minimal AuthManager for client usage. - let client = ModelClient::new( - config.clone(), - auth_manager.clone(), - Some(otel_event_manager.clone()), - provider.clone(), - model_reasoning_effort, - model_reasoning_summary, - model_text_verbosity, - session_id, - debug_logger, - ); - - // abort any current running session and clone its state - let old_session = sess.take(); - let mut state = if let Some(sess_arc) = old_session.as_ref() { - sess_arc.mark_shutting_down(); - sess_arc.notify_wait_interrupted(WaitInterruptReason::SessionAborted); - sess_arc.abort(); - sess_arc.state.lock().unwrap().partial_clone() - } else { - State { - history: ConversationHistory::new(), - ..Default::default() - } - }; - - let writable_roots = get_writable_roots(&cwd); - - // Error messages to dispatch after SessionConfigured is sent. - let mut mcp_connection_errors = Vec::::new(); - let mut excluded_tools = HashSet::new(); - if let Some(client_tools) = config.experimental_client_tools.as_ref() { - for tool in [ - client_tools.request_permission.as_ref(), - client_tools.read_text_file.as_ref(), - client_tools.write_text_file.as_ref(), - ] - .into_iter() - .flatten() - { - excluded_tools.insert(( - tool.mcp_server.to_string(), - tool.tool_name.to_string(), - )); - } - } - - if let Some(old_session_arc) = old_session { - old_session_arc.shutdown_mcp_clients().await; - drop(old_session_arc); - } - - let (mcp_connection_manager, failed_clients) = match McpConnectionManager::new( - config.mcp_servers.clone(), - excluded_tools, - ) - .await - { - Ok((mgr, failures)) => (mgr, failures), - Err(e) => { - let message = format!("Failed to create MCP connection manager: {e:#}"); - error!("{message}"); - mcp_connection_errors.push(message); - (McpConnectionManager::default(), Default::default()) - } - }; - - // Surface individual client start-up failures to the user. - if !failed_clients.is_empty() { - for (server_name, failure) in failed_clients { - let detail = failure.message; - let message = match failure.phase { - crate::protocol::McpServerFailurePhase::Start => { - format!("MCP server `{server_name}` failed to start: {detail}") - } - crate::protocol::McpServerFailurePhase::ListTools => format!( - "MCP server `{server_name}` failed to list tools: {detail}" - ), - }; - error!("{message}"); - mcp_connection_errors.push(message); - } - } - let default_shell = shell::default_user_shell().await; - let active_session_registration = - match crate::active_sessions::register_if_write_capable( - &config.code_home, - &config.cwd, - &config.sandbox_policy, - session_id, - session_source.clone(), - ) { - Ok(registration) => registration, - Err(err) => { - warn!("failed to register active session presence: {err}"); - None - } - }; - let active_session_warning = active_session_registration - .as_ref() - .and_then(|registration| { - crate::active_sessions::active_session_warning( - &config.code_home, - ®istration.conflicts, - ) - }); - let active_session_model_notice = active_session_registration - .as_ref() - .and_then(|registration| { - crate::active_sessions::active_session_conflict_notice( - &config.code_home, - ®istration.conflicts, - ) - .map(|notice| crate::active_sessions::ActiveSessionModelNotice { - fingerprint: notice.fingerprint, - message: notice.message, - checkout_root: notice.checkout_root, - suggested_worktree_path: notice.suggested_worktree_path, - }) - }); - let active_session_guard = - active_session_registration.map(|registration| registration.guard); - let mut tools_config = ToolsConfig::new( - &config.model_family, - approval_policy, - sandbox_policy.clone(), - config.include_plan_tool, - config.include_apply_patch_tool, - config.tools_web_search_request, - config.use_experimental_streamable_shell_tool, - config.include_view_image_tool, - ); - tools_config.web_search_allowed_domains = - config.tools_web_search_allowed_domains.clone(); - tools_config.web_search_external = config.tools_web_search_external; - tools_config.search_tool = config.tools_search_tool; - - let auth_mode = auth_manager - .as_ref() - .and_then(|manager| manager.auth().map(|auth| auth.mode)) - .or(Some(if config.using_chatgpt_auth { - AppAuthMode::Chatgpt - } else { - AppAuthMode::ApiKey - })); - let image_generation_auth_allowed = auth_manager - .as_ref() - .and_then(|manager| manager.auth().map(|auth| auth.mode)) - .is_some_and(|mode| matches!(mode, AppAuthMode::Chatgpt)); - tools_config.image_gen_tool = config.model_family.supports_image_generation - && image_generation_auth_allowed; - let supports_pro_only_models = auth_manager - .as_ref() - .is_some_and(|manager| manager.supports_pro_only_models()); - - let mut agent_models: Vec = if config.agents.is_empty() { - default_agent_configs() - .into_iter() - .filter(|cfg| cfg.enabled) - .map(|cfg| cfg.name) - .collect() - } else { - get_enabled_agents(&config.agents) - }; - agent_models = filter_agent_model_names_for_auth( - agent_models, - auth_mode, - supports_pro_only_models, - ); - if agent_models.is_empty() { - agent_models = - enabled_agent_model_specs_for_auth(auth_mode, supports_pro_only_models) - .into_iter() - .map(|spec| spec.slug.to_string()) - .collect(); - } - agent_models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())); - agent_models.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - tools_config.set_agent_models(agent_models); - - let model_descriptions = model_guide_markdown_with_custom(&config.agents); - let remote_models_manager = auth_manager.as_ref().map(|mgr| { - Arc::new(RemoteModelsManager::new( - Arc::clone(mgr), - provider.clone(), - config.code_home.clone(), - )) - }); - if let Some(remote) = remote_models_manager.as_ref() { - let remote = Arc::clone(remote); - tokio::spawn(async move { - remote.refresh_remote_models().await; - }); - } - let session_skills = skills_outcome - .as_ref() - .map(|outcome| outcome.skills.clone()) - .unwrap_or_default(); - let skill_command_policies = - crate::skills::command_policy::SkillCommandPolicyRuntime::from_skills( - &session_skills, - ); - if let Some(notice) = active_session_model_notice.as_ref() { - state.active_session_write_gate_state.reset_for_pending( - notice.fingerprint.clone(), - notice.checkout_root.clone(), - notice.suggested_worktree_path.clone(), - ); - } - let mut new_session = Arc::new(Session { - id: session_id, - client, - remote_models_manager, - tools_config, - dynamic_tools, - skills: session_skills, - skill_command_policies, - tx_event: tx_event.clone(), - user_instructions: effective_user_instructions.clone(), - base_instructions, - demo_developer_message: demo_developer_message.clone(), - active_session_model_notice, - compact_prompt_override: config.compact_prompt_override.clone(), - approval_policy, - sandbox_policy, - shell_environment_policy: config.shell_environment_policy.clone(), - cwd, - _writable_roots: writable_roots, - mcp_connection_manager, - client_tools: config.experimental_client_tools.clone(), - session_manager: crate::exec_command::ExecSessionManager::default(), - agents: config.agents.clone(), - subagent_max_depth: config.subagent_max_depth, - model_reasoning_effort: config.model_reasoning_effort, - notify, - state: Mutex::new(state), - rollout: Mutex::new(rollout_recorder), - code_linux_sandbox_exe: config.code_linux_sandbox_exe.clone(), - disable_response_storage, - user_shell: default_shell, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - pending_browser_screenshots: Mutex::new(Vec::new()), - last_system_status: Mutex::new(None), - last_screenshot_info: Mutex::new(None), - time_budget: Mutex::new(config.max_run_seconds.map(|secs| { - let total = Duration::from_secs(secs); - let deadline = config - .max_run_deadline - .unwrap_or_else(|| Instant::now() + total); - RunTimeBudget::new(deadline, total) - })), - confirm_guard: ConfirmGuardRuntime::from_config(&config.confirm_guard), - project_hooks: config.project_hooks.clone(), - project_commands: config.project_commands.clone(), - tool_output_max_bytes: config.tool_output_max_bytes, - hook_guard: AtomicBool::new(false), - github: Arc::new(RwLock::new(config.github.clone())), - validation: Arc::new(RwLock::new(config.validation.clone())), - self_handle: Weak::new(), - active_review: Mutex::new(None), - next_turn_text_format: Mutex::new(None), - env_ctx_v2: config.env_ctx_v2, - retention_config: config.retention.clone(), - model_descriptions, - _active_session_guard: active_session_guard, - }); - let weak_handle = Arc::downgrade(&new_session); - if let Some(inner) = Arc::get_mut(&mut new_session) { - inner.self_handle = weak_handle; - } - sess = Some(new_session); - if config.memories_enabled && config.memories.generate_memories { - crate::memories::maybe_spawn_memory_refresh(Arc::clone( - sess.as_ref().expect("session initialized"), - )); - } - if let Some(sess_arc) = &sess { - if !config.always_allow_commands.is_empty() { - let mut st = sess_arc.state.lock().unwrap(); - for pattern in &config.always_allow_commands { - st.approved_commands.insert(pattern.clone()); - } - } - } - let mut replay_history_items: Option> = None; - - - // Patch restored state into the newly created session. - if let Some(sess_arc) = &sess { - if let Some(items) = &restored_items { - let turn_context = sess_arc.make_turn_context(); - let reconstructed = sess_arc.reconstruct_history_from_rollout(&turn_context, items); - { - let mut st = sess_arc.state.lock().unwrap(); - st.history = ConversationHistory::new(); - st.history.record_items(reconstructed.iter()); - } - if let Some(selected_tools) = - extract_mcp_tool_selection_from_history(&reconstructed) - { - sess_arc.set_mcp_tool_selection(selected_tools); - } else { - sess_arc.clear_mcp_tool_selection(); - } - replay_history_items = Some(reconstructed); - } - } - - // Gather history metadata for SessionConfiguredEvent. - let (history_log_id, history_entry_count) = - crate::message_history::history_metadata(&config).await; - - // ack - let sess_arc = sess.as_ref().expect("session initialized"); - let events = std::iter::once(sess_arc.make_event( - INITIAL_SUBMIT_ID, - EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id, - model, - history_log_id, - history_entry_count, - automation_origin: config.automation_origin.clone(), - }), - )) - .chain(mcp_connection_errors.into_iter().map(|message| { - sess_arc.make_event(&sub.id, EventMsg::Error(ErrorEvent { message })) - })); - for event in events { - if let Err(e) = tx_event.send(event).await { - error!("failed to send event: {e:?}"); - } - } - - if config.approval_policy == AskForApproval::OnFailure { - let warning_event = sess_arc.make_event( - &sub.id, - EventMsg::Warning(crate::protocol::WarningEvent { - message: "`on-failure` approval policy is deprecated and will be removed in a future release. Use `on-request` for interactive approvals or `never` for non-interactive runs.".to_string(), - }), - ); - if let Err(e) = tx_event.send(warning_event).await { - warn!("failed to send deprecated approval policy warning: {e}"); - } - } - - if let Some(message) = active_session_warning { - let warning_event = sess_arc.make_event( - &sub.id, - EventMsg::Warning(crate::protocol::WarningEvent { message }), - ); - if let Err(e) = tx_event.send(warning_event).await { - warn!("failed to send active session warning: {e}"); - } - } - - // If we resumed from a rollout, replay the prior transcript into the UI. - if replay_history_items.is_some() - || restored_history_snapshot.is_some() - || restored_items.is_some() - { - let items = replay_history_items.clone().unwrap_or_default(); - let history_snapshot_value = restored_history_snapshot - .as_ref() - .and_then(|snapshot| serde_json::to_value(snapshot).ok()); - let event = sess_arc.make_event( - &sub.id, - EventMsg::ReplayHistory(crate::protocol::ReplayHistoryEvent { - items, - history_snapshot: history_snapshot_value, - }), - ); - if let Err(e) = tx_event.send(event).await { - warn!("failed to send ReplayHistory event: {e}"); - } - } - - if let Some(notice) = resume_notice { - let event = sess_arc.make_event( - &sub.id, - EventMsg::BackgroundEvent(BackgroundEventEvent { message: notice }), - ); - if let Err(e) = tx_event.send(event).await { - warn!("failed to send resume notice event: {e}"); - } - } - - if let Some(sess_arc) = &sess { - spawn_bridge_listener(sess_arc.clone()); - sess_arc.run_session_hooks(ProjectHookEvent::SessionStart).await; - } - - // Initialize agent manager after SessionConfigured is sent - if !agent_manager_initialized { - let mut manager = AGENT_MANAGER.write().await; - let (agent_tx, mut agent_rx) = - tokio::sync::mpsc::unbounded_channel::(); - let sess_for_agents = sess.as_ref().expect("session active").clone(); - manager.set_event_sender(sess_for_agents.session_uuid(), agent_tx); - drop(manager); - - // Forward agent events to the main event channel - let tx_event_clone = tx_event.clone(); - tokio::spawn(async move { - while let Some(payload) = agent_rx.recv().await { - let wake_messages = { - let mut state = sess_for_agents.state.lock().unwrap(); - agent_completion_wake_messages(&payload, &mut state) - }; - if !wake_messages.is_empty() { - enqueue_agent_completion_wake(&sess_for_agents, wake_messages) - .await; - } - let status_event = sess_for_agents.make_event( - "agent_status", - EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { - agents: payload.agents.clone(), - context: payload.context.clone(), - task: payload.task.clone(), - }), - ); - let _ = tx_event_clone.send(status_event).await; - } - }); - agent_manager_initialized = true; - } - } - Op::UserInput { - items, - final_output_json_schema, - } => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - - // Clean up old status items when new user input arrives - // This prevents token buildup from old screenshots/status messages - sess.cleanup_old_status_items().await; - - // Abort synchronously here to avoid a race that can kill the - // newly spawned agent if the async abort runs after set_task. - sess.notify_wait_interrupted(WaitInterruptReason::UserMessage); - sess.abort(); - - spawn_user_turn( - Arc::clone(sess), - sub.id.clone(), - items, - final_output_json_schema, - TaskOriginKind::User, - ) - .await; - } - Op::QueueUserInput { items } => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - - if sess.has_running_task() { - let mut response_item = response_input_from_core_items(items.clone()); - sess.enforce_user_message_limits(&sub.id, &mut response_item); - sess.notify_wait_interrupted(WaitInterruptReason::UserMessage); - let queued = QueuedUserInput { - submission_id: sub.id.clone(), - response_item, - core_items: items, - }; - sess.queue_user_input(queued); - } else { - // No task running: treat this as immediate user input without aborting. - sess.cleanup_old_status_items().await; - spawn_user_turn( - Arc::clone(sess), - sub.id.clone(), - items, - None, - TaskOriginKind::QueuedUser, - ) - .await; - } - } - Op::ExecApproval { - id, - turn_id: _, - decision, - } => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - match decision { - ReviewDecision::Abort => { - sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted); - sess.abort(); - } - other => sess.notify_approval(&id, other), - } - } - Op::UserInputAnswer { id, response } => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - sess.notify_user_input_response(&id, response); - } - Op::DynamicToolResponse { id, response } => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - sess.notify_dynamic_tool_response(&id, response); - } - Op::RegisterApprovedCommand { - command, - match_kind, - semantic_prefix, - } => { - if command.is_empty() { - continue; - } - if let Some(sess) = sess.as_ref() { - sess.add_approved_command(ApprovedCommandPattern::new( - command, - match_kind, - semantic_prefix, - )); - } else { - send_no_session_event(sub.id).await; - } - } - Op::PatchApproval { id, decision } => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - match decision { - ReviewDecision::Abort => { - sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted); - sess.abort(); - } - other => sess.notify_approval(&id, other), - } - } - Op::UpdateValidationTool { name, enable } => { - if let Some(sess) = sess.as_ref() { - sess.update_validation_tool(&name, enable); - } else { - send_no_session_event(sub.id).await; - } - } - Op::UpdateValidationGroup { group, enable } => { - if let Some(sess) = sess.as_ref() { - sess.update_validation_group(group, enable); - } else { - send_no_session_event(sub.id).await; - } - } - Op::AddToHistory { text } => { - // TODO: What should we do if we got AddToHistory before ConfigureSession? - // currently, if ConfigureSession has resume path, this history will be ignored - let id = session_id; - let config = config.clone(); - tokio::spawn(async move { - if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await - { - warn!("failed to append to message history: {e}"); - } - }); - } - - Op::PersistHistorySnapshot { snapshot } => { - let Some(sess) = sess.as_ref() else { - send_no_session_event(sub.id).await; - continue; - }; - if let Some(recorder) = sess.clone_rollout_recorder() { - tokio::spawn(async move { - if let Err(e) = recorder.set_history_snapshot(snapshot).await { - warn!("failed to persist history snapshot: {e}"); - } - }); - } - } - - Op::RunProjectCommand { name } => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - let mut tracker = TurnDiffTracker::new(); - let attempt_req = sess.current_request_ordinal(); - sess.run_project_command(&mut tracker, &sub.id, &name, attempt_req) - .await; - } - - Op::GetHistoryEntryRequest { offset, log_id } => { - let config = config.clone(); - let tx_event = tx_event.clone(); - let sub_id = sub.id.clone(); - - tokio::spawn(async move { - // Run lookup in blocking thread because it does file IO + locking. - let entry_opt = tokio::task::spawn_blocking(move || { - crate::message_history::lookup(log_id, offset, &config) - }) - .await - .unwrap_or(None); - - let event = Event { - id: sub_id, - event_seq: 0, - msg: EventMsg::GetHistoryEntryResponse( - crate::protocol::GetHistoryEntryResponseEvent { - offset, - log_id, - entry: entry_opt, - }, - ), - order: None, - }; - - if let Err(e) = tx_event.send(event).await { - warn!("failed to send GetHistoryEntryResponse event: {e}"); - } - }); - } - Op::ListMcpTools => { - let sess = match sess.as_ref() { - Some(sess) => Arc::clone(sess), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - - let tools = sess - .mcp_connection_manager - .list_all_tools() - .into_iter() - .filter_map(|(name, tool)| { - let value = serde_json::to_value(tool).ok()?; - let converted = code_protocol::mcp::Tool::from_mcp_value(value).ok()?; - Some((name, converted)) - }) - .collect(); - let server_tools = sess.mcp_connection_manager.list_tools_by_server(); - let server_failures = sess.mcp_connection_manager.list_server_failures(); - - let event = Event { - id: sub.id.clone(), - event_seq: 0, - msg: EventMsg::McpListToolsResponse(McpListToolsResponseEvent { - tools, - server_tools: Some(server_tools), - server_failures: Some(server_failures), - resources: std::collections::HashMap::new(), - resource_templates: std::collections::HashMap::new(), - auth_statuses: std::collections::HashMap::new(), - }), - order: None, - }; - - if let Err(e) = tx_event.send(event).await { - warn!("failed to send McpListToolsResponse event: {e}"); - } - } - Op::ListCustomPrompts => { - let sess = match sess.as_ref() { - Some(sess) => Arc::clone(sess), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - - let custom_prompts: Vec = - if let Some(dir) = crate::custom_prompts::default_prompts_dir() { - crate::custom_prompts::discover_prompts_in(&dir).await - } else { - Vec::new() - }; - - let event = Event { - id: sub.id.clone(), - event_seq: 0, - msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { - custom_prompts, - }), - order: None, - }; - - sess.send_event(event).await; - } - Op::ListSkills => { - let sess = match sess.as_ref() { - Some(sess) => Arc::clone(sess), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - - let config_for_skills = Arc::clone(&config); - let skill_load_outcome = tokio::task::spawn_blocking(move || { - crate::skills::loader::load_skills(&config_for_skills) - }) - .await - .unwrap_or_default(); - - let skills: Vec = skill_load_outcome - .skills - .into_iter() - .map(|skill| { - let allow_implicit_invocation = skill.allow_implicit_invocation(); - code_protocol::protocol::SkillMetadata { - name: skill.name, - description: skill.description, - short_description: skill.short_description, - interface: None, - dependencies: None, - path: skill.path, - scope: match skill.scope { - crate::skills::model::SkillScope::Repo => { - code_protocol::protocol::SkillScope::Repo - } - crate::skills::model::SkillScope::User => { - code_protocol::protocol::SkillScope::User - } - crate::skills::model::SkillScope::System => { - code_protocol::protocol::SkillScope::System - } - crate::skills::model::SkillScope::Admin => { - code_protocol::protocol::SkillScope::Admin - } - }, - allow_implicit_invocation, - enabled: true, - } - }) - .collect(); - - let errors: Vec = skill_load_outcome - .errors - .into_iter() - .map(|error| code_protocol::protocol::SkillErrorInfo { - path: error.path, - message: error.message, - }) - .collect(); - - let skills = vec![code_protocol::protocol::SkillsListEntry { - cwd: sess.get_cwd().to_path_buf(), - skills, - errors, - }]; - - let event = Event { - id: sub.id.clone(), - event_seq: 0, - msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }), - order: None, - }; - - sess.send_event(event).await; - } - Op::Compact => { - let sess = match sess.as_ref() { - Some(sess) => sess, - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - - let prompt_text = sess.compact_prompt_text(); - // Attempt to inject input into current task - if let Err(items) = sess.inject_input(vec![InputItem::Text { - text: prompt_text, - }]) { - let turn_context = sess.make_turn_context(); - compact::spawn_compact_task(sess.clone(), turn_context, sub.id.clone(), items); - } else { - let was_empty = sess.enqueue_manual_compact(sub.id.clone()); - let message = if was_empty { - "Manual compact queued; it will run after the current response finishes.".to_string() - } else { - "Manual compact already queued; waiting for the current response to finish.".to_string() - }; - let event = sess.make_event( - &sub.id, - EventMsg::AgentMessage(AgentMessageEvent { message }), - ); - sess.send_event(event).await; - } - } - Op::Review { review_request } => { - let sess = match sess.as_ref() { - Some(sess) => Arc::clone(sess), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - let config = Arc::clone(&config); - let sub_id = sub.id.clone(); - spawn_review_thread(sess, config, sub_id, review_request).await; - } - Op::SetNextTextFormat { format } => { - let sess_arc = match sess.as_ref() { - Some(sess) => Arc::clone(sess), - None => { - send_no_session_event(sub.id).await; - continue; - } - }; - *sess_arc.next_turn_text_format.lock().unwrap() = Some(format); - } - Op::Shutdown => { - info!("Shutting down Codex instance"); - - // Ensure any running agent is aborted so streaming stops promptly. - if let Some(sess_arc) = sess.as_ref() { - sess_arc.mark_shutting_down(); - let s2 = sess_arc.clone(); - tokio::spawn(async move { - s2.notify_wait_interrupted(WaitInterruptReason::SessionAborted); - s2.abort(); - }); - } - - // Gracefully flush and shutdown rollout recorder on session end so tests - // that inspect the rollout file do not race with the background writer. - if let Some(ref sess_arc) = sess { - let recorder_opt = sess_arc.rollout.lock().unwrap().take(); - if let Some(rec) = recorder_opt { - if let Err(e) = rec.shutdown().await { - warn!("failed to shutdown rollout recorder: {e}"); - let event = sess_arc.make_event( - &sub.id, - EventMsg::Error(ErrorEvent { - message: "Failed to shutdown rollout recorder".to_string(), - }), - ); - if let Err(e) = tx_event.send(event).await { - warn!("failed to send error message: {e:?}"); - } - } - } - } - if let Some(ref sess_arc) = sess { - sess_arc.run_session_hooks(ProjectHookEvent::SessionEnd).await; - } - let event = match sess { - Some(ref sess_arc) => sess_arc.make_event(&sub.id, EventMsg::ShutdownComplete), - None => Event { - id: sub.id.clone(), - event_seq: 0, - msg: EventMsg::ShutdownComplete, - order: None, - }, - }; - if let Err(e) = tx_event.send(event).await { - warn!("failed to send Shutdown event: {e}"); - } - break; - } - } - } - debug!("Agent loop exited"); -} - -fn merge_developer_message(existing: Option, extra: &str) -> Option { - let extra_trimmed = extra.trim(); - if extra_trimmed.is_empty() { - return existing; - } - - match existing { - Some(mut message) => { - if !message.trim().is_empty() { - message.push_str("\n\n"); - } - message.push_str(extra_trimmed); - Some(message) - } - None => Some(extra_trimmed.to_string()), - } -} - -fn build_timeboxed_review_message(base: Option) -> Option { - let mut message = merge_developer_message(base.clone(), AUTO_EXEC_TIMEBOXED_REVIEW_GUIDANCE); - if base.as_deref() == Some(AUTO_EXEC_TIMEBOXED_CLI_GUIDANCE) { - message = Some(AUTO_EXEC_TIMEBOXED_REVIEW_GUIDANCE.to_string()); - } - message -} - -fn build_prepend_developer_messages( - demo_developer_message: Option<&String>, - active_session_model_notice: Option<&String>, -) -> Vec { - let mut messages: Vec = demo_developer_message.cloned().into_iter().collect(); - if let Some(notice) = active_session_model_notice { - messages.push(notice.clone()); - } - messages -} - -fn build_auto_review_ledger_message(cwd: &Path) -> Option { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - let active_branch = git_output_sync(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]) - .filter(|branch| branch != "HEAD"); - let active_head = git_output_sync(cwd, &["rev-parse", "HEAD"]); - match AutoReviewRunStore::open_existing(cwd) { - Ok(Some(store)) => store.compact_ledger( - AutoReviewLedgerOptions::new(now).with_active_target(active_branch, active_head), - ), - Ok(None) => None, - Err(err) => { - tracing::warn!(?err, "failed to open auto-review run store for request ledger"); - None - } - } -} - -fn git_output_sync(cwd: &Path, args: &[&str]) -> Option { - let output = std::process::Command::new("git") - .current_dir(cwd) - .args(args) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let value = String::from_utf8(output.stdout).ok()?; - let value = value.trim().to_string(); - (!value.is_empty()).then_some(value) -} - -fn current_turn_insert_at( - input: &[ResponseItem], - initial_user_item: Option<&ResponseItem>, -) -> Option { - let initial_user_item = initial_user_item?; - input.iter().rposition(|item| item == initial_user_item) -} - -fn active_session_model_notice_for_request( - sess: &Session, - submission_id: &str, - fallback: Option<&String>, -) -> Option { - if !matches!( - sess.sandbox_policy, - SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess - ) { - return None; - } - - match crate::active_sessions::active_session_model_notice_for_current( - sess.client.code_home(), - &sess.cwd, - sess.session_uuid(), - ) { - Ok(Some(notice)) => { - if sess.active_session_notice_should_emit(¬ice.fingerprint, submission_id) { - Some(notice.message) - } else { - None - } - } - Ok(None) => { - sess.active_session_notice_clear(); - sess.active_session_write_gate_clear(); - None - } - Err(err) => { - warn!("failed to refresh active session model notice: {err}"); - fallback.cloned() - } - } -} - -async fn spawn_review_thread( - sess: Arc, - config: Arc, - sub_id: String, - review_request: ReviewRequest, -) { - // Ensure any running task is stopped before starting the review flow. - sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted); - sess.abort(); - - let parent_turn_context = sess.make_turn_context(); - - // Determine model + family for review mode. - let review_model = config.review_model.clone(); - let review_family = find_family_for_model(&review_model) - .unwrap_or_else(|| derive_default_model_family(&review_model)); - - // Prepare a per-review configuration that favors deterministic feedback. - let mut review_config = (*config).clone(); - review_config.model = review_model.clone(); - review_config.model_family = review_family.clone(); - review_config.model_reasoning_effort = config.review_model_reasoning_effort; - review_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; - review_config.model_text_verbosity = config.model_text_verbosity; - review_config.user_instructions = None; - review_config.base_instructions = Some(REVIEW_PROMPT.to_string()); - if let Some(cw) = review_family.context_window { - review_config.model_context_window = Some(cw); - } - if let Some(max) = review_family.max_output_tokens { - review_config.model_max_output_tokens = Some(max); - } - let review_config = Arc::new(review_config); - - let review_debug_logger = match crate::debug_logger::DebugLogger::new(review_config.debug) { - Ok(logger) => Arc::new(Mutex::new(logger)), - Err(err) => { - warn!("failed to create review debug logger: {err}"); - Arc::new(Mutex::new( - crate::debug_logger::DebugLogger::new(false).unwrap(), - )) - } - }; - - let review_otel = parent_turn_context - .client - .get_otel_event_manager() - .map(|mgr| mgr.with_model(review_config.model.as_str(), review_config.model_family.slug.as_str())); - - let review_client = ModelClient::new( - review_config.clone(), - parent_turn_context.client.get_auth_manager(), - review_otel, - parent_turn_context.client.get_provider(), - review_config.model_reasoning_effort, - review_config.model_reasoning_summary, - review_config.model_text_verbosity, - sess.session_uuid(), - review_debug_logger, - ); - - let review_demo_message = if config.timeboxed_exec_mode { - build_timeboxed_review_message(parent_turn_context.demo_developer_message.clone()) - } else { - parent_turn_context.demo_developer_message.clone() - }; - - let review_turn_context = Arc::new(TurnContext { - client: review_client, - cwd: parent_turn_context.cwd.clone(), - base_instructions: Some(REVIEW_PROMPT.to_string()), - user_instructions: None, - demo_developer_message: review_demo_message, - active_session_model_notice: parent_turn_context.active_session_model_notice.clone(), - compact_prompt_override: parent_turn_context.compact_prompt_override.clone(), - approval_policy: parent_turn_context.approval_policy, - sandbox_policy: parent_turn_context.sandbox_policy.clone(), - shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), - is_review_mode: true, - text_format_override: None, - final_output_json_schema: None, - }); - - let review_prompt_text = format!( - "{}\n\n---\n\nNow, here's your task: {}", - REVIEW_PROMPT.trim(), - review_request.prompt.trim() - ); - let review_input = vec![InputItem::Text { - text: review_prompt_text, - }]; - - let task = AgentTask::review(Arc::clone(&sess), Arc::clone(&review_turn_context), sub_id.clone(), review_input); - sess.set_active_review(review_request.clone()); - sess.set_task(task); - - let event = sess.make_event( - &sub_id, - EventMsg::EnteredReviewMode(review_request.clone()), - ); - sess.send_event(event).await; -} - -async fn exit_review_mode( - session: Arc, - task_sub_id: String, - review_output: Option, -) { - let snapshot = capture_review_snapshot(&session).await; - let event = session.make_event( - &task_sub_id, - EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: review_output.clone(), - snapshot, - }), - ); - session.send_event(event).await; - - let _active_request = session.take_active_review(); - - let developer_text = match review_output.clone() { - Some(output) => { - let mut sections: Vec = Vec::new(); - if !output.overall_explanation.trim().is_empty() { - sections.push(output.overall_explanation.trim().to_string()); - } - if !output.findings.is_empty() { - sections.push(format_review_findings_block(&output.findings, None)); - } - if !output.overall_correctness.trim().is_empty() { - sections.push(format!( - "Overall correctness: {}", - output.overall_correctness.trim() - )); - } - if output.overall_confidence_score > 0.0 { - sections.push(format!( - "Confidence score: {:.1}", - output.overall_confidence_score - )); - } - - let results = if sections.is_empty() { - "Reviewer did not provide any findings.".to_string() - } else { - sections.join("\n\n") - }; - - format!( - "\n User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve.\n review\n \n {}\n \n\n", - results - ) - } - None => { - "\n User initiated a review task, but it ended without a final response. If the user asks about this, tell them to re-initiate a review with `/review` and wait for it to complete.\n review\n \n None.\n \n\n" - .to_string() - } - }; - - let developer_message = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: developer_text.clone() }], end_turn: None, phase: None}; - - session - .record_conversation_items(&[developer_message]) - .await; -} - -async fn capture_review_snapshot(session: &Session) -> Option { - let cwd = session.cwd.clone(); - let repo_root = crate::git_info::get_git_repo_root(&cwd); - let branch = crate::git_info::current_branch_name(&cwd).await; - - if repo_root.is_none() && branch.is_none() { - return None; - } - - Some(ReviewSnapshotInfo { - snapshot_commit: None, - branch, - worktree_path: Some(cwd), - repo_root, - }) -} - -fn is_context_overflow_stream_error(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("exceeds the context window") - || lower.contains("exceed the context window") - || lower.contains("context length exceeded") - || lower.contains("maximum context length") - || (lower.contains("context window") - && (lower.contains("exceed") - || lower.contains("exceeded") - || lower.contains("full") - || lower.contains("too long"))) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "snake_case")] -enum AutoContextPressureBand { - Medium, - High, - Critical, -} - -impl AutoContextPressureBand { - fn as_str(self) -> &'static str { - match self { - Self::Medium => "medium", - Self::High => "high", - Self::Critical => "critical", - } - } -} - -#[derive(Debug, serde::Deserialize)] -struct AutoContextJudgeDecision { - should_compact_now: bool, - reason: String, - continuation_of_previous_thread: bool, - recent_context_still_useful: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct AutoContextTurnRisk { - estimated_additional_turn_tokens: u64, - projected_post_turn_tokens: u64, - crosses_standard_limit_now: bool, - crosses_standard_limit_after_turn: bool, - crosses_force_compact_after_turn: bool, - crosses_hard_limit_after_turn: bool, -} - -fn auto_context_force_compact_threshold(limit: Option) -> u64 { - limit - .unwrap_or(crate::model_family::EXTENDED_CONTEXT_WINDOW_1M) - .saturating_sub(AUTO_CONTEXT_FORCE_COMPACT_MARGIN_TOKENS) -} - -fn auto_context_pressure_band( - tokens_in_context: u64, - force_compact_threshold: u64, -) -> Option { - if tokens_in_context < AUTO_CONTEXT_JUDGE_MIN_TOKENS { - None - } else if tokens_in_context >= force_compact_threshold.saturating_sub(40_000) { - Some(AutoContextPressureBand::Critical) - } else if tokens_in_context >= crate::model_family::STANDARD_CONTEXT_WINDOW_272K { - Some(AutoContextPressureBand::High) - } else { - Some(AutoContextPressureBand::Medium) - } -} - -fn should_skip_auto_context_judge_for_continuation( - pressure_band: AutoContextPressureBand, - new_user_message: &str, -) -> bool { - pressure_band == AutoContextPressureBand::Medium - && is_obvious_continuation_message(new_user_message) -} - -fn proactive_compact_limit_reached(last_token_usage: Option<&TokenUsage>, limit: i64) -> bool { - last_token_usage - .and_then(|usage| i64::try_from(usage.tokens_in_context_window()).ok()) - .is_some_and(|tokens| tokens >= limit) -} - -fn extract_text_from_response_item(item: &ResponseItem) -> Option { - let ResponseItem::Message { content, .. } = item else { - return None; - }; - let text = crate::content_items_to_text(content)?; - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn summarize_input_items(items: &[InputItem]) -> String { - let mut parts = Vec::new(); - for item in items { - match item { - InputItem::Text { text } => { - let trimmed = text.trim(); - if !trimmed.is_empty() { - parts.push(trimmed.to_string()); - } - } - InputItem::Image { .. } - | InputItem::LocalImage { .. } - | InputItem::EphemeralImage { .. } => parts.push("[image attachment]".to_string()), - } - } - if parts.is_empty() { - "(no text input)".to_string() - } else { - parts.join("\n\n") - } -} - -fn estimate_text_tokens(text: &str) -> u64 { - let bytes = u64::try_from(text.len()).unwrap_or(u64::MAX); - bytes - .saturating_add(AUTO_CONTEXT_ESTIMATED_BYTES_PER_TOKEN - 1) - / AUTO_CONTEXT_ESTIMATED_BYTES_PER_TOKEN -} - -fn estimate_response_item_tokens(item: &ResponseItem) -> u64 { - match item { - ResponseItem::Message { content, .. } => { - let mut total: u64 = 6; - for entry in content { - match entry { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - total = total.saturating_add(estimate_text_tokens(text)); - } - ContentItem::InputImage { .. } => { - total = total.saturating_add(256); - } - } - } - total - } - ResponseItem::FunctionCall { arguments, name, call_id, .. } => estimate_text_tokens(arguments) - .saturating_add(estimate_text_tokens(name)) - .saturating_add(estimate_text_tokens(call_id)) - .saturating_add(12), - ResponseItem::FunctionCallOutput { call_id, output, .. } => estimate_text_tokens(call_id) - .saturating_add(output.body.to_text().as_deref().map(estimate_text_tokens).unwrap_or(0)) - .saturating_add(12), - _ => 0, - } -} - -fn estimate_response_items_tokens(items: &[ResponseItem]) -> u64 { - items - .iter() - .map(estimate_response_item_tokens) - .sum::() -} - -fn estimate_next_turn_context_tokens(history: &[ResponseItem], items: &[InputItem]) -> u64 { - let mut with_next_turn = history.to_vec(); - let next_item: ResponseItem = compact::response_input_from_core_items(items.to_vec()).into(); - with_next_turn.push(next_item); - estimate_response_items_tokens(&with_next_turn) -} - -fn estimate_auto_context_turn_risk( - tokens_in_context: u64, - new_user_message: &str, - last_token_usage: Option<&TokenUsage>, - force_compact_threshold: u64, -) -> AutoContextTurnRisk { - let standard_limit = crate::model_family::STANDARD_CONTEXT_WINDOW_272K; - let hard_limit = crate::model_family::EXTENDED_CONTEXT_WINDOW_1M; - let message_complexity_tokens = estimate_text_tokens(new_user_message).saturating_mul(6); - let last_turn_growth_tokens = last_token_usage - .map(|usage| { - usage - .output_tokens - .saturating_add(usage.reasoning_output_tokens) - .max(usage.blended_total() / 2) - }) - .unwrap_or(0); - let estimated_additional_turn_tokens = message_complexity_tokens - .max(last_turn_growth_tokens) - .clamp( - AUTO_CONTEXT_MIN_PROJECTED_TURN_GROWTH_TOKENS, - AUTO_CONTEXT_MAX_PROJECTED_TURN_GROWTH_TOKENS, - ); - let projected_post_turn_tokens = tokens_in_context.saturating_add(estimated_additional_turn_tokens); - - AutoContextTurnRisk { - estimated_additional_turn_tokens, - projected_post_turn_tokens, - crosses_standard_limit_now: tokens_in_context >= standard_limit, - crosses_standard_limit_after_turn: projected_post_turn_tokens >= standard_limit, - crosses_force_compact_after_turn: projected_post_turn_tokens >= force_compact_threshold, - crosses_hard_limit_after_turn: projected_post_turn_tokens >= hard_limit, - } -} - -fn is_obvious_continuation_message(text: &str) -> bool { - let lower = text.trim().to_ascii_lowercase(); - if lower.is_empty() { - return false; - } - - let continuation_prefixes = [ - "continue", - "keep going", - "go on", - "carry on", - "pick up", - "resume", - "fix that", - "fix this", - "do that", - "do this", - "use that", - "use this", - "now", - "next", - "also", - "can you also", - "let's keep", - "lets keep", - "update that", - "refine that", - "finish that", - ]; - - continuation_prefixes - .iter() - .any(|prefix| lower.starts_with(prefix)) -} - -fn extract_first_json_object(input: &str) -> Option { - let mut depth = 0usize; - let mut start = None; - let mut in_string = false; - let mut escaped = false; - - for (idx, ch) in input.char_indices() { - if in_string { - if escaped { - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - in_string = false; - } - continue; - } - - match ch { - '"' => in_string = true, - '{' => { - if depth == 0 { - start = Some(idx); - } - depth += 1; - } - '}' => { - if depth == 0 { - continue; - } - depth -= 1; - if depth == 0 { - let start_idx = start?; - return Some(input[start_idx..=idx].to_string()); - } - } - _ => {} - } - } - - None -} - -async fn emit_auto_context_phase( - sess: &Arc, - sub_id: &str, - phase: Option, -) { - sess.send_event(sess.make_event( - sub_id, - EventMsg::AutoContextCheck(crate::protocol::AutoContextCheckEvent { phase }), - )) - .await; -} - -async fn request_auto_context_decision(sess: &Arc, prompt: &Prompt) -> CodexResult { - use futures::StreamExt; - - let mut stream = sess.client.clone().stream(prompt).await?; - let mut out = String::new(); - while let Some(event) = stream.next().await { - match event { - Ok(ResponseEvent::OutputTextDelta { delta, .. }) => out.push_str(&delta), - Ok(ResponseEvent::OutputItemDone { item, .. }) => { - if let ResponseItem::Message { content, .. } = item { - for content_item in content { - if let ContentItem::OutputText { text } = content_item { - out.push_str(&text); - } - } - } - } - Ok(ResponseEvent::Completed { .. }) => break, - Err(err) => return Err(err), - _ => {} - } - } - Ok(out) -} - -fn auto_context_judge_models() -> [&'static str; 2] { - [ - AUTO_CONTEXT_JUDGE_PRIMARY_MODEL, - AUTO_CONTEXT_JUDGE_FALLBACK_MODEL, - ] -} - -async fn maybe_run_auto_context_compaction( - sess: &Arc, - sub_id: &str, - items: &[InputItem], -) { - if sess.client.get_context_mode() != Some(crate::config_types::ContextMode::Auto) { - return; - } - - if sess.client.get_model_context_window() != Some(crate::model_family::EXTENDED_CONTEXT_WINDOW_1M) - { - return; - } - - let history = sess.turn_input_with_history(Vec::new()); - let tokens_in_context = estimate_next_turn_context_tokens(&history, items); - if tokens_in_context < AUTO_CONTEXT_JUDGE_MIN_TOKENS { - return; - } - - let auto_compact_limit = sess - .client - .get_auto_compact_token_limit() - .and_then(|limit| u64::try_from(limit).ok()); - let force_compact_threshold = auto_context_force_compact_threshold(auto_compact_limit); - let Some(pressure_band) = auto_context_pressure_band(tokens_in_context, force_compact_threshold) - else { - return; - }; - - if tokens_in_context >= force_compact_threshold { - tracing::info!( - tokens_in_context, - force_compact_threshold, - pressure_band = pressure_band.as_str(), - "auto context forcing compaction before next turn" - ); - emit_auto_context_phase( - sess, - sub_id, - Some(crate::protocol::AutoContextPhase::Compacting), - ) - .await; - let turn_context = sess.make_turn_context(); - let _ = compact::run_inline_auto_compact_task(Arc::clone(sess), turn_context).await; - emit_auto_context_phase(sess, sub_id, None).await; - return; - } - - let recent_messages: Vec = history - .into_iter() - .filter_map(|item| match item { - ResponseItem::Message { ref role, .. } - if role == "user" || role == "assistant" => - { - extract_text_from_response_item(&item).map(|text| { - serde_json::json!({ - "role": role, - "text": text, - }) - }) - } - _ => None, - }) - .rev() - .take(12) - .collect::>() - .into_iter() - .rev() - .collect(); - let new_user_message = summarize_input_items(items); - - if should_skip_auto_context_judge_for_continuation(pressure_band, &new_user_message) { - tracing::info!( - tokens_in_context, - pressure_band = pressure_band.as_str(), - "auto context skipped judge for obvious continuation" - ); - return; - } - - let last_token_usage = { - let state = sess.state.lock().unwrap(); - state.token_usage_info.as_ref().map(|info| info.last_token_usage.clone()) - }; - let turn_risk = estimate_auto_context_turn_risk( - tokens_in_context, - &new_user_message, - last_token_usage.as_ref(), - force_compact_threshold, - ); - let standard_usage_limit_tokens = crate::model_family::STANDARD_CONTEXT_WINDOW_272K; - let hard_context_limit_tokens = crate::model_family::EXTENDED_CONTEXT_WINDOW_1M; - let standard_limit_ratio = if standard_usage_limit_tokens == 0 { - 0.0 - } else { - tokens_in_context as f64 / standard_usage_limit_tokens as f64 - }; - let hard_limit_ratio = if hard_context_limit_tokens == 0 { - 0.0 - } else { - tokens_in_context as f64 / hard_context_limit_tokens as f64 - }; - - let developer_message = ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: AUTO_CONTEXT_JUDGE_DEVELOPER_MESSAGE.to_string(), - }], - end_turn: None, - phase: None, - }; - let user_payload = serde_json::json!({ - "tokens_in_context": tokens_in_context, - "judge_window": { - "start_tokens": AUTO_CONTEXT_JUDGE_MIN_TOKENS, - "standard_usage_limit_tokens": standard_usage_limit_tokens, - "force_compact_at_tokens": force_compact_threshold, - "hard_context_limit_tokens": hard_context_limit_tokens, - }, - "pressure_band": pressure_band.as_str(), - "pressure": { - "standard_limit_ratio": standard_limit_ratio, - "hard_limit_ratio": hard_limit_ratio, - "distance_to_standard_limit_tokens": i64::try_from(standard_usage_limit_tokens).unwrap_or(i64::MAX) - - i64::try_from(tokens_in_context).unwrap_or(i64::MAX), - "distance_to_force_compact_tokens": i64::try_from(force_compact_threshold).unwrap_or(i64::MAX) - - i64::try_from(tokens_in_context).unwrap_or(i64::MAX), - "distance_to_hard_limit_tokens": i64::try_from(hard_context_limit_tokens).unwrap_or(i64::MAX) - - i64::try_from(tokens_in_context).unwrap_or(i64::MAX), - }, - "turn_risk": { - "estimated_additional_turn_tokens": turn_risk.estimated_additional_turn_tokens, - "projected_post_turn_tokens": turn_risk.projected_post_turn_tokens, - "would_cross_standard_limit_now": turn_risk.crosses_standard_limit_now, - "would_cross_standard_limit_after_turn": turn_risk.crosses_standard_limit_after_turn, - "would_cross_force_compact_after_turn": turn_risk.crosses_force_compact_after_turn, - "would_cross_hard_limit_after_turn": turn_risk.crosses_hard_limit_after_turn, - }, - "new_user_message": new_user_message, - "recent_messages": recent_messages, - }); - let user_message = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: serde_json::to_string_pretty(&user_payload) - .unwrap_or_else(|_| user_payload.to_string()), - }], - end_turn: None, - phase: None, - }; - - let mut prompt = Prompt::default(); - prompt.input = vec![developer_message, user_message]; - prompt.include_additional_instructions = false; - prompt.text_format = Some(TextFormat { - r#type: "json_schema".to_string(), - name: Some("auto_context_decision".to_string()), - strict: Some(true), - schema: Some(serde_json::json!({ - "type": "object", - "properties": { - "should_compact_now": { "type": "boolean" }, - "reason": { "type": "string", "minLength": 1, "maxLength": 240 }, - "continuation_of_previous_thread": { "type": "boolean" }, - "recent_context_still_useful": { "type": "boolean" } - }, - "required": [ - "should_compact_now", - "reason", - "continuation_of_previous_thread", - "recent_context_still_useful" - ], - "additionalProperties": false - })), - }); - prompt.set_log_tag("auto_context/judge"); - - emit_auto_context_phase( - sess, - sub_id, - Some(crate::protocol::AutoContextPhase::Checking), - ) - .await; - - let mut raw_decision: Option = None; - for model in auto_context_judge_models() { - prompt.model_override = Some(model.to_string()); - prompt.model_family_override = Some(derive_default_model_family(model)); - - match tokio::time::timeout( - std::time::Duration::from_secs(12), - request_auto_context_decision(sess, &prompt), - ) - .await - { - Ok(Ok(raw)) => { - raw_decision = Some(raw); - break; - } - Ok(Err(err)) => { - tracing::warn!(?err, model, "auto context judge request failed"); - } - Err(_) => { - tracing::warn!(model, "auto context judge timed out"); - } - } - } - - let Some(raw_decision) = raw_decision else { - emit_auto_context_phase(sess, sub_id, None).await; - return; - }; - - emit_auto_context_phase(sess, sub_id, None).await; - - let parsed = serde_json::from_str::(&raw_decision) - .or_else(|_| { - extract_first_json_object(&raw_decision) - .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing JSON object"))) - .and_then(|json| serde_json::from_str::(&json)) - }); - let decision = match parsed { - Ok(decision) => decision, - Err(err) => { - tracing::warn!(?err, raw_decision, "auto context judge returned invalid JSON"); - return; - } - }; - - tracing::info!( - tokens_in_context, - pressure_band = pressure_band.as_str(), - estimated_additional_turn_tokens = turn_risk.estimated_additional_turn_tokens, - projected_post_turn_tokens = turn_risk.projected_post_turn_tokens, - crosses_standard_limit_now = turn_risk.crosses_standard_limit_now, - crosses_standard_limit_after_turn = turn_risk.crosses_standard_limit_after_turn, - crosses_force_compact_after_turn = turn_risk.crosses_force_compact_after_turn, - crosses_hard_limit_after_turn = turn_risk.crosses_hard_limit_after_turn, - should_compact_now = decision.should_compact_now, - continuation_of_previous_thread = decision.continuation_of_previous_thread, - recent_context_still_useful = decision.recent_context_still_useful, - reason = decision.reason, - "auto context judge completed" - ); - - if decision.should_compact_now { - emit_auto_context_phase( - sess, - sub_id, - Some(crate::protocol::AutoContextPhase::Compacting), - ) - .await; - let turn_context = sess.make_turn_context(); - let _ = compact::run_inline_auto_compact_task(Arc::clone(sess), turn_context).await; - emit_auto_context_phase(sess, sub_id, None).await; - } -} - -async fn spawn_user_turn( - sess: Arc, - sub_id: String, - items: Vec, - final_output_json_schema: Option, - origin: TaskOriginKind, -) { - maybe_run_auto_context_compaction(&sess, &sub_id, &items).await; - let turn_context = match final_output_json_schema { - Some(schema) => sess.make_turn_context_with_schema(Some(schema)), - None => sess.make_turn_context(), - }; - let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub_id, items, origin, true); - sess.set_task(agent); -} - -fn context_window_for_model(model: &str) -> Option { - find_family_for_model(model) - .or_else(|| Some(derive_default_model_family(model))) - .and_then(|family| family.context_window) -} - -#[derive(Debug, Clone)] -struct ContextFallbackCandidate { - model: String, - context_window: Option, - priority: i32, -} - -fn is_deprecated_context_fallback_model(model: &str) -> bool { - let lower = model.to_ascii_lowercase(); - lower == "gpt-4.1" || lower.starts_with("gpt-4.1-") -} - -fn choose_larger_context_model_from_candidates( - current_model: &str, - candidates: Vec, -) -> Option { - let current_window = context_window_for_model(current_model).unwrap_or(0); - let mut best: Option<(u64, i32, String)> = None; - - for candidate in candidates { - let model = candidate.model; - if model.eq_ignore_ascii_case(current_model) { - continue; - } - if is_deprecated_context_fallback_model(&model) { - continue; - } - let Some(window) = candidate - .context_window - .or_else(|| context_window_for_model(&model)) - else { - continue; - }; - if window <= current_window { - continue; - } - - match best { - Some((best_window, _, _)) if window < best_window => {} - Some((best_window, best_priority, _)) - if window == best_window && candidate.priority < best_priority => {} - _ => { - best = Some((window, candidate.priority, model)); - } - } - } - - best.map(|(_, _, model)| model) -} - -async fn choose_larger_context_model(sess: &Arc, current_model: &str) -> Option { - let mut candidates: Vec = Vec::new(); - - if let Some(remote) = sess.remote_models_manager.as_ref() { - for model in remote.remote_models_snapshot().await { - let context_window = model.context_window.and_then(|window| { - if window <= 0 { - None - } else { - u64::try_from(window).ok() - } - }); - candidates.push(ContextFallbackCandidate { - model: model.slug, - context_window, - priority: model.priority, - }); - } - } - - choose_larger_context_model_from_candidates(current_model, candidates) -} - -fn parse_review_output_event(text: &str) -> ReviewOutputEvent { - if let Ok(parsed) = serde_json::from_str::(text) { - return parsed; - } - - // Attempt to extract JSON from fenced code blocks if present. - if let Some(idx) = text.find("```json") { - if let Some(end_idx) = text[idx + 7..].find("```") { - let json_slice = &text[idx + 7..idx + 7 + end_idx]; - if let Ok(parsed) = serde_json::from_str::(json_slice) { - return parsed; - } - } - } - - ReviewOutputEvent { - findings: Vec::new(), - overall_correctness: String::new(), - overall_explanation: text.trim().to_string(), - overall_confidence_score: 0.0, - } -} - -// Intentionally omit upstream review thread spawning; our fork handles review flows differently. -/// Takes a user message as input and runs a loop where, at each turn, the model -/// replies with either: -/// -/// - requested function calls -/// - an assistant message -/// -/// While it is possible for the model to return multiple of these items in a -/// single turn, in practice, we generally one item per turn: -/// -/// - If the model requests a function call, we execute it and send the output -/// back to the model in the next turn. -/// - If the model sends only an assistant message, we record it in the -/// conversation history and consider the agent complete. -async fn run_agent( - sess: Arc, - turn_context: Arc, - sub_id: String, - input: Vec, - origin: TaskOriginKind, - visible_to_user: bool, -) { - if input.is_empty() { - return; - } - let lifecycle = sess.make_event( - &sub_id, - EventMsg::TaskLifecycle(TaskLifecycleEvent { - phase: TaskLifecyclePhase::Started, - origin, - visible_to_user, - last_agent_message: None, - }), - ); - if sess.tx_event.send(lifecycle).await.is_err() { - return; - } - let event = sess.make_event(&sub_id, EventMsg::TaskStarted); - if sess.tx_event.send(event).await.is_err() { - return; - } - // Continue with our fork's history and input handling. - - let is_review_mode = turn_context.is_review_mode; - let mut review_history: Vec = Vec::new(); - let mut review_messages: Vec = Vec::new(); - let mut review_exit_emitted = false; - - let post_turn_only_turn = input.len() == 1 - && matches!( - &input[0], - InputItem::Text { text } if text == POST_TURN_PENDING_ONLY_SENTINEL - ); - let pending_only_turn = input.len() == 1 - && matches!( - &input[0], - InputItem::Text { text } if text == PENDING_ONLY_SENTINEL - ) - || post_turn_only_turn; - - // Debug logging for ephemeral images - let ephemeral_count = input - .iter() - .filter(|item| matches!(item, InputItem::EphemeralImage { .. })) - .count(); - - if ephemeral_count > 0 { - tracing::info!( - "Processing {} ephemeral images in user input", - ephemeral_count - ); - } - - let mut initial_response_item: Option = None; - let mut initial_skill_messages: Vec = Vec::new(); - - if !pending_only_turn { - // Convert input to ResponseInputItem - let mut response_input = response_input_from_core_items(input.clone()); - sess.enforce_user_message_limits(&sub_id, &mut response_input); - let response_item: ResponseItem = response_input.into(); - let selected_skill_messages = - selected_skill_messages_from_input(&input, &sess.skills); - - if is_review_mode { - review_history.push(response_item.clone()); - review_history.extend(selected_skill_messages.clone()); - } else { - // Record to history but we'll handle ephemeral images separately - sess.record_conversation_items(&[response_item.clone()]) - .await; - } - initial_response_item = Some(response_item); - initial_skill_messages = selected_skill_messages; - } - - let mut last_task_message: Option = None; - // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Agent which contains - // many turns, from the perspective of the user, it is a single turn. - let mut turn_diff_tracker = TurnDiffTracker::new(); - - // Track if this is the first iteration - if so, include the initial input - let mut first_iteration = true; - - // Track if we've done a proactive compaction in this iteration to prevent - // infinite loops. As long as compaction works well in getting us way below - // the token limit, we shouldn't need more than one compaction per iteration. - let mut did_proactive_compact_this_iteration = false; - let mut auto_compact_pending = false; - - loop { - // Note that pending_input would be something like a message the user - // submitted through the UI while the model was running. Though the UI - // may support this, the model might not. - // IMPORTANT: Do not inject queued user inputs into the review thread. - // Doing so routes user messages (e.g., auto-resolve fix prompts) to the - // review model, causing loops. Only include queued user inputs when not in - // review mode. They will be picked up after TaskComplete via - // pop_next_queued_user_input. - let pending_input = if is_review_mode || post_turn_only_turn { - sess.get_pending_input_filtered(false) - } else { - sess.get_pending_input() - } - .into_iter() - .map(ResponseItem::from) - .collect::>(); - let mut pending_input_tail = pending_input.clone(); - - if initial_response_item.is_none() { - if let Some(first_pending) = pending_input_tail.first().cloned() { - pending_input_tail.remove(0); - if is_review_mode { - review_history.push(first_pending.clone()); - } else { - sess.record_conversation_items(&[first_pending.clone()]) - .await; - } - initial_response_item = Some(first_pending); - } else { - tracing::warn!( - "pending-only turn had no queued input; skipping model invocation" - ); - break; - } - } - - let compact_snapshot = if auto_compact_pending && !is_review_mode { - Some(sess.turn_input_with_history(pending_input_tail.clone())) - } else { - None - }; - - // Do not duplicate the initial input in `pending_input`. - // It is already recorded to history above; ephemeral items are appended separately. - if first_iteration { - first_iteration = false; - } else { - // Only record pending input to history on subsequent iterations - let existing_history = sess.state.lock().unwrap().history.contents(); - let pending_input_to_record = - pending_items_not_already_recorded(&pending_input, &existing_history); - if !pending_input_to_record.is_empty() { - sess.record_conversation_items(&pending_input_to_record).await; - } - } - - if auto_compact_pending && !is_review_mode { - let compacted_history = if compact::should_use_remote_compact_task(&sess).await { - run_inline_remote_auto_compact_task( - Arc::clone(&sess), - Arc::clone(&turn_context), - Vec::new(), - ) - .await - } else { - compact::run_inline_auto_compact_task( - Arc::clone(&sess), - Arc::clone(&turn_context), - ) - .await - }; - - if !compacted_history.is_empty() { - let mut rebuilt = compacted_history; - if !pending_input_tail.is_empty() { - let previous_input_snapshot = compact_snapshot.unwrap_or_default(); - let (missing_calls, filtered_outputs) = reconcile_pending_tool_outputs( - &pending_input_tail, - &rebuilt, - &previous_input_snapshot, - ); - if !missing_calls.is_empty() { - rebuilt.extend(missing_calls); - } - if !filtered_outputs.is_empty() { - rebuilt.extend(filtered_outputs); - } - } - sess.replace_history(rebuilt); - pending_input_tail.clear(); - did_proactive_compact_this_iteration = true; - } - auto_compact_pending = false; - } - - // Construct the input that we will send to the model. When using the - // Chat completions API (or ZDR clients), the model needs the full - // conversation history on each turn. The rollout file, however, should - // only record the new items that originated in this turn so that it - // represents an append-only log without duplicates. - let turn_input: Vec = if is_review_mode { - if !pending_input_tail.is_empty() { - review_history.extend(pending_input_tail.clone()); - } - review_history.clone() - } else { - let existing_history = sess.state.lock().unwrap().history.contents(); - let pending_input_tail = - pending_items_not_already_recorded(&pending_input_tail, &existing_history); - let mut turn_input = sess.turn_input_with_history(pending_input_tail); - if !initial_skill_messages.is_empty() { - turn_input.extend(initial_skill_messages.clone()); - } - turn_input - }; - - let turn_input_messages: Vec = turn_input - .iter() - .filter_map(|item| match item { - ResponseItem::Message { role, content, .. } - if role == "user" && !is_skill_instructions_message(content) => - { - Some(content) - } - _ => None, - }) - .flat_map(|content| { - content.iter().filter_map(|item| match item { - ContentItem::InputText { text } => Some(text.clone()), - _ => None, - }) - }) - .collect(); - match run_turn( - &sess, - &turn_context, - &mut turn_diff_tracker, - sub_id.clone(), - initial_response_item.clone(), - initial_skill_messages.clone(), - pending_input_tail, - turn_input, - ) - .await - { - Ok(turn_output) => { - let mut items_to_record_in_conversation_history = Vec::::new(); - let mut responses = Vec::::new(); - for processed_response_item in turn_output { - let ProcessedResponseItem { item, response } = processed_response_item; - match (&item, &response) { - (ResponseItem::Message { role, .. }, None) if role == "assistant" => { - // If the model returned a message, we need to record it. - items_to_record_in_conversation_history.push(item.clone()); - if is_review_mode { - if let ResponseItem::Message { content, .. } = &item { - for ci in content { - if let ContentItem::OutputText { text } = ci { - review_messages.push(text.clone()); - } - } - } - } - } - ( - ResponseItem::LocalShellCall { .. }, - Some(ResponseInputItem::FunctionCallOutput { call_id, output }), - ) => { - items_to_record_in_conversation_history.push(item.clone()); - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::FunctionCall { .. }, - Some(ResponseInputItem::FunctionCallOutput { call_id, output }), - ) => { - debug!( - "Recording function call and output for call_id: {}", - call_id - ); - items_to_record_in_conversation_history.push(item.clone()); - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::CustomToolCall { .. }, - Some(ResponseInputItem::CustomToolCallOutput { - call_id, - name, - output, - }), - ) => { - items_to_record_in_conversation_history.push(item.clone()); - items_to_record_in_conversation_history.push( - ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), - name: name.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::FunctionCall { .. }, - Some(ResponseInputItem::McpToolCallOutput { call_id, result }), - ) => { - items_to_record_in_conversation_history.push(item.clone()); - let output = - convert_call_tool_result_to_function_call_output_payload(&result); - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output, - }, - ); - } - ( - ResponseItem::ToolSearchCall { .. }, - Some(ResponseInputItem::ToolSearchOutput { - call_id, - status, - execution, - tools, - }), - ) => { - items_to_record_in_conversation_history.push(item.clone()); - items_to_record_in_conversation_history.push( - ResponseItem::ToolSearchOutput { - call_id: Some(call_id.clone()), - status: status.clone(), - execution: execution.clone(), - tools: tools.clone(), - }, - ); - } - ( - ResponseItem::Reasoning { - id, - summary, - content, - encrypted_content, - }, - None, - ) => { - items_to_record_in_conversation_history.push(ResponseItem::Reasoning { - id: id.clone(), - summary: summary.clone(), - content: content.clone(), - encrypted_content: encrypted_content.clone(), - }); - } - (ResponseItem::ImageGenerationCall { .. }, None) => { - items_to_record_in_conversation_history.push(item.clone()); - } - _ => { - warn!("Unexpected response item: {item:?} with response: {response:?}"); - } - }; - if let Some(response) = response { - responses.push(response); - } - } - - // Only attempt to take the lock if there is something to record. - if !items_to_record_in_conversation_history.is_empty() { - if is_review_mode { - review_history.extend(items_to_record_in_conversation_history.clone()); - } else { - // Record items in their original chronological order to maintain - // proper sequence of events. This ensures function calls and their - // outputs appear in the correct order in conversation history. - sess.record_conversation_items(&items_to_record_in_conversation_history) - .await; - } - } - - // Check whether we should proactively compact before queuing follow-up work. - // Upstream codex-rs compacts as soon as usage hits the configured threshold, - // which keeps us from hitting hard context-window errors mid-session. - let limit = turn_context - .client - .get_auto_compact_token_limit() - .unwrap_or(i64::MAX); - let token_limit_reached = { - let state = sess.state.lock().unwrap(); - proactive_compact_limit_reached( - state.token_usage_info.as_ref().map(|info| &info.last_token_usage), - limit, - ) - }; - - // If there are responses, add them to pending input for the next iteration - if !responses.is_empty() { - if !is_review_mode { - for response in &responses { - sess.add_pending_input(response.clone()); - } - } - // Reset the proactive compact guard for the next iteration since we're - // about to process new tool calls and may need to compact again - did_proactive_compact_this_iteration = false; - } - - // As long as compaction works well in getting us way below the token limit, - // we shouldn't worry about being in an infinite loop. However, guard against - // repeated compaction attempts within a single iteration. - if token_limit_reached && !did_proactive_compact_this_iteration && !is_review_mode { - let attempt_req = sess.current_request_ordinal(); - let order = sess.next_background_order(&sub_id, attempt_req, None); - sess - .notify_background_event_with_order( - &sub_id, - order, - "Token limit reached; running /compact and continuing…".to_string(), - ) - .await; - - if responses.is_empty() { - did_proactive_compact_this_iteration = true; - // Choose between local and remote compact based on auth mode, - // matching upstream codex-rs behavior - if compact::should_use_remote_compact_task(&sess).await { - let _ = run_inline_remote_auto_compact_task( - Arc::clone(&sess), - Arc::clone(&turn_context), - Vec::new(), - ) - .await; - } else { - let _ = compact::run_inline_auto_compact_task( - Arc::clone(&sess), - Arc::clone(&turn_context), - ) - .await; - } - - // Restart this loop with the newly compacted history so the - // next turn can see the trimmed conversation state. - continue; - } - - if !auto_compact_pending { - auto_compact_pending = true; - } - } - - if responses.is_empty() { - debug!("Turn completed"); - last_task_message = get_last_assistant_message_from_turn( - &items_to_record_in_conversation_history, - ); - if let Some(m) = last_task_message.as_ref() { - tracing::info!("core.turn completed: last_assistant_message.len={}", m.len()); - } - sess.maybe_notify(UserNotification::AgentTurnComplete { - turn_id: sub_id.clone(), - input_messages: turn_input_messages, - last_assistant_message: last_task_message.clone(), - }); - break; - } - } - Err(e) => { - info!("Turn error: {e:#}"); - let event = sess.make_event( - &sub_id, - EventMsg::Error(ErrorEvent { message: e.to_string() }), - ); - sess.tx_event.send(event).await.ok(); - if is_review_mode && !review_exit_emitted { - exit_review_mode(sess.clone(), sub_id.clone(), None).await; - review_exit_emitted = true; - } - // let the user continue the conversation - break; - } - } - } - if is_review_mode && !review_exit_emitted { - let combined = if !review_messages.is_empty() { - review_messages.join("\n\n") - } else { - last_task_message.clone().unwrap_or_default() - }; - let output = if combined.trim().is_empty() { - None - } else { - Some(parse_review_output_event(&combined)) - }; - exit_review_mode(sess.clone(), sub_id.clone(), output).await; - } - - sess.remove_task(&sub_id); - let lifecycle = sess.make_event( - &sub_id, - EventMsg::TaskLifecycle(TaskLifecycleEvent { - phase: TaskLifecyclePhase::Quiescent, - origin, - visible_to_user, - last_agent_message: last_task_message.clone(), - }), - ); - sess.tx_event.send(lifecycle).await.ok(); - - let event = sess.make_event( - &sub_id, - EventMsg::TaskComplete(TaskCompleteEvent { - last_agent_message: last_task_message, - }), - ); - match &event.msg { - EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: Some(m) }) => { - tracing::info!("core.emit TaskComplete last_agent_message.len={}", m.len()); - } - _ => {} - } - sess.tx_event.send(event).await.ok(); - - if let Some(action) = sess.take_follow_up_turn_action() { - match action { - FollowUpTurnAction::PostTurnPendingInput => { - sess.start_internal_pending_only_turn( - POST_TURN_PENDING_ONLY_SENTINEL, - TaskOriginKind::PostTurn, - false, - ) - .await; - } - FollowUpTurnAction::ManualCompact(compact_sub_id) => { - let turn_context = sess.make_turn_context(); - let prompt_text = sess.compact_prompt_text(); - compact::spawn_compact_task( - Arc::clone(&sess), - turn_context, - compact_sub_id, - vec![InputItem::Text { - text: prompt_text, - }], - ); - } - FollowUpTurnAction::PendingInput => { - sess.start_internal_pending_only_turn( - PENDING_ONLY_SENTINEL, - TaskOriginKind::PendingInput, - false, - ) - .await; - } - FollowUpTurnAction::QueuedUserInput(queued) => { - let sess_clone = Arc::clone(&sess); - tokio::spawn(async move { - sess_clone.cleanup_old_status_items().await; - let submission_id = queued.submission_id; - let items = queued.core_items; - spawn_user_turn( - sess_clone, - submission_id, - items, - None, - TaskOriginKind::QueuedUser, - ) - .await; - }); - } - } - } -} - -async fn run_turn( - sess: &Arc, - turn_context: &Arc, - turn_diff_tracker: &mut TurnDiffTracker, - sub_id: String, - initial_user_item: Option, - initial_skill_messages: Vec, - pending_input_tail: Vec, - mut input: Vec, -) -> CodexResult> { - // Check if browser is enabled - let browser_enabled = code_browser::global::get_browser_manager().await.is_some(); - - let tc = &**turn_context; - let agents_active = { - let manager = AGENT_MANAGER.read().await; - manager.has_active_agents() - }; - - let mut retries = 0; - let mut rate_limit_switch_state = RateLimitSwitchState::default(); - // Ensure we only auto-compact once per turn to avoid loops - let mut did_auto_compact = false; - let mut did_context_model_fallback = false; - let mut forced_model_override: Option = None; - let mut fallback_metadata_warning_sent = false; - let auto_review_ledger_message = build_auto_review_ledger_message(&tc.cwd); - // Attempt input starts as the provided input, and may be augmented with - // items from a previous dropped stream attempt so we don't lose progress. - let mut attempt_input: Vec = input.clone(); - loop { - // Each loop iteration corresponds to a single provider HTTP request. - // Increment the attempt ordinal first and capture its value so all - // OrderMeta emitted during this attempt share the same `req`, even if - // later attempts start before all events have been delivered. - sess.begin_http_attempt(); - let attempt_req = sess.current_request_ordinal(); - // Build status items (screenshots, system status) fresh for each attempt - let status_items = build_turn_status_items(sess).await; - - let mut prepend_developer_messages = build_prepend_developer_messages( - tc.demo_developer_message.as_ref(), - active_session_model_notice_for_request( - sess, - &sub_id, - tc.active_session_model_notice.as_ref(), - ) - .as_ref(), - ); - if should_inject_html_sanitizer_guardrails(&attempt_input) { - prepend_developer_messages.push(HTML_SANITIZER_GUARDRAILS_MESSAGE.to_string()); - } - if tc.client.memories_enabled() && tc.client.memories_use_enabled() { - if let Some(memory_prompt) = - crate::memories::build_memory_tool_developer_instructions(tc.client.code_home()).await - { - prepend_developer_messages.push(memory_prompt); - } - } - if let Some(auto_review_ledger) = auto_review_ledger_message.clone() { - prepend_developer_messages.push(auto_review_ledger); - } - let mut prompt = Prompt { - input: attempt_input.clone(), - volatile_context_insert_at: current_turn_insert_at( - &attempt_input, - initial_user_item.as_ref(), - ), - volatile_context_items: sess.assemble_timeline_prompt_items().unwrap_or_default(), - store: !sess.disable_response_storage, - user_instructions: tc.user_instructions.clone(), - environment_context: Some(EnvironmentContext::new( - Some(tc.cwd.clone()), - Some(tc.approval_policy), - Some(tc.sandbox_policy.clone()), - Some(sess.user_shell.clone()), - )), - tools: Vec::new(), - status_items, // Include status items with this request - base_instructions_override: tc.base_instructions.clone(), - include_additional_instructions: true, - prepend_developer_messages, - text_format: tc.text_format_override.clone(), - model_override: None, - model_family_override: None, - output_schema: tc.final_output_json_schema.clone(), - log_tag: Some("codex/turn".to_string()), - session_id_override: None, - model_descriptions: sess.model_descriptions.clone(), - }; - - let used_fallback_model_metadata = sess.apply_remote_model_overrides(&mut prompt).await; - - if let Some(override_model) = forced_model_override.clone() { - let override_family = if let Some(remote) = sess.remote_models_manager.as_ref() { - let base_family = find_family_for_model(&override_model) - .unwrap_or_else(|| derive_default_model_family(&override_model)); - remote - .apply_remote_overrides_with_personality( - &override_model, - base_family, - tc.client.model_personality(), - ) - .await - } else { - find_family_for_model(&override_model) - .unwrap_or_else(|| derive_default_model_family(&override_model)) - }; - prompt.model_override = Some(override_model); - prompt.model_family_override = Some(override_family); - } - - if used_fallback_model_metadata - && forced_model_override.is_none() - && !fallback_metadata_warning_sent - { - let resolved_model_slug = prompt - .model_override - .clone() - .unwrap_or_else(|| sess.client.get_model()); - sess.send_event(sess.make_event( - &sub_id, - EventMsg::Warning(crate::protocol::WarningEvent { - message: format!( - "Model metadata for `{resolved_model_slug}` not found. Defaulting to fallback metadata; this can degrade performance and cause issues." - ), - }), - )) - .await; - fallback_metadata_warning_sent = true; - } - - let effective_family = prompt - .model_family_override - .as_ref() - .unwrap_or_else(|| tc.client.default_model_family()); - let tools_config = tc.client.build_tools_config_with_sandbox_for_family( - tc.sandbox_policy.clone(), - effective_family, - ); - let mcp_tools = select_mcp_tools_for_turn( - sess.mcp_connection_manager.list_all_tools(), - sess.get_mcp_tool_selection(), - tools_config.search_tool, - ); - - if tools_config.search_tool - && !prompt - .prepend_developer_messages - .iter() - .any(|message| message == SEARCH_TOOL_DEVELOPER_INSTRUCTIONS) - { - prompt - .prepend_developer_messages - .push(SEARCH_TOOL_DEVELOPER_INSTRUCTIONS.to_string()); - } - prompt.tools = get_openai_tools( - &tools_config, - Some(mcp_tools), - browser_enabled, - agents_active, - sess.dynamic_tools.as_slice(), - ); - - // Start a new scratchpad for this HTTP attempt - sess.begin_attempt_scratchpad(); - - match try_run_turn(sess, turn_diff_tracker, &sub_id, &prompt, attempt_req).await { - Ok(output) => { - // Record status items to conversation history after successful turn - // This ensures they persist for future requests in the right chronological order - if !prompt.status_items.is_empty() { - sess.record_conversation_items(&prompt.status_items).await; - } - // Commit successful attempt – scratchpad is no longer needed. - sess.clear_scratchpad(); - return Ok(output); - } - Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), - Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), - Err(CodexErr::UsageLimitReached(limit_err)) => { - if let Some(ctx) = account_usage_context(sess) { - let usage_home = ctx.code_home.clone(); - let usage_account = ctx.account_id.clone(); - let usage_plan = ctx.plan.clone(); - let resets = limit_err.resets_in_seconds; - let reached_type = limit_err.rate_limit_reached_type; - spawn_usage_task(move || { - if let Err(err) = account_usage::record_usage_limit_hint_with_type( - &usage_home, - &usage_account, - usage_plan.as_deref(), - resets, - Utc::now(), - reached_type, - ) { - warn!("Failed to persist usage limit hint: {err}"); - } - }); - } - - let mut switched = false; - if sess.client.auto_switch_accounts_on_rate_limit() - && auth::read_code_api_key_from_env().is_none() - { - if let Some(auth_manager) = sess.client.get_auth_manager() { - let auth = auth_manager.auth(); - let current_account_id = auth - .as_ref() - .and_then(|current| current.get_account_id()) - .or_else(|| { - auth_accounts::get_active_account_id(sess.client.code_home()) - .ok() - .flatten() - }); - if let Some(current_account_id) = current_account_id { - let now = Utc::now(); - let blocked_until = limit_err.resets_in_seconds.map(|seconds| { - now + chrono::Duration::seconds(seconds as i64) - }); - let current_auth_mode = auth - .as_ref() - .map(|current| current.mode) - .unwrap_or(AppAuthMode::ApiKey); - match crate::account_switching::switch_active_account_on_rate_limit( - sess.client.code_home(), - &mut rate_limit_switch_state, - sess.client.api_key_fallback_on_all_accounts_limited(), - now, - current_account_id.as_str(), - current_auth_mode, - blocked_until, - ) { - Ok(Some(next_account_id)) => { - let next_label = auth_accounts::find_account( - sess.client.code_home(), - &next_account_id, - ) - .ok() - .flatten() - .and_then(|account| account.label) - .unwrap_or_else(|| next_account_id.clone()); - tracing::info!( - from_account_id = %current_account_id, - to_account_id = %next_account_id, - reason = "usage_limit_reached", - "rate limit hit; auto-switching active account" - ); - auth_manager.reload(); - let order = sess.next_background_order(&sub_id, attempt_req, None); - let notice = format!( - "Auto-switch: now using {next_label} due to usage limit." - ); - sess - .notify_background_event_with_order( - &sub_id, - order, - notice, - ) - .await; - switched = true; - } - Ok(None) => {} - Err(err) => { - tracing::warn!( - from_account_id = %current_account_id, - error = %err, - "failed to activate account after usage limit" - ); - } - } - } - } - } - - if switched { - retries = 0; - continue; - } - - let now = Utc::now(); - let retry_after = limit_err - .retry_after(now) - .unwrap_or_else(|| RetryAfter::from_duration(std::time::Duration::from_secs(5 * 60), now)); - let eta = format_retry_eta(&retry_after); - let mut retry_message = format!("{limit_err} Auto-retrying"); - if let Some(eta) = eta { - retry_message.push_str(&format!(" at {eta}")); - } - retry_message.push('…'); - sess.notify_stream_error(&sub_id, retry_message).await; - tokio::time::sleep(retry_after.delay).await; - retries = 0; - continue; - } - Err(CodexErr::UsageNotIncluded) => { - return Err(CodexErr::UsageNotIncluded); - } - Err(CodexErr::QuotaExceeded) => return Err(CodexErr::QuotaExceeded), - Err(e) => { - if let CodexErr::Stream(msg, _maybe_delay, _req_id) = &e - && is_context_overflow_stream_error(msg) - { - if !did_auto_compact { - did_auto_compact = true; - sess - .notify_stream_error( - &sub_id, - "Model hit context-window limit; running /compact and retrying…" - .to_string(), - ) - .await; - - let previous_input_snapshot = input.clone(); - let compacted_history = if compact::should_use_remote_compact_task(sess).await { - run_inline_remote_auto_compact_task( - Arc::clone(&sess), - Arc::clone(&turn_context), - Vec::new(), - ) - .await - } else { - compact::run_inline_auto_compact_task( - Arc::clone(&sess), - Arc::clone(&turn_context), - ) - .await - }; - - // Reset any partial attempt state and rebuild the request payload using the - // newly compacted history plus the current user turn items. - sess.clear_scratchpad(); - - if compacted_history.is_empty() { - attempt_input = input.clone(); - } else { - let mut rebuilt = compacted_history; - if let Some(initial_item) = initial_user_item.clone() { - rebuilt.push(initial_item); - } - rebuilt.extend(initial_skill_messages.clone()); - if !pending_input_tail.is_empty() { - let (missing_calls, filtered_outputs) = - reconcile_pending_tool_outputs(&pending_input_tail, &rebuilt, &previous_input_snapshot); - if !missing_calls.is_empty() { - rebuilt.extend(missing_calls); - } - if !filtered_outputs.is_empty() { - rebuilt.extend(filtered_outputs); - } - } - input = rebuilt.clone(); - attempt_input = rebuilt; - } - continue; - } - - if !did_context_model_fallback { - let active_model = prompt - .model_override - .clone() - .unwrap_or_else(|| tc.client.get_model()); - if let Some(fallback_model) = - choose_larger_context_model(sess, &active_model).await - { - did_context_model_fallback = true; - did_auto_compact = false; - forced_model_override = Some(fallback_model.clone()); - retries = 0; - sess.clear_scratchpad(); - attempt_input = input.clone(); - sess - .notify_stream_error( - &sub_id, - format!( - "History still exceeds {active_model}; retrying with larger-context model {fallback_model}…" - ), - ) - .await; - continue; - } - } - - return Err(e); - } - - // Use the configured provider-specific stream retry budget. - let max_retries = tc.client.get_provider().stream_max_retries(); - let req_id = match &e { - CodexErr::Stream(_, _, req) => req.clone(), - _ => None, - }; - let is_connectivity = is_connectivity_error(&e); - let drain_scratchpad_into_attempt = |attempt_input: &mut Vec| { - if let Some(sp) = sess.take_scratchpad() { - // Build a set of call_ids we have already included to avoid duplicate calls - let mut seen_calls: std::collections::HashSet = attempt_input - .iter() - .filter_map(|ri| match ri { - ResponseItem::FunctionCall { call_id, .. } => Some(call_id.clone()), - ResponseItem::LocalShellCall { call_id: Some(c), .. } => Some(c.clone()), - _ => None, - }) - .collect(); - - // Append finalized function/local shell calls from the dropped attempt - for item in sp.items { - match &item { - ResponseItem::FunctionCall { call_id, .. } => { - if seen_calls.insert(call_id.clone()) { - attempt_input.push(item.clone()); - } - } - ResponseItem::LocalShellCall { call_id: Some(c), .. } => { - if seen_calls.insert(c.clone()) { - attempt_input.push(item.clone()); - } - } - _ => { - // Avoid injecting assistant/Reasoning messages on retry to reduce duplication. - } - } - } - - // Append tool outputs produced during the dropped attempt - for resp in sp.responses { - attempt_input.push(ResponseItem::from(resp)); - } - - // If we have partial deltas, include a short ephemeral hint so the model can resume. - if !sp.partial_assistant_text.is_empty() || !sp.partial_reasoning_summary.is_empty() { - use code_protocol::models::ContentItem; - let mut hint = String::from( - "[EPHEMERAL:RETRY_HINT]\nPrevious attempt aborted mid-stream. Continue without repeating.\n", - ); - if !sp.partial_reasoning_summary.is_empty() { - let s = &sp.partial_reasoning_summary; - // Take the last 800 characters, respecting UTF-8 boundaries - let start_idx = if s.chars().count() > 800 { - s.char_indices() - .rev() - .nth(800 - 1) - .map(|(i, _)| i) - .unwrap_or(0) - } else { - 0 - }; - let tail = &s[start_idx..]; - hint.push_str(&format!("Last reasoning summary fragment:\n{}\n\n", tail)); - } - if !sp.partial_assistant_text.is_empty() { - let s = &sp.partial_assistant_text; - // Take the last 800 characters, respecting UTF-8 boundaries - let start_idx = if s.chars().count() > 800 { - s.char_indices() - .rev() - .nth(800 - 1) - .map(|(i, _)| i) - .unwrap_or(0) - } else { - 0 - }; - let tail = &s[start_idx..]; - hint.push_str(&format!("Last assistant text fragment:\n{}\n", tail)); - } - attempt_input.push(ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: hint }], end_turn: None, phase: None}); - } - } - }; - - if is_connectivity && retries >= max_retries { - let probe = tc.client.get_provider().base_url_for_probe(); - let wait_message = format!( - "Network unavailable; waiting to reconnect to {probe} ({e})" - ); - sess.notify_stream_error(&sub_id, wait_message).await; - drain_scratchpad_into_attempt(&mut attempt_input); - wait_for_connectivity(&probe).await; - retries = 0; - continue; - } - - if retries < max_retries { - retries += 1; - let (delay, retry_eta) = match e { - CodexErr::Stream(_, Some(ref retry_after), _) => { - let eta = format_retry_eta(&retry_after); - (retry_after.delay, eta) - } - _ => (backoff(retries), None), - }; - warn!( - error = %e, - request_id = req_id.as_deref(), - "stream disconnected - retrying turn in {delay:?} (attempt {retries}/{max_retries})", - ); - - // Surface retry information to any UI/front‑end so the - // user understands what is happening instead of staring - // at a seemingly frozen screen. - let mut retry_message = - format!("stream error: {e}; retrying in {delay:?}"); - if let Some(eta) = retry_eta { - retry_message.push_str(&format!(" (next attempt at {eta})")); - } - retry_message.push('…'); - sess.notify_stream_error(&sub_id, retry_message.clone()).await; - // Pull any partial progress from this attempt and append to - // the next request's input so we do not lose tool progress - // or already-finalized items. - drain_scratchpad_into_attempt(&mut attempt_input); - - tokio::time::sleep(delay).await; - } else { - error!( - retries, - max_retries, - auto_compact_attempted = did_auto_compact, - request_id = req_id.as_deref(), - error = %e, - "stream disconnected - retries exhausted" - ); - return Err(e); - } - } - } - } -} - -fn select_mcp_tools_for_turn( - mcp_tools: HashMap, - selected_tools: Option>, - search_tool_enabled: bool, -) -> HashMap { - if !search_tool_enabled { - return mcp_tools; - } - - let selected: std::collections::HashSet = selected_tools - .unwrap_or_default() - .into_iter() - .collect(); - mcp_tools - .into_iter() - .filter(|(name, _tool)| { - if !name.starts_with(CODEX_APPS_TOOL_PREFIX) { - return true; - } - selected.contains(name) - }) - .collect() -} - -fn extract_mcp_tool_selection_from_history(history: &[ResponseItem]) -> Option> { - let mut search_call_ids = HashSet::new(); - let mut active_selected_tools: Option> = None; - - for item in history { - match item { - ResponseItem::FunctionCall { name, call_id, .. } => { - if name == TOOL_SEARCH_TOOL_NAME || name == LEGACY_SEARCH_TOOL_BM25_TOOL_NAME { - search_call_ids.insert(call_id.clone()); - } - } - ResponseItem::ToolSearchCall { call_id, .. } => { - if let Some(call_id) = call_id { - search_call_ids.insert(call_id.clone()); - } - } - ResponseItem::FunctionCallOutput { call_id, output } => { - if !search_call_ids.contains(call_id) { - continue; - } - let Some(content) = output.body.to_text() else { - continue; - }; - let Ok(payload) = serde_json::from_str::(&content) else { - continue; - }; - let Some(selected_tools) = payload - .get("active_selected_tools") - .and_then(serde_json::Value::as_array) - else { - continue; - }; - let Some(selected_tools) = selected_tools - .iter() - .map(|value| value.as_str().map(str::to_string)) - .collect::>>() - else { - continue; - }; - active_selected_tools = Some(selected_tools); - } - ResponseItem::ToolSearchOutput { call_id, tools, .. } => { - let Some(call_id) = call_id else { - continue; - }; - if !search_call_ids.contains(call_id) { - continue; - } - let selected_tools = tools - .iter() - .filter_map(|tool| { - tool.get("name") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) - .collect::>(); - if selected_tools.is_empty() { - continue; - } - active_selected_tools = Some(selected_tools); - } - _ => {} - } - } - - active_selected_tools -} - -#[cfg(test)] -mod current_turn_split_tests { - use super::current_turn_insert_at; - use code_protocol::models::ContentItem; - use code_protocol::models::ResponseItem; - - fn message(role: &str, text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: role.to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } - } - - #[test] - fn current_turn_insert_at_uses_latest_matching_user_item() { - let history = message("user", "old stable turn"); - let current = message("user", "live current turn"); - let repeated = current.clone(); - let tool_tail = message("assistant", "tool call after current turn"); - - let input = vec![history, current.clone(), repeated.clone(), tool_tail]; - - assert_eq!(current_turn_insert_at(&input, Some(&repeated)), Some(2)); - } - - #[test] - fn current_turn_insert_at_is_none_without_marker() { - let history = message("user", "old stable turn"); - let input = vec![history]; - - assert_eq!(current_turn_insert_at(&input, None), None); - } -} - -#[cfg(test)] -mod mcp_tool_selection_tests { - use super::extract_mcp_tool_selection_from_history; - use super::select_mcp_tools_for_turn; - use code_protocol::models::FunctionCallOutputPayload; - use code_protocol::models::ResponseItem; - use mcp_types::Tool; - use mcp_types::ToolInputSchema; - use std::collections::HashMap; - - fn test_tool(name: &str) -> Tool { - Tool { - name: name.to_string(), - title: None, - description: Some(format!("{name} description")), - input_schema: ToolInputSchema { - properties: Some(serde_json::json!({})), - required: None, - r#type: "object".to_string(), - }, - output_schema: None, - annotations: None, - } - } - - #[test] - fn search_tool_enabled_hides_apps_tools_without_selection() { - let mcp_tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - test_tool("calendar_create_event"), - ), - ("mcp__two__b".to_string(), test_tool("b")), - ]); - - let selected = select_mcp_tools_for_turn(mcp_tools, None, true); - assert_eq!(selected.len(), 1); - assert!(selected.contains_key("mcp__two__b")); - } - - #[test] - fn search_tool_enabled_includes_selected_apps_plus_non_apps() { - let mcp_tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - test_tool("calendar_create_event"), - ), - ( - "mcp__codex_apps__calendar_list_events".to_string(), - test_tool("calendar_list_events"), - ), - ("mcp__rmcp__echo".to_string(), test_tool("echo")), - ]); - - let selected = select_mcp_tools_for_turn( - mcp_tools, - Some(vec!["mcp__codex_apps__calendar_list_events".to_string()]), - true, - ); - assert_eq!(selected.len(), 2); - assert!(selected.contains_key("mcp__rmcp__echo")); - assert!(selected.contains_key("mcp__codex_apps__calendar_list_events")); - assert!(!selected.contains_key("mcp__codex_apps__calendar_create_event")); - } - - #[test] - fn search_tool_disabled_returns_all_mcp_tools() { - let mcp_tools = HashMap::from([ - ("mcp__one__a".to_string(), test_tool("a")), - ("mcp__two__b".to_string(), test_tool("b")), - ]); - - let selected = select_mcp_tools_for_turn(mcp_tools, None, false); - assert_eq!(selected.len(), 2); - assert!(selected.contains_key("mcp__one__a")); - assert!(selected.contains_key("mcp__two__b")); - } - - #[test] - fn restore_selection_reads_latest_valid_search_output() { - let history = vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-shell".to_string(), - }, - ResponseItem::FunctionCall { - id: None, - name: super::LEGACY_SEARCH_TOOL_BM25_TOOL_NAME.to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-search-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-search-1".to_string(), - output: FunctionCallOutputPayload::from_text( - serde_json::json!({ - "active_selected_tools": ["mcp__codex_apps__calendar_create_event"] - }) - .to_string(), - ), - }, - ResponseItem::FunctionCall { - id: None, - name: super::LEGACY_SEARCH_TOOL_BM25_TOOL_NAME.to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-search-2".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-search-2".to_string(), - output: FunctionCallOutputPayload::from_text( - serde_json::json!({ - "active_selected_tools": [ - "mcp__codex_apps__calendar_list_events", - "mcp__codex_apps__calendar_delete_event" - ] - }) - .to_string(), - ), - }, - ]; - - let selected = extract_mcp_tool_selection_from_history(&history); - assert_eq!( - selected, - Some(vec![ - "mcp__codex_apps__calendar_list_events".to_string(), - "mcp__codex_apps__calendar_delete_event".to_string(), - ]) - ); - } - - #[test] - fn restore_selection_ignores_non_search_and_invalid_payloads() { - let history = vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-shell".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-shell".to_string(), - output: FunctionCallOutputPayload::from_text( - serde_json::json!({ - "active_selected_tools": ["mcp__codex_apps__ignored"] - }) - .to_string(), - ), - }, - ResponseItem::FunctionCall { - id: None, - name: super::LEGACY_SEARCH_TOOL_BM25_TOOL_NAME.to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-search".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-search".to_string(), - output: FunctionCallOutputPayload::from_text("not-json".to_string()), - }, - ]; - - assert!(extract_mcp_tool_selection_from_history(&history).is_none()); - } -} - -const HTML_SANITIZER_GUARDRAILS_MESSAGE: &str = - "TB2 HTML/XSS guardrails:\n- Do NOT use DOTALL/full-document regex (e.g. `.*?`); catastrophic backtracking risk.\n- Prefer linear-time scanning with quote/state tracking; if using regex, only on bounded substrings (single tags).\n- Perf smoke test: write malformed `/tmp/stress.html` and run `timeout 5s python3 /app/filter.py /tmp/stress.html` (or equivalent). If it times out, rewrite for linear-time behavior."; - -fn should_inject_html_sanitizer_guardrails(input: &[ResponseItem]) -> bool { - let mut user_messages_seen = 0u32; - let mut text = String::new(); - for item in input.iter().rev() { - if user_messages_seen >= 6 || text.len() >= 1_200 { - break; - } - let ResponseItem::Message { role, content, .. } = item else { - continue; - }; - if role != "user" { - continue; - } - user_messages_seen = user_messages_seen.saturating_add(1); - for entry in content { - let ContentItem::InputText { text: piece } = entry else { - continue; - }; - if piece.trim().is_empty() { - continue; - } - text.push_str(piece); - text.push('\n'); - if text.len() >= 1_200 { - break; - } - } - } - - if text.is_empty() { - return false; - } - - let lower = text.to_ascii_lowercase(); - let has_xss = lower.contains("xss"); - let has_sanitize = lower.contains("sanitize") || lower.contains("sanitiz"); - let has_filter_js_from_html = - lower.contains("filter-js-from-html") || lower.contains("break-filter-js-from-html"); - let has_html = lower.contains("html"); - let has_script_tag = - lower.contains(" (Vec, Vec) { - let mut call_ids = collect_tool_call_ids(rebuilt_history); - let mut missing_calls = Vec::new(); - let mut filtered_outputs = Vec::new(); - - for item in pending_outputs { - match item { - ResponseItem::FunctionCallOutput { call_id, .. } - | ResponseItem::CustomToolCallOutput { call_id, .. } => { - if call_ids.contains(call_id) { - filtered_outputs.push(item.clone()); - continue; - } - - if let Some(call_item) = find_call_item_by_id(previous_input_snapshot, call_id) { - call_ids.insert(call_id.clone()); - missing_calls.push(call_item); - filtered_outputs.push(item.clone()); - } else { - warn!("Skipping tool output for missing call_id={call_id} after auto-compact"); - } - } - _ => { - filtered_outputs.push(item.clone()); - } - } - } - - (missing_calls, filtered_outputs) -} - -fn collect_tool_call_ids(items: &[ResponseItem]) -> HashSet { - let mut ids = HashSet::new(); - for item in items { - match item { - ResponseItem::FunctionCall { call_id, .. } => { - ids.insert(call_id.clone()); - } - ResponseItem::LocalShellCall { call_id: Some(call_id), .. } => { - ids.insert(call_id.clone()); - } - ResponseItem::CustomToolCall { call_id, .. } => { - ids.insert(call_id.clone()); - } - _ => {} - } - } - ids -} - -fn pending_items_not_already_recorded( - pending_items: &[ResponseItem], - recorded_items: &[ResponseItem], -) -> Vec { - if recorded_items.is_empty() { - return pending_items.to_vec(); - } - - pending_items - .iter() - .filter(|pending| { - !is_tool_output_item(pending) || !recorded_items.iter().any(|recorded| recorded == *pending) - }) - .cloned() - .collect() -} - -fn is_tool_output_item(item: &ResponseItem) -> bool { - matches!( - item, - ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - ) -} - -fn find_call_item_by_id(items: &[ResponseItem], call_id: &str) -> Option { - items.iter().rev().find_map(|item| match item { - ResponseItem::FunctionCall { call_id: existing, .. } if existing == call_id => Some(item.clone()), - ResponseItem::LocalShellCall { call_id: Some(existing), .. } if existing == call_id => Some(item.clone()), - ResponseItem::CustomToolCall { call_id: existing, .. } if existing == call_id => Some(item.clone()), - _ => None, - }) -} - -/// When the model is prompted, it returns a stream of events. Some of these -/// events map to a `ResponseItem`. A `ResponseItem` may need to be -/// "handled" such that it produces a `ResponseInputItem` that needs to be -/// sent back to the model on the next turn. -#[derive(Debug)] -struct ProcessedResponseItem { - item: ResponseItem, - response: Option, -} - -struct TurnLatencyGuard<'a> { - sess: &'a Session, - attempt_req: u64, - active: bool, -} - -impl<'a> TurnLatencyGuard<'a> { - fn new(sess: &'a Session, attempt_req: u64, prompt: &Prompt) -> Self { - sess.turn_latency_request_scheduled(attempt_req, prompt); - Self { - sess, - attempt_req, - active: true, - } - } - - fn mark_completed(&mut self, output_item_count: usize, token_usage: Option<&TokenUsage>) { - if !self.active { - return; - } - self - .sess - .turn_latency_request_completed(self.attempt_req, output_item_count, token_usage); - self.active = false; - } - - fn mark_failed(&mut self, note: Option) { - if !self.active { - return; - } - self.sess.turn_latency_request_failed(self.attempt_req, note); - self.active = false; - } -} - -impl Drop for TurnLatencyGuard<'_> { - fn drop(&mut self) { - if self.active { - self - .sess - .turn_latency_request_failed(self.attempt_req, Some("dropped_without_outcome".to_string())); - } - } -} - -fn response_model_matches_request(requested_model: &str, response_model: &str) -> bool { - let requested = requested_model.trim().to_ascii_lowercase(); - let response = response_model.trim().to_ascii_lowercase(); - - if response == requested { - return true; - } - - response - .strip_prefix(&requested) - .is_some_and(|suffix| suffix.starts_with('-') && suffix.len() > 1) -} - -async fn try_run_turn( - sess: &Session, - turn_diff_tracker: &mut TurnDiffTracker, - sub_id: &str, - prompt: &Prompt, - attempt_req: u64, -) -> CodexResult> { - // call_ids that are part of this response. - let completed_call_ids = prompt - .input - .iter() - .filter_map(|ri| match ri { - ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id), - ResponseItem::LocalShellCall { - call_id: Some(call_id), - .. - } => Some(call_id), - ResponseItem::CustomToolCallOutput { call_id, .. } => Some(call_id), - _ => None, - }) - .collect::>(); - - // call_ids that were pending but are not part of this response. - // This usually happens because the user interrupted the model before we responded to one of its tool calls - // and then the user sent a follow-up message. - let missing_calls = { - prompt - .input - .iter() - .filter_map(|ri| match ri { - ResponseItem::FunctionCall { call_id, .. } => Some(call_id), - ResponseItem::LocalShellCall { - call_id: Some(call_id), - .. - } => Some(call_id), - ResponseItem::CustomToolCall { call_id, .. } => Some(call_id), - _ => None, - }) - .filter_map(|call_id| { - if completed_call_ids.contains(&call_id) { - None - } else { - Some(call_id.clone()) - } - }) - .map(|call_id| ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), - name: None, - output: FunctionCallOutputPayload::from_text("aborted".to_string()), - }) - .collect::>() - }; - let prompt: Cow = if missing_calls.is_empty() { - Cow::Borrowed(prompt) - } else { - // Add the synthetic aborted missing calls to the beginning of the input to ensure all call ids have responses. - let input = [missing_calls, prompt.input.clone()].concat(); - Cow::Owned(Prompt { - input, - ..prompt.clone() - }) - }; - - let mut turn_latency_guard = TurnLatencyGuard::new(sess, attempt_req, prompt.as_ref()); - let requested_model = prompt - .model_override - .clone() - .unwrap_or_else(|| sess.client.get_model()); - let mut latest_response_model: Option = None; - let mut latest_response_headers: Option = None; - let mut stream = match sess.client.clone().stream(&prompt).await { - Ok(stream) => stream, - Err(e) => { - turn_latency_guard.mark_failed(Some(format!("stream_init_failed: {e}"))); - sess - .notify_stream_error( - &sub_id, - format!("[transport] failed to start stream: {e}"), - ) - .await; - return Err(e); - } - }; - - let mut output = Vec::new(); - loop { - // Poll the next item from the model stream. We must inspect *both* Ok and Err - // cases so that transient stream failures (e.g., dropped SSE connection before - // `response.completed`) bubble up and trigger the caller's retry logic. - let event = stream.next().await; - let Some(event) = event else { - // Channel closed without yielding a final Completed event or explicit error. - // Treat as a disconnected stream so the caller can retry. - turn_latency_guard - .mark_failed(Some("stream_closed_before_completed".to_string())); - return Err(CodexErr::Stream( - "stream closed before response.completed".into(), - None, - None, - )); - }; - - let event = match event { - Ok(ev) => ev, - Err(e) => { - // Propagate the underlying stream error to the caller (run_turn), which - // will apply the configured `stream_max_retries` policy. - turn_latency_guard.mark_failed(Some(format!("stream_event_error: {e}"))); - return Err(e); - } - }; - - match event { - ResponseEvent::ContextLedger(ledger) => { - let msg = EventMsg::ContextLedger(crate::protocol::ContextLedgerEvent { ledger }); - sess.send_event(sess.make_event(&sub_id, msg)).await; - } - ResponseEvent::Created { - response_id, - response_model, - } => { - if let Some(model) = response_model.clone() { - latest_response_model = Some(model.clone()); - - if !response_model_matches_request(&requested_model, &model) { - let should_emit_warning = { - let mut state = sess.state.lock().unwrap(); - let already_warned = state - .last_model_reroute_notice - .as_ref() - .is_some_and(|(requested, response)| { - requested == &requested_model && response == &model - }); - if already_warned { - false - } else { - state.last_model_reroute_notice = - Some((requested_model.clone(), model.clone())); - true - } - }; - - if should_emit_warning { - let warning = crate::protocol::WarningEvent { - message: format!( - "Requested model `{requested_model}` was rerouted to `{model}`. OpenAI may have rerouted you to protect against cyber abuse.\nTo verify and restore access, visit https://chatgpt.com/cyber" - ), - }; - let _ = sess - .tx_event - .send(sess.make_event(&sub_id, EventMsg::Warning(warning))) - .await; - } - } - } - - tracing::debug!( - response_id = response_id.as_deref().unwrap_or(""), - response_model = response_model.as_deref().unwrap_or(""), - requested_model, - "received response.created" - ); - } - ResponseEvent::ServerReasoningIncluded(_included) => {} - ResponseEvent::ResponseHeaders(headers) => { - latest_response_headers = Some(headers); - } - ResponseEvent::OutputItemDone { item, sequence_number, output_index } => { - let (item, rollout_ids) = crate::memories::sanitize_response_item(item); - if !rollout_ids.is_empty() { - let code_home = sess.client.code_home().to_path_buf(); - tokio::spawn(async move { - crate::memories::note_memory_usage(&code_home, &rollout_ids).await; - }); - } - let response = - handle_response_item( - sess, - turn_diff_tracker, - sub_id, - item.clone(), - sequence_number, - output_index, - attempt_req, - &ImageGenerationTurnMetadata { - requested_model: requested_model.clone(), - latest_response_model: latest_response_model.clone(), - response_headers: latest_response_headers.clone(), - }, - ) - .await?; - - // Save into scratchpad so we can seed a retry if the stream drops later. - sess.scratchpad_push(&item, &response, &sub_id); - - // If this was a finalized assistant message, clear partial text buffer - if let ResponseItem::Message { .. } = &item { - sess.scratchpad_clear_partial_message(); - } - - output.push(ProcessedResponseItem { item, response }); - } - ResponseEvent::WebSearchCallBegin { call_id } => { - // Stamp OrderMeta so the TUI can place the search block within - // the correct request window instead of using an internal epilogue. - let ctx = ToolCallCtx::new(sub_id.to_string(), call_id.clone(), None, None); - let order = ctx.order_meta(attempt_req); - let ev = sess.make_event_with_order( - &sub_id, - EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: None }), - order, - None, - ); - sess.send_event(ev).await; - } - ResponseEvent::WebSearchCallCompleted { call_id, query } => { - let ctx = ToolCallCtx::new(sub_id.to_string(), call_id.clone(), None, None); - let order = ctx.order_meta(attempt_req); - let ev = sess.make_event_with_order( - &sub_id, - EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query }), - order, - None, - ); - sess.send_event(ev).await; - } - ResponseEvent::Completed { - response_id: _, - token_usage, - } => { - let (new_info, rate_limits, should_emit); - { - let mut state = sess.state.lock().unwrap(); - let mut info = TokenUsageInfo::new_or_append( - &state.token_usage_info, - &token_usage, - sess.client.get_model_context_window(), - ); - if let Some(info) = info.as_mut() { - info.requested_model = Some(requested_model.clone()); - if let Some(response_model) = latest_response_model.clone() { - info.latest_response_model = Some(response_model); - } - } - let limits = state.latest_rate_limits.clone(); - let emit = info.is_some() || limits.is_some(); - state.token_usage_info = info.clone(); - new_info = info; - rate_limits = limits; - should_emit = emit; - } - - if should_emit { - let payload = TokenCountEvent { - info: new_info, - rate_limits, - }; - sess.tx_event - .send(sess.make_event(&sub_id, EventMsg::TokenCount(payload))) - .await - .ok(); - } - - if let Some(usage) = token_usage.as_ref() { - if let Some(ctx) = account_usage_context(sess) { - let usage_home = ctx.code_home.clone(); - let usage_account = ctx.account_id.clone(); - let usage_plan = ctx.plan.clone(); - let usage_clone = usage.clone(); - spawn_usage_task(move || { - if let Err(err) = account_usage::record_token_usage( - &usage_home, - &usage_account, - usage_plan.as_deref(), - &usage_clone, - Utc::now(), - ) { - warn!("Failed to persist token usage: {err}"); - } - }); - } - } - - let unified_diff = turn_diff_tracker.get_unified_diff(); - if let Ok(Some(unified_diff)) = unified_diff { - let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); - let _ = sess.tx_event.send(sess.make_event(&sub_id, msg)).await; - } - - turn_latency_guard.mark_completed(output.len(), token_usage.as_ref()); - return Ok(output); - } - ResponseEvent::OutputTextDelta { delta, item_id, sequence_number, output_index } => { - // Don't append to history during streaming - only send UI events. - // The complete message will be added to history when OutputItemDone arrives. - // This ensures items are recorded in the correct chronological order. - - // Use the item_id if present, otherwise fall back to sub_id - let event_id = item_id.unwrap_or_else(|| sub_id.to_string()); - let order = crate::protocol::OrderMeta { - request_ordinal: attempt_req, - output_index, - sequence_number, - }; - let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: delta.clone() }), order, sequence_number); - sess.tx_event.send(stamped).await.ok(); - - // Track partial assistant text in the scratchpad to help resume on retry. - // Only accumulate when we have an item context or a single active stream. - // We deliberately do not scope by item_id to keep implementation simple. - sess.scratchpad_add_text_delta(&delta); - } - ResponseEvent::ReasoningSummaryDelta { delta, item_id, sequence_number, output_index, summary_index } => { - // Use the item_id if present, otherwise fall back to sub_id - let mut event_id = item_id.unwrap_or_else(|| sub_id.to_string()); - if let Some(si) = summary_index { event_id = format!("{}#s{}", event_id, si); } - let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number }; - let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: delta.clone() }), order, sequence_number); - sess.tx_event.send(stamped).await.ok(); - - // Buffer reasoning summary so we can include a hint on retry. - sess.scratchpad_add_reasoning_delta(&delta); - } - ResponseEvent::ReasoningSummaryPartAdded => { - let stamped = sess.make_event(&sub_id, EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {})); - sess.tx_event.send(stamped).await.ok(); - } - ResponseEvent::ReasoningContentDelta { delta, item_id, sequence_number, output_index, content_index } => { - if sess.show_raw_agent_reasoning { - // Use the item_id if present, otherwise fall back to sub_id - let mut event_id = item_id.unwrap_or_else(|| sub_id.to_string()); - if let Some(ci) = content_index { event_id = format!("{}#c{}", event_id, ci); } - let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number }; - let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta }), order, sequence_number); - sess.tx_event.send(stamped).await.ok(); - } - } - ResponseEvent::ModelsEtag(etag) => { - if let Some(remote) = sess.remote_models_manager.as_ref() { - remote.refresh_if_new_etag(etag).await; - } - } - ResponseEvent::RateLimits(snapshot) => { - let mut state = sess.state.lock().unwrap(); - state.latest_rate_limits = Some(snapshot.clone()); - if let Some(ctx) = account_usage_context(sess) { - let usage_home = ctx.code_home.clone(); - let usage_account = ctx.account_id.clone(); - let usage_plan = ctx.plan.clone(); - let snapshot_clone = snapshot.clone(); - spawn_usage_task(move || { - if let Err(err) = account_usage::record_rate_limit_snapshot( - &usage_home, - &usage_account, - usage_plan.as_deref(), - &snapshot_clone, - Utc::now(), - ) { - warn!("Failed to persist rate limit snapshot: {err}"); - } - }); - } - } - // Note: ReasoningSummaryPartAdded handled above without scratchpad mutation. - } - } -} - -async fn handle_response_item( - sess: &Session, - turn_diff_tracker: &mut TurnDiffTracker, - sub_id: &str, - item: ResponseItem, - seq_hint: Option, - output_index: Option, - attempt_req: u64, - image_generation_metadata: &ImageGenerationTurnMetadata, -) -> CodexResult> { - debug!(?item, "Output item"); - let output = match item { - ResponseItem::Message { content, id, .. } => { - // Use the item_id if present, otherwise fall back to sub_id - let event_id = id.unwrap_or_else(|| sub_id.to_string()); - for item in content { - if let ContentItem::OutputText { text } = item { - let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint }; - let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentMessage(AgentMessageEvent { message: text }), order, seq_hint); - sess.tx_event.send(stamped).await.ok(); - } - } - None - } - ResponseItem::CompactionSummary { .. } | ResponseItem::ContextCompaction { .. } => { - // Keep compaction summaries in history; no user-visible event to emit. - None - } - ResponseItem::Reasoning { - id, - summary, - content, - encrypted_content: _, - } => { - // Use the item_id if present and not empty, otherwise fall back to sub_id - let event_id = if !id.is_empty() { - id.clone() - } else { - sub_id.to_string() - }; - for (i, item) in summary.into_iter().enumerate() { - let text = match item { - ReasoningItemReasoningSummary::SummaryText { text } => text, - }; - let eid = format!("{}#s{}", event_id, i); - let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint }; - let stamped = sess.make_event_with_order(&eid, EventMsg::AgentReasoning(AgentReasoningEvent { text }), order, seq_hint); - sess.tx_event.send(stamped).await.ok(); - } - if sess.show_raw_agent_reasoning && content.is_some() { - let content = content.unwrap(); - for item in content.into_iter() { - let text = match item { - ReasoningItemContent::ReasoningText { text } => text, - ReasoningItemContent::Text { text } => text, - }; - let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint }; - let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }), order, seq_hint); - sess.tx_event.send(stamped).await.ok(); - } - } - None - } - ResponseItem::FunctionCall { - name, - namespace, - arguments, - call_id, - .. - } => { - info!("FunctionCall: {name}({arguments})"); - Some( - handle_function_call( - sess, - turn_diff_tracker, - sub_id.to_string(), - namespace, - name, - arguments, - call_id, - seq_hint, - output_index, - attempt_req, - ) - .await, - ) - } - ResponseItem::ToolSearchCall { - call_id, - execution, - arguments, - .. - } => Some( - handle_tool_search( - sess, - ToolSearchResponseMode::ToolSearchOutput { - call_id: call_id.unwrap_or_default(), - execution, - }, - arguments, - ) - .await, - ), - ResponseItem::LocalShellCall { - id, - call_id, - status: _, - action, - } => { - let LocalShellAction::Exec(action) = action; - tracing::info!("LocalShellCall: {action:?}"); - let params = ShellToolCallParams { - command: action.command, - workdir: action.working_directory, - timeout_ms: action.timeout_ms, - sandbox_permissions: None, - prefix_rule: None, - additional_permissions: None, - justification: None, - }; - let effective_call_id = match (call_id, id) { - (Some(call_id), _) => call_id, - (None, Some(id)) => id, - (None, None) => { - error!("LocalShellCall without call_id or id"); - return Ok(Some(ResponseInputItem::FunctionCallOutput { - call_id: "".to_string(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("LocalShellCall without call_id or id".to_string()), - success: None}, - })); - } - }; - - let exec_params = to_exec_params(params, sess); - Some( - handle_container_exec_with_params( - exec_params, - sess, - turn_diff_tracker, - sub_id.to_string(), - effective_call_id, - seq_hint, - output_index, - attempt_req, - ) - .await, - ) - } - ResponseItem::CustomToolCall { call_id, name, .. } => { - // Minimal placeholder: custom tools are not handled here. - Some(ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Custom tool '{name}' is not supported in this build")), - success: Some(false)}, - }) - } - ResponseItem::FunctionCallOutput { .. } => { - debug!("unexpected FunctionCallOutput from stream"); - None - } - ResponseItem::ToolSearchOutput { .. } => { - debug!("unexpected ToolSearchOutput from stream"); - None - } - ResponseItem::CustomToolCallOutput { .. } => { - debug!("unexpected CustomToolCallOutput from stream"); - None - } - ResponseItem::WebSearchCall { id, action, .. } => { - if let Some(WebSearchAction::Search { query, queries }) = action { - let call_id = id.unwrap_or_else(|| "".to_string()); - let query = web_search_query(&query, &queries); - let event = sess.make_event_with_hint( - &sub_id, - EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query }), - seq_hint, - ); - sess.tx_event.send(event).await.ok(); - } - None - } - ResponseItem::ImageGenerationCall { - id, - status, - revised_prompt, - result, - } => { - handle_image_generation_call( - sess, - sub_id, - id, - status, - revised_prompt, - result, - seq_hint, - output_index, - attempt_req, - image_generation_metadata, - ) - .await; - None - } - ResponseItem::GhostSnapshot { .. } => None, - ResponseItem::Other => None, - }; - Ok(output) -} - -async fn handle_image_generation_call( - sess: &Session, - sub_id: &str, - call_id: String, - status: String, - revised_prompt: Option, - result: String, - seq_hint: Option, - output_index: Option, - attempt_req: u64, - metadata: &ImageGenerationTurnMetadata, -) { - let order = crate::protocol::OrderMeta { - request_ordinal: attempt_req, - output_index, - sequence_number: seq_hint, - }; - let begin = sess.make_event_with_order( - sub_id, - EventMsg::ImageGenerationBegin(crate::protocol::ImageGenerationBeginEvent { - call_id: call_id.clone(), - }), - order.clone(), - seq_hint, - ); - sess.send_event(begin).await; - - let saved_path = match save_image_generation_result( - sess.client.code_home(), - &sess.session_uuid().to_string(), - &call_id, - &result, - ) - .await - { - Ok(path) => { - let image_output_dir = path - .as_path() - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| sess.client.code_home().to_path_buf()); - let text = format!( - "Generated images are saved under {}. This image was saved to {}.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - image_output_dir.display(), - path.display() - ); - let message = ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - end_turn: None, - phase: None, - }; - sess.record_conversation_items(&[message]).await; - Some(path) - } - Err(err) => { - let expected_path = image_generation_artifact_path( - sess.client.code_home(), - &sess.session_uuid().to_string(), - &call_id, - ); - warn!( - "failed to save image generation result to {}: {err}", - expected_path.display() - ); - None - } - }; - - if let Some(path) = saved_path.as_ref() - && let Err(err) = save_image_generation_sidecar( - path, - &call_id, - &status, - revised_prompt.as_deref(), - metadata, - ) - .await - { - warn!( - "failed to save image generation metadata sidecar for {}: {err}", - path.display() - ); - } - - let end = sess.make_event_with_order( - sub_id, - EventMsg::ImageGenerationEnd(crate::protocol::ImageGenerationEndEvent { - call_id, - status, - revised_prompt, - result, - saved_path, - }), - order, - seq_hint, - ); - sess.send_event(end).await; -} - -fn image_generation_artifact_path(code_home: &Path, session_id: &str, call_id: &str) -> PathBuf { - fn sanitize(value: &str) -> String { - let sanitized: String = value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - "generated_image".to_string() - } else { - sanitized - } - } - - code_home - .join(GENERATED_IMAGE_ARTIFACTS_DIR) - .join(sanitize(session_id)) - .join(format!("{}.png", sanitize(call_id))) -} - -async fn save_image_generation_result( - code_home: &Path, - session_id: &str, - call_id: &str, - result: &str, -) -> std::result::Result { - let bytes = base64::engine::general_purpose::STANDARD - .decode(result.trim().as_bytes()) - .map_err(|err| format!("invalid image generation payload: {err}"))?; - let path = image_generation_artifact_path(code_home, session_id, call_id); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|err| err.to_string())?; - } - tokio::fs::write(&path, bytes) - .await - .map_err(|err| err.to_string())?; - code_utils_absolute_path::AbsolutePathBuf::from_absolute_path(path) - .map_err(|err| err.to_string()) -} - -async fn save_image_generation_sidecar( - artifact_path: &code_utils_absolute_path::AbsolutePathBuf, - call_id: &str, - status: &str, - revised_prompt: Option<&str>, - metadata: &ImageGenerationTurnMetadata, -) -> std::result::Result { - let sidecar_path = artifact_path.as_path().with_extension("metadata.json"); - let sidecar = ImageGenerationSidecar { - call_id, - status, - revised_prompt, - artifact_path: artifact_path.display().to_string(), - requested_model: &metadata.requested_model, - latest_response_model: metadata.latest_response_model.as_deref(), - response_headers: metadata.response_headers.as_ref(), - }; - let json = serde_json::to_vec_pretty(&sidecar).map_err(|err| err.to_string())?; - tokio::fs::write(&sidecar_path, json) - .await - .map_err(|err| err.to_string())?; - code_utils_absolute_path::AbsolutePathBuf::from_absolute_path(sidecar_path) - .map_err(|err| err.to_string()) -} - -fn web_search_query(query: &Option, queries: &Option>) -> Option { - if let Some(value) = query.clone().filter(|q| !q.is_empty()) { - return Some(value); - } - - let items = queries.as_ref(); - let first = items - .and_then(|queries| queries.first()) - .cloned() - .unwrap_or_default(); - if first.is_empty() { - return None; - } - if items.is_some_and(|queries| queries.len() > 1) { - Some(format!("{first} ...")) - } else { - Some(first) - } -} - -// Helper utilities for agent output/progress management -fn ensure_agent_dir(cwd: &Path, agent_id: &str) -> Result { - let safe_agent_id = crate::fs_sanitize::safe_path_component(agent_id, "agent"); - let dir = cwd.join(".code").join("agents").join(safe_agent_id); - std::fs::create_dir_all(&dir) - .map_err(|e| format!("Failed to create agent dir {}: {}", dir.display(), e))?; - Ok(dir) -} - -pub(super) fn ensure_user_dir(cwd: &Path) -> Result { - let dir = cwd.join(".code").join("users"); - std::fs::create_dir_all(&dir) - .map_err(|e| format!("Failed to create user dir {}: {}", dir.display(), e))?; - Ok(dir) -} - -pub(super) fn write_agent_file(dir: &Path, filename: &str, content: &str) -> Result { - if filename - .chars() - .any(|ch| matches!(ch, '/' | '\\' | '\0')) - { - return Err(format!("Refusing to write invalid filename: {filename}")); - } - let candidate = Path::new(filename); - if candidate.is_absolute() || candidate.components().count() != 1 { - return Err(format!("Refusing to write non-file component: {filename}")); - } - let Some(file_name) = candidate.file_name() else { - return Err(format!("Refusing to write invalid filename: {filename}")); - }; - let file_name = file_name.to_string_lossy(); - if file_name.is_empty() || file_name == "." || file_name == ".." { - return Err(format!("Refusing to write invalid filename: {filename}")); - } - - let path = dir.join(file_name.as_ref()); - std::fs::write(&path, content) - .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; - Ok(path) -} - -const AGENT_PREVIEW_MAX_BYTES: usize = 32 * 1024; // 32 KiB - -fn preview_first_n_lines(s: &str, n: usize) -> (String, usize) { - let total_lines = s.lines().count(); - let mut preview = s.lines().take(n).collect::>().join("\n"); - - let (maybe_truncated, was_truncated, _, _) = - truncate_middle_bytes(&preview, AGENT_PREVIEW_MAX_BYTES); - if was_truncated { - preview = maybe_truncated; - preview.push_str(&format!( - "\n…preview truncated to roughly {AGENT_PREVIEW_MAX_BYTES} bytes…" - )); - } else { - preview = maybe_truncated; - } - - if total_lines > n { - if !preview.ends_with('\n') { - preview.push('\n'); - } - preview.push_str("…additional lines omitted…"); - } - - (preview, total_lines) -} - -#[cfg(test)] -mod preview_tests { - use super::*; - - #[test] - fn truncates_excessively_long_single_line() { - let input = "x".repeat(AGENT_PREVIEW_MAX_BYTES + 1024); - let (preview, total_lines) = preview_first_n_lines(&input, 500); - assert_eq!(total_lines, 1); - assert!(preview.contains("…truncated…")); - assert!(preview.contains("preview truncated to roughly")); - } - - #[test] - fn notes_when_additional_lines_omitted() { - let input = (0..600) - .map(|i| format!("line {i}")) - .collect::>() - .join("\n"); - let (preview, total_lines) = preview_first_n_lines(&input, 500); - assert_eq!(total_lines, 600); - assert!(preview.contains("…additional lines omitted…")); - assert!(!preview.contains("preview truncated to roughly")); - } -} - -async fn handle_function_call( - sess: &Session, - turn_diff_tracker: &mut TurnDiffTracker, - sub_id: String, - namespace: Option, - name: String, - arguments: String, - call_id: String, - seq_hint: Option, - output_index: Option, - attempt_req: u64, -) -> ResponseInputItem { - let ctx = ToolCallCtx::new(sub_id.clone(), call_id.clone(), seq_hint, output_index); - match name.as_str() { - "container.exec" | "shell" | "local_shell" => { - let params = match parse_container_exec_arguments(arguments, sess, &call_id) { - Ok(params) => params, - Err(output) => { - return *output; - } - }; - handle_container_exec_with_params(params, sess, turn_diff_tracker, sub_id, call_id, seq_hint, output_index, attempt_req) - .await - } - "shell_command" => { - let params = match parse_shell_command_arguments(arguments, sess, &call_id) { - Ok(params) => params, - Err(output) => { - return *output; - } - }; - handle_container_exec_with_params( - params, - sess, - turn_diff_tracker, - sub_id, - call_id, - seq_hint, - output_index, - attempt_req, - ) - .await - } - "update_plan" => handle_update_plan(sess, &ctx, arguments).await, - "request_user_input" => handle_request_user_input(sess, &ctx, arguments).await, - "declare_worktree_decision" => handle_declare_worktree_decision(sess, &ctx, arguments).await, - // agent tool - "agent" => handle_agent_tool(sess, &ctx, arguments).await, - // unified browser tool - "browser" => handle_browser_tool(sess, &ctx, arguments).await, - "web_fetch" => handle_web_fetch(sess, &ctx, arguments).await, - "image_view" => handle_image_view(sess, &ctx, arguments).await, - "wait" => handle_wait(sess, &ctx, arguments).await, - "gh_run_wait" => handle_gh_run_wait(sess, &ctx, arguments).await, - "kill" => handle_kill(sess, &ctx, arguments).await, - "code_bridge" | "code_bridge_subscription" => handle_code_bridge(sess, &ctx, arguments).await, - "auto_review_detail" => handle_auto_review_detail(sess, &ctx, arguments).await, - TOOL_SEARCH_TOOL_NAME | LEGACY_SEARCH_TOOL_BM25_TOOL_NAME => { - let arguments = match serde_json::from_str::(&arguments) { - Ok(arguments) => arguments, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "invalid {TOOL_SEARCH_TOOL_NAME} arguments: {err}" - )), - success: Some(false), - }, - }; - } - }; - - handle_tool_search( - sess, - ToolSearchResponseMode::FunctionCallOutput(ctx.call_id.clone()), - arguments, - ) - .await - } - _ => { - if sess.is_dynamic_tool(namespace.as_deref(), &name) { - return handle_dynamic_tool_call(sess, &ctx, namespace, name, arguments).await; - } - match sess.mcp_connection_manager.parse_tool_name(&name) { - Some((server, tool_name)) => { - // Tool timeouts are derived from per-server config; no per-call override here. - handle_mcp_tool_call(sess, &ctx, server, tool_name, arguments).await - } - None => { - // Unknown function: reply with structured failure so the model can adapt. - ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("unsupported call: {name}")), - success: None}, - } - } - } - } - } -} - -async fn handle_request_user_input( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - use code_protocol::request_user_input::RequestUserInputArgs; - use code_protocol::request_user_input::RequestUserInputEvent; - - let mut args: RequestUserInputArgs = match serde_json::from_str(&arguments) { - Ok(args) => args, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("invalid request_user_input arguments: {err}")), - success: Some(false)}, - }; - } - }; - - if args.questions.is_empty() { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("request_user_input requires at least one question".to_string()), - success: Some(false)}, - }; - } - - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().map_or(true, Vec::is_empty)); - if missing_options { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("request_user_input requires non-empty options for every question" - .to_string()), - success: Some(false)}, - }; - } - for question in &mut args.questions { - question.is_other = true; - } - - let rx_response = match sess.register_pending_user_input(ctx.sub_id.clone()) { - Ok(rx) => rx, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(err), - success: Some(false)}, - }; - } - }; - - sess.send_ordered_from_ctx( - ctx, - EventMsg::RequestUserInput(RequestUserInputEvent { - call_id: ctx.call_id.clone(), - turn_id: ctx.sub_id.clone(), - questions: args.questions, - }), - ) - .await; - - if let Some(task) = sess.task_lifecycle(&ctx.sub_id) { - let lifecycle = sess.make_event( - &ctx.sub_id, - EventMsg::TaskLifecycle(TaskLifecycleEvent { - phase: TaskLifecyclePhase::AwaitingExternalInput, - origin: task.origin, - visible_to_user: task.visible_to_user, - last_agent_message: None, - }), - ); - sess.tx_event.send(lifecycle).await.ok(); - } - - let response = match rx_response.await { - Ok(response) => response, - Err(_) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("request_user_input was cancelled before receiving a response".to_string()), - success: Some(false)}, - }; - } - }; - - let content = match serde_json::to_string(&response) { - Ok(content) => content, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("failed to serialize request_user_input response: {err}")), - success: Some(false)}, - }; - } - }; - - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(true), - }, - } -} - -#[derive(Deserialize)] -struct DeclareWorktreeDecisionArgs { - decision: String, - path: Option, - reason: Option, -} - -async fn handle_declare_worktree_decision( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - let args: DeclareWorktreeDecisionArgs = match serde_json::from_str(&arguments) { - Ok(args) => args, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "invalid declare_worktree_decision arguments: {err}" - )), - success: Some(false), - }, - }; - } - }; - - let Some(notice) = active_session_write_gate_notice(sess) else { - sess.active_session_write_gate_clear(); - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text( - "No concurrent checkout write session is currently active; no worktree decision is required." - .to_string(), - ), - success: Some(true), - }, - }; - }; - - let checkout_root = normalize_for_containment(¬ice.checkout_root) - .unwrap_or_else(|| notice.checkout_root.clone()); - let decision = match args.decision.as_str() { - "use_worktree" => { - let selected = args.path.unwrap_or_else(|| notice.suggested_worktree_path.clone()); - let selected_abs = if selected.is_absolute() { - selected - } else { - sess.get_cwd().join(selected) - }; - let selected_norm = normalize_for_containment(&selected_abs).unwrap_or(selected_abs); - if path_within(&selected_norm, &checkout_root) { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "declare_worktree_decision rejected: use_worktree path {} is still inside the conflicted checkout {}. Use an isolated worktree such as {}.", - selected_norm.display(), - checkout_root.display(), - notice.suggested_worktree_path.display() - )), - success: Some(false), - }, - }; - } - ActiveSessionWorktreeDecision::UseWorktree { - fingerprint: notice.fingerprint.clone(), - checkout_root: notice.checkout_root.clone(), - selected_worktree_path: selected_norm, - } - } - "stay_here" => { - let reason = args.reason.unwrap_or_default().trim().to_string(); - if reason.len() < 8 { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text( - "declare_worktree_decision rejected: stay_here requires a concrete reason." - .to_string(), - ), - success: Some(false), - }, - }; - } - ActiveSessionWorktreeDecision::StayHere { - fingerprint: notice.fingerprint.clone(), - checkout_root: notice.checkout_root.clone(), - reason, - } - } - other => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "declare_worktree_decision rejected: unknown decision {other:?}; use use_worktree or stay_here." - )), - success: Some(false), - }, - }; - } - }; - - let summary = match &decision { - ActiveSessionWorktreeDecision::UseWorktree { selected_worktree_path, .. } => { - format!("WORKTREE DECISION recorded: use isolated worktree {}.", selected_worktree_path.display()) - } - ActiveSessionWorktreeDecision::StayHere { reason, .. } => { - format!("WORKTREE DECISION recorded: stay in checkout because {reason}.") - } - ActiveSessionWorktreeDecision::Unset | ActiveSessionWorktreeDecision::AwaitingDecision { .. } => { - "WORKTREE DECISION not recorded.".to_string() - } - }; - sess.set_active_session_worktree_decision(decision); - - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(summary), - success: Some(true), - }, - } -} - -#[derive(Debug, serde::Deserialize)] -struct AutoReviewDetailArgs { - run_id: String, - #[serde(default)] - finding_id: Option, - #[serde(default)] - max_bytes: Option, -} - -async fn handle_auto_review_detail( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - let args = match serde_json::from_str::(&arguments) { - Ok(args) => args, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text( - serde_json::json!({ - "success": false, - "error": { - "code": "invalid_arguments", - "message": format!("invalid auto_review_detail arguments: {err}"), - } - }) - .to_string(), - ), - success: Some(false), - }, - }; - } - }; - let run_id = match Uuid::parse_str(&args.run_id) { - Ok(run_id) => run_id, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text( - serde_json::json!({ - "success": false, - "error": { - "code": "invalid_run_id", - "run_id": args.run_id, - "message": format!("invalid auto review run_id: {err}"), - } - }) - .to_string(), - ), - success: Some(false), - }, - }; - } - }; - let cwd = sess.cwd.clone(); - let finding_id = args.finding_id.clone(); - let max_bytes = args.max_bytes; - let lookup = tokio::task::spawn_blocking(move || { - let store = AutoReviewRunStore::open_existing_read_only(&cwd).map_err(|err| { - serde_json::json!({ - "code": "store_unavailable", - "message": format!("failed to open auto review run store: {err}"), - }) - })?; - let Some(store) = store else { - return Err(serde_json::json!({ - "code": "store_missing", - "message": "auto review run store is not available for this workspace", - })); - }; - store - .read_detail(run_id, finding_id.as_deref(), max_bytes) - .map_err(|err| serde_json::to_value(err).unwrap_or_else(|ser_err| { - serde_json::json!({ - "code": "detail_error", - "message": format!("failed to serialize auto review detail error: {ser_err}"), - }) - })) - }) - .await; - - let body = match lookup { - Ok(Ok(detail)) => serde_json::json!({ - "success": true, - "detail": detail, - "defaults": { - "default_max_bytes": default_auto_review_detail_max_bytes(), - "hard_max_bytes": hard_auto_review_detail_max_bytes(), - } - }), - Ok(Err(error)) => serde_json::json!({ - "success": false, - "error": error, - "defaults": { - "default_max_bytes": default_auto_review_detail_max_bytes(), - "hard_max_bytes": hard_auto_review_detail_max_bytes(), - } - }), - Err(err) => serde_json::json!({ - "success": false, - "error": { - "code": "lookup_join_error", - "message": format!("auto review detail lookup task failed: {err}"), - } - }), - }; - let success = body - .get("success") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), - success: Some(success), - }, - } -} - -async fn handle_dynamic_tool_call( - sess: &Session, - ctx: &ToolCallCtx, - namespace: Option, - tool_name: String, - arguments: String, -) -> ResponseInputItem { - let args = if arguments.trim().is_empty() { - serde_json::Value::Object(serde_json::Map::new()) - } else { - match serde_json::from_str::(&arguments) { - Ok(args) => args, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("invalid dynamic tool arguments: {err}")), - success: Some(false)}, - }; - } - } - }; - - let rx_response = match sess.register_pending_dynamic_tool(ctx.call_id.clone()) { - Ok(rx) => rx, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(err), - success: Some(false)}, - }; - } - }; - - sess.send_ordered_from_ctx( - ctx, - EventMsg::DynamicToolCallRequest(code_protocol::dynamic_tools::DynamicToolCallRequest { - call_id: ctx.call_id.clone(), - turn_id: ctx.sub_id.clone(), - namespace, - tool: tool_name, - arguments: args, - }), - ) - .await; - - let response = match rx_response.await { - Ok(response) => response, - Err(_) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("dynamic tool call was cancelled before receiving a response" - .to_string()), - success: Some(false)}, - }; - } - }; - - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: { - let content_items = response - .content_items - .into_iter() - .map(FunctionCallOutputContentItem::from) - .collect::>(); - let mut payload = FunctionCallOutputPayload::from_content_items(content_items); - payload.success = Some(response.success); - payload - }, - } -} - -const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8; - -fn tool_search_default_limit() -> usize { - TOOL_SEARCH_DEFAULT_LIMIT -} - -#[derive(Deserialize)] -struct ToolSearchArgs { - query: String, - #[serde(default = "tool_search_default_limit")] - limit: usize, -} - -enum ToolSearchResponseMode { - FunctionCallOutput(String), - ToolSearchOutput { call_id: String, execution: String }, -} - -impl ToolSearchResponseMode { - fn error(self, message: impl Into) -> ResponseInputItem { - let message = message.into(); - match self { - Self::FunctionCallOutput(call_id) => ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(message), - success: Some(false), - }, - }, - Self::ToolSearchOutput { call_id, execution } => ResponseInputItem::ToolSearchOutput { - call_id, - status: "failed".to_string(), - execution, - tools: Vec::new(), - }, - } - } - - fn success(self, query: &str, total_tools: usize, active_selected_tools: Vec, tools: Vec) -> ResponseInputItem { - match self { - Self::FunctionCallOutput(call_id) => { - let content = serde_json::json!({ - "query": query, - "total_tools": total_tools, - "active_selected_tools": active_selected_tools, - "tools": tools, - }) - .to_string(); - - ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(true), - }, - } - } - Self::ToolSearchOutput { call_id, execution } => ResponseInputItem::ToolSearchOutput { - call_id, - status: "completed".to_string(), - execution, - tools, - }, - } - } -} - -#[derive(Clone)] -struct SearchToolCandidate { - name: String, - server_name: String, - description: Option, - input_keys: Vec, - search_text: String, -} - -impl SearchToolCandidate { - fn from_mcp_tool(name: String, server_name: String, tool: mcp_types::Tool) -> Self { - let description = tool.description.map(|value| value.to_string()); - let input_keys = tool - .input_schema - .properties - .as_ref() - .and_then(serde_json::Value::as_object) - .map_or_else(Vec::new, |map| map.keys().cloned().collect()); - - let mut search_parts = vec![name.clone(), server_name.clone()]; - if let Some(desc) = description.as_ref() - && !desc.trim().is_empty() - { - search_parts.push(desc.clone()); - } - if !input_keys.is_empty() { - search_parts.extend(input_keys.iter().cloned()); - } - - let search_text = search_parts.join(" ").to_ascii_lowercase(); - Self { - name, - server_name, - description, - input_keys, - search_text, - } - } -} - -#[cfg(test)] -mod search_tool_candidate_tests { - use super::SearchToolCandidate; - - #[test] - fn preserves_server_name_with_delimiter() { - let tool = mcp_types::Tool { - name: "run".to_string(), - title: None, - description: Some("desc".to_string()), - input_schema: mcp_types::ToolInputSchema { - properties: Some(serde_json::json!({"query": {"type": "string"}})), - required: None, - r#type: "object".to_string(), - }, - output_schema: None, - annotations: None, - }; - - let candidate = SearchToolCandidate::from_mcp_tool( - "alpha__beta__run".to_string(), - "alpha__beta".to_string(), - tool, - ); - - assert_eq!(candidate.server_name, "alpha__beta"); - } -} - -fn tokenize_search_query(query: &str) -> Vec { - query - .split(|char: char| !char.is_ascii_alphanumeric()) - .filter(|token| !token.is_empty()) - .map(|token| token.to_ascii_lowercase()) - .collect() -} - -fn score_search_candidate( - normalized_query: &str, - query_tokens: &[String], - candidate: &SearchToolCandidate, -) -> f64 { - let mut score = 0.0; - if candidate.search_text.contains(normalized_query) { - score += 8.0; - } - - for token in query_tokens { - if token.len() <= 1 { - continue; - } - if candidate.search_text.contains(token) { - score += 2.0; - } - } - - score -} - -async fn handle_tool_search( - sess: &Session, - response_mode: ToolSearchResponseMode, - arguments: serde_json::Value, -) -> ResponseInputItem { - let args = match serde_json::from_value::(arguments) { - Ok(args) => args, - Err(err) => { - return response_mode.error(format!("invalid {TOOL_SEARCH_TOOL_NAME} arguments: {err}")); - } - }; - - let query = args.query.trim(); - if query.is_empty() { - return response_mode.error("query must not be empty"); - } - - if args.limit == 0 { - return response_mode.error("limit must be greater than zero"); - } - - let all_mcp_tools = sess.mcp_connection_manager.list_all_tools_with_server_names(); - let total_tools = all_mcp_tools.len(); - - let mut candidates: Vec = all_mcp_tools - .into_iter() - .map(|(name, server_name, tool)| SearchToolCandidate::from_mcp_tool(name, server_name, tool)) - .collect(); - candidates.sort_by(|a, b| a.name.cmp(&b.name)); - - let normalized_query = query.to_ascii_lowercase(); - let query_tokens = tokenize_search_query(&normalized_query); - - let mut ranked: Vec<(f64, SearchToolCandidate)> = candidates - .into_iter() - .map(|candidate| { - ( - score_search_candidate(&normalized_query, &query_tokens, &candidate), - candidate, - ) - }) - .collect(); - ranked.sort_by(|(left_score, left), (right_score, right)| { - right_score - .partial_cmp(left_score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| left.name.cmp(&right.name)) - }); - - let mut selected_tools: Vec = ranked - .iter() - .filter(|(score, _candidate)| *score > 0.0) - .take(args.limit) - .map(|(_score, candidate)| candidate.name.clone()) - .collect(); - - if selected_tools.is_empty() { - selected_tools = ranked - .iter() - .take(args.limit) - .map(|(_score, candidate)| candidate.name.clone()) - .collect(); - } - - let active_selected_tools = sess.merge_mcp_tool_selection(selected_tools.clone()); - - let mut tools_payload = Vec::new(); - for (score, candidate) in ranked - .into_iter() - .filter(|(_score, candidate)| selected_tools.iter().any(|name| name == &candidate.name)) - { - tools_payload.push(serde_json::json!({ - "name": candidate.name, - "server": candidate.server_name, - "description": candidate.description, - "input_keys": candidate.input_keys, - "score": score, - })); - } - - response_mode.success(query, total_tools, active_selected_tools, tools_payload) -} - -async fn handle_browser_cleanup(sess: &Session, ctx: &ToolCallCtx) -> ResponseInputItem { - let call_id_clone = ctx.call_id.clone(); - let _sess_clone = sess; - execute_custom_tool( - sess, - ctx, - "browser_cleanup".to_string(), - Some(serde_json::json!({})), - || async move { - if let Some(browser_manager) = get_browser_manager_for_session(_sess_clone).await { - match browser_manager.cleanup().await { - Ok(_) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text("Browser cleanup completed".to_string()), success: Some(true)}, - }, - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Cleanup failed: {}", e)), success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser.".to_string()), success: Some(false)}, - } - } - } - ).await -} - -#[derive(Deserialize)] -struct BridgeControlArgs { - action: String, - #[serde(default)] - level: Option, - #[serde(default)] - code: Option, -} - -fn normalise_level(level: &str) -> Option { - let l = level.trim().to_lowercase(); - match l.as_str() { - "errors" | "error" => Some("errors".to_string()), - "warn" | "warning" => Some("warn".to_string()), - "info" => Some("info".to_string()), - "trace" | "debug" => Some("trace".to_string()), - _ => None, - } -} - -fn full_capabilities() -> Vec { - vec![ - "console".to_string(), - "error".to_string(), - "pageview".to_string(), - "screenshot".to_string(), - "control".to_string(), - ] -} - -async fn handle_code_bridge( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - handle_code_bridge_with_cwd(sess.get_cwd(), ctx, arguments).await -} - -async fn handle_code_bridge_with_cwd( - cwd: &Path, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - let parsed: Result = serde_json::from_str(&arguments); - let args = match parsed { - Ok(a) => a, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("invalid arguments: {e}")), - success: Some(false)}, - }; - } - }; - - let action = args.action.to_lowercase(); - - match action.as_str() { - "subscribe" => { - let level = match args.level.as_ref().and_then(|l| normalise_level(l)) { - Some(lvl) => lvl, - None => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("invalid or missing level (use errors|warn|info|trace)".to_string()), - success: Some(false)}, - } - } - }; - - let mut sub = get_effective_subscription(); - sub.levels = vec![level]; - sub.capabilities = full_capabilities(); - sub.llm_filter = "off".to_string(); - - set_session_subscription(Some(sub.clone())); - if let Err(e) = persist_workspace_subscription(&cwd, Some(sub.clone())) { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("persist failed: {e}")), - success: Some(false)}, - }; - } - set_workspace_subscription(Some(sub)); - - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text("ok".to_string()), success: Some(true)}, - } - } - "screenshot" => { - send_bridge_control("screenshot", serde_json::json!({})); - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text("requested screenshot".to_string()), success: Some(true)}, - } - } - "javascript" => { - let code = match args.code.as_ref().map(|c| c.trim()).filter(|c| !c.is_empty()) { - Some(c) => c, - None => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("missing code for javascript action".to_string()), - success: Some(false)}, - } - } - }; - send_bridge_control("javascript", serde_json::json!({ "code": code })); - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text("sent javascript".to_string()), success: Some(true)}, - } - } - // Keep legacy actions for backward compatibility with older prompts/tools - "show" | "set" | "clear" => ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("deprecated action; use subscribe, screenshot, or javascript".to_string()), - success: Some(false)}, - }, - _ => ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("unsupported action: {}", action)), - success: Some(false)}, - }, - } -} - -#[cfg(test)] -mod bridge_tool_tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - fn call_tool_with_cwd(cwd: &Path, args: &str) -> ResponseInputItem { - // Build a minimal ToolCallCtx (sub_id/call_id arbitrary for tests) - let ctx = ToolCallCtx::new("sub".into(), "call".into(), None, None); - tokio::runtime::Runtime::new() - .unwrap() - .block_on(async { handle_code_bridge_with_cwd(cwd, &ctx, args.to_string()).await }) - } - - #[test] - fn bridge_tool_show_set_clear() { - let tmp = TempDir::new().unwrap(); - let cwd = tmp.path(); - - // set (session-only) is now deprecated; ensure we emit a helpful failure - let out = call_tool_with_cwd( - cwd, - r#"{"action":"set","levels":["trace"],"capabilities":["console"],"llm_filter":"off"}"#, - ); - match out { - ResponseInputItem::FunctionCallOutput { output, .. } => { - assert_eq!(output.success, Some(false)); - assert!(output.to_string().contains("deprecated action")); - } - _ => panic!("unexpected output"), - } - - // show is also deprecated; we should return the same guidance - let out = call_tool_with_cwd(cwd, r#"{"action":"show"}"#); - match out { - ResponseInputItem::FunctionCallOutput { output, .. } => { - assert_eq!(output.success, Some(false)); - assert!(output.to_string().contains("deprecated action")); - } - _ => panic!("unexpected output"), - } - - // clear - let out = call_tool_with_cwd(cwd, r#"{"action":"clear","persist":true}"#); - match out { - ResponseInputItem::FunctionCallOutput { output, .. } => { - assert_eq!(output.success, Some(false)); - assert!(output.to_string().contains("deprecated action")); - } - _ => panic!("unexpected output"), - } - } -} - -async fn handle_web_fetch(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - // Include raw params in begin event for observability - let mut params_for_event = serde_json::from_str::(&arguments).ok(); - // If call_id is provided, include a friendly "for" string with the command we are waiting on - if let Some(serde_json::Value::Object(map)) = params_for_event.as_mut() { - if let Some(serde_json::Value::String(cid)) = map.get("call_id") { - let st = sess.state.lock().unwrap(); - if let Some(bg) = st.background_execs.get(cid) { - map.insert("for".to_string(), serde_json::Value::String(bg.cmd_display.clone())); - } - } - } - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "web_fetch".to_string(), - params_for_event, - || async move { - #[derive(serde::Deserialize)] - struct WebFetchParams { - url: String, - #[serde(default)] - timeout_ms: Option, - #[serde(default)] - mode: Option, // "auto" (default), "browser", or "http" - } - - let parsed: Result = serde_json::from_str(&arguments_clone); - let params = match parsed { - Ok(p) => p, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid web_fetch arguments: {e}")), - success: None}, - }; - } - }; - - struct BrowserFetchOutcome { - html: String, - final_url: Option, - headless: bool, - } - - async fn fetch_html_via_headless_browser( - url: &str, - timeout: Duration, - ) -> Result { - let mut config = CodexBrowserConfig::default(); - config.enabled = true; - config.headless = true; - config.fullpage = false; - config.segments_max = 2; - config.persist_profile = false; - config.idle_timeout_ms = 10_000; - - let manager = BrowserManager::new(config); - manager.set_enabled_sync(true); - - const CHECK_JS: &str = r#"(function(){ - const discuss = document.querySelectorAll('[data-test-selector=\"issue-comment-body\"]'); - const timeline = document.querySelectorAll('.js-timeline-item'); - const article = document.querySelectorAll('article, main'); - return (discuss.length + timeline.length + article.length); -})()"#; - const HTML_JS: &str = - "(function(){ return { html: document.documentElement.outerHTML, title: document.title||'' }; })()"; - - let goto_result = match tokio::time::timeout(timeout, manager.goto(url)).await { - Ok(Ok(res)) => res, - Ok(Err(e)) => { - let _ = manager.stop().await; - return Err(format!("Headless goto failed: {e}")); - } - Err(_) => { - let _ = manager.stop().await; - return Err("Headless goto timed out".to_string()); - } - }; - - for _ in 0..6 { - match tokio::time::timeout(Duration::from_millis(1500), manager.execute_javascript(CHECK_JS)).await { - Ok(Ok(val)) => { - let count = val - .get("value") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - if count > 0 { - break; - } - } - Ok(Err(e)) => { - tracing::debug!("Headless readiness check failed: {}", e); - break; - } - Err(_) => { - tracing::debug!("Headless readiness check timed out"); - break; - } - } - tokio::time::sleep(Duration::from_millis(800)).await; - } - - let html_value = match tokio::time::timeout(timeout, manager.execute_javascript(HTML_JS)).await { - Ok(Ok(val)) => val, - Ok(Err(e)) => { - let _ = manager.stop().await; - return Err(format!("Headless HTML extraction failed: {e}")); - } - Err(_) => { - let _ = manager.stop().await; - return Err("Headless HTML extraction timed out".to_string()); - } - }; - - let html = html_value - .get("value") - .and_then(|v| v.get("html")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - if html.trim().is_empty() { - let _ = manager.stop().await; - return Err("Headless browser returned empty HTML".to_string()); - } - - let final_url = Some(goto_result.url.clone()); - let _ = manager.stop().await; - - Ok(BrowserFetchOutcome { - html, - final_url, - headless: true, - }) - } - - async fn fetch_html_via_browser( - url: &str, - timeout: Duration, - prefer_global: bool, - ) -> Option { - const HTML_JS: &str = - "(function(){ return { html: document.documentElement.outerHTML, title: document.title||'' }; })()"; - const CHECK_JS: &str = r#"(function(){ - const discuss = document.querySelectorAll('[data-test-selector=\"issue-comment-body\"]'); - const timeline = document.querySelectorAll('.js-timeline-item'); - const article = document.querySelectorAll('article, main'); - return (discuss.length + timeline.length + article.length); -})()"#; - - if prefer_global { - if let Some(manager) = code_browser::global::get_browser_manager().await { - if manager.is_enabled_sync() { - match tokio::time::timeout(timeout, manager.goto(url)).await { - Ok(Ok(res)) => { - for _ in 0..6 { - match tokio::time::timeout(Duration::from_millis(1500), manager.execute_javascript(CHECK_JS)).await { - Ok(Ok(val)) => { - let count = val - .get("value") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - if count > 0 { - break; - } - } - Ok(Err(e)) => { - tracing::debug!("Global browser readiness check failed: {}", e); - break; - } - Err(_) => { - tracing::debug!("Global browser readiness timed out"); - break; - } - } - tokio::time::sleep(Duration::from_millis(800)).await; - } - - match tokio::time::timeout(timeout, manager.execute_javascript(HTML_JS)).await { - Ok(Ok(val)) => { - if let Some(html) = val - .get("value") - .and_then(|v| v.get("html")) - .and_then(|v| v.as_str()) - { - if !html.trim().is_empty() { - return Some(BrowserFetchOutcome { - html: html.to_string(), - final_url: Some(res.url.clone()), - headless: false, - }); - } - } - } - Ok(Err(e)) => { - tracing::debug!("Global browser HTML extraction failed: {}", e); - } - Err(_) => { - tracing::debug!("Global browser HTML extraction timed out"); - } - } - } - Ok(Err(e)) => { - tracing::warn!("Global browser navigation failed: {}", e); - } - Err(_) => { - tracing::warn!("Global browser navigation timed out"); - } - } - } else { - tracing::debug!("Global browser manager disabled; skipping UI fetch"); - } - } - } - - match fetch_html_via_headless_browser(url, timeout).await { - Ok(outcome) => Some(outcome), - Err(err) => { - tracing::warn!("Headless browser fallback failed for {}: {}", url, err); - None - } - } - } - - // Helper: build a client with a specific UA and common headers. - async fn do_request( - url: &str, - ua: &str, - timeout: Duration, - extra_headers: Option<&[(reqwest::header::HeaderName, &'static str)]>, - ) -> Result { - let client = reqwest::Client::builder() - .timeout(timeout) - .user_agent(ua) - .build()?; - let mut req = client.get(url) - // Add a few browser-like headers to reduce blocks - .header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - .header(reqwest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9"); - if let Some(pairs) = extra_headers { - for (k, v) in pairs.iter() { - req = req.header(k, *v); - } - } - req.send().await - } - - // Helper: remove obvious noisy blocks before markdown conversion. - // This uses a lightweight ASCII-insensitive scan to drop whole - // elements whose contents should never be surfaced to the model - // (scripts, styles, templates, headers/footers/navigation, etc.). - fn strip_noisy_tags(mut html: String) -> String { - // Remove " starting at or after `from`. - // Returns the byte index just after the closing '>' if found. - fn find_close_after_ci(s: &str, tag: &str, from: usize) -> Option { - let bytes = s.as_bytes(); - let tag_bytes = tag.as_bytes(); - let mut i = from; - while i + 2 < bytes.len() { // need at least '<' '/' and one tag byte - if bytes[i] == b'<' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - let mut j = i + 2; - // Optional whitespace before tag name - while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'\n' || bytes[j] == b'\r') { - j += 1; - } - if starts_with_tag_ci(&bytes[j..], tag_bytes) { - // Advance past tag name - j += tag_bytes.len(); - // Skip optional whitespace until '>' - while j < bytes.len() && bytes[j] != b'>' { - j += 1; - } - if j < bytes.len() && bytes[j] == b'>' { - return Some(j + 1); - } - return None; // No closing '>' - } - } - i += 1; - } - None - } - - // Keep this conservative to avoid dropping content. - let tags = ["script", "style", "noscript"]; - for tag in tags.iter() { - let mut guard = 0; - loop { - if guard > 64 { break; } - let Some(start) = find_open_tag_ci(&html, tag, 0) else { break; }; - let search_from = start + 1; // after '<' - if let Some(end) = find_close_after_ci(&html, tag, search_from) { - // Safe because both start and end are on ASCII boundaries ('<' and '>') - html.replace_range(start..end, ""); - } else { - // No close tag found; drop from the opening tag to end - html.truncate(start); - break; - } - guard += 1; - } - } - html - } - - // Try to keep only
content if present; drastically reduces - // boilerplate from navigation and login banners on many sites. - fn extract_main(html: &str) -> Option { - // Find opening
- let bytes = html.as_bytes(); - let open = { - let mut i = 0usize; - let tag = b"main"; - while i + 5 < bytes.len() { // < m a i n > (min) - if bytes[i] == b'<' { - // skip '<' and whitespace - let mut j = i + 1; - while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; } - if j + tag.len() <= bytes.len() && bytes[j..j+tag.len()].eq_ignore_ascii_case(tag) { - // Found '' - while j < bytes.len() && bytes[j] != b'>' { j += 1; } - if j < bytes.len() { Some((i, j + 1)) } else { None } - } else { None } - } else { None } - .map(|pair| return pair); - i += 1; - } - None - }; - let (start, after_open) = open?; - // Find closing
- let mut i = after_open; - let tag_close = b"= tag_close.len() && bytes[i..i+tag_close.len()].eq_ignore_ascii_case(tag_close) { - // Find closing '>' - let mut j = i + tag_close.len(); - while j < bytes.len() && bytes[j] != b'>' { j += 1; } - if j < bytes.len() { - return Some(html[start..j+1].to_string()); - } else { - return Some(html[start..].to_string()); - } - } - } - i += 1; - } - Some(html[start..].to_string()) - } - - // Inside fenced code blocks, collapse massively-escaped Windows paths like - // `C:\\Users\\...` to `C:\Users\...`. Only applies to drive-rooted paths. - fn unescape_windows_paths(line: &str) -> String { - let bytes = line.as_bytes(); - let mut out = String::with_capacity(line.len()); - let mut i = 0usize; - while i < bytes.len() { - // Pattern: [A-Za-z] : \\+ - if i + 3 < bytes.len() - && bytes[i].is_ascii_alphabetic() - && bytes[i+1] == b':' - && bytes[i+2] == b'\\' - && bytes[i+3] == b'\\' - { - // Emit drive and a single backslash - out.push(bytes[i] as char); - out.push(':'); - out.push('\\'); - // Skip all following backslashes in this run - i += 4; - while i < bytes.len() && bytes[i] == b'\\' { i += 1; } - continue; - } - out.push(bytes[i] as char); - i += 1; - } - out - } - - // Lightweight cleanup on the resulting markdown to remove leaked - // JSON blobs and obvious client boot payloads that sometimes escape - // the - if let Some(close_rel) = after_open.to_lowercase().find("") { - let json_str = &after_open[..close_rel]; - if let Ok(v) = serde_json::from_str::(json_str) { - return Some(v); - } - // Some pages JSON-encode the JSON-LD; try to unescape once - if let Ok(un) = serde_json::from_str::(json_str) { - if let Ok(v2) = serde_json::from_str::(&un) { - return Some(v2); - } - } - // Advance after this script to search for next - s = &after_open[close_rel + 9..]; - continue; - } - } - // Advance and continue search - s = &rest[1..]; - } - } - - // Helper: extract substring for the JSON array that follows key - fn extract_json_array_after(html: &str, key: &str) -> Option { - let idx = html.find(key)?; - let bytes = html.as_bytes(); - // Find the first '[' after key - let mut i = idx + key.len(); - while i < bytes.len() && bytes[i] != b'[' { i += 1; } - if i >= bytes.len() { return None; } - let start = i; - // Scan to matching ']' accounting for strings and escapes - let mut depth: i32 = 0; - let mut in_str = false; - let mut escape = false; - while i < bytes.len() { - let c = bytes[i] as char; - if in_str { - if escape { escape = false; } - else if c == '\\' { escape = true; } - else if c == '"' { in_str = false; } - i += 1; continue; - } - match c { - '"' => { in_str = true; }, - '[' => { depth += 1; }, - ']' => { depth -= 1; if depth == 0 { let end = i + 1; return Some(html[start..end].to_string()); } }, - _ => {} - } - i += 1; - } - None - } - - // Parse JSON-LD for headline, articleBody, author, date - let mut title: Option = None; - let mut issue_body_md: Option = None; - let mut opened_by: Option = None; - let mut opened_at: Option = None; - if let Some(ld) = extract_ld_json(html) { - if ld.get("@type").and_then(|v| v.as_str()) == Some("DiscussionForumPosting") { - title = ld.get("headline").and_then(|v| v.as_str()).map(|s| s.to_string()); - issue_body_md = ld.get("articleBody").and_then(|v| v.as_str()).map(|s| s.to_string()); - opened_by = ld.get("author").and_then(|a| a.get("name")).and_then(|v| v.as_str()).map(|s| s.to_string()); - opened_at = ld.get("datePublished").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - } - - // Parse GraphQL payload for comments and state - let arr_str = extract_json_array_after(html, "\"preloadedQueries\"")?; - let arr: serde_json::Value = serde_json::from_str(&arr_str).ok()?; - let mut comments: Vec<(String, String, String)> = Vec::new(); - let mut state: Option = None; - let mut state_reason: Option = None; - if let Some(items) = arr.as_array() { - for item in items { - let repo = item.get("result").and_then(|v| v.get("data")).and_then(|v| v.get("repository")); - let issue = repo.and_then(|r| r.get("issue")); - if let Some(issue) = issue { - if state.is_none() { - state = issue.get("state").and_then(|v| v.as_str()).map(|s| s.to_string()); - state_reason = issue.get("stateReason").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if let Some(edges) = issue.get("frontTimelineItems").and_then(|v| v.get("edges")).and_then(|v| v.as_array()) { - for e in edges { - let node = e.get("node"); - let typename = node.and_then(|n| n.get("__typename")).and_then(|v| v.as_str()).unwrap_or(""); - if typename == "IssueComment" { - let author = node.and_then(|n| n.get("author")).and_then(|a| a.get("login")).and_then(|v| v.as_str()).unwrap_or(""); - let created = node.and_then(|n| n.get("createdAt")).and_then(|v| v.as_str()).unwrap_or(""); - let body = node.and_then(|n| n.get("body")).and_then(|v| v.as_str()).unwrap_or(""); - if !body.is_empty() { - comments.push((author.to_string(), created.to_string(), body.to_string())); - } else { - let body_html = node.and_then(|n| n.get("bodyHTML")).and_then(|v| v.as_str()).unwrap_or(""); - if !body_html.is_empty() { - // Minimal HTML→MD for comments if body missing - let options = htmd::options::Options { heading_style: htmd::options::HeadingStyle::Atx, code_block_style: htmd::options::CodeBlockStyle::Fenced, link_style: htmd::options::LinkStyle::Inlined, ..Default::default() }; - let conv = htmd::HtmlToMarkdown::builder().options(options).build(); - if let Ok(md) = conv.convert(body_html) { - comments.push((author.to_string(), created.to_string(), md)); - } - } - } - } - } - } - } - } - } - - // If nothing meaningful extracted, bail out. - if title.is_none() && comments.is_empty() && issue_body_md.is_none() { - return None; - } - - // Compose readable markdown - let mut out = String::new(); - if let Some(t) = title { out.push_str(&format!("# {}\n\n", t)); } - if let (Some(by), Some(at)) = (opened_by, opened_at) { out.push_str(&format!("Opened by {} on {}\n\n", by, at)); } - if let (Some(s), _) = (state.clone(), state_reason.clone()) { out.push_str(&format!("State: {}\n\n", s)); } - if let Some(body) = issue_body_md { out.push_str(&format!("{}\n\n", body)); } - if !comments.is_empty() { - out.push_str("## Comments\n\n"); - for (author, created, body) in comments { - out.push_str(&format!("- {} — {}\n\n{}\n\n", author, created, body)); - } - } - Some(out) - } - - // Helper: convert HTML to markdown and truncate if too large. - fn convert_html_to_markdown_trimmed(html: String, max_chars: usize) -> crate::error::Result<(String, bool)> { - let options = htmd::options::Options { - heading_style: htmd::options::HeadingStyle::Atx, - code_block_style: htmd::options::CodeBlockStyle::Fenced, - link_style: htmd::options::LinkStyle::Inlined, - ..Default::default() - }; - let converter = htmd::HtmlToMarkdown::builder().options(options).build(); - let reduced = extract_main(&html).unwrap_or(html); - let sanitized = strip_noisy_tags(reduced); - let markdown = converter.convert(&sanitized)?; - let markdown = postprocess_markdown(&markdown); - let mut truncated = false; - let rendered = { - let char_count = markdown.chars().count(); - if char_count > max_chars { - truncated = true; - let mut s: String = markdown.chars().take(max_chars).collect(); - s.push_str("\n\n… (truncated)\n"); - s - } else { - markdown - } - }; - Ok((rendered, truncated)) - } - - // Helper: detect WAF/challenge pages to avoid dumping challenge content. - fn detect_block_vendor(_status: reqwest::StatusCode, body: &str) -> Option<&'static str> { - // Identify common bot-challenge pages regardless of HTTP status. - // Cloudflare often returns 200 with a challenge that requires JS/cookies. - let lower = body.to_lowercase(); - if lower.contains("cloudflare") - || lower.contains("cf-ray") - || lower.contains("_cf_chl_opt") - || lower.contains("challenge-platform") - || lower.contains("checking if the site connection is secure") - || lower.contains("waiting for") - || lower.contains("just a moment") - { - return Some("cloudflare"); - } - None - } - - fn headers_indicate_block(headers: &reqwest::header::HeaderMap) -> bool { - let h = headers; - let has_cf_ray = h.get("cf-ray").is_some(); - let has_cf_mitigated = h.get("cf-mitigated").is_some(); - let has_cf_bm = h.get("set-cookie").and_then(|v| v.to_str().ok()).map(|s| s.contains("__cf_bm=")).unwrap_or(false); - let has_chlray = h.get("server-timing").and_then(|v| v.to_str().ok()).map(|s| s.to_lowercase().contains("chlray")).unwrap_or(false); - has_cf_ray || has_cf_mitigated || has_cf_bm || has_chlray - } - - fn looks_like_challenge_markdown(md: &str) -> bool { - let l = md.to_lowercase(); - l.contains("just a moment") || l.contains("enable javascript and cookies") || l.contains("waiting for ") - } - - let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(15000)); - let code_ua = crate::default_client::get_code_user_agent(Some("web_fetch")); - - if matches!(params.mode.as_deref(), Some("browser")) { - if let Some(browser_fetch) = fetch_html_via_browser(¶ms.url, timeout, true).await { - let (markdown, truncated) = match convert_html_to_markdown_trimmed(browser_fetch.html, 120_000) { - Ok(t) => t, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Markdown conversion failed: {e}")), success: Some(false)}, - }; - } - }; - - let body = serde_json::json!({ - "url": params.url, - "status": 200, - "final_url": browser_fetch.final_url.unwrap_or_else(|| params.url.clone()), - "content_type": "text/html", - "used_browser_ua": true, - "via_browser": true, - "headless": browser_fetch.headless, - "truncated": truncated, - "markdown": markdown, - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(true)}, - }; - } - } - // Attempt 1: Codex UA + polite headers - let resp = match do_request(¶ms.url, &code_ua, timeout, None).await { - Ok(r) => r, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Request failed: {e}")), success: Some(false)}, - }; - } - }; - - // Capture metadata before consuming the response body. - let mut status = resp.status(); - let mut final_url = resp.url().to_string(); - let mut headers = resp.headers().clone(); - // Read body - let mut body_text = match resp.text().await { - Ok(t) => t, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to read response body: {e}")), success: Some(false)}, - }; - } - }; - let mut used_browser_ua = false; - let browser_ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; - if !matches!(params.mode.as_deref(), Some("http")) && (detect_block_vendor(status, &body_text).is_some() || headers_indicate_block(&headers)) { - // Simple retry with a browser UA and extra headers - let extra = [ - (reqwest::header::HeaderName::from_static("upgrade-insecure-requests"), "1"), - ]; - if let Ok(r2) = do_request(¶ms.url, browser_ua, timeout, Some(&extra)).await { - let status2 = r2.status(); - let final_url2 = r2.url().to_string(); - let headers2 = r2.headers().clone(); - if let Ok(t2) = r2.text().await { - used_browser_ua = true; - status = status2; - final_url = final_url2; - headers = headers2; - body_text = t2; - } - } - } - - // Response metadata - let content_type = headers - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - - // Provide structured diagnostics if blocked by WAF (even if HTTP 200) - if !matches!(params.mode.as_deref(), Some("http")) && (detect_block_vendor(status, &body_text).is_some() || headers_indicate_block(&headers)) { - let vendor = "cloudflare"; - let retry_after = headers - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - let cf_ray = headers - .get("cf-ray") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - let mut diag = serde_json::json!({ - "final_url": final_url, - "content_type": content_type, - "used_browser_ua": used_browser_ua, - "blocked_by_waf": true, - "vendor": vendor, - }); - if let Some(ra) = retry_after { diag["retry_after"] = serde_json::json!(ra); } - if let Some(ray) = cf_ray { diag["cf_ray"] = serde_json::json!(ray); } - - if let Some(browser_fetch) = fetch_html_via_browser(¶ms.url, timeout, false).await { - let (markdown, truncated) = match convert_html_to_markdown_trimmed(browser_fetch.html, 120_000) { - Ok(t) => t, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Markdown conversion failed: {e}")), success: Some(false)}, - }; - } - }; - - diag["via_browser"] = serde_json::json!(true); - if browser_fetch.headless { - diag["headless"] = serde_json::json!(true); - } - - let body = serde_json::json!({ - "url": params.url, - "status": 200, - "final_url": browser_fetch.final_url.unwrap_or_else(|| final_url.clone()), - "content_type": content_type, - "used_browser_ua": true, - "via_browser": true, - "headless": browser_fetch.headless, - "truncated": truncated, - "markdown": markdown, - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(true)}, - }; - } - - let (md_preview, _trunc) = match convert_html_to_markdown_trimmed(body_text, 2000) { - Ok(t) => t, - Err(_) => ("".to_string(), false), - }; - - let body = serde_json::json!({ - "url": params.url, - "status": status.as_u16(), - "error": "Blocked by site challenge", - "diagnostics": diag, - "markdown": md_preview, - }); - - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(false)}, - }; - } - - // If not success, provide structured, minimal diagnostics without dumping content. - if !status.is_success() { - let waf_vendor = detect_block_vendor(status, &body_text); - let retry_after = headers - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - let cf_ray = headers - .get("cf-ray") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - let mut diag = serde_json::json!({ - "final_url": final_url, - "content_type": content_type, - "used_browser_ua": used_browser_ua, - }); - if let Some(vendor) = waf_vendor { diag["blocked_by_waf"] = serde_json::json!(true); diag["vendor"] = serde_json::json!(vendor); } - if let Some(ra) = retry_after { diag["retry_after"] = serde_json::json!(ra); } - if let Some(ray) = cf_ray { diag["cf_ray"] = serde_json::json!(ray); } - - // Provide a tiny, safe preview of visible text only (converted and truncated). - let (md_preview, _trunc) = match convert_html_to_markdown_trimmed(body_text, 2000) { - Ok(t) => t, - Err(_) => ("".to_string(), false), - }; - - let body = serde_json::json!({ - "url": params.url, - "status": status.as_u16(), - "error": format!("HTTP {} {}", status.as_u16(), status.canonical_reason().unwrap_or("")), - "diagnostics": diag, - // Keep a short, human-friendly preview; avoid dumping raw HTML or long JS. - "markdown": md_preview, - }); - - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(false)}, - }; - } - - // Domain-specific extraction first (e.g., GitHub issues) - if params.url.contains("github.com/") && params.url.contains("/issues/") { - if let Some(md) = try_extract_github_issue_markdown(&body_text) { - let body = serde_json::json!({ - "url": params.url, - "status": status.as_u16(), - "final_url": final_url, - "content_type": content_type, - "used_browser_ua": used_browser_ua, - "truncated": false, - "markdown": md, - }); - return ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(true)} }; - } - } - - // Success: convert to markdown (sanitized and size-limited) - let (markdown, truncated) = match convert_html_to_markdown_trimmed(body_text, 120_000) { - Ok(t) => t, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Markdown conversion failed: {e}")), success: Some(false)}, - }; - } - }; - - // If the rendered markdown still looks like a challenge page, attempt browser fallback (unless http-only). - if !matches!(params.mode.as_deref(), Some("http")) && looks_like_challenge_markdown(&markdown) { - if let Some(browser_fetch) = fetch_html_via_browser(¶ms.url, timeout, false).await { - let (md2, truncated2) = match convert_html_to_markdown_trimmed(browser_fetch.html, 120_000) { - Ok(t) => t, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Markdown conversion failed: {e}")), success: Some(false)}, - }; - } - }; - - let body = serde_json::json!({ - "url": params.url, - "status": 200, - "final_url": browser_fetch.final_url.unwrap_or_else(|| final_url.clone()), - "content_type": content_type, - "used_browser_ua": true, - "via_browser": true, - "headless": browser_fetch.headless, - "truncated": truncated2, - "markdown": md2, - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(true)}, - }; - } - - // If fallback not possible, return structured error rather than a useless challenge page - let body = serde_json::json!({ - "url": params.url, - "status": 200, - "error": "Blocked by site challenge", - "diagnostics": { "final_url": final_url, "content_type": content_type, "used_browser_ua": used_browser_ua, "blocked_by_waf": true, "vendor": "cloudflare", "detected_via": "markdown" }, - "markdown": markdown.chars().take(2000).collect::(), - }); - return ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(false)} }; - } - - let body = serde_json::json!({ - "url": params.url, - "status": status.as_u16(), - "final_url": final_url, - "content_type": content_type, - "used_browser_ua": used_browser_ua, - "truncated": truncated, - "markdown": markdown, - }); - - ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(body.to_string()), success: Some(true)} } - }, - ).await -} - -async fn handle_image_view(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - use crate::protocol::ViewImageToolCallEvent; - use serde::Deserialize; - use serde_json::Value; - use std::path::PathBuf; - - #[derive(Deserialize)] - struct Params { - path: String, - #[serde(default)] - alt_text: Option, - } - - let mut params_for_event = serde_json::from_str::(&arguments).ok(); - let parsed: Params = match serde_json::from_str(&arguments) { - Ok(p) => p, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid image_view arguments: {e}")), - success: Some(false)}, - }; - } - }; - - execute_custom_tool( - sess, - ctx, - "image_view".to_string(), - params_for_event.take(), - move || async move { - let call_id = ctx.call_id.clone(); - let path_str = parsed.path.trim(); - if path_str.is_empty() { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("image_view requires a non-empty path".to_string()), - success: Some(false)}, - }; - } - - let mut resolved = PathBuf::from(path_str); - if resolved.is_relative() { - resolved = sess.get_cwd().join(&resolved); - } - if let Ok(canon) = resolved.canonicalize() { - resolved = canon; - } - let metadata = match std::fs::metadata(&resolved) { - Ok(meta) => meta, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "image_view could not read {}: {err}", - resolved.display() - )), - success: Some(false)}, - }; - } - }; - if !metadata.is_file() { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "image_view requires a file path, got {}", - resolved.display() - )), - success: Some(false)}, - }; - } - - let bytes = match std::fs::read(&resolved) { - Ok(bytes) => bytes, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "image_view could not read {}: {err}", - resolved.display() - )), - success: Some(false)}, - }; - } - }; - let mime = mime_guess::from_path(&resolved) - .first() - .map(|m| m.essence_str().to_owned()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - if !mime.starts_with("image/") { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "image_view only supports image files (got {mime})" - )), - success: Some(false)}, - }; - } - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - let filename = resolved - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("image"); - let label = parsed - .alt_text - .as_ref() - .map(|text| text.trim()) - .filter(|text| !text.is_empty()) - .unwrap_or(filename); - let marker = format!("[image: {label}]"); - let image_url = format!("data:{mime};base64,{encoded}"); - let image_detail = sess - .client - .get_model_family() - .supports_image_detail_original - .then_some(ImageDetail::Original); - - let order = ctx.order_meta(sess.current_request_ordinal()); - let event = sess.make_event_with_order( - &ctx.sub_id, - EventMsg::ViewImageToolCall(ViewImageToolCallEvent { - call_id: ctx.call_id.clone(), - path: resolved.clone(), - }), - order, - ctx.seq_hint, - ); - let _ = sess.send_event(event).await; - - ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::ContentItems(vec![ - FunctionCallOutputContentItem::InputText { text: marker }, - FunctionCallOutputContentItem::InputImage { - image_url, - detail: image_detail, - }, - ]), - success: Some(true), - }, - } - }, - ) - .await -} - -// Wait for a background shell execution to complete. -// Parameters: { call_id?: string, timeout_ms?: number } -async fn handle_wait( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - use serde::Deserialize; - #[derive(Deserialize, Clone)] - struct Params { #[serde(default)] call_id: Option, #[serde(default)] timeout_ms: Option } - let mut params_for_event = serde_json::from_str::(&arguments).ok(); - if let Some(serde_json::Value::Object(map)) = params_for_event.as_mut() { - if let Some(serde_json::Value::String(cid)) = map.get("call_id") { - let st = sess.state.lock().unwrap(); - if let Some(bg) = st.background_execs.get(cid) { - map.insert("for".to_string(), serde_json::Value::String(bg.cmd_display.clone())); - } - } - } - let arguments_clone = arguments.clone(); - let ctx_clone = ToolCallCtx::new(ctx.sub_id.clone(), ctx.call_id.clone(), ctx.seq_hint, ctx.output_index); - let ctx_for_closure = ctx_clone.clone(); - execute_custom_tool( - sess, - &ctx_clone, - "wait".to_string(), - params_for_event, - move || async move { - let ctx_inner = ctx_for_closure.clone(); - let parsed: Params = match serde_json::from_str(&arguments_clone) { - Ok(p) => p, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { call_id: ctx_inner.call_id.clone(), output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid wait arguments: {}", e)), success: Some(false)} }; - } - }; - let call_id = match parsed.call_id { - Some(cid) if !cid.is_empty() => cid, - _ => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("wait requires a call_id".to_string()), - success: Some(false)}, - }; - } - }; - let max_ms: u64 = 3_600_000; // 60 minutes cap - let default_ms: u64 = 600_000; // 10 minutes default - let timeout_ms = parsed.timeout_ms.unwrap_or(default_ms).min(max_ms); - use std::sync::atomic::Ordering; - let (initial_wait_epoch, _) = sess.wait_interrupt_snapshot(); - let (notify_opt, done_opt, tail, suppress_flag) = { - let st = sess.state.lock().unwrap(); - match st.background_execs.get(&call_id) { - Some(bg) => ( - Some(bg.notify.clone()), - bg.result_cell.lock().unwrap().clone(), - bg.tail_buf.clone(), - Some(bg.suppress_event.clone()), - ), - None => (None, None, None, None), - } - }; - - struct WaitSuppressGuard { - flag: Option>, - } - - impl WaitSuppressGuard { - fn new(flag: Option>) -> Self { - if let Some(flag) = flag.as_ref() { - flag.store(true, Ordering::Relaxed); - } - Self { flag } - } - - fn disarm(mut self) { - self.flag = None; - } - } - - impl Drop for WaitSuppressGuard { - fn drop(&mut self) { - if let Some(flag) = self.flag.as_ref() { - flag.store(false, Ordering::Relaxed); - } - } - } - - let suppress_guard = WaitSuppressGuard::new(suppress_flag.clone()); - - if let Some(done) = done_opt { - { - let mut st = sess.state.lock().unwrap(); - st.background_execs.remove(&call_id); - } - let content = format_exec_output_with_limit( - sess.get_cwd(), - &ctx_inner.sub_id, - &ctx_inner.call_id, - &done, - sess.tool_output_max_bytes, - ); - suppress_guard.disarm(); - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(done.exit_code == 0), - }, - }; - } - let Some(spec_notify) = notify_opt else { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("No background job found for call_id={call_id}")), - success: Some(false)}, - }; - }; - let any_notify = ANY_BG_NOTIFY.get().cloned().unwrap(); - - let deadline = tokio::time::Instant::now() - + std::time::Duration::from_millis(timeout_ms); - - loop { - let (known_done, known_missing, task_finished) = { - let st = sess.state.lock().unwrap(); - match st.background_execs.get(&call_id) { - Some(bg) => ( - bg.result_cell.lock().unwrap().is_some(), - false, - bg.task_handle - .as_ref() - .is_some_and(|handle| handle.is_finished()), - ), - None => (false, true, false), - } - }; - - if known_missing { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("No background job found for call_id={call_id}")), - success: Some(false)}, - }; - } - - if task_finished && !known_done { - let mut st = sess.state.lock().unwrap(); - st.background_execs.remove(&call_id); - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "Background job {call_id} ended without a result; it may have been cancelled or crashed." - )), - success: Some(false)}, - }; - } - - if known_done { - break; - } - - let time_budget_message = { - let mut guard = sess.time_budget.lock().unwrap(); - guard - .as_mut() - .and_then(|budget| budget.maybe_nudge(Instant::now())) - }; - - if let Some(budget_text) = time_budget_message { - let msg = format!( - "{budget_text}\n\nWait interrupted so the assistant can adapt. Background job {call_id} still running.\n\nContinue by calling wait(call_id=\"{call_id}\")." - ); - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(msg), - success: Some(false)}, - }; - } - - let (current_epoch, reason) = sess.wait_interrupt_snapshot(); - if current_epoch != initial_wait_epoch { - let message = match reason { - Some(WaitInterruptReason::UserMessage) => { - format!( - "wait ended due to new user message (background job {call_id} still running)" - ) - } - _ => format!( - "wait ended because the session was interrupted (background job {call_id} still running)" - ), - }; - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(message), - success: Some(false)}, - }; - } - - let now = tokio::time::Instant::now(); - if now >= deadline { - let tail_text = tail - .as_ref() - .map(|arc| String::from_utf8_lossy(&arc.lock().unwrap()).to_string()) - .unwrap_or_default(); - let msg = if tail_text.is_empty() { - format!("Background job {call_id} still running...") - } else { - format!( - "Background job {call_id} still running...\n\nOutput so far (tail):\n{tail_text}" - ) - }; - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(msg), - success: Some(false)}, - }; - } - - let remaining = deadline - now; - let poll = std::time::Duration::from_millis(200); - let sleep_for = std::cmp::min(poll, remaining); - - tokio::select! { - _ = spec_notify.notified() => {}, - _ = any_notify.notified() => {}, - _ = tokio::time::sleep(sleep_for) => {}, - } - } - - let done = { - let mut st = sess.state.lock().unwrap(); - if let Some(bg) = st.background_execs.remove(&call_id) { - bg.result_cell.lock().unwrap().clone() - } else { - let found = st - .background_execs - .iter() - .find_map(|(k, v)| if v.result_cell.lock().unwrap().is_some() { Some(k.clone()) } else { None }); - found - .and_then(|k| st.background_execs.remove(&k)) - .and_then(|bg| bg.result_cell.lock().unwrap().clone()) - } - }; - if let Some(done) = done { - let content = format_exec_output_with_limit( - sess.get_cwd(), - &ctx_inner.sub_id, - &ctx_inner.call_id, - &done, - sess.tool_output_max_bytes, - ); - suppress_guard.disarm(); - ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(done.exit_code == 0), - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("No completed background job found".to_string()), - success: Some(false)}, - } - } - } - ).await -} - -async fn handle_gh_run_wait( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - use serde::Deserialize; - use serde_json::Value; - use std::path::Path; - use std::time::Duration; - use chrono::{DateTime, Utc}; - use crate::protocol::CustomToolCallUpdateEvent; - - #[derive(Deserialize, Clone)] - struct Params { - #[serde(default)] - run_id: Option, - #[serde(default)] - repo: Option, - #[serde(default)] - workflow: Option, - #[serde(default)] - branch: Option, - #[serde(default)] - head_sha: Option, - #[serde(default)] - interval_seconds: Option, - } - - async fn run_gh(args: &[&str], repo: Option<&str>) -> Result { - let mut display_args = Vec::new(); - if let Some(repo) = repo { - display_args.push("-R"); - display_args.push(repo); - } - display_args.extend_from_slice(args); - - let mut command = tokio::process::Command::new("gh"); - if let Some(repo) = repo { - command.arg("-R").arg(repo); - } - let output = command - .args(args) - .output() - .await - .map_err(|err| format!("failed to run gh {}: {err}", display_args.join(" ")))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let message = if !stderr.is_empty() { stderr } else { stdout }; - return Err(format!( - "gh {} failed{}", - display_args.join(" "), - if message.is_empty() { - String::new() - } else { - format!(": {message}") - } - )); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } - - async fn run_git(cwd: &Path, args: &[&str]) -> Option { - let output = tokio::process::Command::new("git") - .current_dir(cwd) - .args(args) - .output() - .await - .ok()?; - if !output.status.success() { - return None; - } - let value = String::from_utf8(output.stdout).ok()?; - let trimmed = value.trim().to_string(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - } - - async fn detect_branch(cwd: &Path) -> String { - if let Some(branch) = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).await { - if branch != "HEAD" { - return branch; - } - } - - if let Some(symref) = run_git(cwd, &["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]).await { - if let Some((_, name)) = symref.rsplit_once('/') { - if !name.is_empty() { - return name.to_string(); - } - } - } - - if let Some(show) = run_git(cwd, &["remote", "show", "origin"]).await { - for line in show.lines() { - let line = line.trim(); - if let Some(rest) = line.strip_prefix("HEAD branch:") { - let name = rest.trim(); - if !name.is_empty() { - return name.to_string(); - } - } - } - } - - "main".to_string() - } - - let params_for_event = serde_json::from_str::(&arguments).ok(); - let parsed: Params = match serde_json::from_str(&arguments) { - Ok(p) => p, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid gh_run_wait arguments: {e}")), - success: Some(false)}, - }; - } - }; - - let cwd = sess.cwd.clone(); - - #[derive(Clone, Default, PartialEq, Eq)] - struct JobFailure { - name: String, - conclusion: String, - step: Option, - } - - #[derive(Clone, Default, PartialEq, Eq)] - struct JobSummary { - total: usize, - completed: usize, - in_progress: usize, - queued: usize, - success: usize, - failure: usize, - cancelled: usize, - skipped: usize, - neutral: usize, - steps_total: usize, - steps_completed: usize, - steps_in_progress: usize, - steps_queued: usize, - running_names: Vec, - queued_names: Vec, - failed_jobs: Vec, - } - - #[derive(Clone, PartialEq, Eq)] - struct UpdateSnapshot { - jobs: JobSummary, - url: Option, - } - - impl JobSummary { - fn to_json(&self) -> Value { - serde_json::json!({ - "total": self.total, - "completed": self.completed, - "in_progress": self.in_progress, - "queued": self.queued, - "success": self.success, - "failure": self.failure, - "cancelled": self.cancelled, - "skipped": self.skipped, - "neutral": self.neutral, - "steps_total": self.steps_total, - "steps_completed": self.steps_completed, - "steps_in_progress": self.steps_in_progress, - "steps_queued": self.steps_queued, - "running": self.running_names, - "queued_names": self.queued_names, - }) - } - } - - fn parse_jobs(view: &Value) -> JobSummary { - let mut summary = JobSummary::default(); - let jobs = view - .get("jobs") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - summary.total = jobs.len(); - - for job in jobs { - let name = job - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("(unnamed)") - .to_string(); - let status = job.get("status").and_then(|v| v.as_str()).unwrap_or(""); - let conclusion = job - .get("conclusion") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - match status { - "completed" => summary.completed += 1, - "in_progress" => { - summary.in_progress += 1; - summary.running_names.push(name.clone()); - } - "queued" => { - summary.queued += 1; - summary.queued_names.push(name.clone()); - } - _ => {} - } - - if status == "completed" { - match conclusion { - "success" => summary.success += 1, - "cancelled" => summary.cancelled += 1, - "skipped" => summary.skipped += 1, - "neutral" => summary.neutral += 1, - "" => {} - _ => { - summary.failure += 1; - let failed_step = job - .get("steps") - .and_then(|v| v.as_array()) - .and_then(|steps| { - steps.iter().find_map(|step| { - let status = step - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let conclusion = step - .get("conclusion") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let is_failure_step = - status == "completed" - && !matches!( - conclusion, - "" | "success" | "skipped" | "neutral" - ); - if is_failure_step { - step.get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - } else { - None - } - }) - }); - summary.failed_jobs.push(JobFailure { - name, - conclusion: conclusion.to_string(), - step: failed_step, - }); - } - } - } - - if let Some(steps) = job.get("steps").and_then(|v| v.as_array()) { - summary.steps_total = summary.steps_total.saturating_add(steps.len()); - for step in steps { - let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or(""); - match step_status { - "completed" => summary.steps_completed += 1, - "in_progress" => summary.steps_in_progress += 1, - "queued" => summary.steps_queued += 1, - _ => {} - } - } - } - } - - summary - } - - fn format_duration(duration: Duration) -> String { - let total = duration.as_secs(); - let hours = total / 3600; - let minutes = (total % 3600) / 60; - let seconds = total % 60; - if hours > 0 { - format!("{hours}h{minutes:02}m{seconds:02}s") - } else if minutes > 0 { - format!("{minutes}m{seconds:02}s") - } else { - format!("{seconds}s") - } - } - - fn parse_timestamp(value: Option<&str>) -> Option> { - value - .and_then(|val| DateTime::parse_from_rfc3339(val).ok()) - .map(|dt| dt.with_timezone(&Utc)) - } - - fn run_duration_from_view(view: &Value) -> Option { - let started_at = view.get("startedAt").and_then(|v| v.as_str()); - let created_at = view.get("createdAt").and_then(|v| v.as_str()); - let updated_at = view.get("updatedAt").and_then(|v| v.as_str()); - let start = parse_timestamp(started_at).or_else(|| parse_timestamp(created_at)); - let end = parse_timestamp(updated_at); - if let (Some(start), Some(end)) = (start, end) { - let duration = end.signed_duration_since(start); - if duration.num_seconds() >= 0 { - return Some(format_duration(Duration::from_secs(duration.num_seconds() as u64))); - } - } - None - } - - fn run_summary_text( - run_id: &str, - branch: &str, - head_sha: Option<&str>, - status: &str, - conclusion: &str, - workflow: Option, - title: Option, - url: Option, - job_summary: &JobSummary, - duration: Option, - ) -> String { - let outcome = if conclusion.is_empty() { - status.to_string() - } else { - conclusion.to_string() - }; - let mut lines = Vec::new(); - lines.push(format!("GitHub Actions run {outcome}")); - if let Some(workflow) = workflow { - if !workflow.is_empty() { - lines.push(format!("Workflow: {workflow}")); - } - } - if let Some(title) = title { - if !title.is_empty() { - lines.push(format!("Title: {title}")); - } - } - lines.push(format!("Run: {run_id}")); - lines.push(format!("Branch: {branch}")); - if let Some(head_sha) = head_sha { - if !head_sha.is_empty() { - let short_sha: String = head_sha.chars().take(12).collect(); - lines.push(format!("Commit: {short_sha}")); - } - } - if let Some(url) = url { - if !url.is_empty() { - lines.push(format!("URL: {url}")); - } - } - if let Some(duration) = duration { - lines.push(format!("Duration: {duration}")); - } - - if job_summary.total == 0 { - lines.push("Jobs: none reported".to_string()); - } else { - let total = job_summary.total; - let success = job_summary.success; - let failure = job_summary.failure; - let cancelled = job_summary.cancelled; - let skipped = job_summary.skipped; - let neutral = job_summary.neutral; - let mut parts = Vec::new(); - parts.push(format!("{total} total")); - if success > 0 { - parts.push(format!("{success} success")); - } - if failure > 0 { - parts.push(format!("{failure} failed")); - } - if cancelled > 0 { - parts.push(format!("{cancelled} cancelled")); - } - if skipped > 0 { - parts.push(format!("{skipped} skipped")); - } - if neutral > 0 { - parts.push(format!("{neutral} neutral")); - } - lines.push(format!("Jobs: {}", parts.join(" • "))); - } - - if !job_summary.failed_jobs.is_empty() { - lines.push("Failures:".to_string()); - for failed in &job_summary.failed_jobs { - let mut line = format!( - "- {name} ({conclusion})", - name = failed.name, - conclusion = failed.conclusion - ); - if let Some(step) = &failed.step { - if !step.is_empty() { - line.push_str(&format!(" — step: {step}")); - } - } - lines.push(line); - } - } - - lines.join("\n") - } - - let mut resolved_params = params_for_event - .clone() - .and_then(|value| match value { - Value::Object(map) => Some(map), - other => { - let mut map = serde_json::Map::new(); - map.insert("args".to_string(), other); - Some(map) - } - }) - .unwrap_or_else(serde_json::Map::new); - - let mut resolution_error: Option = None; - let mut prepared_run_id: Option = None; - let mut prepared_workflow: Option = None; - let mut prepared_branch: Option = None; - let mut prepared_repo: Option = None; - let mut prepared_view: Option = None; - let mut prepared_job_summary: Option = None; - let mut prepared_url: Option = None; - let mut prepared_title: Option = None; - - let repo = parsed - .repo - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let branch = match parsed - .branch - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - { - Some(value) => value.to_string(), - None => detect_branch(&cwd).await, - }; - let requested_head_sha = parsed - .head_sha - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let run_list_limit = if requested_head_sha.is_some() { "20" } else { "1" }; - - let mut resolved_run_id = match parsed.run_id { - Some(Value::String(value)) if !value.trim().is_empty() => Some(value), - Some(Value::Number(num)) => num.as_u64().map(|v| v.to_string()), - _ => None, - }; - let mut resolved_workflow = parsed - .workflow - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - if resolved_run_id.is_none() { - let json = if let Some(workflow) = resolved_workflow.as_ref() { - match run_gh( - &[ - "run", - "list", - "--workflow", - workflow, - "--branch", - &branch, - "--limit", - run_list_limit, - "--json", - "databaseId,displayTitle,workflowName,headBranch,headSha,status,conclusion", - ], - repo.as_deref(), - ) - .await - { - Ok(out) => out, - Err(err) => { - resolution_error = Some(err); - String::new() - } - } - } else { - match run_gh( - &[ - "run", - "list", - "--branch", - &branch, - "--limit", - run_list_limit, - "--json", - "databaseId,displayTitle,workflowName,headBranch,headSha,status,conclusion", - ], - repo.as_deref(), - ) - .await - { - Ok(out) => out, - Err(err) => { - resolution_error = Some(err); - String::new() - } - } - }; - - if resolution_error.is_none() { - let runs: Vec = serde_json::from_str(&json).unwrap_or_default(); - let run = select_github_run_for_wait(runs, requested_head_sha.as_deref()); - resolved_run_id = run - .as_ref() - .and_then(|item| item.get("databaseId").cloned()) - .and_then(|val| match val { - Value::Number(num) => num.as_u64().map(|v| v.to_string()), - Value::String(s) => Some(s), - _ => None, - }); - if resolved_workflow.is_none() { - resolved_workflow = run - .as_ref() - .and_then(|item| item.get("workflowName")) - .and_then(|value| value.as_str()) - .map(|value| value.to_string()); - } - } - } - - if resolution_error.is_none() { - if resolved_run_id.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) { - let detail = if let Some(workflow) = resolved_workflow.as_ref() { - format!("workflow '{workflow}' on {branch}") - } else { - format!("branch {branch}") - }; - let sha_detail = requested_head_sha - .as_deref() - .map(|sha| format!(" at commit {sha}")) - .unwrap_or_default(); - resolution_error = Some(format!("No runs found for {detail}{sha_detail}")); - } - } - - if resolution_error.is_none() { - if let Some(run_id) = resolved_run_id.as_ref() { - let json = match run_gh( - &[ - "run", - "view", - run_id, - "--json", - "status,conclusion,jobs,url,displayTitle,workflowName,createdAt,startedAt,updatedAt", - ], - repo.as_deref(), - ) - .await - { - Ok(out) => out, - Err(err) => { - resolution_error = Some(err); - String::new() - } - }; - if resolution_error.is_none() { - let view: Value = serde_json::from_str(&json).unwrap_or(Value::Null); - let job_summary = parse_jobs(&view); - prepared_job_summary = Some(job_summary.clone()); - prepared_url = view - .get("url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - prepared_title = view - .get("displayTitle") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - prepared_view = Some(view); - } - } - } - - if resolution_error.is_none() { - if let Some(run_id) = resolved_run_id.clone() { - prepared_run_id = Some(run_id.clone()); - resolved_params.insert("run_id".to_string(), Value::String(run_id)); - } - prepared_branch = Some(branch.clone()); - resolved_params.insert("branch".to_string(), Value::String(branch.clone())); - if let Some(head_sha) = requested_head_sha.clone() { - resolved_params.insert("head_sha".to_string(), Value::String(head_sha)); - } - if let Some(workflow) = resolved_workflow.clone() { - prepared_workflow = Some(workflow.clone()); - resolved_params.insert("workflow".to_string(), Value::String(workflow)); - } - if let Some(url) = prepared_url.clone() { - resolved_params.insert("url".to_string(), Value::String(url)); - } - if let Some(jobs) = prepared_job_summary.clone() { - resolved_params.insert("jobs".to_string(), jobs.to_json()); - } - prepared_repo = repo.clone(); - } - - execute_custom_tool( - sess, - ctx, - "gh_run_wait".to_string(), - Some(Value::Object(resolved_params)), - move || async move { - let call_id = ctx.call_id.clone(); - if let Some(error) = resolution_error { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(error), - success: Some(false)}, - }; - } - - let run_id = prepared_run_id.clone().unwrap_or_default(); - if run_id.is_empty() { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("gh_run_wait requires a valid run_id".to_string()), - success: Some(false)}, - }; - } - - let interval = parsed.interval_seconds.unwrap_or(8).max(1); - let (initial_wait_epoch, _) = sess.wait_interrupt_snapshot(); - let mut last_view = prepared_view.clone(); - let mut last_update: Option = None; - - loop { - let view = if let Some(cached) = last_view.take() { - cached - } else { - let json = match run_gh( - &[ - "run", - "view", - &run_id, - "--json", - "status,conclusion,jobs,url,displayTitle,workflowName,createdAt,startedAt,updatedAt", - ], - prepared_repo.as_deref(), - ) - .await - { - Ok(out) => out, - Err(err) => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(err), - success: Some(false)}, - }; - } - }; - serde_json::from_str::(&json).unwrap_or(Value::Null) - }; - - let status = view - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let conclusion = view - .get("conclusion") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let display_title = view - .get("displayTitle") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| prepared_title.clone()); - let workflow_name = view - .get("workflowName") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| prepared_workflow.clone()); - let html_url = view - .get("url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| prepared_url.clone()); - let job_summary = parse_jobs(&view); - let total_jobs = job_summary.total; - let active_jobs = job_summary.in_progress + job_summary.queued; - let jobs_complete = total_jobs > 0 && job_summary.completed == total_jobs; - let run_complete = status == "completed" || (jobs_complete && active_jobs == 0); - - if run_complete { - let summary = run_summary_text( - &run_id, - prepared_branch.as_deref().unwrap_or(""), - requested_head_sha.as_deref(), - &status, - &conclusion, - workflow_name, - display_title, - html_url, - &job_summary, - run_duration_from_view(&view), - ); - let success = if conclusion.is_empty() { - None - } else { - Some(conclusion == "success") - }; - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(summary), - success}, - }; - } - - let update_url = html_url.clone().or_else(|| prepared_url.clone()); - if job_summary.total > 0 || update_url.is_some() { - let snapshot = UpdateSnapshot { - jobs: job_summary.clone(), - url: update_url.clone(), - }; - if last_update.as_ref() != Some(&snapshot) { - last_update = Some(snapshot.clone()); - let mut update_params = serde_json::Map::new(); - update_params.insert("jobs".to_string(), snapshot.jobs.to_json()); - if let Some(url) = snapshot.url.clone() { - update_params.insert("url".to_string(), Value::String(url)); - } - let update_msg = EventMsg::CustomToolCallUpdate(CustomToolCallUpdateEvent { - call_id: call_id.clone(), - tool_name: "gh_run_wait".to_string(), - parameters: Some(Value::Object(update_params)), - }); - let order = sess.background_order_for_ctx(ctx, sess.current_request_ordinal()); - let event = sess.make_event_with_order(&ctx.sub_id, update_msg, order, ctx.seq_hint); - sess.send_event(event).await; - } - } - - let time_budget_message = { - let mut guard = sess.time_budget.lock().unwrap(); - guard - .as_mut() - .and_then(|budget| budget.maybe_nudge(std::time::Instant::now())) - }; - - if let Some(budget_text) = time_budget_message { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "{budget_text}\n\nRun {run_id} still in progress. Call gh_run_wait again to continue." - )), - success: Some(false)}, - }; - } - - let (current_epoch, reason) = sess.wait_interrupt_snapshot(); - if current_epoch != initial_wait_epoch { - let message = match reason { - Some(WaitInterruptReason::UserMessage) => { - "wait ended due to new user message".to_string() - } - _ => "wait ended because the session was interrupted".to_string(), - }; - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(message), - success: Some(false)}, - }; - } - - tokio::time::sleep(std::time::Duration::from_secs(interval)).await; - } - }, - ) - .await -} - -// Kill a background shell execution by call_id. -async fn handle_kill( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - use serde::Deserialize; - #[derive(Deserialize, Clone)] - struct Params { - call_id: String, - } - - let mut params_for_event = serde_json::from_str::(&arguments).ok(); - let arguments_clone = arguments.clone(); - let ctx_clone = ToolCallCtx::new(ctx.sub_id.clone(), ctx.call_id.clone(), ctx.seq_hint, ctx.output_index); - let ctx_for_closure = ctx_clone.clone(); - let tx_event = sess.tx_event.clone(); - - execute_custom_tool( - sess, - &ctx_clone, - "kill".to_string(), - params_for_event.take(), - move || async move { - let ctx_inner = ctx_for_closure.clone(); - let parsed: Params = match serde_json::from_str(&arguments_clone) { - Ok(p) => p, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid kill arguments: {e}")), - success: Some(false)}, - }; - } - }; - - use std::sync::atomic::Ordering; - - let ( - notify, - result_cell, - suppress_flag, - cmd_display, - order_meta_for_end, - sub_id_for_end, - handle_opt, - already_done, - ) = { - let mut st = sess.state.lock().unwrap(); - match st.background_execs.get_mut(&parsed.call_id) { - Some(bg) => { - let done = bg.result_cell.lock().unwrap().is_some(); - let handle = bg.task_handle.take(); - ( - bg.notify.clone(), - bg.result_cell.clone(), - bg.suppress_event.clone(), - bg.cmd_display.clone(), - bg.order_meta_for_end.clone(), - bg.sub_id.clone(), - handle, - done, - ) - } - None => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("No background job found for call_id={}", parsed.call_id)), - success: Some(false)}, - }; - } - } - }; - - if already_done { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Background job {} has already completed.", parsed.call_id)), - success: Some(false)}, - }; - } - - suppress_flag.store(true, Ordering::Relaxed); - if let Some(handle) = handle_opt { - handle.abort(); - let _ = handle.await; - } - - let cancel_message = "Cancelled by user.".to_string(); - let output = ExecToolCallOutput { - exit_code: 130, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(cancel_message.clone()), - aggregated_output: StreamOutput::new(cancel_message.clone()), - duration: std::time::Duration::ZERO, - timed_out: false, - }; - - { - let mut slot = result_cell.lock().unwrap(); - *slot = Some(output.clone()); - } - - notify.notify_waiters(); - if let Some(global) = ANY_BG_NOTIFY.get() { - global.notify_waiters(); - } - - let end_msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: parsed.call_id.clone(), - stdout: output.stdout.text.clone(), - stderr: output.stderr.text.clone(), - exit_code: output.exit_code, - duration: output.duration, - }); - let event = Event { - id: sub_id_for_end.clone(), - event_seq: 0, - msg: end_msg, - order: Some(order_meta_for_end), - }; - let _ = tx_event.send(event).await; - - let status = if cmd_display.trim().is_empty() { - format!("Killed background job {}", parsed.call_id) - } else { - format!("Killed background command: {}", cmd_display) - }; - - ResponseInputItem::FunctionCallOutput { - call_id: ctx_inner.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(status), - success: Some(true)}, - } - }, - ).await -} - -fn to_exec_params(params: ShellToolCallParams, sess: &Session) -> ExecParams { - let timeout_ms = params - .timeout_ms - .map(|ms| ms.max(MIN_SHELL_TIMEOUT_MS)); - let with_escalated_permissions = params - .sandbox_permissions - .and_then(|p| p.requires_escalated_permissions().then_some(true)); - ExecParams { - command: params.command, - shell_script: None, - cwd: sess.resolve_path(params.workdir.clone()), - timeout_ms, - env: create_env(&sess.shell_environment_policy), - with_escalated_permissions, - justification: params.justification, - } -} - -fn to_exec_params_from_shell_command(params: ShellCommandToolCallParams, sess: &Session) -> ExecParams { - let timeout_ms = params.timeout_ms.map(|ms| ms.max(MIN_SHELL_TIMEOUT_MS)); - let with_escalated_permissions = params - .sandbox_permissions - .and_then(|p| p.requires_escalated_permissions().then_some(true)); - let use_login_shell = params.login.unwrap_or(true); - - ExecParams { - command: vec![params.command.clone()], - shell_script: Some(crate::exec::DeferredShellScript { - command: params.command, - use_login_shell, - }), - cwd: sess.resolve_path(params.workdir.clone()), - timeout_ms, - env: create_env(&sess.shell_environment_policy), - with_escalated_permissions, - justification: params.justification, - } -} - -fn resolve_agent_read_only( - write: Option, - read_only: Option, - config: Option<&crate::config_types::AgentConfig>, -) -> bool { - if let Some(flag) = write { - return !flag; - } - if let Some(flag) = read_only { - return flag; - } - config.map(|c| c.read_only).unwrap_or(false) -} - -#[cfg(test)] -mod resolve_read_only_tests { - use super::*; - use crate::config_types::AgentConfig; - - fn make_config(read_only: bool) -> AgentConfig { - AgentConfig { - name: "test".into(), - command: "test".into(), - args: Vec::new(), - read_only, - enabled: true, - description: None, - env: None, - args_read_only: None, - args_write: None, - instructions: None, - } - } - - #[test] - fn explicit_write_overrides_config_read_only() { - let cfg = make_config(true); - assert!( - !resolve_agent_read_only(Some(true), None, Some(&cfg)), - "write=true should allow writes even when config prefers read-only" - ); - } - - #[test] - fn explicit_read_only_flag_takes_precedence() { - let cfg = make_config(false); - assert!( - resolve_agent_read_only(None, Some(true), Some(&cfg)), - "read_only=true should force read-only even when config allows writes" - ); - assert!( - resolve_agent_read_only(Some(false), None, Some(&cfg)), - "write=false should force read-only" - ); - } - - #[test] - fn falls_back_to_config_when_request_absent() { - let cfg = make_config(true); - assert!(resolve_agent_read_only(None, None, Some(&cfg))); - } - - #[test] - fn defaults_to_false_without_config() { - assert!(!resolve_agent_read_only(None, None, None)); - } -} - -#[cfg(test)] -mod resolve_agent_command_for_check_tests { - use super::resolve_agent_command_for_check; - - #[test] - fn external_models_use_cli_for_command_checks() { - let (cmd, is_builtin) = resolve_agent_command_for_check("claude-opus-4.8", None); - assert_eq!(cmd, "claude"); - assert!(!is_builtin, "Claude should not be treated as a built-in family"); - } - - #[test] - fn antigravity_fallback_uses_agy_for_command_checks() { - let (cmd, is_builtin) = resolve_agent_command_for_check("antigravity", None); - assert_eq!(cmd, "agy"); - assert!(!is_builtin, "Antigravity should not be treated as a built-in family"); - } -} - -fn parse_container_exec_arguments( - arguments: String, - sess: &Session, - call_id: &str, -) -> Result> { - // Parse command. - // - // Newer prompts use `sandbox_permissions` ("use_default" | - // "with_additional_permissions" | "require_escalated"); - // older ones used `with_escalated_permissions: bool`. Accept both. - let parsed: std::result::Result = - serde_json::from_str(&arguments); - - match parsed - .and_then(|mut value| { - if value.get("sandbox_permissions").is_none() { - let needs_escalated = value - .get("with_escalated_permissions") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if needs_escalated { - value["sandbox_permissions"] = serde_json::json!(SandboxPermissions::RequireEscalated); - } - } - serde_json::from_value::(value) - }) { - Ok(shell_tool_call_params) => Ok(to_exec_params(shell_tool_call_params, sess)), - Err(e) => { - // allow model to re-sample - let output = ResponseInputItem::FunctionCallOutput { - call_id: call_id.to_string(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("failed to parse function arguments: {e}")), - success: None}, - }; - Err(Box::new(output)) - } - } -} - -fn parse_shell_command_arguments( - arguments: String, - sess: &Session, - call_id: &str, -) -> Result> { - match serde_json::from_str::(&arguments) { - Ok(shell_command_params) => Ok(to_exec_params_from_shell_command(shell_command_params, sess)), - Err(err) => { - let output = ResponseInputItem::FunctionCallOutput { - call_id: call_id.to_string(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "failed to parse function arguments: {err}" - )), - success: None, - }, - }; - Err(Box::new(output)) - } - } -} - -fn agent_tool_failure(ctx: &ToolCallCtx, message: impl Into) -> ResponseInputItem { - ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(message.into()), - success: Some(false)}, - } -} - -pub(crate) async fn handle_agent_tool( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, -) -> ResponseInputItem { - let parsed = serde_json::from_str::(&arguments); - let mut req = match parsed { - Ok(req) => req, - Err(e) => { - return agent_tool_failure(ctx, format!("Invalid agent arguments: {}", e)); - } - }; - - let action = req.action.to_ascii_lowercase(); - match action.as_str() { - "create" => { - let mut create_opts = match req.create.take() { - Some(opts) => opts, - None => { - return agent_tool_failure( - ctx, - "action=create requires a 'create' object", - ); - } - }; - - let task = match create_opts.task.take() { - Some(task) if !task.trim().is_empty() => task, - _ => { - return agent_tool_failure( - ctx, - "action=create requires a non-empty 'create.task' field", - ); - } - }; - - let models = std::mem::take(&mut create_opts.models); - let context = create_opts.context.take(); - let output = create_opts.output.take(); - let files = create_opts.files.take(); - let context_files = create_opts.context_files.take(); - let context_budget_tokens = create_opts.context_budget_tokens.take(); - let write = create_opts.write.take(); - let read_only = create_opts.read_only.take(); - let mut normalized_name = normalize_agent_name(create_opts.name.take()); - if normalized_name.is_none() { - normalized_name = derive_agent_name_from_task(&task); - } - - let run_params = RunAgentParams { - task: task.clone(), - models: models.clone(), - context: context.clone(), - output: output.clone(), - files: files.clone(), - context_files: context_files.clone(), - context_budget_tokens, - write, - read_only, - name: normalized_name.clone(), - }; - - let mut create_event = serde_json::Map::new(); - create_event.insert("task".to_string(), serde_json::Value::String(task)); - if !models.is_empty() { - create_event.insert( - "models".to_string(), - serde_json::Value::Array( - models - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - if let Some(ref ctx_str) = context { - if !ctx_str.is_empty() { - create_event.insert("context".to_string(), serde_json::Value::String(ctx_str.clone())); - } - } - if let Some(ref output_str) = output { - if !output_str.is_empty() { - create_event.insert("output".to_string(), serde_json::Value::String(output_str.clone())); - } - } - if let Some(ref files_vec) = files { - if !files_vec.is_empty() { - create_event.insert( - "files".to_string(), - serde_json::Value::Array( - files_vec - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - } - if let Some(ref context_files_vec) = context_files { - if !context_files_vec.is_empty() { - create_event.insert( - "context_files".to_string(), - serde_json::Value::Array( - context_files_vec - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - } - if let Some(budget) = context_budget_tokens { - create_event.insert( - "context_budget_tokens".to_string(), - serde_json::Value::Number(serde_json::Number::from(budget)), - ); - } - if let Some(flag) = write { - create_event.insert("write".to_string(), serde_json::Value::Bool(flag)); - } - if let Some(flag) = read_only { - create_event.insert("read_only".to_string(), serde_json::Value::Bool(flag)); - } - if let Some(ref name_str) = normalized_name { - if !name_str.is_empty() { - create_event.insert("name".to_string(), serde_json::Value::String(name_str.clone())); - } - } - - let mut event_root = serde_json::Map::new(); - event_root.insert("action".to_string(), serde_json::Value::String("create".to_string())); - event_root.insert("create".to_string(), serde_json::Value::Object(create_event)); - let event_payload = serde_json::Value::Object(event_root); - - match serde_json::to_string(&run_params) { - Ok(json) => handle_run_agent(sess, ctx, json, event_payload).await, - Err(e) => agent_tool_failure(ctx, format!("Failed to encode create arguments: {}", e)), - } - } - "status" => { - let mut status_opts = match req.status.take() { - Some(opts) => opts, - None => { - return agent_tool_failure( - ctx, - "action=status requires a 'status' object", - ); - } - }; - let agent_id = match status_opts.agent_id.take() { - Some(id) if !id.trim().is_empty() => id, - _ => { - return agent_tool_failure( - ctx, - "action=status requires 'status.agent_id'", - ); - } - }; - let batch_id = match status_opts.batch_id.take() { - Some(batch) if !batch.trim().is_empty() => batch, - _ => { - return agent_tool_failure( - ctx, - "action=status requires 'status.batch_id'", - ); - } - }; - let params = CheckAgentStatusParams { - agent_id: agent_id.clone(), - batch_id: batch_id.clone(), - }; - let mut status_event = serde_json::Map::new(); - status_event.insert("agent_id".to_string(), serde_json::Value::String(agent_id)); - status_event.insert("batch_id".to_string(), serde_json::Value::String(batch_id)); - let mut status_event_root = serde_json::Map::new(); - status_event_root.insert("action".to_string(), serde_json::Value::String("status".to_string())); - status_event_root.insert("status".to_string(), serde_json::Value::Object(status_event)); - let status_event_payload = serde_json::Value::Object(status_event_root); - match serde_json::to_string(¶ms) { - Ok(json) => handle_check_agent_status(sess, ctx, json, status_event_payload).await, - Err(e) => agent_tool_failure(ctx, format!("Failed to encode status arguments: {}", e)), - } - } - "result" => { - let mut result_opts = match req.result.take() { - Some(opts) => opts, - None => { - return agent_tool_failure( - ctx, - "action=result requires a 'result' object", - ); - } - }; - let agent_id = match result_opts.agent_id.take() { - Some(id) if !id.trim().is_empty() => id, - _ => { - return agent_tool_failure( - ctx, - "action=result requires 'result.agent_id'", - ); - } - }; - let batch_id = match result_opts.batch_id.take() { - Some(batch) if !batch.trim().is_empty() => batch, - _ => { - return agent_tool_failure( - ctx, - "action=result requires 'result.batch_id'", - ); - } - }; - let params = GetAgentResultParams { - agent_id: agent_id.clone(), - batch_id: batch_id.clone(), - }; - let mut result_event = serde_json::Map::new(); - result_event.insert("agent_id".to_string(), serde_json::Value::String(agent_id)); - result_event.insert("batch_id".to_string(), serde_json::Value::String(batch_id)); - let mut result_event_root = serde_json::Map::new(); - result_event_root.insert("action".to_string(), serde_json::Value::String("result".to_string())); - result_event_root.insert("result".to_string(), serde_json::Value::Object(result_event)); - let result_event_payload = serde_json::Value::Object(result_event_root); - match serde_json::to_string(¶ms) { - Ok(json) => handle_get_agent_result(sess, ctx, json, result_event_payload).await, - Err(e) => agent_tool_failure(ctx, format!("Failed to encode result arguments: {}", e)), - } - } - "cancel" => { - let mut cancel_opts = match req.cancel.take() { - Some(opts) => opts, - None => { - return agent_tool_failure( - ctx, - "action=cancel requires a 'cancel' object", - ); - } - }; - let cancel_agent_id = cancel_opts.agent_id.clone(); - let cancel_batch_id = match cancel_opts.batch_id.take() { - Some(batch) if !batch.trim().is_empty() => batch, - _ => { - return agent_tool_failure( - ctx, - "action=cancel requires 'cancel.batch_id'", - ); - } - }; - let params = CancelAgentParams { - agent_id: cancel_opts.agent_id.take(), - batch_id: Some(cancel_batch_id.clone()), - }; - let mut cancel_event = serde_json::Map::new(); - if let Some(id) = cancel_agent_id { - cancel_event.insert("agent_id".to_string(), serde_json::Value::String(id)); - } - cancel_event.insert("batch_id".to_string(), serde_json::Value::String(cancel_batch_id)); - let mut cancel_event_root = serde_json::Map::new(); - cancel_event_root.insert("action".to_string(), serde_json::Value::String("cancel".to_string())); - cancel_event_root.insert("cancel".to_string(), serde_json::Value::Object(cancel_event)); - let cancel_event_payload = serde_json::Value::Object(cancel_event_root); - match serde_json::to_string(¶ms) { - Ok(json) => handle_cancel_agent(sess, ctx, json, cancel_event_payload).await, - Err(e) => agent_tool_failure(ctx, format!("Failed to encode cancel arguments: {}", e)), - } - } - "wait" => { - let mut wait_opts = match req.wait.take() { - Some(opts) => opts, - None => { - return agent_tool_failure( - ctx, - "action=wait requires a 'wait' object", - ); - } - }; - let wait_agent_id = wait_opts.agent_id.clone(); - let wait_batch_id = match wait_opts.batch_id.take() { - Some(batch) if !batch.trim().is_empty() => batch, - _ => { - return agent_tool_failure( - ctx, - "action=wait requires 'wait.batch_id'", - ); - } - }; - let wait_timeout = wait_opts.timeout_seconds; - let wait_return_all = wait_opts.return_all; - let params = WaitForAgentParams { - agent_id: wait_opts.agent_id.take(), - batch_id: Some(wait_batch_id.clone()), - timeout_seconds: wait_timeout, - return_all: wait_return_all, - }; - let mut wait_event = serde_json::Map::new(); - if let Some(id) = wait_agent_id { - wait_event.insert("agent_id".to_string(), serde_json::Value::String(id)); - } - wait_event.insert("batch_id".to_string(), serde_json::Value::String(wait_batch_id)); - if let Some(timeout) = wait_timeout { - wait_event.insert("timeout_seconds".to_string(), serde_json::Value::from(timeout)); - } - if let Some(return_all) = wait_return_all { - wait_event.insert("return_all".to_string(), serde_json::Value::Bool(return_all)); - } - let mut wait_event_root = serde_json::Map::new(); - wait_event_root.insert("action".to_string(), serde_json::Value::String("wait".to_string())); - wait_event_root.insert("wait".to_string(), serde_json::Value::Object(wait_event)); - let wait_event_payload = serde_json::Value::Object(wait_event_root); - match serde_json::to_string(¶ms) { - Ok(json) => handle_wait_for_agent(sess, ctx, json, wait_event_payload).await, - Err(e) => agent_tool_failure(ctx, format!("Failed to encode wait arguments: {}", e)), - } - } - "list" => { - let mut list_opts = match req.list.take() { - Some(opts) => opts, - None => { - return agent_tool_failure( - ctx, - "action=list requires a 'list' object", - ); - } - }; - let status_filter = list_opts.status_filter.take(); - let batch_id = match list_opts.batch_id.take() { - Some(batch) if !batch.trim().is_empty() => batch, - _ => { - return agent_tool_failure( - ctx, - "action=list requires 'list.batch_id'", - ); - } - }; - let recent_only = list_opts.recent_only; - let params = ListAgentsParams { - status_filter: status_filter.clone(), - batch_id: Some(batch_id.clone()), - recent_only, - }; - let mut list_event = serde_json::Map::new(); - if let Some(ref status) = status_filter { - if !status.is_empty() { - list_event.insert("status_filter".to_string(), serde_json::Value::String(status.clone())); - } - } - list_event.insert("batch_id".to_string(), serde_json::Value::String(batch_id)); - if let Some(recent) = recent_only { - list_event.insert("recent_only".to_string(), serde_json::Value::Bool(recent)); - } - let mut list_event_root = serde_json::Map::new(); - list_event_root.insert("action".to_string(), serde_json::Value::String("list".to_string())); - list_event_root.insert("list".to_string(), serde_json::Value::Object(list_event)); - let list_event_payload = serde_json::Value::Object(list_event_root); - match serde_json::to_string(¶ms) { - Ok(json) => handle_list_agents(sess, ctx, json, list_event_payload).await, - Err(e) => agent_tool_failure(ctx, format!("Failed to encode list arguments: {}", e)), - } - } - other => agent_tool_failure(ctx, format!("Unsupported agent action: {}", other)), - } -} - -fn resolve_agent_command_for_check( - model: &str, - cfg: Option<&crate::config_types::AgentConfig>, -) -> (String, bool) { - let spec = agent_model_spec(model) - .or_else(|| cfg.and_then(|c| agent_model_spec(&c.name))) - .or_else(|| cfg.and_then(|c| agent_model_spec(&c.command))); - - let cfg_trimmed = cfg.map(|c| { - let (base, _) = split_command_and_args(&c.command); - let trimmed = base.trim(); - if trimmed.is_empty() { - c.command.trim().to_string() - } else { - trimmed.to_string() - } - }); - - if let Some(spec) = spec { - let is_builtin_family = matches!(spec.family, "code" | "codex" | "cloud"); - let uses_default_cli = cfg_trimmed - .as_ref() - .map(|cmd| cmd.is_empty() || cmd.eq_ignore_ascii_case(spec.cli)) - .unwrap_or(true); - - if uses_default_cli { - return (spec.cli.to_string(), is_builtin_family); - } - } - - if let Some(cmd) = cfg_trimmed { - if !cmd.is_empty() { - return (cmd, false); - } - } - - let m = model.to_lowercase(); - match m.as_str() { - "code" | "codex" | "cloud" => ("coder".to_string(), true), - "antigravity" => ("agy".to_string(), false), - "claude" => ("claude".to_string(), false), - "gemini" => ("gemini".to_string(), false), - "qwen" => ("qwen".to_string(), false), - other => (other.to_string(), false), - } -} - -pub(crate) async fn handle_run_agent( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, - event_payload: serde_json::Value, -) -> ResponseInputItem { - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - let generated_batch_id = Uuid::new_v4().to_string(); - let payload_with_batch = match event_payload { - serde_json::Value::Object(mut map) => { - map.insert( - "batch_id".to_string(), - serde_json::Value::String(generated_batch_id.clone()), - ); - serde_json::Value::Object(map) - } - other => other, - }; - let closure_batch_id = generated_batch_id.clone(); - execute_custom_tool( - sess, - ctx, - "agent".to_string(), - Some(payload_with_batch), - move || async move { - let batch_id = closure_batch_id.clone(); - match serde_json::from_str::(&arguments_clone) { - Ok(mut params) => { - let trimmed_task = params.task.trim().to_string(); - let word_count = trimmed_task - .split_whitespace() - .filter(|segment| !segment.is_empty()) - .count(); - - if trimmed_task.is_empty() || word_count < 4 { - let guidance = format!( - "⚠️ Agent prompt too short: give the manager more context (at least a full sentence) before running agents. Current prompt: \"{}\".", - trimmed_task - ); - let req = sess.current_request_ordinal(); - let order = sess.background_order_for_ctx(ctx, req); - sess - .notify_background_event_with_order(&ctx.sub_id, order, guidance.clone()) - .await; - - let response = serde_json::json!({ - "status": "blocked", - "reason": "prompt_too_short", - "message": guidance, - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(false)}, - }; - } - - let current_depth = current_agent_spawn_depth(); - if current_depth >= sess.subagent_max_depth { - let guidance = format!( - "⚠️ Agent nesting limit reached (current depth: {current_depth}, max depth: {}). Finish current agent runs before spawning additional layers.", - sess.subagent_max_depth, - ); - let req = sess.current_request_ordinal(); - let order = sess.background_order_for_ctx(ctx, req); - sess - .notify_background_event_with_order(&ctx.sub_id, order, guidance.clone()) - .await; - - let response = serde_json::json!({ - "status": "blocked", - "reason": "max_depth_reached", - "message": guidance, - "current_depth": current_depth, - "max_depth": sess.subagent_max_depth, - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text( - response.to_string(), - ), - success: Some(false), - }, - }; - } - - let mut manager = AGENT_MANAGER.write().await; - let mut agent_name = params.name.clone(); - if agent_name.is_none() { - if let Some(fallback) = derive_agent_name_from_task(trimmed_task.as_str()) { - agent_name = Some(fallback.clone()); - params.name = Some(fallback); - } - } - - // Collect requested models from the `models` field. - let explicit_models = params.models.iter().any(|model| !model.trim().is_empty()); - let raw_models: Vec = params.models.clone(); - - // Split comma-delimited strings, trim whitespace, and deduplicate case-insensitively. - let mut seen_models = HashSet::new(); - let mut models: Vec = Vec::new(); - for entry in raw_models { - for candidate in entry.split(',') { - let trimmed = candidate.trim(); - if trimmed.is_empty() { - continue; - } - let dedupe_key = trimmed.to_lowercase(); - if seen_models.insert(dedupe_key) { - models.push(trimmed.to_string()); - } - } - } - - if models.is_empty() { - if sess.tools_config.agent_model_allowed_values.is_empty() { - models.push("code".to_string()); - } else { - models.extend( - sess - .tools_config - .agent_model_allowed_values - .iter() - .cloned(), - ); - } - } - - models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())); - models.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - - let multi_model = models.len() > 1; - let display_label_for = |model: &str| -> String { - agent_name - .as_ref() - .and_then(|value| { - if value.is_empty() { - None - } else if multi_model { - Some(format!("{} ({})", value, model)) - } else { - Some(value.to_string()) - } - }) - .unwrap_or_else(|| model.to_string()) - }; - - let mut agent_ids = Vec::new(); - let mut agent_labels: Vec<(String, String)> = Vec::new(); - let mut skipped: Vec = Vec::new(); - for model in models { - let model_key = model.to_lowercase(); - // Check if this model is configured and enabled - let agent_config = sess.agents.iter().find(|a| { - a.name.to_lowercase() == model_key - || a.command.to_lowercase() == model_key - }); - - if let Some(config) = agent_config { - if !config.enabled { - continue; // Skip disabled agents - } - - let (cmd_to_check, is_builtin) = - resolve_agent_command_for_check(&model, Some(config)); - if !is_builtin && !external_agent_command_exists(&cmd_to_check) { - skipped.push(format!("{} (missing: {})", model, cmd_to_check)); - continue; - } - - // Respect explicit read_only flag from the caller; otherwise fall back to the config default. - let read_only = resolve_agent_read_only( - params.write, - params.read_only, - Some(config), - ); - - let agent_id = manager - .create_agent_with_config_in_workspace( - model.clone(), - agent_name.clone(), - params.task.clone(), - params.context.clone(), - params.output.clone(), - params.files.clone().unwrap_or_default(), - params.context_files.clone().unwrap_or_default(), - params.context_budget_tokens, - read_only, - Some(batch_id.clone()), - config.clone(), - sess.session_uuid(), - Some(sess.get_cwd().to_path_buf()), - sess.model_reasoning_effort.into(), - ) - .await; - agent_ids.push(agent_id); - let label = display_label_for(&model); - agent_labels.push((agent_ids.last().cloned().unwrap(), label)); - } else { - // Use default configuration for unknown agents - let (cmd_to_check, is_builtin) = resolve_agent_command_for_check(&model, None); - if !is_builtin && !external_agent_command_exists(&cmd_to_check) { - skipped.push(format!("{} (missing: {})", model, cmd_to_check)); - continue; - } - let read_only = resolve_agent_read_only(params.write, params.read_only, None); - let agent_id = manager - .create_agent_in_workspace( - model.clone(), - agent_name.clone(), - params.task.clone(), - params.context.clone(), - params.output.clone(), - params.files.clone().unwrap_or_default(), - params.context_files.clone().unwrap_or_default(), - params.context_budget_tokens, - read_only, - Some(batch_id.clone()), - sess.session_uuid(), - Some(sess.get_cwd().to_path_buf()), - sess.model_reasoning_effort.into(), - ) - .await; - agent_ids.push(agent_id); - let label = display_label_for(&model); - agent_labels.push((agent_ids.last().cloned().unwrap(), label)); - } - } - - // If nothing runnable remains, only fall back to a built‑in Codex agent when - // the caller did not explicitly request models. - if agent_ids.is_empty() { - if explicit_models { - let mut response_map = serde_json::Map::new(); - response_map.insert( - "batch_id".to_string(), - serde_json::Value::String(batch_id.clone()), - ); - response_map.insert( - "status".to_string(), - serde_json::Value::String("failed".to_string()), - ); - let message = if skipped.is_empty() { - "No runnable agents matched the requested models.".to_string() - } else { - format!( - "No runnable agents matched the requested models. Skipped: {}", - skipped.join(", ") - ) - }; - response_map.insert( - "message".to_string(), - serde_json::Value::String(message), - ); - response_map.insert( - "skipped".to_string(), - if skipped.is_empty() { - serde_json::Value::Null - } else { - serde_json::Value::Array( - skipped - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(), - ) - }, - ); - let response = serde_json::Value::Object(response_map); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(false)}, - }; - } - - let read_only = resolve_agent_read_only(params.write, params.read_only, None); - let agent_id = manager - .create_agent_in_workspace( - "code".to_string(), - agent_name.clone(), - params.task.clone(), - params.context.clone(), - params.output.clone(), - params.files.clone().unwrap_or_default(), - params.context_files.clone().unwrap_or_default(), - params.context_budget_tokens, - read_only, - Some(batch_id.clone()), - sess.session_uuid(), - Some(sess.get_cwd().to_path_buf()), - sess.model_reasoning_effort.into(), - ) - .await; - agent_ids.push(agent_id); - let label = display_label_for("code"); - agent_labels.push((agent_ids.last().cloned().unwrap(), label)); - } - - // Send agent status update event - drop(manager); // Release the write lock first - if agent_ids.len() > 0 { - send_agent_status_update(sess).await; - } - - let launch_hint = if agent_ids.len() > 1 { - let short_batch = short_id(&batch_id); - let agent_phrase = agent_labels - .iter() - .map(|(id, label)| format!("{} [{}]", short_id(id), label)) - .collect::>() - .join(", "); - let first_agent = agent_labels - .first() - .map(|(id, _)| id.as_str()) - .unwrap_or(batch_id.as_str()); - format!( - "🤖 Agent batch {short_batch} started: {agent_phrase}.\nUse `agent {{\"action\":\"wait\",\"wait\":{{\"batch_id\":\"{batch}\",\"return_all\":true}}}}` to wait for all agents, then `agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{first_agent}\"}}}}` for a detailed report.", - batch = batch_id, - ) - } else { - let (single_id, single_model) = agent_labels - .first() - .map(|(id, model)| (id.as_str(), model.as_str())) - .unwrap(); - let short_batch = short_id(&batch_id); - format!( - "🤖 Agent batch {short_batch} started with {model}. Use `agent {{\"action\":\"wait\",\"wait\":{{\"batch_id\":\"{batch}\",\"return_all\":true}}}}` to follow progress, or `agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{agent}\"}}}}` when it finishes.", - model = single_model, - batch = batch_id, - agent = single_id, - ) - }; - - let mut response_map = serde_json::Map::new(); - response_map.insert( - "batch_id".to_string(), - serde_json::Value::String(batch_id.clone()), - ); - response_map.insert( - "agent_ids".to_string(), - serde_json::Value::Array( - agent_ids - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(), - ), - ); - response_map.insert( - "status".to_string(), - serde_json::Value::String("started".to_string()), - ); - let message = if agent_ids.len() > 1 { - format!("Started {} agents", agent_labels.len()) - } else { - "Agent started successfully".to_string() - }; - response_map.insert( - "message".to_string(), - serde_json::Value::String(message), - ); - response_map.insert( - "next_steps".to_string(), - serde_json::Value::String(launch_hint.clone()), - ); - if agent_ids.len() == 1 { - if let Some(first) = agent_ids.first() { - response_map.insert( - "agent_id".to_string(), - serde_json::Value::String(first.clone()), - ); - } - } - if skipped.is_empty() { - response_map.insert("skipped".to_string(), serde_json::Value::Null); - } else { - response_map.insert( - "skipped".to_string(), - serde_json::Value::Array( - skipped - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - let response = serde_json::Value::Object(response_map); - - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid agent arguments: {}", e)), - success: None}, - }, - } - } - ).await -} - -fn short_id(id: &str) -> String { - id.chars().take(8).collect() -} - -fn derive_agent_name_from_task(task: &str) -> Option { - let trimmed = task.trim(); - if trimmed.is_empty() { - return None; - } - - let first_clause = trimmed - .split(|c: char| matches!(c, '.' | '!' | '?' | '\n')) - .find(|part| !part.trim().is_empty()) - .unwrap_or(trimmed) - .trim(); - - let words: Vec<&str> = first_clause.split_whitespace().take(5).collect(); - if words.is_empty() { - return None; - } - - normalize_agent_name(Some(words.join(" "))) -} - -async fn handle_check_agent_status( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, - event_payload: serde_json::Value, -) -> ResponseInputItem { - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - execute_custom_tool( - sess, - ctx, - "agent".to_string(), - Some(event_payload), - || async move { - match serde_json::from_str::(&arguments_clone) { - Ok(params) => { - let manager = AGENT_MANAGER.read().await; - - if let Some(agent) = manager.get_agent_for_session(¶ms.agent_id, sess.session_uuid()) { - match agent.batch_id.as_deref() { - Some(batch) if batch == params.batch_id => {} - _ => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "Agent {} does not belong to batch {}", - params.agent_id, params.batch_id - )), - success: Some(false)}, - }; - } - } - - // Limit progress in the response; write full progress to file if large - let max_progress_lines = 50usize; - let total_progress = agent.progress.len(); - let progress_preview: Vec = if total_progress > max_progress_lines { - agent - .progress - .iter() - .skip(total_progress - max_progress_lines) - .cloned() - .collect() - } else { - agent.progress.clone() - }; - - let mut progress_file: Option = None; - if total_progress > max_progress_lines { - let cwd = sess.get_cwd().to_path_buf(); - drop(manager); - let dir = match ensure_agent_dir(&cwd, &agent.id) { - Ok(d) => d, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to prepare agent progress file: {}", e)), - success: Some(false)}, - }; - } - }; - // Re-acquire manager to get fresh progress after potential delay - let manager = AGENT_MANAGER.read().await; - if let Some(agent) = manager.get_agent_for_session(¶ms.agent_id, sess.session_uuid()) { - let joined = agent.progress.join("\n"); - match write_agent_file(&dir, "progress.log", &joined) { - Ok(p) => progress_file = Some(p.display().to_string()), - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to write progress file: {}", e)), - success: Some(false)}, - }; - } - } - } - } else { - drop(manager); - } - - let response = serde_json::json!({ - "agent_id": params.agent_id, - "name": agent.name, - "status": agent.status, - "model": agent.model, - "retry": agent.retry, - "batch_id": agent.batch_id, - "created_at": agent.created_at, - "started_at": agent.started_at, - "completed_at": agent.completed_at, - "progress_preview": progress_preview, - "progress_total": total_progress, - "progress_file": progress_file, - "error": agent.error, - "worktree_path": agent.worktree_path, - "branch_name": agent.branch_name, - }); - - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(true)}, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Agent not found: {}", params.agent_id)), - success: Some(false)}, - } - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid agent arguments for action=status: {}", e)), - success: None}, - }, - } - }, - ).await -} - -async fn handle_get_agent_result( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, - event_payload: serde_json::Value, -) -> ResponseInputItem { - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - execute_custom_tool( - sess, - ctx, - "agent".to_string(), - Some(event_payload), - || async move { - match serde_json::from_str::(&arguments_clone) { - Ok(params) => { - let manager = AGENT_MANAGER.read().await; - - if let Some(agent) = manager.get_agent_for_session(¶ms.agent_id, sess.session_uuid()) { - match agent.batch_id.as_deref() { - Some(batch) if batch == params.batch_id => {} - _ => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "Agent {} does not belong to batch {}", - params.agent_id, params.batch_id - )), - success: Some(false)}, - }; - } - } - let cwd = sess.get_cwd().to_path_buf(); - let dir = match ensure_agent_dir(&cwd, ¶ms.agent_id) { - Ok(d) => d, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to prepare agent output dir: {}", e)), - success: Some(false)}, - }; - } - }; - - match agent.status { - AgentStatus::Completed => { - let output_text = agent.result.unwrap_or_default(); - let (preview, total_lines) = preview_first_n_lines(&output_text, 500); - let file_path = match write_agent_file(&dir, "result.txt", &output_text) { - Ok(p) => p.display().to_string(), - Err(e) => format!("Failed to write result file: {}", e), - }; - let response = serde_json::json!({ - "agent_id": params.agent_id, - "batch_id": params.batch_id.clone(), - "status": agent.status, - "retry": agent.retry, - "output_preview": preview, - "output_total_lines": total_lines, - "output_file": file_path, - }); - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(true)}, - } - } - AgentStatus::Failed => { - let error_text = agent.error.unwrap_or_else(|| "Unknown error".to_string()); - let (preview, total_lines) = preview_first_n_lines(&error_text, 500); - let file_path = match write_agent_file(&dir, "error.txt", &error_text) { - Ok(p) => p.display().to_string(), - Err(e) => format!("Failed to write error file: {}", e), - }; - let response = serde_json::json!({ - "agent_id": params.agent_id, - "batch_id": params.batch_id.clone(), - "status": agent.status, - "retry": agent.retry, - "error_preview": preview, - "error_total_lines": total_lines, - "error_file": file_path, - }); - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(false)}, - } - } - _ => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "Agent is still {}: cannot get result yet", - serde_json::to_string(&agent.status) - .unwrap_or_else(|_| "running".to_string()) - )), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Agent not found: {}", params.agent_id)), - success: Some(false)}, - } - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid agent arguments for action=result: {}", e)), - success: None}, - }, - } - }, - ).await -} - -async fn handle_cancel_agent( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, - event_payload: serde_json::Value, -) -> ResponseInputItem { - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - execute_custom_tool( - sess, - ctx, - "agent".to_string(), - Some(event_payload), - || async move { - match serde_json::from_str::(&arguments_clone) { - Ok(params) => { - let mut manager = AGENT_MANAGER.write().await; - - if let Some(agent_id) = params.agent_id { - let batch_id = match params.batch_id.as_ref() { - Some(batch) => batch, - None => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("action=cancel requires 'cancel.batch_id'".to_string()), - success: Some(false)}, - }; - } - }; - if let Some(agent) = manager.get_agent_for_session(&agent_id, sess.session_uuid()) { - if agent.batch_id.as_deref() != Some(batch_id.as_str()) { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "Agent {} does not belong to batch {}", - agent_id, batch_id - )), - success: Some(false)}, - }; - } - } - if manager.cancel_agent_for_session(&agent_id, sess.session_uuid()).await { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Agent {} cancelled", agent_id)), - success: Some(true)}, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to cancel agent {}", agent_id)), - success: Some(false)}, - } - } - } else if let Some(batch_id) = params.batch_id { - let count = manager.cancel_batch_for_session(&batch_id, sess.session_uuid()).await; - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Cancelled {} agents in batch {}", count, batch_id)), - success: Some(true)}, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Either agent_id or batch_id must be provided".to_string()), - success: Some(false)}, - } - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid agent arguments for action=cancel: {}", e)), - success: None}, - }, - } - }, - ).await -} - -async fn handle_wait_for_agent( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, - event_payload: serde_json::Value, -) -> ResponseInputItem { - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - execute_custom_tool( - sess, - ctx, - "agent".to_string(), - Some(event_payload), - || async move { - let (initial_wait_epoch, _) = sess.wait_interrupt_snapshot(); - match serde_json::from_str::(&arguments_clone) { - Ok(params) => { - let batch_id = match params.batch_id.as_ref() { - Some(batch) if !batch.trim().is_empty() => batch.clone(), - _ => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("action=wait requires 'wait.batch_id'".to_string()), - success: Some(false)}, - }; - } - }; - let timeout = std::time::Duration::from_secs( - params.timeout_seconds.unwrap_or(300).min(600), - ); - let start = std::time::Instant::now(); - - loop { - if start.elapsed() > timeout { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Timeout waiting for agent completion".to_string()), - success: Some(false)}, - }; - } - - let manager = AGENT_MANAGER.read().await; - - if let Some(agent_id) = ¶ms.agent_id { - if let Some(agent) = manager.get_agent_for_session(agent_id, sess.session_uuid()) { - match agent.batch_id.as_deref() { - Some(batch) if batch == batch_id => {} - _ => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "Agent {} does not belong to batch {}", - agent_id, batch_id - )), - success: Some(false)}, - }; - } - } - if matches!( - agent.status, - AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled - ) { - // Include output/error preview and file path - // Avoid holding manager lock during filesystem I/O - drop(manager); - let cwd = sess.get_cwd().to_path_buf(); - let dir = match ensure_agent_dir(&cwd, &agent.id) { - Ok(d) => d, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to prepare agent output dir: {}", e)), - success: Some(false)}, - }; - } - }; - let (preview_key, file_key, preview, file_path, total_lines) = match agent.status { - AgentStatus::Completed => { - let text = agent.result.clone().unwrap_or_default(); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "result.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write result file: {}", e)); - ("output_preview", "output_file", p, fp, total) - } - AgentStatus::Failed => { - let text = agent.error.clone().unwrap_or_else(|| "Unknown error".to_string()); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "error.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write error file: {}", e)); - ("error_preview", "error_file", p, fp, total) - } - AgentStatus::Cancelled => { - let text = "Agent cancelled".to_string(); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "status.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write status file: {}", e)); - ("status_preview", "status_file", p, fp, total) - } - _ => unreachable!(), - }; - - let hint = format!( - "agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{}\",\"batch_id\":\"{}\"}}}}", - agent.id, - batch_id - ); - let mut response = serde_json::json!({ - "agent_id": agent.id, - "batch_id": batch_id, - "status": agent.status, - "retry": agent.retry, - "wait_time_seconds": start.elapsed().as_secs(), - "total_lines": total_lines, - "agent_result_hint": hint, - "agent_result_params": { "action": "result", "result": { "agent_id": agent.id, "batch_id": batch_id } }, - }); - if let Some(obj) = response.as_object_mut() { - obj.insert(preview_key.to_string(), serde_json::Value::String(preview)); - obj.insert(file_key.to_string(), serde_json::Value::String(file_path)); - } - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(true)}, - }; - } - } - } else { - let agents = manager.list_agents_for_session(None, Some(batch_id.clone()), false, sess.session_uuid()); - - // Separate terminal vs non-terminal agents - let completed_agents: Vec<_> = agents - .iter() - .filter(|t| { - matches!( - t.status, - AgentStatus::Completed - | AgentStatus::Failed - | AgentStatus::Cancelled - ) - }) - .cloned() - .collect(); - let any_in_progress = agents.iter().any(|a| { - matches!(a.status, AgentStatus::Pending | AgentStatus::Running) - }); - - if params.return_all.unwrap_or(false) { - // Wait for ALL agents in the batch to reach a terminal state - if !any_in_progress { - // Enriched response: include per-agent previews and file paths - // Avoid holding manager lock during filesystem I/O - drop(manager); - let cwd = sess.get_cwd().to_path_buf(); - let mut summaries: Vec = Vec::new(); - for a in &completed_agents { - let dir = match ensure_agent_dir(&cwd, &a.id) { - Ok(d) => d, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to prepare agent output dir: {}", e)), - success: Some(false)}, - }; - } - }; - let (preview_key, file_key, preview, file_path, total_lines) = match a.status { - AgentStatus::Completed => { - let text = a.result.clone().unwrap_or_default(); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "result.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write result file: {}", e)); - ("output_preview", "output_file", p, fp, total) - } - AgentStatus::Failed => { - let text = a.error.clone().unwrap_or_else(|| "Unknown error".to_string()); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "error.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write error file: {}", e)); - ("error_preview", "error_file", p, fp, total) - } - AgentStatus::Cancelled => { - let text = "Agent cancelled".to_string(); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "status.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write status file: {}", e)); - ("status_preview", "status_file", p, fp, total) - } - _ => unreachable!(), - }; - - let hint = format!( - "agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{}\",\"batch_id\":\"{}\"}}}}", - a.id, - batch_id - ); - let mut obj = serde_json::json!({ - "agent_id": a.id, - "status": a.status, - "retry": a.retry, - "total_lines": total_lines, - "agent_result_hint": hint, - "agent_result_params": { "action": "result", "result": { "agent_id": a.id, "batch_id": batch_id } }, - }); - if let Some(map) = obj.as_object_mut() { - map.insert(preview_key.to_string(), serde_json::Value::String(preview)); - map.insert(file_key.to_string(), serde_json::Value::String(file_path)); - } - summaries.push(obj); - } - - let response = serde_json::json!({ - "batch_id": batch_id, - "completed_agents": completed_agents.iter().map(|t| t.id.clone()).collect::>(), - "completed_summaries": summaries, - "wait_time_seconds": start.elapsed().as_secs(), - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(true)}, - }; - } - } else { - // Sequential behavior: return the next unseen completed agent if available - let mut state = sess.state.lock().unwrap(); - ensure_wait_batch_tracking_capacity(&mut state, &batch_id); - let unseen = { - let seen = state - .seen_completed_agents_by_batch - .entry(batch_id.clone()) - .or_default(); - - completed_agents - .iter() - .find(|a| !seen.contains(&a.id)) - .cloned() - }; - - // Find the first completed agent that we haven't returned yet - if let Some(unseen) = unseen { - // Record as seen and return immediately - track_seen_completed_agent_for_batch( - &mut state, - &batch_id, - unseen.id.as_str(), - ); - drop(state); - - // Include output/error preview for the unseen completed agent - // Avoid holding manager lock during filesystem I/O - drop(manager); - let cwd = sess.get_cwd().to_path_buf(); - let dir = match ensure_agent_dir(&cwd, &unseen.id) { - Ok(d) => d, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to prepare agent output dir: {}", e)), - success: Some(false)}, - }; - } - }; - let (preview_key, file_key, preview, file_path, total_lines) = match unseen.status { - AgentStatus::Completed => { - let text = unseen.result.clone().unwrap_or_default(); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "result.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write result file: {}", e)); - ("output_preview", "output_file", p, fp, total) - } - AgentStatus::Failed => { - let text = unseen.error.clone().unwrap_or_else(|| "Unknown error".to_string()); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "error.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write error file: {}", e)); - ("error_preview", "error_file", p, fp, total) - } - AgentStatus::Cancelled => { - let text = "Agent cancelled".to_string(); - let (p, total) = preview_first_n_lines(&text, 500); - let fp = write_agent_file(&dir, "status.txt", &text) - .map(|p| p.display().to_string()) - .unwrap_or_else(|e| format!("Failed to write status file: {}", e)); - ("status_preview", "status_file", p, fp, total) - } - _ => unreachable!(), - }; - - let hint = format!( - "agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{}\",\"batch_id\":\"{}\"}}}}", - unseen.id, - batch_id - ); - let mut response = serde_json::json!({ - "agent_id": unseen.id, - "status": unseen.status, - "retry": unseen.retry, - "wait_time_seconds": start.elapsed().as_secs(), - "total_lines": total_lines, - "agent_result_hint": hint, - "agent_result_params": { "action": "result", "result": { "agent_id": unseen.id, "batch_id": batch_id } }, - }); - if let Some(obj) = response.as_object_mut() { - obj.insert(preview_key.to_string(), serde_json::Value::String(preview)); - obj.insert(file_key.to_string(), serde_json::Value::String(file_path)); - } - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(true)}, - }; - } - - // If all agents in the batch are terminal and all have been seen, return immediately - if !any_in_progress && !completed_agents.is_empty() { - // Mark all as seen to keep state consistent - for a in &completed_agents { - track_seen_completed_agent_for_batch( - &mut state, - &batch_id, - a.id.as_str(), - ); - } - drop(state); - - let response = serde_json::json!({ - "batch_id": batch_id, - "status": "no_agents_remaining", - "wait_time_seconds": start.elapsed().as_secs(), - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(true)}, - }; - } - } - } - - drop(manager); - - let time_budget_message = { - let mut guard = sess.time_budget.lock().unwrap(); - guard - .as_mut() - .and_then(|budget| budget.maybe_nudge(Instant::now())) - }; - - if let Some(budget_text) = time_budget_message { - let response = serde_json::json!({ - "batch_id": batch_id, - "status": "time_budget_update", - "wait_time_seconds": start.elapsed().as_secs(), - "time_budget_message": budget_text, - "message": "Wait interrupted so the assistant can adapt. Agents may still be running; call agent wait again to continue.", - }); - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(response.to_string()), - success: Some(false)}, - }; - } - - let (current_epoch, reason) = sess.wait_interrupt_snapshot(); - if current_epoch != initial_wait_epoch { - let message = match reason { - Some(WaitInterruptReason::UserMessage) => { - "wait ended due to new user message".to_string() - } - _ => "wait ended because the session was interrupted".to_string(), - }; - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(message), - success: Some(false)}, - }; - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid agent arguments for action=wait: {}", e)), - success: None}, - }, - } - }, - ).await -} - -async fn handle_list_agents( - sess: &Session, - ctx: &ToolCallCtx, - arguments: String, - event_payload: serde_json::Value, -) -> ResponseInputItem { - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - execute_custom_tool( - sess, - ctx, - "agent".to_string(), - Some(event_payload), - || async move { - match serde_json::from_str::(&arguments_clone) { - Ok(params) => { - let manager = AGENT_MANAGER.read().await; - - let batch_id = match params.batch_id.clone() { - Some(batch) if !batch.trim().is_empty() => batch, - _ => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("action=list requires 'list.batch_id'".to_string()), - success: Some(false)}, - }; - } - }; - - let status_filter = - params - .status_filter - .and_then(|s| match s.to_lowercase().as_str() { - "pending" => Some(AgentStatus::Pending), - "running" => Some(AgentStatus::Running), - "completed" => Some(AgentStatus::Completed), - "failed" => Some(AgentStatus::Failed), - "cancelled" => Some(AgentStatus::Cancelled), - _ => None, - }); - - let agents = manager.list_agents_for_session( - status_filter, - Some(batch_id.clone()), - params.recent_only.unwrap_or(false), - sess.session_uuid(), - ); - - // Count running agents for status update - let running_count = agents - .iter() - .filter(|a| a.status == AgentStatus::Running) - .count(); - if running_count > 0 { - let status_msg = format!( - "🤖 {} agent{} currently running", - running_count, - if running_count != 1 { "s" } else { "" } - ); - let event = sess.make_event( - "agent-status", - EventMsg::BackgroundEvent(BackgroundEventEvent { message: status_msg }), - ); - let _ = sess.tx_event.send(event).await; - } - - // Add status counts to summary - let pending_count = agents - .iter() - .filter(|a| a.status == AgentStatus::Pending) - .count(); - let running_count = agents - .iter() - .filter(|a| a.status == AgentStatus::Running) - .count(); - let completed_count = agents - .iter() - .filter(|a| a.status == AgentStatus::Completed) - .count(); - let failed_count = agents - .iter() - .filter(|a| a.status == AgentStatus::Failed) - .count(); - let cancelled_count = agents - .iter() - .filter(|a| a.status == AgentStatus::Cancelled) - .count(); - - let summary = serde_json::json!({ - "total_agents": agents.len(), - "status_counts": { - "pending": pending_count, - "running": running_count, - "completed": completed_count, - "failed": failed_count, - "cancelled": cancelled_count, - }, - "batch_id": batch_id, - "agents": agents.iter().map(|t| { - serde_json::json!({ - "id": t.id, - "name": t.name.clone(), - "model": t.model, - "status": t.status, - "retry": t.retry, - "created_at": t.created_at, - "batch_id": t.batch_id, - "worktree_path": t.worktree_path, - "branch_name": t.branch_name, - }) - }).collect::>(), - }); - - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(summary.to_string()), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid agent arguments for action=list: {}", e)), - success: None}, - }, - } - }, - ).await -} - -async fn command_guard_output( - sess: &Session, - sub_id: &str, - call_id: String, - attempt_req: u64, - output_index: Option, - guidance: String, -) -> ResponseInputItem { - let order = sess.next_background_order(sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload::from_text(guidance), - } -} - -fn active_session_write_gate_notice( - sess: &Session, -) -> Option { - if !matches!( - sess.sandbox_policy, - SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess - ) { - return None; - } - - match crate::active_sessions::active_session_model_notice_for_current( - sess.client.code_home(), - &sess.cwd, - sess.session_uuid(), - ) { - Ok(Some(notice)) => Some(notice), - Ok(None) => { - sess.active_session_notice_clear(); - sess.active_session_write_gate_clear(); - None - } - Err(err) => { - warn!("failed to refresh active session write gate notice: {err}"); - sess.active_session_model_notice.clone() - } - } -} - -fn active_session_write_gate_message( - notice: &crate::active_sessions::ActiveSessionModelNotice, - operation: &str, -) -> String { - format!( - "Blocked {operation} because another write-capable Every Code session is active in this checkout. Call `declare_worktree_decision` before editing: use `{{\"decision\":\"use_worktree\",\"path\":\"{suggested}\"}}` after creating/switching to that isolated worktree, or use `{{\"decision\":\"stay_here\",\"reason\":\"\"}}` if this checkout must be edited. Linked worktrees do not automatically carry checkout-local setup such as .env files, virtualenvs, node_modules, local secrets, or generated files, so staying can be valid when that setup is required.", - suggested = notice.suggested_worktree_path.display(), - ) -} - -fn active_session_write_decision_allows( - decision: &ActiveSessionWorktreeDecision, - notice: &crate::active_sessions::ActiveSessionModelNotice, - cwd: &Path, -) -> bool { - match decision { - ActiveSessionWorktreeDecision::StayHere { fingerprint, .. } => fingerprint == ¬ice.fingerprint, - ActiveSessionWorktreeDecision::UseWorktree { - fingerprint, - selected_worktree_path, - .. - } => { - if fingerprint != ¬ice.fingerprint { - return false; - } - let Some(cwd) = normalize_for_containment(cwd) else { - return false; - }; - let selected = normalize_for_containment(selected_worktree_path) - .unwrap_or_else(|| selected_worktree_path.clone()); - path_within(&cwd, &selected) - } - ActiveSessionWorktreeDecision::Unset - | ActiveSessionWorktreeDecision::AwaitingDecision { .. } => false, - } -} - -fn active_session_apply_patch_decision_allows( - decision: &ActiveSessionWorktreeDecision, - notice: &crate::active_sessions::ActiveSessionModelNotice, - action: &ApplyPatchAction, -) -> bool { - match decision { - ActiveSessionWorktreeDecision::StayHere { fingerprint, .. } => fingerprint == ¬ice.fingerprint, - ActiveSessionWorktreeDecision::UseWorktree { - fingerprint, - selected_worktree_path, - .. - } => { - if fingerprint != ¬ice.fingerprint { - return false; - } - let Some(cwd) = normalize_for_containment(&action.cwd) else { - return false; - }; - let selected = normalize_for_containment(selected_worktree_path) - .unwrap_or_else(|| selected_worktree_path.clone()); - if !path_within(&cwd, &selected) { - return false; - } - - action.changes().keys().all(|path| { - normalize_for_containment(path) - .as_ref() - .is_some_and(|path| path_within(path, &selected)) - }) - } - ActiveSessionWorktreeDecision::Unset - | ActiveSessionWorktreeDecision::AwaitingDecision { .. } => false, - } -} - -fn active_session_gate_for_cwd( - notice: &crate::active_sessions::ActiveSessionModelNotice, - cwd: &Path, - operation: &str, -) -> Option { - let cwd = normalize_for_containment(cwd)?; - let checkout_root = normalize_for_containment(¬ice.checkout_root)?; - if path_within(&cwd, &checkout_root) { - Some(active_session_write_gate_message(notice, operation)) - } else { - None - } -} - -fn active_session_gate_for_apply_patch( - notice: &crate::active_sessions::ActiveSessionModelNotice, - action: &ApplyPatchAction, -) -> Option { - let checkout_root = normalize_for_containment(¬ice.checkout_root)?; - if normalize_for_containment(&action.cwd) - .as_ref() - .is_some_and(|cwd| path_within(cwd, &checkout_root)) - { - return Some(active_session_write_gate_message(notice, "apply_patch")); - } - - for path in action.changes().keys() { - if normalize_for_containment(path) - .as_ref() - .is_some_and(|path| path_within(path, &checkout_root)) - { - return Some(active_session_write_gate_message(notice, "apply_patch")); - } - } - - None -} - -fn shell_script_is_likely_write(script: &str) -> bool { - let trimmed = script.trim_start(); - if trimmed.is_empty() { - return false; - } - - if script_contains_cat_write(trimmed) || script_contains_python_write(trimmed) { - return true; - } - - if trimmed.contains('>') && !trimmed.contains("2>") { - return true; - } - - if trimmed.contains("| tee ") || trimmed.starts_with("tee ") || trimmed.contains(" tee -a ") { - return true; - } - - let normalized = trimmed.replace(['\n', ';', '&', '|'], " "); - let tokens = normalized.split_whitespace().collect::>(); - let mut idx = 0usize; - while idx < tokens.len() { - let token = tokens[idx].trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')); - if token.is_empty() || token.contains('=') && !token.starts_with('-') { - idx += 1; - continue; - } - if matches!(token, "env" | "sudo" | "command" | "time" | "nohup" | "nice") { - idx += 1; - continue; - } - let command = token.rsplit('/').next().unwrap_or(token); - if command == "git" { - let mut git_idx = idx + 1; - while git_idx < tokens.len() { - let arg = tokens[git_idx].trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')); - if matches!(arg, "-C" | "--git-dir" | "--work-tree" | "-c") { - git_idx += 2; - continue; - } - if arg.starts_with("--git-dir=") || arg.starts_with("--work-tree=") || arg.starts_with("-c") || arg.starts_with('-') { - git_idx += 1; - continue; - } - return matches!( - arg, - "add" - | "am" - | "apply" - | "branch" - | "checkout" - | "cherry-pick" - | "clean" - | "commit" - | "merge" - | "mv" - | "pull" - | "push" - | "rebase" - | "reset" - | "restore" - | "revert" - | "rm" - | "stash" - | "switch" - ); - } - return false; - } - - return command_is_obvious_write(command, tokens.get(idx + 1).copied()); - } - false -} - -fn command_is_obvious_write(command: &str, first_arg: Option<&str>) -> bool { - match command { - "apply_patch" | "touch" | "rm" | "mv" | "cp" | "mkdir" | "install" | "tee" - | "rustfmt" | "prettier" | "black" | "gofmt" => true, - "sed" => first_arg.is_some_and(|arg| arg == "-i" || arg.starts_with("-i")), - "perl" => first_arg.is_some_and(|arg| arg.contains('i')), - "ruff" => first_arg.is_some_and(|arg| matches!(arg, "format" | "check")), - "npm" | "pnpm" | "yarn" | "bun" => first_arg.is_some_and(|arg| { - matches!( - arg, - "add" | "install" | "i" | "remove" | "rm" | "update" | "upgrade" - ) - }), - "cargo" => first_arg.is_some_and(|arg| matches!(arg, "fmt" | "fix")), - "go" => first_arg.is_some_and(|arg| matches!(arg, "fmt" | "mod" | "work" | "get")), - _ => false, - } -} - -fn argv_command_is_obvious_write(argv: &[String]) -> bool { - let mut idx = 0usize; - while idx < argv.len() { - let token = argv[idx].trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')); - if token.is_empty() || token.contains('=') && !token.starts_with('-') { - idx += 1; - continue; - } - if matches!(token, "env" | "sudo" | "command" | "time" | "nohup" | "nice") { - idx += 1; - continue; - } - let command = token.rsplit('/').next().unwrap_or(token); - if command == "git" { - return false; - } - return command_is_obvious_write(command, argv.get(idx + 1).map(String::as_str)); - } - false -} - -fn argv_contains_direct_redirection(argv: &[String]) -> bool { - argv.iter().any(|token| { - let trimmed = token.trim_start(); - (trimmed.starts_with('>') || trimmed.contains(" >") || trimmed.contains(" >>")) - && !trimmed.starts_with("2>") - && !trimmed.contains(" 2>") - }) -} - -fn argv_is_likely_write(argv: &[String]) -> bool { - if let Some((_, script)) = extract_shell_script(argv) { - if shell_script_is_likely_write(&script) { - return true; - } - } - - if let Some(analysis) = analyze_command(argv) { - return analysis.disposition == DryRunDisposition::Mutating; - } - - argv_contains_direct_redirection(argv) || argv_command_is_obvious_write(argv) -} - -fn exec_params_is_likely_write(params: &ExecParams) -> bool { - params - .shell_script - .as_ref() - .is_some_and(|script| shell_script_is_likely_write(&script.command)) - || argv_is_likely_write(¶ms.command) -} - -async fn handle_container_exec_with_params( - params: ExecParams, - sess: &Session, - turn_diff_tracker: &mut TurnDiffTracker, - sub_id: String, - call_id: String, - seq_hint: Option, - output_index: Option, - attempt_req: u64, -) -> ResponseInputItem { - // Intercept risky git commands and require an explicit confirm prefix. - // We support a simple convention: prefix the script with `confirm:` to proceed. - // The prefix is stripped before execution. - #[derive(Copy, Clone, Debug, PartialEq, Eq)] - enum SensitiveGitKind { - BranchChange, - PathCheckout, - Reset, - Revert, - } - - fn detect_sensitive_git(script: &str) -> Option { - // Goal: detect sensitive git invocations (branch changes, resets) while - // avoiding false positives from commit messages or other quoted strings. - // We do a lightweight scan that strips quoted regions before token analysis. - - // 1) Strip quote characters but preserve content inside quotes, while - // neutralizing control separators to avoid over-splitting tokens. - let mut cleaned = String::with_capacity(script.len()); - let mut in_squote = false; - let mut in_dquote = false; - let mut prev_was_backslash = false; - for ch in script.chars() { - let mut emit_space = false; - match ch { - '\\' => { - // Track escapes inside double quotes; in single quotes, backslash has no special meaning in POSIX sh. - prev_was_backslash = !prev_was_backslash; - } - '\'' if !in_dquote => { - in_squote = !in_squote; - emit_space = true; // token boundary at quote edges - prev_was_backslash = false; - } - '"' if !in_squote && !prev_was_backslash => { - in_dquote = !in_dquote; - emit_space = true; // token boundary at quote edges - prev_was_backslash = false; - } - _ => { - prev_was_backslash = false; - } - } - if emit_space { - cleaned.push(' '); - continue; - } - if in_squote || in_dquote { - if matches!(ch, '|' | '&' | ';' | '\n' | '\r') { - cleaned.push(' '); - } else { - cleaned.push(ch); - } - } else { - cleaned.push(ch); - } - } - - // 2) Split into simple commands at common separators. - for chunk in cleaned.split(|c| matches!(c, ';' | '\n' | '\r')) { - // Further split on conditional operators while keeping order. - for part in chunk.split(|c| matches!(c, '|' | '&')) { - let s = part.trim(); - if s.is_empty() { continue; } - // Tokenize on whitespace, skip wrappers and git globals to find the real subcommand. - let raw_tokens: Vec<&str> = s.split_whitespace().collect(); - if raw_tokens.is_empty() { continue; } - fn strip_tok(t: &str) -> &str { t.trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')) } - let mut i = 0usize; - // Skip env assignments and lightweight wrappers/keywords. - loop { - if i >= raw_tokens.len() { break; } - let tok = strip_tok(raw_tokens[i]); - if tok.is_empty() { i += 1; continue; } - // Skip KEY=val assignments. - if tok.contains('=') && !tok.starts_with('=') && !tok.starts_with('-') { - i += 1; continue; - } - // Skip simple wrappers and control keywords. - if matches!(tok, "env" | "sudo" | "command" | "time" | "nohup" | "nice" | "then" | "do" | "{" | "(") { - // Best-effort: skip immediate option-like flags after some wrappers. - i += 1; - while i < raw_tokens.len() { - let peek = strip_tok(raw_tokens[i]); - if peek.starts_with('-') { i += 1; } else { break; } - } - continue; - } - break; - } - if i >= raw_tokens.len() { continue; } - let cmd = strip_tok(raw_tokens[i]); - let is_git = cmd.ends_with("/git") || cmd == "git"; - if !is_git { continue; } - i += 1; // advance past git - // Skip git global options to find the real subcommand. - while i < raw_tokens.len() { - let t = strip_tok(raw_tokens[i]); - if t.is_empty() { i += 1; continue; } - if matches!(t, "-C" | "--git-dir" | "--work-tree" | "-c") { - i += 1; // skip option key - if i < raw_tokens.len() { i += 1; } // skip its value - continue; - } - if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") || t.starts_with("-c") { - i += 1; continue; - } - if t.starts_with('-') { i += 1; continue; } - break; - } - if i >= raw_tokens.len() { continue; } - let sub = strip_tok(raw_tokens[i]); - i += 1; - match sub { - "checkout" => { - let args: Vec<&str> = raw_tokens[i..].iter().map(|t| strip_tok(t)).collect(); - let has_path_delimiter = args.iter().any(|a| *a == "--"); - if has_path_delimiter { - return Some(SensitiveGitKind::PathCheckout); - } - - // If any of the strong branch-changing flags are present, flag it. - let mut saw_branch_change_flag = false; - for a in &args { - if matches!(*a, "-b" | "-B" | "--orphan" | "--detach") { - saw_branch_change_flag = true; - break; - } - } - if saw_branch_change_flag { return Some(SensitiveGitKind::BranchChange); } - - // `git checkout -` switches to previous branch. - if args.first().copied() == Some("-") { - return Some(SensitiveGitKind::BranchChange); - } - - // Heuristic: a single non-flag argument likely denotes a branch. - if let Some(first_arg) = args.first() { - let a = *first_arg; - if !a.starts_with('-') && a != "." && a != ".." { - return Some(SensitiveGitKind::BranchChange); - } - } - } - "switch" => { - // `git switch -c ` creates; `git switch ` changes. - let mut saw_c = false; - let mut saw_detach = false; - let mut first_non_flag: Option<&str> = None; - for a in &raw_tokens[i..] { - let a = strip_tok(a); - if a == "-c" { saw_c = true; break; } - if a == "--detach" { saw_detach = true; break; } - if a.starts_with('-') { continue; } - first_non_flag = Some(a); - break; - } - if saw_c || saw_detach || first_non_flag.is_some() { return Some(SensitiveGitKind::BranchChange); } - } - "reset" => { - // Any form of git reset is considered sensitive. - return Some(SensitiveGitKind::Reset); - } - "revert" => { - // Any form of git revert is considered sensitive. - return Some(SensitiveGitKind::Revert); - } - // Future: consider `git branch -D/-m` as branch‑modifying, but keep - // this minimal to avoid over‑blocking normal workflows. - _ => {} - } - } - } - None - } - - fn strip_leading_confirm_prefix(argv: &mut Vec) -> bool { - if argv.is_empty() { - return false; - } - - let first = argv[0].trim().to_string(); - for prefix in ["confirm:", "CONFIRM:"] { - if first == prefix { - argv.remove(0); - return true; - } - if let Some(rest) = first.strip_prefix(prefix) { - let trimmed = rest.trim_start(); - if trimmed.is_empty() { - argv.remove(0); - } else { - argv[0] = trimmed.to_string(); - } - return true; - } - } - - false - } - - fn guidance_for_sensitive_git(kind: SensitiveGitKind, original_label: &str, original_value: &str, suggested: &str) -> String { - match kind { - SensitiveGitKind::BranchChange => format!( - "Blocked git checkout/switch on a branch. Switching branches can discard or hide in-progress changes. Only continue if the user explicitly requested this branch change. Resend with 'confirm:' if you intend to proceed.\n\n{}: {}\nresend_exact_argv: {}", - original_label, - original_value, - suggested - ), - SensitiveGitKind::PathCheckout => format!( - "Blocked git checkout -- . This command overwrites local modifications to the specified files. Consider backing up the files first. If you intentionally want to discard those edits, resend the exact command prefixed with 'confirm:'.\n\n{}: {}\nresend_exact_argv: {}", - original_label, - original_value, - suggested - ), - SensitiveGitKind::Reset => format!( - "Blocked git reset. Reset rewrites the working tree/index and may delete local work. Consider backing up the files first. If backups exist and this was explicitly requested, resend prefixed with 'confirm:'.\n\n{}: {}\nresend_exact_argv: {}", - original_label, - original_value, - suggested - ), - SensitiveGitKind::Revert => format!( - "Blocked git revert. Reverting commits alters history and should only happen when the user asks for it. If that’s the case, resend the command with 'confirm:'.\n\n{}: {}\nresend_exact_argv: {}", - original_label, - original_value, - suggested - ), - } - } - - fn guidance_for_dry_run_guard( - analysis: &DryRunAnalysis, - original_label: &str, - original_value: &str, - resend_exact_argv: Vec, - ) -> String { - let suggested_confirm = serde_json::to_string(&resend_exact_argv) - .unwrap_or_else(|_| "".to_string()); - let suggested_dry_run = analysis - .suggested_dry_run() - .unwrap_or_else(|| "".to_string()); - format!( - "Blocked {} without a prior dry run. Run the dry-run variant first or resend with 'confirm:' if explicitly requested.\n\n{}: {}\nresend_exact_argv: {}\nsuggested_dry_run: {}", - analysis.display_name(), - original_label, - original_value, - suggested_confirm, - suggested_dry_run - ) - } - - - // If the command is a shell script, analyze and optionally strip `confirm:`. - let mut params = params; - let seq_hint_for_exec = seq_hint; - let otel_event_manager = sess.client.get_otel_event_manager(); - let tool_name = "local_shell"; - if let Some((script_index, script)) = extract_shell_script(¶ms.command) { - let trimmed = script.trim_start(); - let confirm_prefixes = ["confirm:", "CONFIRM:"]; - let has_confirm_prefix = confirm_prefixes - .iter() - .any(|p| trimmed.starts_with(p)); - - if let Some(policy_match) = sess - .skill_command_policies - .check(¶ms.command, has_confirm_prefix) - { - let guidance = policy_match.guidance("original_script", &script); - return command_guard_output( - sess, - &sub_id, - call_id, - attempt_req, - output_index, - guidance, - ) - .await; - } - - // If no confirm prefix and it looks like a sensitive git command, reject with guidance. - if !has_confirm_prefix { - if let Some(pattern) = if sess.confirm_guard.is_empty() { - None - } else { - sess.confirm_guard.matched_pattern(trimmed) - } { - let mut argv_confirm = params.command.clone(); - argv_confirm[script_index] = format!("confirm: {}", script.trim_start()); - let suggested = serde_json::to_string(&argv_confirm) - .unwrap_or_else(|_| "".to_string()); - let guidance = pattern.guidance("original_script", &script, &suggested); - - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(guidance), success: None}, - }; - } - - if let Some(kind) = detect_sensitive_git(trimmed) { - // Provide the exact argv the model should resend with the confirm prefix. - let mut argv_confirm = params.command.clone(); - argv_confirm[script_index] = format!("confirm: {}", script.trim_start()); - let suggested = serde_json::to_string(&argv_confirm) - .unwrap_or_else(|_| "".to_string()); - - let guidance = guidance_for_sensitive_git(kind, "original_script", &script, &suggested); - - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(guidance), success: None}, - }; - } - } - - // If confirm prefix present, strip it before execution. - if has_confirm_prefix { - let without_prefix = confirm_prefixes - .iter() - .find_map(|p| { - let t = trimmed.strip_prefix(p)?; - Some(t.trim_start().to_string()) - }) - .unwrap_or_else(|| trimmed.to_string()); - params.command[script_index] = without_prefix; - } - - let dry_run_analysis = analyze_command(¶ms.command); - if !has_confirm_prefix { - if let Some(analysis) = dry_run_analysis.as_ref() { - if analysis.disposition == DryRunDisposition::Mutating { - let needs_dry_run = { - let state = sess.state.lock().unwrap(); - !state.dry_run_guard.has_recent_dry_run(analysis.key) - }; - if needs_dry_run { - let mut argv_confirm = params.command.clone(); - argv_confirm[script_index] = format!("confirm: {}", params.command[script_index].trim_start()); - let guidance = guidance_for_dry_run_guard( - analysis, - "original_script", - ¶ms.command[script_index], - argv_confirm, - ); - - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(guidance), success: None}, - }; - } - } - } - } - } - - strip_leading_confirm_prefix(&mut params.command); - - if let Some(redundant) = detect_redundant_cd(¶ms.command, ¶ms.cwd) { - let guidance = guidance_for_redundant_cd(&redundant); - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(guidance), - success: None}, - }; - } - - if let Some(cat_guard) = detect_cat_write(¶ms.command) { - let guidance = guidance_for_cat_write(&cat_guard); - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(guidance), - success: None}, - }; - } - - if let Some(python_guard) = detect_python_write(¶ms.command) { - let guidance = guidance_for_python_write(&python_guard); - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(guidance), - success: None}, - }; - } - - // If no shell script is present, perform a lightweight argv inspection for sensitive git commands. - if extract_shell_script(¶ms.command).is_none() { - let joined = params.command.join(" "); - if let Some(policy_match) = sess.skill_command_policies.check(¶ms.command, false) { - let guidance = policy_match.guidance( - "original_argv", - &format!("{:?}", params.command), - ); - return command_guard_output( - sess, - &sub_id, - call_id, - attempt_req, - output_index, - guidance, - ) - .await; - } - if !sess.confirm_guard.is_empty() { - if let Some(pattern) = sess.confirm_guard.matched_pattern(&joined) { - let suggested = serde_json::to_string(&vec![ - "bash".to_string(), - "-lc".to_string(), - format!("confirm: {}", joined), - ]) - .unwrap_or_else(|_| "".to_string()); - let guidance = pattern.guidance( - "original_argv", - &format!("{:?}", params.command), - &suggested, - ); - - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(guidance), success: None}, - }; - } - } - - if let Some(analysis) = analyze_command(¶ms.command) { - if analysis.disposition == DryRunDisposition::Mutating { - let needs_dry_run = { - let state = sess.state.lock().unwrap(); - !state.dry_run_guard.has_recent_dry_run(analysis.key) - }; - if needs_dry_run { - let resend = vec![ - "bash".to_string(), - "-lc".to_string(), - format!("confirm: {}", joined), - ]; - let guidance = guidance_for_dry_run_guard( - &analysis, - "original_argv", - &format!("{:?}", params.command), - resend, - ); - - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(guidance), success: None}, - }; - } - } - } - - fn strip_tok2(t: &str) -> &str { t.trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')) } - let mut i = 0usize; - // Skip env assignments and simple wrappers at the front - while i < params.command.len() { - let tok = strip_tok2(¶ms.command[i]); - if tok.is_empty() { i += 1; continue; } - if tok.contains('=') && !tok.starts_with('=') && !tok.starts_with('-') { i += 1; continue; } - if matches!(tok, "env" | "sudo" | "command" | "time" | "nohup" | "nice") { - i += 1; - while i < params.command.len() && strip_tok2(¶ms.command[i]).starts_with('-') { i += 1; } - continue; - } - break; - } - if i < params.command.len() { - let cmd = strip_tok2(¶ms.command[i]); - if cmd.ends_with("/git") || cmd == "git" { - i += 1; - while i < params.command.len() { - let t = strip_tok2(¶ms.command[i]); - if t.is_empty() { i += 1; continue; } - if matches!(t, "-C" | "--git-dir" | "--work-tree" | "-c") { - i += 1; if i < params.command.len() { i += 1; } - continue; - } - if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") || t.starts_with("-c") { i += 1; continue; } - if t.starts_with('-') { i += 1; continue; } - break; - } - if i < params.command.len() { - let sub = strip_tok2(¶ms.command[i]); - let args: Vec<&str> = params.command[i + 1..].iter().map(|t| strip_tok2(t)).collect(); - let kind = match sub { - "checkout" => { - if args.iter().any(|a| *a == "--") { - Some(SensitiveGitKind::PathCheckout) - } else if args.iter().any(|a| matches!(*a, "-b" | "-B" | "--orphan" | "--detach")) { - Some(SensitiveGitKind::BranchChange) - } else if args.first().copied() == Some("-") { - Some(SensitiveGitKind::BranchChange) - } else if let Some(first_arg) = args.first() { - let a = *first_arg; - if !a.starts_with('-') && a != "." && a != ".." { - Some(SensitiveGitKind::BranchChange) - } else { - None - } - } else { - None - } - } - "switch" => Some(SensitiveGitKind::BranchChange), - "reset" => Some(SensitiveGitKind::Reset), - "revert" => Some(SensitiveGitKind::Revert), - _ => None, - }; - if let Some(kind) = kind { - let suggested = serde_json::to_string(&vec![ - "bash".to_string(), - "-lc".to_string(), - format!("confirm: {}", params.command.join(" ")), - ]).unwrap_or_else(|_| "".to_string()); - - let guidance = guidance_for_sensitive_git(kind, "original_argv", &format!("{:?}", params.command), &suggested); - - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { call_id, output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(guidance), success: None} }; - } - } - } - } - } - - // Check if this was a patch, and apply it in-process if so. - match sess - .maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd) - .await - { - MaybeApplyPatchVerified::Body(action) => { - if let Some(branch_root) = git_worktree::branch_worktree_root(sess.get_cwd()) { - if let Some(guidance) = guard_apply_patch_outside_branch(&branch_root, &action) { - let order = sess.next_background_order(&sub_id, attempt_req, output_index); - sess - .notify_background_event_with_order( - &sub_id, - order, - format!("Command guard: {}", guidance.clone()), - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(guidance), success: None}, - }; - } - } - - if let Some(notice) = active_session_write_gate_notice(sess) { - if let Some(guidance) = active_session_gate_for_apply_patch(¬ice, &action) { - let decision = sess.active_session_worktree_decision(); - if !active_session_apply_patch_decision_allows(&decision, ¬ice, &action) { - sess.active_session_write_gate_set_pending(¬ice); - return command_guard_output( - sess, - &sub_id, - call_id, - attempt_req, - output_index, - guidance, - ) - .await; - } - } - } - - let changes = convert_apply_patch_to_protocol(&action); - turn_diff_tracker.on_patch_begin(&changes); - - let mut hook_ctx = ExecCommandContext { - sub_id: sub_id.clone(), - call_id: call_id.clone(), - command_for_display: params.command.clone(), - cwd: params.cwd.clone(), - apply_patch: Some(ApplyPatchCommandContext { - user_explicitly_approved_this_action: false, - changes: changes.clone(), - }), - }; - - // FileBeforeWrite hook for apply_patch - sess - .run_hooks_for_exec_event( - turn_diff_tracker, - ProjectHookEvent::FileBeforeWrite, - &hook_ctx, - ¶ms, - None, - attempt_req, - ) - .await; - - let patch_start = std::time::Instant::now(); - - match apply_patch::apply_patch( - sess, - &sub_id, - &call_id, - attempt_req, - output_index, - action, - ) - .await - { - ApplyPatchResult::Reply(item) => return item, - ApplyPatchResult::Applied(run) => { - hook_ctx.apply_patch.as_mut().map(|ctx| { - ctx.user_explicitly_approved_this_action = !run.auto_approved; - }); - - let order_begin = crate::protocol::OrderMeta { - request_ordinal: attempt_req, - output_index, - sequence_number: seq_hint, - }; - let begin_event = EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: call_id.clone(), - auto_approved: run.auto_approved, - changes, - }); - let event = sess.make_event_with_order(&sub_id, begin_event, order_begin, seq_hint); - let _ = sess.tx_event.send(event).await; - - let order_end = crate::protocol::OrderMeta { - request_ordinal: attempt_req, - output_index, - sequence_number: seq_hint.map(|h| h.saturating_add(1)), - }; - let end_event = EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: call_id.clone(), - stdout: run.stdout.clone(), - stderr: run.stderr.clone(), - success: run.success, - }); - let event = sess.make_event_with_order( - &sub_id, - end_event, - order_end, - seq_hint.map(|h| h.saturating_add(1)), - ); - let _ = sess.tx_event.send(event).await; - - let hook_output = ExecToolCallOutput { - exit_code: if run.success { 0 } else { 1 }, - stdout: StreamOutput::new(run.stdout.clone()), - stderr: StreamOutput::new(run.stderr.clone()), - aggregated_output: StreamOutput::new({ - if run.stdout.is_empty() { - run.stderr.clone() - } else if run.stderr.is_empty() { - run.stdout.clone() - } else { - format!("{}\n{}", run.stdout, run.stderr) - } - }), - duration: patch_start.elapsed(), - timed_out: false, - }; - - sess - .run_hooks_for_exec_event( - turn_diff_tracker, - ProjectHookEvent::FileAfterWrite, - &hook_ctx, - ¶ms, - Some(&hook_output), - attempt_req, - ) - .await; - - if let Ok(Some(unified_diff)) = turn_diff_tracker.get_unified_diff() { - let diff_event = sess.make_event( - &sub_id, - EventMsg::TurnDiff(TurnDiffEvent { unified_diff }), - ); - let _ = sess.tx_event.send(diff_event).await; - } - - let mut content = run.stdout; - if !run.success && !run.stderr.is_empty() { - if !content.is_empty() { - content.push('\n'); - } - content.push_str(&format!("stderr: {}", run.stderr)); - } - if let Some(summary) = run.harness_summary_json { - if !summary.is_empty() { - if !content.is_empty() { - content.push('\n'); - } - content.push_str(&summary); - } - } - - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(run.success), - }, - }; - } - } - } - MaybeApplyPatchVerified::CorrectnessError(parse_error) => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("error: {parse_error:#}")), - success: None}, - }; - } - MaybeApplyPatchVerified::ShellParseError(error) => { - trace!("Failed to parse shell command, {error:?}"); - } - MaybeApplyPatchVerified::NotApplyPatch => {} - } - - if exec_params_is_likely_write(¶ms) { - if let Some(notice) = active_session_write_gate_notice(sess) { - if let Some(guidance) = active_session_gate_for_cwd(¬ice, ¶ms.cwd, "write command") { - let decision = sess.active_session_worktree_decision(); - if !active_session_write_decision_allows(&decision, ¬ice, ¶ms.cwd) { - sess.active_session_write_gate_set_pending(¬ice); - return command_guard_output( - sess, - &sub_id, - call_id, - attempt_req, - output_index, - guidance, - ) - .await; - } - } - } - } - - let safety = { - let state = sess.state.lock().unwrap(); - assess_command_safety( - ¶ms.command, - sess.approval_policy, - &sess.sandbox_policy, - &state.approved_commands, - params.with_escalated_permissions.unwrap_or(false), - ) - }; - let command_for_display = params.command.clone(); - let harness_summary_json: Option = None; - - let sandbox_type = match safety { - SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved, - } => { - if let Some(manager) = otel_event_manager.as_ref() { - let (decision_for_log, source) = if user_explicitly_approved { - ( - ReviewDecision::ApprovedForSession, - ToolDecisionSource::User, - ) - } else { - (ReviewDecision::Approved, ToolDecisionSource::Config) - }; - manager.tool_decision( - tool_name, - call_id.as_str(), - to_proto_review_decision(decision_for_log), - source, - ); - } - sandbox_type - } - SafetyCheck::AskUser => { - let rx_approve = sess - .request_command_approval( - sub_id.clone(), - call_id.clone(), - None, - params.command.clone(), - params.cwd.clone(), - params.justification.clone(), - None, - None, - ) - .await; - - let decision = rx_approve.await.unwrap_or_default(); - if let Some(manager) = otel_event_manager.as_ref() { - manager.tool_decision( - tool_name, - call_id.as_str(), - to_proto_review_decision(decision), - ToolDecisionSource::User, - ); - } - - match decision { - ReviewDecision::Approved => {} - ReviewDecision::ApprovedForSession => { - sess.add_approved_command(ApprovedCommandPattern::new( - params.command.clone(), - ApprovedCommandMatchKind::Exact, - None, - )); - } - ReviewDecision::Denied | ReviewDecision::Abort => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("exec command rejected by user".to_string()), - success: None}, - }; - } - } - // No sandboxing is applied because the user has given - // explicit approval. Often, we end up in this case because - // the command cannot be run in a sandbox, such as - // installing a new dependency that requires network access. - SandboxType::None - } - SafetyCheck::Reject { reason } => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("exec command rejected: {reason}")), - success: None}, - }; - } - }; - - let exec_command_context = ExecCommandContext { - sub_id: sub_id.clone(), - call_id: call_id.clone(), - command_for_display: command_for_display.clone(), - cwd: params.cwd.clone(), - apply_patch: None, - }; - - let display_label = crate::util::strip_bash_lc_and_escape(&exec_command_context.command_for_display); - let params = maybe_run_with_user_profile(params, sess); - - // ToolBefore hook for shell/container.exec commands - let params_for_hooks = params.clone(); - sess - .run_hooks_for_exec_event( - turn_diff_tracker, - ProjectHookEvent::ToolBefore, - &exec_command_context, - ¶ms_for_hooks, - None, - attempt_req, - ) - .await; - - // Prepare tail buffer and background registry entry - let tail_buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let notify = std::sync::Arc::new(tokio::sync::Notify::new()); - let result_cell: std::sync::Arc>> = std::sync::Arc::new(std::sync::Mutex::new(None)); - let backgrounded = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let suppress_event_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let order_meta_for_end = crate::protocol::OrderMeta { - request_ordinal: attempt_req, - output_index, - sequence_number: seq_hint_for_exec.map(|h| h.saturating_add(1)), - }; - let order_meta_for_deltas = crate::protocol::OrderMeta { - request_ordinal: attempt_req, - output_index, - sequence_number: None, - }; - { - let mut st = sess.state.lock().unwrap(); - st.background_execs.insert( - call_id.clone(), - BackgroundExecState { - notify: notify.clone(), - result_cell: result_cell.clone(), - tail_buf: Some(tail_buf.clone()), - cmd_display: display_label.clone(), - suppress_event: suppress_event_flag.clone(), - task_handle: None, - order_meta_for_end: order_meta_for_end.clone(), - sub_id: sub_id.clone(), - }, - ); - } - - let sess_for_hooks = sess.self_handle.clone(); - let params_for_after_hooks = params_for_hooks.clone(); - let exec_ctx_for_hooks = exec_command_context.clone(); - let exec_ctx_for_task = exec_command_context.clone(); - let attempt_req_for_task = attempt_req; - - // Emit BEGIN event using the normal path so the TUI shows a running cell - sess - .on_exec_command_begin( - turn_diff_tracker, - exec_command_context.clone(), - seq_hint_for_exec, - output_index, - attempt_req, - ) - .await; - - // Spawn the runner that streams output and, on completion, emits END and records result. - let tx_event = sess.tx_event.clone(); - let sub_id_for_events = sub_id.clone(); - let call_id_for_events = call_id.clone(); - let sandbox_policy = sess.sandbox_policy.clone(); - let sandbox_cwd = sess.get_cwd().to_path_buf(); - let code_linux_sandbox_exe = sess.code_linux_sandbox_exe.clone(); - let exec_spool_dir_for_task = if sess.client.debug_enabled() { - Some( - sess.client - .code_home() - .join("debug_logs") - .join("exec"), - ) - } else { - None - }; - let result_cell_for_task = result_cell.clone(); - let notify_task = notify.clone(); - let tail_buf_task = tail_buf.clone(); - let backgrounded_task = backgrounded.clone(); - let suppress_event_flag_task = suppress_event_flag.clone(); - let display_label_task = display_label.clone(); - let tool_output_max_bytes = sess.tool_output_max_bytes; - let sess_for_background_completion = sess.self_handle.clone(); - let task_handle = tokio::spawn(async move { - // Build stdout stream with tail capture. We cannot stamp via `Session` here, - // but deltas will be delivered with neutral ordering which the UI tolerates. - let stdout_stream = if exec_ctx_for_task.apply_patch.is_some() { - None - } else { - Some(StdoutStream { - sub_id: sub_id_for_events.clone(), - call_id: call_id_for_events.clone(), - tx_event: tx_event.clone(), - session: None, - tail_buf: Some(tail_buf_task.clone()), - order: Some(order_meta_for_deltas.clone()), - spool_dir: exec_spool_dir_for_task.clone(), - }) - }; - - let start = std::time::Instant::now(); - let res = crate::exec::process_exec_tool_call( - params.clone(), - sandbox_type, - &sandbox_policy, - &sandbox_cwd, - &code_linux_sandbox_exe, - stdout_stream, - ) - .await; - - // Normalize to ExecToolCallOutput - let (out, exit_code) = match res { - Ok(o) => { let exit = o.exit_code; (o, exit) }, - Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => (output.as_ref().clone(), 124), - Err(e) => { - let msg = get_error_message_ui(&e); - ( - ExecToolCallOutput { - exit_code: -1, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(msg.clone()), - aggregated_output: StreamOutput::new(msg), - duration: start.elapsed(), - timed_out: false, - }, - -1, - ) - } - }; - - // Emit END event directly - let end_msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: call_id_for_events.clone(), - stdout: out.stdout.text.clone(), - stderr: out.stderr.text.clone(), - exit_code, - duration: out.duration, - }); - if let Some(sess_arc) = sess_for_background_completion.upgrade() - && !sess_arc.is_shutting_down() - { - let ev = Event { id: sub_id_for_events.clone(), event_seq: 0, msg: end_msg, order: Some(order_meta_for_end) }; - let _ = tx_event.send(ev).await; - } - - // Store result for waiters - { - let mut slot = result_cell_for_task.lock().unwrap(); - *slot = Some(out.clone()); - } - - if backgrounded_task.load(std::sync::atomic::Ordering::Relaxed) { - if let Some(sess_arc) = sess_for_hooks.upgrade() { - let mut hook_tracker = TurnDiffTracker::new(); - sess_arc - .run_hooks_for_exec_event( - &mut hook_tracker, - ProjectHookEvent::ToolAfter, - &exec_ctx_for_hooks, - ¶ms_for_after_hooks, - Some(&out), - attempt_req_for_task, - ) - .await; - } - } - // Only emit background completion notifications if the command actually backgrounded - if backgrounded_task.load(std::sync::atomic::Ordering::Relaxed) { - if !suppress_event_flag_task.load(std::sync::atomic::Ordering::Relaxed) { - let label = display_label_task.trim(); - let message = if label.is_empty() { - format!("Background shell '{}' completed.", call_id_for_events) - } else { - format!("{label} completed in background") - }; - let bg_event = EventMsg::BackgroundEvent(BackgroundEventEvent { message }); - if let Some(sess_arc) = sess_for_background_completion.upgrade() { - if sess_arc.is_shutting_down() { - if let Some(n) = ANY_BG_NOTIFY.get() { n.notify_waiters(); } - notify_task.notify_waiters(); - return; - } - let ev = sess_arc.make_event(&sub_id_for_events, bg_event); - sess_arc.send_event(ev).await; - - let header_label = if label.is_empty() { - format!("call_id={}", call_id_for_events) - } else { - display_label_task.clone() - }; - let header = format!("Background shell completed ({header_label}), exit_code={}, duration={:?}.", out.exit_code, out.duration); - let full_body = format_exec_output_str(&out); - let body = truncate_exec_output_for_storage( - &sandbox_cwd, - &sub_id_for_events, - &call_id_for_events, - &full_body, - tool_output_max_bytes, - ); - let dev_text = format!("{}\n\n{}", header, body); - let dev_msg = ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text: dev_text }], - }; - if sess_arc.enqueue_out_of_turn_item(dev_msg) { - sess_arc.cleanup_old_status_items().await; - let turn_context = sess_arc.make_turn_context(); - let sub_id = sess_arc.next_internal_sub_id(); - let sentinel_input = vec![InputItem::Text { - text: PENDING_ONLY_SENTINEL.to_string(), - }]; - let agent = AgentTask::spawn( - Arc::clone(&sess_arc), - turn_context, - sub_id, - sentinel_input, - TaskOriginKind::OutOfTurnDeveloper, - false, - ); - sess_arc.set_task(agent); - } - } - } - if let Some(n) = ANY_BG_NOTIFY.get() { n.notify_waiters(); } - } - notify_task.notify_waiters(); - }); - - { - let mut st = sess.state.lock().unwrap(); - if let Some(bg) = st.background_execs.get_mut(&call_id) { - bg.task_handle = Some(task_handle); - } - } - - // Wait up to 10 seconds for completion - let waited = tokio::time::timeout(std::time::Duration::from_secs(10), notify.notified()).await; - if waited.is_ok() { - // Completed within 10s - return the real output and drop the background entry. - let done_opt = { - let mut st = sess.state.lock().unwrap(); - st.background_execs - .remove(&call_id) - .and_then(|bg| bg.result_cell.lock().unwrap().clone()) - .or_else(|| { - st.background_execs - .iter() - .find_map(|(k, v)| { - if v.result_cell.lock().unwrap().is_some() { - Some(k.clone()) - } else { - None - } - }) - .and_then(|k| st.background_execs.remove(&k)) - .and_then(|bg| bg.result_cell.lock().unwrap().clone()) - }) - }; - if let Some(done) = done_opt { - let is_success = done.exit_code == 0; - let mut content = format_exec_output_with_limit( - sess.get_cwd(), - &sub_id, - &call_id, - &done, - sess.tool_output_max_bytes, - ); - if let Some(harness) = harness_summary_json.as_ref() { - if !harness.is_empty() { - content.push('\n'); - content.push_str(harness); - } - } - - sess - .run_hooks_for_exec_event( - turn_diff_tracker, - ProjectHookEvent::ToolAfter, - &exec_command_context, - ¶ms_for_hooks, - Some(&done), - attempt_req, - ) - .await; - - return ResponseInputItem::FunctionCallOutput { - call_id: call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(is_success), - }, - }; - } else { - // Fallback (should not happen): indicate completion without detail - let msg = format!("Command completed."); - return ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(msg), success: Some(true)} }; - } - } - - // Still running: mark as backgrounded and return background notice + tail and instructions - backgrounded.store(true, std::sync::atomic::Ordering::Relaxed); - let tail = String::from_utf8_lossy(&tail_buf.lock().unwrap()).to_string(); - let header = format!( - "Command running in background (call_id={}).\nTo wait: wait(call_id=\"{}\")\nYou can continue other work or wait. You'll be notified when the command completes.", - call_id, - call_id - ); - let msg = if tail.is_empty() { - header - } else { - format!("{}\n\nOutput so far (tail):\n{}", header, tail) - }; - ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(msg), success: Some(true)} } -} - -#[allow(dead_code)] -async fn handle_sandbox_error( - turn_diff_tracker: &mut TurnDiffTracker, - params: ExecParams, - exec_command_context: ExecCommandContext, - error: SandboxErr, - sandbox_type: SandboxType, - sess: &Session, - attempt_req: u64, -) -> ResponseInputItem { - let call_id = exec_command_context.call_id.clone(); - let sub_id = exec_command_context.sub_id.clone(); - let cwd = exec_command_context.cwd.clone(); - let otel_event_manager = sess.client.get_otel_event_manager(); - let tool_name = "local_shell"; - - if let SandboxErr::OutOfMemory { - output, - memory_max_bytes, - } = &error - { - let limit_note = memory_max_bytes - .as_ref() - .map(|bytes| format!(" (memory.max={bytes} bytes)")) - .unwrap_or_default(); - let tail = format_exec_output_with_limit( - sess.get_cwd(), - &sub_id, - &call_id, - output.as_ref(), - sess.tool_output_max_bytes, - ); - let content = format!( - "command exceeded memory limit{limit_note}. Try reducing parallelism (e.g. fewer jobs) and retry.\n\n{tail}" - ); - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(false), - }, - }; - } - - // Early out if either the user never wants to be asked for approval, or - // we're letting the model manage escalation requests. Otherwise, continue - match sess.approval_policy { - AskForApproval::Never | AskForApproval::OnRequest | AskForApproval::Reject(_) => { - // Clarify when Read Only mode is the reason a command cannot proceed. - let content = if matches!(sess.sandbox_policy, SandboxPolicy::ReadOnly) { - format!("command blocked by Read Only mode: {error}") - } else { - format!("failed in sandbox {sandbox_type:?} with execution error: {error}") - }; - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(false), - }, - }; - } - AskForApproval::UnlessTrusted | AskForApproval::OnFailure => (), - } - - // similarly, if the command timed out, we can simply return this failure to the model - if matches!(error, SandboxErr::Timeout { .. }) { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("command timed out".to_string()), - success: Some(false)}, - }; - } - - // Note that when `error` is `SandboxErr::Denied`, it could be a false - // positive. That is, it may have exited with a non-zero exit code, not - // because the sandbox denied it, but because that is its expected behavior, - // i.e., a grep command that did not match anything. Ideally we would - // include additional metadata on the command to indicate whether non-zero - // exit codes merit a retry. - - // For now, we categorically ask the user to retry without sandbox and - // emit the raw error as a background event. - let failure_order = sess.next_background_order(&sub_id, attempt_req, None); - sess - .notify_background_event_with_order( - &sub_id, - failure_order, - format!("Execution failed: {error}"), - ) - .await; - - let rx_approve = sess - .request_command_approval( - sub_id.clone(), - call_id.clone(), - None, - params.command.clone(), - cwd.clone(), - Some("command failed; retry without sandbox?".to_string()), - None, - None, - ) - .await; - - let decision = rx_approve.await.unwrap_or_default(); - if let Some(manager) = otel_event_manager.as_ref() { - manager.tool_decision( - tool_name, - call_id.as_str(), - to_proto_review_decision(decision), - ToolDecisionSource::User, - ); - } - - match decision { - ReviewDecision::Approved => {} - ReviewDecision::ApprovedForSession => { - // Persist this command as pre‑approved for the - // remainder of the session so future executions skip the sandbox directly. - sess.add_approved_command(ApprovedCommandPattern::new( - params.command.clone(), - ApprovedCommandMatchKind::Exact, - None, - )); - } - ReviewDecision::Denied | ReviewDecision::Abort => { - // Fall through to original failure handling. - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("exec command rejected by user".to_string()), - success: None}, - }; - } - }; - - // Inform UI we are retrying without sandbox. - let retry_order = sess.next_background_order(&sub_id, attempt_req, None); - sess - .notify_background_event_with_order( - &sub_id, - retry_order, - "retrying command without sandbox", - ) - .await; - // This is an escalated retry; the policy will not be examined and the sandbox has been set to `None`. - // Use the same attempt_req as the tool call that failed; this retry is still part of the current provider attempt. - let retry_output_result = sess - .run_exec_with_events( - turn_diff_tracker, - exec_command_context.clone(), - ExecInvokeArgs { - params, - sandbox_type: SandboxType::None, - sandbox_policy: &sess.sandbox_policy, - sandbox_cwd: sess.get_cwd(), - code_linux_sandbox_exe: &sess.code_linux_sandbox_exe, - stdout_stream: if exec_command_context.apply_patch.is_some() { - None - } else { - Some(StdoutStream { - sub_id: sub_id.clone(), - call_id: call_id.clone(), - tx_event: sess.tx_event.clone(), - session: None, - tail_buf: None, - order: Some(crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index: None, sequence_number: None }), - spool_dir: if sess.client.debug_enabled() { - Some(sess.client.code_home().join("debug_logs").join("exec")) - } else { - None - }, - }) - }, - }, - None, - None, - attempt_req, - ) - .await; - - match retry_output_result { - Ok(retry_output) => { - let ExecToolCallOutput { exit_code, .. } = &retry_output; - - let is_success = *exit_code == 0; - let content = format_exec_output_with_limit( - sess.get_cwd(), - &sub_id, - &call_id, - &retry_output, - sess.tool_output_max_bytes, - ); - - ResponseInputItem::FunctionCallOutput { - call_id: call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(is_success), - }, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("retry failed: {e}")), - success: None}, - }, - } -} - -/// Marker inserted when tool output is truncated. -pub(super) const TRUNCATION_MARKER: &str = "…truncated…\n"; - -pub(super) fn truncate_middle_bytes(s: &str, max_bytes: usize) -> (String, bool, usize, usize) { - if s.len() <= max_bytes { - return (s.to_string(), false, s.len(), s.len()); - } - if max_bytes == 0 { - return (TRUNCATION_MARKER.trim_end().to_string(), true, 0, s.len()); - } - - // Try to keep some head/tail, favoring newline boundaries when possible. - let keep = max_bytes.saturating_sub("…truncated…\n".len()); - let left_budget = keep / 2; - let right_budget = keep - left_budget; - - // Safe prefix end on a char boundary, prefer last newline within budget. - let prefix_end = { - let mut end = left_budget.min(s.len()); - if let Some(head) = s.get(..end) { - if let Some(i) = head.rfind('\n') { end = i + 1; } - } - while end > 0 && !s.is_char_boundary(end) { end -= 1; } - end - }; - - // Safe suffix start on a char boundary, prefer first newline within budget. - let suffix_start = { - let mut start = s.len().saturating_sub(right_budget); - if let Some(tail) = s.get(start..) { - if let Some(i) = tail.find('\n') { start += i + 1; } - } - while start < s.len() && !s.is_char_boundary(start) { start += 1; } - start - }; - - let mut out = String::with_capacity(max_bytes); - out.push_str(&s[..prefix_end]); - out.push_str(TRUNCATION_MARKER); - out.push_str(&s[suffix_start..]); - (out, true, prefix_end, suffix_start) -} - -fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String { - let ExecToolCallOutput { - aggregated_output, - duration, - timed_out, - .. - } = exec_output; - - // Always use the aggregated (stdout + stderr interleaved) stream so the - // model sees the full build log regardless of which stream a tool used. - let mut formatted_output = aggregated_output.text.clone(); - if let Some(truncated_before_bytes) = aggregated_output.truncated_before_bytes { - let note = format!( - "… clipped {} from the start of command output (showing last {}).\n\n", - format_bytes(truncated_before_bytes), - format_bytes(EXEC_CAPTURE_MAX_BYTES), - ); - formatted_output = format!("{note}{formatted_output}"); - } - - if *timed_out { - let timeout_ms = duration.as_millis(); - formatted_output = - format!("command timed out after {timeout_ms} milliseconds\n{formatted_output}"); - } - if let Some(truncated_after_lines) = aggregated_output.truncated_after_lines { - formatted_output.push_str(&format!( - "\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]", - )); - } - - formatted_output -} - -fn truncate_exec_output_for_storage( - cwd: &Path, - sub_id: &str, - call_id: &str, - full: &str, - max_tool_output_bytes: usize, -) -> String { - let (maybe_truncated, was_truncated, _, _) = - truncate_middle_bytes(full, max_tool_output_bytes); - if !was_truncated { - return maybe_truncated; - } - - let safe_call_id = crate::fs_sanitize::safe_path_component(call_id, "exec"); - let filename = format!("exec-{safe_call_id}.txt"); - let file_note = match ensure_agent_dir(cwd, sub_id) - .and_then(|dir| write_agent_file(&dir, &filename, full)) - { - Ok(path) => format!("\n\n[Full output saved to: {}]", path.display()), - Err(e) => format!("\n\n[Full output was too large and truncation applied; failed to save file: {e}]") - }; - let mut truncated = maybe_truncated; - truncated.push_str(&file_note); - truncated -} - -/// Exec output serialized for the model. If the payload is too large, -/// write the full output to a file and include a truncated preview here. -fn format_exec_output_with_limit( - cwd: &Path, - sub_id: &str, - call_id: &str, - exec_output: &ExecToolCallOutput, - max_tool_output_bytes: usize, -) -> String { - let ExecToolCallOutput { - exit_code, - duration, - .. - } = exec_output; - - #[derive(Serialize)] - struct ExecMetadata { - exit_code: i32, - duration_seconds: f32, - } - - #[derive(Serialize)] - struct ExecOutput<'a> { output: &'a str, metadata: ExecMetadata } - - // round to 1 decimal place - let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0; - - let full = format_exec_output_str(exec_output); - let final_output = - truncate_exec_output_for_storage(cwd, sub_id, call_id, &full, max_tool_output_bytes); - - let payload = ExecOutput { - output: &final_output, - metadata: ExecMetadata { - exit_code: *exit_code, - duration_seconds, - }, - }; - - #[expect(clippy::expect_used)] - serde_json::to_string(&payload).expect("serialize ExecOutput") -} - -fn format_bytes(bytes: usize) -> String { - const KIB: f64 = 1024.0; - const MIB: f64 = KIB * 1024.0; - const GIB: f64 = MIB * 1024.0; - let bytes_f = bytes as f64; - if bytes >= GIB as usize { - format!("{:.1} GiB", bytes_f / GIB) - } else if bytes >= MIB as usize { - format!("{:.1} MiB", bytes_f / MIB) - } else if bytes >= KIB as usize { - format!("{:.1} KiB", bytes_f / KIB) - } else { - format!("{bytes} B") - } -} - -pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option { - responses.iter().rev().find_map(|item| { - if let ResponseItem::Message { role, content, .. } = item { - if role == "assistant" { - content.iter().rev().find_map(|ci| { - if let ContentItem::OutputText { text } = ci { - Some(text.clone()) - } else { - None - } - }) - } else { - None - } - } else { - None - } - }) -} - -/// Capture a screenshot from the browser and store it for the next model request -pub(super) async fn capture_browser_screenshot( - _sess: &Session, -) -> Result<(PathBuf, String), String> { - let browser_manager = code_browser::global::get_browser_manager() - .await - .ok_or_else(|| "No browser manager available".to_string())?; - - if !browser_manager.is_enabled().await { - return Err("Browser manager is not enabled".to_string()); - } - - // Get current URL first - let url = browser_manager - .get_current_url() - .await - .unwrap_or_else(|| "Browser".to_string()); - tracing::debug!("Attempting to capture screenshot at URL: {}", url); - - match browser_manager.capture_screenshot().await { - Ok(screenshots) => { - if let Some(first_screenshot) = screenshots.first() { - tracing::info!( - "Captured browser screenshot: {} at URL: {}", - first_screenshot.display(), - url - ); - Ok((first_screenshot.clone(), url)) - } else { - let msg = format!("Screenshot capture returned empty results at URL: {}", url); - tracing::warn!("{}", msg); - Err(msg) - } - } - Err(e) => { - let msg = format!("Failed to capture screenshot at {}: {}", url, e); - tracing::warn!("{}", msg); - Err(msg) - } - } -} - -#[derive(Default)] -struct AgentBatchCompletionStatus { - has_terminal: bool, - has_non_terminal: bool, -} - -fn is_terminal_agent_status(status: &str) -> bool { - matches!( - status.trim().to_ascii_lowercase().as_str(), - "completed" | "failed" | "cancelled" | "canceled" - ) -} - -fn is_auto_review_agent_info(agent: &crate::protocol::AgentInfo) -> bool { - matches!(agent.source_kind, Some(AgentSourceKind::AutoReview)) - || agent - .batch_id - .as_deref() - .map(|batch| batch.eq_ignore_ascii_case("auto-review")) - .unwrap_or(false) -} - -fn build_agent_completion_wake_message(batch_id: &str) -> ResponseInputItem { - let text = format!( - "Agents in batch {batch_id} have completed. Call agent {{\"action\":\"wait\",\"wait\":{{\"batch_id\":\"{batch_id}\",\"return_all\":true}}}} to collect their results, then continue the task.", - ); - ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - } -} - -fn ensure_wait_batch_tracking_capacity(state: &mut State, batch_id: &str) { - if !state.seen_completed_agents_by_batch.contains_key(batch_id) { - state - .seen_completed_batch_order - .push_back(batch_id.to_string()); - } - - while state.seen_completed_agents_by_batch.len() > MAX_WAIT_TRACKED_BATCHES { - let Some(oldest_batch) = state.seen_completed_batch_order.pop_front() else { - break; - }; - if state - .seen_completed_agents_by_batch - .remove(&oldest_batch) - .is_some() - { - warn!( - cap = MAX_WAIT_TRACKED_BATCHES, - dropped_batch = oldest_batch, - retained = state.seen_completed_agents_by_batch.len(), - "trimmed wait-for-agent seen batch tracking" - ); - } - } -} - -fn track_seen_completed_agent_for_batch(state: &mut State, batch_id: &str, agent_id: &str) { - ensure_wait_batch_tracking_capacity(state, batch_id); - { - let seen = state - .seen_completed_agents_by_batch - .entry(batch_id.to_string()) - .or_default(); - seen.insert(agent_id.to_string()); - - while seen.len() > MAX_WAIT_TRACKED_AGENT_IDS_PER_BATCH { - let Some(evicted) = seen.iter().next().cloned() else { - break; - }; - seen.remove(&evicted); - warn!( - cap = MAX_WAIT_TRACKED_AGENT_IDS_PER_BATCH, - batch_id, - dropped_agent_id = evicted, - retained = seen.len(), - "trimmed wait-for-agent seen-id tracking for batch" - ); - } - } - - ensure_wait_batch_tracking_capacity(state, batch_id); -} - -fn select_github_run_for_wait( - runs: Vec, - head_sha: Option<&str>, -) -> Option { - let expected_sha = head_sha.map(str::trim).filter(|value| !value.is_empty()); - if let Some(expected_sha) = expected_sha { - runs.into_iter().find(|item| { - item - .get("headSha") - .and_then(|value| value.as_str()) - .is_some_and(|head_sha| head_sha.eq_ignore_ascii_case(expected_sha)) - }) - } else { - runs.into_iter().next() - } -} - -fn agent_completion_wake_messages( - payload: &AgentStatusUpdatePayload, - state: &mut State, -) -> Vec { - let mut batches: HashMap = HashMap::new(); - - for agent in &payload.agents { - if is_auto_review_agent_info(agent) { - continue; - } - let Some(batch_id) = agent.batch_id.as_ref() else { - continue; - }; - let trimmed = batch_id.trim(); - if trimmed.is_empty() { - continue; - } - - let status = batches.entry(trimmed.to_string()).or_default(); - if is_terminal_agent_status(agent.status.as_str()) { - status.has_terminal = true; - } else { - status.has_non_terminal = true; - } - } - - let mut messages = Vec::new(); - for (batch_id, status) in batches { - if !status.has_terminal || status.has_non_terminal { - continue; - } - if !state.agent_completion_wake_batches.insert(batch_id.clone()) { - continue; - } - state.agent_completion_wake_order.push_back(batch_id.clone()); - while state.agent_completion_wake_batches.len() > MAX_AGENT_COMPLETION_WAKE_BATCHES { - let Some(oldest) = state.agent_completion_wake_order.pop_front() else { - break; - }; - state.agent_completion_wake_batches.remove(&oldest); - warn!( - cap = MAX_AGENT_COMPLETION_WAKE_BATCHES, - dropped_batch = oldest, - retained = state.agent_completion_wake_batches.len(), - "trimmed agent completion wake dedupe state" - ); - } - messages.push(build_agent_completion_wake_message(batch_id.as_str())); - } - - messages -} - -async fn enqueue_agent_completion_wake( - sess: &Arc, - messages: Vec, -) { - if messages.is_empty() { - return; - } - - let mut should_start_turn = false; - for message in messages { - if sess.enqueue_out_of_turn_item(message) { - should_start_turn = true; - } - } - - if should_start_turn { - sess.cleanup_old_status_items().await; - let turn_context = sess.make_turn_context(); - let sub_id = sess.next_internal_sub_id(); - let sentinel_input = vec![InputItem::Text { - text: PENDING_ONLY_SENTINEL.to_string(), - }]; - let agent = AgentTask::spawn( - Arc::clone(sess), - turn_context, - sub_id, - sentinel_input, - TaskOriginKind::OutOfTurnDeveloper, - false, - ); - sess.set_task(agent); - } -} - -#[cfg(test)] -mod agent_completion_wake_tests { - use super::agent_completion_wake_messages; - use super::select_github_run_for_wait; - use super::track_seen_completed_agent_for_batch; - use super::State; - use super::AgentSourceKind; - use crate::codex::session::{ - MAX_AGENT_COMPLETION_WAKE_BATCHES, - MAX_WAIT_TRACKED_AGENT_IDS_PER_BATCH, - MAX_WAIT_TRACKED_BATCHES, - }; - use crate::agent_tool::AgentStatusUpdatePayload; - use crate::protocol::AgentInfo; - use serde_json::json; - - fn agent_info( - id: &str, - status: &str, - batch_id: Option<&str>, - source_kind: Option, - ) -> AgentInfo { - AgentInfo { - id: id.to_string(), - name: id.to_string(), - status: status.to_string(), - batch_id: batch_id.map(str::to_string), - model: None, - last_progress: None, - result: None, - error: None, - elapsed_ms: None, - token_count: None, - last_activity_at: None, - seconds_since_last_activity: None, - source_kind, - owner_session_id: None, - worktree_base: None, - } - } - - #[test] - fn agent_completion_wake_messages_dedupes_and_skips_non_terminal() { - let mut state = State::default(); - let running = AgentStatusUpdatePayload { - agents: vec![agent_info("agent-1", "running", Some("batch-1"), None)], - context: None, - task: None, - }; - assert!(agent_completion_wake_messages(&running, &mut state).is_empty()); - - let mixed = AgentStatusUpdatePayload { - agents: vec![ - agent_info("agent-1", "completed", Some("batch-1"), None), - agent_info("agent-2", "running", Some("batch-1"), None), - ], - context: None, - task: None, - }; - assert!(agent_completion_wake_messages(&mixed, &mut state).is_empty()); - - let completed = AgentStatusUpdatePayload { - agents: vec![agent_info("agent-1", "completed", Some("batch-1"), None)], - context: None, - task: None, - }; - let messages = agent_completion_wake_messages(&completed, &mut state); - assert_eq!(messages.len(), 1); - - let messages_again = agent_completion_wake_messages(&completed, &mut state); - assert!(messages_again.is_empty()); - - let auto_review = AgentStatusUpdatePayload { - agents: vec![agent_info( - "agent-3", - "completed", - Some("auto-review"), - Some(AgentSourceKind::AutoReview), - )], - context: None, - task: None, - }; - assert!(agent_completion_wake_messages(&auto_review, &mut state).is_empty()); - } - - #[test] - fn agent_completion_wake_messages_caps_seen_batches() { - let mut state = State::default(); - - for idx in 0..(MAX_AGENT_COMPLETION_WAKE_BATCHES + 16) { - let batch = format!("batch-{idx}"); - let payload = AgentStatusUpdatePayload { - agents: vec![agent_info( - &format!("agent-{idx}"), - "completed", - Some(batch.as_str()), - None, - )], - context: None, - task: None, - }; - - let messages = agent_completion_wake_messages(&payload, &mut state); - assert_eq!(messages.len(), 1, "each fresh batch should emit one wake message"); - } - - assert!(state.agent_completion_wake_batches.len() <= MAX_AGENT_COMPLETION_WAKE_BATCHES); - assert!(state.agent_completion_wake_order.len() <= MAX_AGENT_COMPLETION_WAKE_BATCHES); - } - - #[test] - fn wait_seen_tracking_caps_batches_and_agent_ids() { - let mut state = State::default(); - - for idx in 0..(MAX_WAIT_TRACKED_BATCHES + 12) { - let batch = format!("batch-{idx}"); - track_seen_completed_agent_for_batch(&mut state, &batch, "agent-1"); - } - - assert!(state.seen_completed_agents_by_batch.len() <= MAX_WAIT_TRACKED_BATCHES); - - let hot_batch = "batch-hot"; - for idx in 0..(MAX_WAIT_TRACKED_AGENT_IDS_PER_BATCH + 16) { - track_seen_completed_agent_for_batch( - &mut state, - hot_batch, - &format!("agent-{idx}"), - ); - } - - let seen = state - .seen_completed_agents_by_batch - .get(hot_batch) - .expect("hot batch should be tracked"); - assert!(seen.len() <= MAX_WAIT_TRACKED_AGENT_IDS_PER_BATCH); - } - - #[test] - fn github_run_wait_selects_matching_head_sha() { - let runs = vec![ - json!({ - "databaseId": 2002, - "headSha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - }), - json!({ - "databaseId": 1001, - "headSha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }), - ]; - - let selected = select_github_run_for_wait( - runs, - Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), - ) - .expect("matching run should be selected"); - - assert_eq!(selected["databaseId"], 1001); - } - - #[test] - fn github_run_wait_without_head_sha_uses_latest_run() { - let runs = vec![ - json!({ "databaseId": 2002, "headSha": "bbbb" }), - json!({ "databaseId": 1001, "headSha": "aaaa" }), - ]; - - let selected = select_github_run_for_wait(runs, None) - .expect("latest run should be selected without a SHA constraint"); - - assert_eq!(selected["databaseId"], 2002); - } -} - -/// Send agent status update event to the TUI -async fn send_agent_status_update(sess: &Session) { - let manager = AGENT_MANAGER.read().await; - - // Collect active agents plus a bounded tail of terminal agents so the HUD - // stays responsive in long-running sessions. - let now = Utc::now(); - let agents: Vec = manager - .status_visible_agents_for_session(sess.session_uuid()) - .into_iter() - .map(|agent| { - let status = agent.status.clone(); - let start = agent.started_at.unwrap_or(agent.created_at); - let end = agent.completed_at.unwrap_or(now); - let elapsed_ms = match end.signed_duration_since(start).num_milliseconds() { - value if value >= 0 => Some(value as u64), - _ => None, - }; - - crate::protocol::AgentInfo { - id: agent.id, - name: agent.model.clone(), // Use model name as the display name - status: match &status { - AgentStatus::Pending => "pending".to_string(), - AgentStatus::Running => "running".to_string(), - AgentStatus::Completed => "completed".to_string(), - AgentStatus::Failed => "failed".to_string(), - AgentStatus::Cancelled => "cancelled".to_string(), - }, - batch_id: agent.batch_id, - model: Some(agent.model.clone()), - last_progress: agent.progress.last().cloned(), - result: agent.result, - error: agent.error, - elapsed_ms, - token_count: agent.token_count, - last_activity_at: matches!(status, AgentStatus::Pending | AgentStatus::Running) - .then(|| agent.last_activity.to_rfc3339()), - seconds_since_last_activity: matches!( - status, - AgentStatus::Pending | AgentStatus::Running - ) - .then(|| { - Utc::now() - .signed_duration_since(agent.last_activity) - .num_seconds() - .max(0) as u64 - }), - source_kind: agent.source_kind, - owner_session_id: agent.owner_session_id.map(|id| id.to_string()), - worktree_base: agent.worktree_base, - } - }) - .collect(); - - let event = sess.make_event( - "agent_status", - EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { - agents, - context: None, - task: None, - }), - ); - - // Send event asynchronously - let tx_event = sess.tx_event.clone(); - tokio::spawn(async move { - if let Err(e) = tx_event.send(event).await { - tracing::error!("Failed to send agent status update event: {}", e); - } - }); -} - -/// Add a screenshot to pending screenshots for the next model request -pub(super) fn add_pending_screenshot( - sess: &Session, - screenshot_path: PathBuf, - url: String, -) { - // Do not queue screenshots for next turn anymore; we inject fresh per-turn. - tracing::info!("Captured screenshot; updating UI and using per-turn injection"); - - // Also send an immediate event to update the TUI display - let event = sess.make_event( - "browser_screenshot", - EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent { - screenshot_path, - url, - }), - ); - - // Send event asynchronously to avoid blocking - let tx_event = sess.tx_event.clone(); - tokio::spawn(async move { - if let Err(e) = tx_event.send(event).await { - tracing::error!("Failed to send browser screenshot update event: {}", e); - } - }); -} - -/// Consume pending screenshots and return them as ResponseInputItems -#[allow(dead_code)] -fn consume_pending_screenshots(sess: &Session) -> Vec { - let mut pending = sess.pending_browser_screenshots.lock().unwrap(); - let screenshots = pending.drain(..).collect::>(); - - screenshots - .into_iter() - .map(|path| { - let metadata = format!( - "[EPHEMERAL:browser_screenshot] Browser screenshot at {}", - chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") - ); - - // Read the screenshot file and create an ephemeral image - match std::fs::read(&path) { - Ok(bytes) => { - let mime = mime_guess::from_path(&path) - .first() - .map(|m| m.to_string()) - .unwrap_or_else(|| "image/png".to_string()); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - - ResponseInputItem::Message { - role: "user".to_string(), - content: vec![ - ContentItem::InputText { text: metadata }, - ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - }, - ], - } - } - Err(e) => { - tracing::error!("Failed to read screenshot {}: {}", path.display(), e); - ResponseInputItem::Message { - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("Failed to load browser screenshot: {}", e), - }], - } - } - } - }) - .collect() -} - -fn custom_tool_event_result_text(output: &FunctionCallOutputPayload) -> String { - output.body.to_text().unwrap_or_else(|| output.to_string()) -} - -/// Helper function to wrap custom tool calls with events -async fn execute_custom_tool( - sess: &Session, - ctx: &ToolCallCtx, - tool_name: String, - parameters: Option, - tool_fn: F, -) -> ResponseInputItem -where - F: FnOnce() -> Fut, - Fut: std::future::Future, -{ - use crate::protocol::{CustomToolCallBeginEvent, CustomToolCallEndEvent}; - use std::time::Instant; - - // Send begin event with ordering - let begin_msg = EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent { - call_id: ctx.call_id.clone(), - tool_name: tool_name.clone(), - parameters: parameters.clone(), - }); - let begin_order = ctx.order_meta(sess.current_request_ordinal()); - let begin_event = sess.make_event_with_order(&ctx.sub_id, begin_msg, begin_order, ctx.seq_hint); - sess.send_event(begin_event).await; - - // Execute the tool - let start = Instant::now(); - let result = tool_fn().await; - let duration = start.elapsed(); - - // Extract success/failure from result. Prefer explicit success flag when available. - let (success, message) = match &result { - ResponseInputItem::FunctionCallOutput { output, .. } => { - let content = custom_tool_event_result_text(output); - let success_flag = output.success; - (success_flag.unwrap_or(true), content) - } - _ => (true, String::from("Tool completed")), - }; - - // Send end event with ordering - let end_msg = EventMsg::CustomToolCallEnd(CustomToolCallEndEvent { - call_id: ctx.call_id.clone(), - tool_name, - parameters, - duration, - result: if success { Ok(message) } else { Err(message) }, - }); - let end_order = ctx.order_meta(sess.current_request_ordinal()); - let end_event = sess.make_event_with_order(&ctx.sub_id, end_msg, end_order, ctx.seq_hint); - sess.send_event(end_event).await; - - result -} - -async fn handle_browser_tool(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - use serde_json::Value; - - let parsed_value = match serde_json::from_str::(&arguments) { - Ok(value) => value, - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Invalid browser arguments: {e}")), - success: Some(false)}, - }; - } - }; - - let mut object = match parsed_value { - Value::Object(map) => map, - _ => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Invalid browser arguments: expected an object".to_string()), - success: Some(false)}, - }; - } - }; - - let action_value = object.remove("action"); - let action = match action_value.and_then(|v| v.as_str().map(|s| s.to_string())) { - Some(value) => value, - None => { - return ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Invalid browser arguments: missing 'action'".to_string()), - success: Some(false)}, - }; - } - }; - - let payload_value = Value::Object(object.clone()); - let payload_string = if object.is_empty() { - "{}".to_string() - } else { - serde_json::to_string(&payload_value).unwrap_or_else(|_| "{}".to_string()) - }; - - let action_lower = action.to_lowercase(); - - match action_lower.as_str() { - "open" => handle_browser_open(sess, ctx, payload_string.clone()).await, - "close" => handle_browser_close(sess, ctx).await, - "status" => handle_browser_status(sess, ctx).await, - "click" => handle_browser_click(sess, ctx, payload_string.clone()).await, - "move" => handle_browser_move(sess, ctx, payload_string.clone()).await, - "type" => handle_browser_type(sess, ctx, payload_string.clone()).await, - "key" => handle_browser_key(sess, ctx, payload_string.clone()).await, - "javascript" => handle_browser_javascript(sess, ctx, payload_string.clone()).await, - "scroll" => handle_browser_scroll(sess, ctx, payload_string.clone()).await, - "history" => handle_browser_history(sess, ctx, payload_string.clone()).await, - "inspect" => handle_browser_inspect(sess, ctx, payload_string.clone()).await, - "console" => handle_browser_console(sess, ctx, payload_string.clone()).await, - "cdp" => handle_browser_cdp(sess, ctx, payload_string.clone()).await, - "cleanup" => handle_browser_cleanup(sess, ctx).await, - "fetch" => handle_web_fetch(sess, ctx, payload_string.clone()).await, - _ => ResponseInputItem::FunctionCallOutput { - call_id: ctx.call_id.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Unknown browser action: {}", action)), - success: Some(false)}, - }, - } -} - -async fn handle_browser_open(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - // Parse arguments as JSON for the event - let params = serde_json::from_str(&arguments).ok(); - - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_open".to_string(), - params, - || async move { - // Parse the URL from arguments - let args: Result = serde_json::from_str(&arguments_clone); - - match args { - Ok(json) => { - let url = json - .get("url") - .and_then(|v| v.as_str()) - .unwrap_or("about:blank"); - - if url.trim().to_ascii_lowercase().starts_with("devtools://") { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Developer tools are disabled for this browser session. Use the browser.console tool to inspect logs instead.".to_string()), - success: Some(false)}, - }; - } - - // Use the global browser manager (create if needed) - let browser_manager = { - let existing_global = code_browser::global::get_browser_manager().await; - if let Some(existing) = existing_global { - tracing::info!("Using existing global browser manager"); - Some(existing) - } else { - tracing::info!("Creating new browser manager"); - let new_manager = - code_browser::global::get_or_create_browser_manager().await; - Some(new_manager) - } - }; - - if let Some(browser_manager) = browser_manager { - // Ensure the browser manager is marked enabled so status reflects reality - browser_manager.set_enabled_sync(true); - // Clear any lingering node highlight from previous commands - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - // Navigate to the URL with detailed timing logs - let step_start = std::time::Instant::now(); - tracing::info!("[browser_open] begin goto: {}", url); - match browser_manager.goto(url).await { - Ok(_) => { - tracing::info!( - "[browser_open] goto success: {} in {:?}", - url, - step_start.elapsed() - ); - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Browser opened to: {}", url)), - success: Some(true)}, - } - } - Err(e) => { - let error_string = e.to_string(); - let error_lower = error_string.to_ascii_lowercase(); - let url_lower = url.to_ascii_lowercase(); - let is_local = url_lower.starts_with("http://localhost") - || url_lower.starts_with("https://localhost") - || url_lower.starts_with("http://127.") - || url_lower.starts_with("https://127.") - || url_lower.starts_with("http://[::1]") - || url_lower.starts_with("https://[::1]") - || url_lower.starts_with("http://0.0.0.0") - || url_lower.starts_with("https://0.0.0.0"); - let mut content = - format!("Failed to navigate browser to {url}: {error_string}"); - if error_lower.contains("oneshot error") - || error_lower.contains("oneshot canceled") - || error_lower.contains("oneshot cancelled") - { - content.push_str( - " The CDP navigation was cancelled before it completed.", - ); - if is_local { - content.push_str( - " If this is a local server, make sure it is reachable from the browser process (binding to 0.0.0.0 or using the machine IP can help).", - ); - } else { - content.push_str( - " Reopening the browser page and retrying can resolve transient target resets.", - ); - } - } - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(false), - }, - } - } - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Failed to initialize browser manager.".to_string()), - success: Some(false)}, - } - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_open arguments: {}", e)), - success: Some(false)}, - }, - } - }, - ) - .await -} - -/// Get the browser manager for the session (always uses global) -async fn get_browser_manager_for_session( - _sess: &Session, -) -> Option> { - // Always use the global browser manager - code_browser::global::get_browser_manager().await -} - -async fn handle_browser_close(sess: &Session, ctx: &ToolCallCtx) -> ResponseInputItem { - let sess_clone = sess; - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_close".to_string(), - None, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - // Clear any lingering highlight before closing - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - match browser_manager.stop().await { - Ok(_) => { - // Clear the browser manager from global - code_browser::global::clear_browser_manager().await; - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser closed. Screenshot capture disabled.".to_string()), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to close browser: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not currently open.".to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_status(sess: &Session, ctx: &ToolCallCtx) -> ResponseInputItem { - let sess_clone = sess; - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_status".to_string(), - None, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - let status = browser_manager.get_status().await; - let status_msg = if status.enabled { - if let Some(url) = status.current_url { - format!("Browser status: Enabled, currently at {}", url) - } else { - "Browser status: Enabled, no page loaded".to_string() - } - } else { - "Browser status: Disabled".to_string() - }; - - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(status_msg), - success: Some(true)}, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser." - .to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_click(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str::(&arguments).ok(); - let sess_clone = sess; - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_click".to_string(), - params.clone(), - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - // Determine click type: default 'click', or 'mousedown'/'mouseup' - let click_type = params - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()) - .unwrap_or("click") - .to_lowercase(); - - // Optional absolute coordinates - let (mut target_x, mut target_y) = (None, None); - if let Some(p) = params.as_ref() { - if let Some(vx) = p.get("x").and_then(|v| v.as_f64()) { - target_x = Some(vx); - } - if let Some(vy) = p.get("y").and_then(|v| v.as_f64()) { - target_y = Some(vy); - } - } - - // If x or y provided, resolve missing coord from current position, then move - if target_x.is_some() || target_y.is_some() { - // get current cursor for missing values - match browser_manager.get_cursor_position().await { - Ok((cx, cy)) => { - let x = target_x.unwrap_or(cx); - let y = target_y.unwrap_or(cy); - if let Err(e) = browser_manager.move_mouse(x, y).await { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to move before click: {}", e)), - success: Some(false)}, - }; - } - } - Err(e) => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to get current cursor position: {}", e)), - success: Some(false)}, - }; - } - } - } - - // Perform the action at current (possibly moved) position - let action_result = match click_type.as_str() { - "mousedown" => match browser_manager.mouse_down_at_current().await { - Ok((x, y)) => Ok((x, y, "Mouse down".to_string())), - Err(e) => Err(e), - }, - "mouseup" => match browser_manager.mouse_up_at_current().await { - Ok((x, y)) => Ok((x, y, "Mouse up".to_string())), - Err(e) => Err(e), - }, - "click" | _ => match browser_manager.click_at_current().await { - Ok((x, y)) => Ok((x, y, "Clicked".to_string())), - Err(e) => Err(e), - }, - }; - - match action_result { - Ok((x, y, label)) => { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("{} at ({}, {})", label, x, y)), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to perform mouse action: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser." - .to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_move(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_move".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - // Check if we have relative movement (dx, dy) or absolute (x, y) - let has_dx = json.get("dx").is_some(); - let has_dy = json.get("dy").is_some(); - let has_x = json.get("x").is_some(); - let has_y = json.get("y").is_some(); - - let result = if has_dx || has_dy { - // Relative movement - let dx = json.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0); - let dy = json.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0); - browser_manager.move_mouse_relative(dx, dy).await - } else if has_x || has_y { - // Absolute movement - let x = json.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); - let y = json.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); - browser_manager.move_mouse(x, y).await.map(|_| (x, y)) - } else { - // No parameters provided, just return current position - browser_manager.get_cursor_position().await - }; - - match result { - Ok((x, y)) => { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Moved mouse position to ({}, {})", x, y)), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to move mouse: {}", e)), - success: Some(false)}, - }, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_move arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser." - .to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_type(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_type".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - let text = json.get("text").and_then(|v| v.as_str()).unwrap_or(""); - - match browser_manager.type_text(text).await { - Ok(_) => { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Typed: {}", text)), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to type text: {}", e)), - success: Some(false)}, - }, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_type arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser." - .to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_key(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_key".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - let key = json.get("key").and_then(|v| v.as_str()).unwrap_or(""); - - let normalized = key - .split_whitespace() - .collect::() - .to_ascii_lowercase(); - if matches!(normalized.as_str(), "f12" | "ctrl+shift+i" | "control+shift+i") { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Developer tools are disabled for this browser session. Use the browser.console tool to inspect logs instead.".to_string()), - success: Some(false)}, - }; - } - - match browser_manager.press_key(key).await { - Ok(_) => { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Pressed key: {}", key)), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to press key: {}", e)), - success: Some(false)}, - }, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_key arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser." - .to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_javascript(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_javascript".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - let code = json.get("code").and_then(|v| v.as_str()).unwrap_or(""); - - match browser_manager.execute_javascript(code).await { - Ok(result) => { - // Log the JavaScript execution result - tracing::info!("JavaScript execution returned: {:?}", result); - - // Format the result for the LLM - let formatted_result = if let Some(obj) = result.as_object() { - // Check if it's our wrapped result format - if let (Some(success), Some(value)) = - (obj.get("success"), obj.get("value")) - { - let logs = obj.get("logs").and_then(|v| v.as_array()); - let mut output = String::new(); - - if let Some(logs) = logs { - if !logs.is_empty() { - output.push_str("Console logs:\n"); - for log in logs { - if let Some(log_str) = log.as_str() { - output - .push_str(&format!(" {}\n", log_str)); - } - } - output.push_str("\n"); - } - } - - if success.as_bool().unwrap_or(false) { - output.push_str("Result: "); - output.push_str( - &serde_json::to_string_pretty(value) - .unwrap_or_else(|_| "null".to_string()), - ); - } else if let Some(error) = obj.get("error") { - output.push_str("Error: "); - output.push_str(&error.to_string()); - } - - output - } else { - // Fallback to raw JSON if not in expected format - serde_json::to_string_pretty(&result) - .unwrap_or_else(|_| "null".to_string()) - } - } else { - // Not an object, return as-is - serde_json::to_string_pretty(&result) - .unwrap_or_else(|_| "null".to_string()) - }; - - tracing::info!("Returning to LLM: {}", formatted_result); - - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(formatted_result), - success: Some(true)}, - } - } - Err(e) => { - let error_string = e.to_string(); - let mut content = - format!("Failed to execute JavaScript: {error_string}"); - if error_string.to_ascii_lowercase().contains("oneshot") { - content.push_str(" (CDP request was cancelled or the page session was reset; reconnecting the browser and retrying usually helps.)"); - } - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(content), - success: Some(false), - }, - } - } - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_javascript arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser." - .to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_scroll(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_scroll".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - let dx = json.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0); - let dy = json.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0); - - match browser_manager.scroll_by(dx, dy).await { - Ok(_) => { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Scrolled by ({}, {})", dx, dy)), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to scroll: {}", e)), - success: Some(false)}, - }, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_scroll arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser.".to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_console(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_console".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let args: Result = serde_json::from_str(&arguments_clone); - let lines = match args { - Ok(json) => json.get("lines").and_then(|v| v.as_u64()).map(|n| n as usize), - Err(_) => None, - }; - - match browser_manager.get_console_logs(lines).await { - Ok(logs) => { - // Format the logs for display - let formatted = if let Some(logs_array) = logs.as_array() { - if logs_array.is_empty() { - "No console logs captured.".to_string() - } else { - let mut output = String::new(); - output.push_str("Console logs:\n"); - for log in logs_array { - if let Some(log_obj) = log.as_object() { - let timestamp = log_obj.get("timestamp") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let level = log_obj.get("level") - .and_then(|v| v.as_str()) - .unwrap_or("log"); - let message = log_obj.get("message") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - output.push_str(&format!("[{}] [{}] {}\n", timestamp, level.to_uppercase(), message)); - } - } - output - } - } else { - "No console logs captured.".to_string() - }; - - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(formatted), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to get console logs: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not enabled. Use browser_open to enable it first.".to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_cdp(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_cdp".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let _ = browser_manager - .execute_cdp("Overlay.hideHighlight", serde_json::json!({})) - .await; - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - let method = json - .get("method") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let params = json.get("params").cloned().unwrap_or_else(|| Value::Object(serde_json::Map::new())); - let target = json - .get("target") - .and_then(|v| v.as_str()) - .unwrap_or("page"); - - if method.is_empty() { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Missing required field: method".to_string()), - success: Some(false)}, - }; - } - - let exec_res = if target == "browser" { - browser_manager.execute_cdp_browser(&method, params).await - } else { - browser_manager.execute_cdp(&method, params).await - }; - - match exec_res { - Ok(result) => { - let pretty = serde_json::to_string_pretty(&result) - .unwrap_or_else(|_| "".to_string()); - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(pretty), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to execute CDP command: {}", e)), - success: Some(false)}, - }, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_cdp arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser.".to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_inspect(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - use serde_json::json; - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_inspect".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - // Determine target element: by id, by coords, or by cursor - let id_attr = json.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); - let mut x = json.get("x").and_then(|v| v.as_f64()); - let mut y = json.get("y").and_then(|v| v.as_f64()); - - if (x.is_none() || y.is_none()) && id_attr.is_none() { - // No coords provided; use current cursor - if let Ok((cx, cy)) = browser_manager.get_cursor_position().await { - x = Some(cx); - y = Some(cy); - } - } - - // Resolve nodeId - let node_id_value = if let Some(id_attr) = id_attr.clone() { - // Use DOM.getDocument -> DOM.querySelector with selector `#id` - let doc = browser_manager - .execute_cdp("DOM.getDocument", json!({})) - .await - .map_err(|e| e); - let root_id = match doc { - Ok(v) => v.get("root").and_then(|r| r.get("nodeId")).and_then(|n| n.as_u64()), - Err(_) => None, - }; - if let Some(root_node_id) = root_id { - let sel = format!("#{}", id_attr); - let q = browser_manager - .execute_cdp( - "DOM.querySelector", - json!({"nodeId": root_node_id, "selector": sel}), - ) - .await; - match q { - Ok(v) => v.get("nodeId").cloned(), - Err(_) => None, - } - } else { - None - } - } else if let (Some(x), Some(y)) = (x, y) { - // Use DOM.getNodeForLocation - let res = browser_manager - .execute_cdp( - "DOM.getNodeForLocation", - json!({ - "x": x, - "y": y, - "includeUserAgentShadowDOM": true - }), - ) - .await; - match res { - Ok(v) => { - // Prefer nodeId; if absent, push backendNodeId - if let Some(n) = v.get("nodeId").cloned() { - Some(n) - } else if let Some(backend) = v.get("backendNodeId").and_then(|b| b.as_u64()) { - let pushed = browser_manager - .execute_cdp( - "DOM.pushNodesByBackendIdsToFrontend", - json!({ "backendNodeIds": [backend] }), - ) - .await - .ok(); - pushed - .and_then(|pv| pv.get("nodeIds").and_then(|arr| arr.as_array().cloned())) - .and_then(|arr| arr.first().cloned()) - } else { - None - } - } - Err(_) => None, - } - } else { - None - }; - - let node_id = match node_id_value.and_then(|v| v.as_u64()) { - Some(id) => id, - None => { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Failed to resolve target node for inspection".to_string()), - success: Some(false)}, - }; - } - }; - - // Enable CSS domain to get matched rules - let _ = browser_manager.execute_cdp("CSS.enable", json!({})).await; - - // Gather details - let attrs = browser_manager - .execute_cdp("DOM.getAttributes", json!({"nodeId": node_id})) - .await - .unwrap_or_else(|_| json!({})); - let outer = browser_manager - .execute_cdp("DOM.getOuterHTML", json!({"nodeId": node_id})) - .await - .unwrap_or_else(|_| json!({})); - let box_model = browser_manager - .execute_cdp("DOM.getBoxModel", json!({"nodeId": node_id})) - .await - .unwrap_or_else(|_| json!({})); - let styles = browser_manager - .execute_cdp("CSS.getMatchedStylesForNode", json!({"nodeId": node_id})) - .await - .unwrap_or_else(|_| json!({})); - - // Highlight the inspected node using Overlay domain (no screenshot capture here) - let _ = browser_manager.execute_cdp("Overlay.enable", json!({})).await; - let highlight_config = json!({ - "showInfo": true, - "showStyles": false, - "showRulers": false, - "contentColor": {"r": 111, "g": 168, "b": 220, "a": 0.20}, - "paddingColor": {"r": 147, "g": 196, "b": 125, "a": 0.55}, - "borderColor": {"r": 255, "g": 229, "b": 153, "a": 0.60}, - "marginColor": {"r": 246, "g": 178, "b": 107, "a": 0.60} - }); - let _ = browser_manager.execute_cdp( - "Overlay.highlightNode", - json!({ "nodeId": node_id, "highlightConfig": highlight_config }) - ).await; - // Do not hide here; keep highlight until the next browser command. - - // Format output - let mut out = String::new(); - if let (Some(ix), Some(iy)) = (x, y) { - out.push_str(&format!("Target: coordinates ({}, {})\n", ix, iy)); - } - if let Some(id_attr) = id_attr { - out.push_str(&format!("Target: id '#{}'\n", id_attr)); - } - out.push_str(&format!("NodeId: {}\n", node_id)); - - // Attributes - if let Some(arr) = attrs.get("attributes").and_then(|v| v.as_array()) { - out.push_str("Attributes:\n"); - let mut it = arr.iter(); - while let (Some(k), Some(v)) = (it.next(), it.next()) { - out.push_str(&format!(" {}=\"{}\"\n", k.as_str().unwrap_or(""), v.as_str().unwrap_or(""))); - } - } - - // Outer HTML - if let Some(html) = outer.get("outerHTML").and_then(|v| v.as_str()) { - let one = html.replace('\n', " "); - let snippet: String = one.chars().take(800).collect(); - out.push_str("\nOuterHTML (truncated):\n"); - out.push_str(&snippet); - if one.len() > snippet.len() { out.push_str("…"); } - out.push('\n'); - } - - // Box Model summary - if box_model.get("model").is_some() { - out.push_str("\nBoxModel: available (content/padding/border/margin)\n"); - } - - // Matched styles summary - if let Some(rules) = styles.get("matchedCSSRules").and_then(|v| v.as_array()) { - out.push_str(&format!("Matched CSS rules: {}\n", rules.len())); - } - - // No inline screenshot capture; result reflects DOM details only. - - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload {body: code_protocol::models::FunctionCallOutputBody::Text(out), success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_inspect arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser.".to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -async fn handle_browser_history(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem { - let params = serde_json::from_str(&arguments).ok(); - let sess_clone = sess; - let arguments_clone = arguments.clone(); - let call_id_clone = ctx.call_id.clone(); - - execute_custom_tool( - sess, - ctx, - "browser_history".to_string(), - params, - || async move { - let browser_manager = get_browser_manager_for_session(sess_clone).await; - if let Some(browser_manager) = browser_manager { - let args: Result = serde_json::from_str(&arguments_clone); - match args { - Ok(json) => { - let direction = - json.get("direction").and_then(|v| v.as_str()).unwrap_or(""); - - if direction != "back" && direction != "forward" { - return ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!( - "Unsupported direction: {} (expected 'back' or 'forward')", - direction - )), - success: Some(false)}, - }; - } - - let action_res = if direction == "back" { - browser_manager.history_back().await - } else { - browser_manager.history_forward().await - }; - - match action_res { - Ok(_) => { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("History {} triggered", direction)), - success: Some(true)}, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone.clone(), - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to navigate history: {}", e)), - success: Some(false)}, - }, - } - } - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text(format!("Failed to parse browser_history arguments: {}", e)), - success: Some(false)}, - }, - } - } else { - ResponseInputItem::FunctionCallOutput { - call_id: call_id_clone, - output: FunctionCallOutputPayload { - body: code_protocol::models::FunctionCallOutputBody::Text("Browser is not initialized. Use browser_open to start the browser." - .to_string()), - success: Some(false)}, - } - } - }, - ) - .await -} - -fn extract_shell_script(argv: &[String]) -> Option<(usize, String)> { - crate::util::extract_shell_script(argv).map(|(index, script)| (index, script.to_string())) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CatWriteSuggestion { - label: &'static str, - original_value: String, -} - -fn detect_cat_write(argv: &[String]) -> Option { - if let Some((_, script)) = extract_shell_script(argv) { - if script_contains_cat_write(&script) { - return Some(CatWriteSuggestion { - label: "original_script", - original_value: script, - }); - } - } - - None -} - -fn script_contains_cat_write(script: &str) -> bool { - script - .lines() - .any(|line| line_contains_cat_heredoc_write(line)) -} - -fn line_contains_cat_heredoc_write(line: &str) -> bool { - let trimmed = line.trim_start(); - if trimmed.is_empty() || trimmed.starts_with('#') { - return false; - } - - let lower = line.to_ascii_lowercase(); - if !lower.contains("<<") || !lower.contains('>') { - return false; - } - - let bytes = lower.as_bytes(); - let mut idx = 0; - while idx + 3 <= bytes.len() { - if bytes[idx..].starts_with(b"cat") { - if idx > 0 { - let prev = bytes[idx - 1]; - if prev.is_ascii_alphanumeric() || prev == b'_' { - idx += 1; - continue; - } - } - - let after = &lower[idx + 3..]; - let after_trimmed = after.trim_start(); - if after_trimmed.starts_with("<<") { - let heredoc_offset_in_after = after.find("<<").unwrap_or(0); - let heredoc_offset = idx + 3 + heredoc_offset_in_after; - let redirect_section = &lower[heredoc_offset..]; - if let Some(rel_redirect_idx) = redirect_section.find('>') { - let redirect_idx = heredoc_offset + rel_redirect_idx; - if redirect_idx > heredoc_offset { - let redirect_slice = &lower[redirect_idx..]; - if redirect_slice.starts_with(">&") { - idx += 1; - continue; - } - let after_gt = redirect_slice[1..].trim_start(); - if after_gt.starts_with('&') { - idx += 1; - continue; - } - if after_gt.starts_with('(') { - idx += 1; - continue; - } - return true; - } - } - } - } - idx += 1; - } - - false -} - -fn guard_apply_patch_outside_branch(branch_root: &Path, action: &ApplyPatchAction) -> Option { - let branch_norm = match normalize_absolute(branch_root) { - Some(path) => path, - None => { - return Some(format!( - "apply_patch blocked: failed to resolve /branch worktree root {}. Stay inside the worktree until you finish with `/merge`.", - branch_root.display() - )); - } - }; - let action_cwd_norm = match normalize_absolute(&action.cwd) { - Some(path) => path, - None => { - return Some(format!( - "apply_patch blocked: the command resolved outside the /branch worktree (cwd {}). Stay inside {} until you finish with `/merge`.", - action.cwd.display(), - branch_root.display() - )); - } - }; - if !path_within(&action_cwd_norm, &branch_norm) { - return Some(format!( - "apply_patch blocked: the active /branch worktree is {} but the command tried to run from {}. Stay inside the worktree until you finish with `/merge`.", - branch_root.display(), - action.cwd.display() - )); - } - - for path in action.changes().keys() { - let normalized = match normalize_absolute(path) { - Some(value) => value, - None => { - return Some(format!( - "apply_patch blocked: could not resolve patch target {} inside worktree {}. Keep edits within the /branch directory.", - path.display(), - branch_root.display() - )); - } - }; - if !path_within(&normalized, &branch_norm) { - return Some(format!( - "apply_patch blocked: patch would modify {} outside the active /branch worktree {}. Apply changes from within the worktree before `/merge`.", - path.display(), - branch_root.display() - )); - } - } - - None -} - -fn normalize_absolute(path: &Path) -> Option { - if !path.is_absolute() { - return None; - } - let mut result = PathBuf::new(); - for component in path.components() { - match component { - Component::Prefix(prefix) => result.push(prefix.as_os_str()), - Component::RootDir => result.push(component.as_os_str()), - Component::CurDir => {} - Component::ParentDir => { - if !result.pop() { - return None; - } - } - Component::Normal(part) => result.push(part), - } - } - if result.as_os_str().is_empty() { - None - } else { - Some(result) - } -} - -fn normalize_for_containment(path: &Path) -> Option { - if let Ok(canonical) = path.canonicalize() { - return normalize_absolute(&canonical); - } - normalize_absolute(path) -} - -fn path_within(path: &Path, base: &Path) -> bool { - path.starts_with(base) -} - -fn guidance_for_cat_write(suggestion: &CatWriteSuggestion) -> String { - format!( - "Blocked cat heredoc that writes files directly. Use apply_patch to edit files so changes stay reviewable.\n\n{}: {}", - suggestion.label, - suggestion.original_value - ) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PythonWriteSuggestion { - label: &'static str, - original_value: String, -} - -fn detect_python_write(argv: &[String]) -> Option { - if let Some((_, script)) = extract_shell_script(argv) { - if script_contains_python_write(&script) { - return Some(PythonWriteSuggestion { - label: "original_script", - original_value: script, - }); - } - } - - detect_python_write_in_argv(argv) -} - -fn detect_python_write_in_argv(argv: &[String]) -> Option { - if argv.is_empty() { - return None; - } - - if !is_python_command(&argv[0]) { - return None; - } - - if argv.len() >= 3 && argv[1] == "-c" { - let code = &argv[2]; - if python_code_writes_files(code) { - return Some(PythonWriteSuggestion { - label: "python_inline_script", - original_value: code.clone(), - }); - } - } - - None -} - -fn script_contains_python_write(script: &str) -> bool { - let lower = script.to_ascii_lowercase(); - if !(lower.contains("python ") - || lower.contains("python3") - || lower.contains("python\n")) - { - return false; - } - contains_python_write_keywords(&lower) -} - -fn python_code_writes_files(code: &str) -> bool { - contains_python_write_keywords(&code.to_ascii_lowercase()) -} - -fn contains_python_write_keywords(lower: &str) -> bool { - const KEYWORDS: &[&str] = &[ - "write_text(", - "write_bytes(", - ".write_text(", - ".write_bytes(", - ]; - if (lower.contains("open(") || lower.contains(".open(")) - && ["'w'", "\"w\"", "'a'", "\"a\"", "'x'", "\"x\"", "'w+", "\"w+", "'a+", "\"a+"] - .iter() - .any(|mode| lower.contains(mode)) - { - return true; - } - KEYWORDS.iter().any(|needle| lower.contains(needle)) -} - -fn is_python_command(cmd: &str) -> bool { - std::path::Path::new(cmd) - .file_name() - .and_then(|s| s.to_str()) - .map(|name| { - let lower = name.to_ascii_lowercase(); - matches!(lower.as_str(), "python" | "python3" | "python2") - }) - .unwrap_or(false) -} - -fn guidance_for_python_write(suggestion: &PythonWriteSuggestion) -> String { - format!( - "Blocked python command that writes files directly. Use apply_patch to edit files so changes stay reviewable.\n\n{}: {}", - suggestion.label, - suggestion.original_value - ) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RedundantCdSuggestion { - label: &'static str, - original_value: String, - suggested: Vec, - target_arg: String, - cwd: PathBuf, -} - -fn detect_redundant_cd(argv: &[String], cwd: &Path) -> Option { - let normalized_cwd = normalize_path(cwd); - if let Some((script_index, script)) = extract_shell_script(argv) { - if let Some(suggestion) = detect_redundant_cd_in_shell( - argv, - script_index, - &script, - cwd, - &normalized_cwd, - ) { - return Some(suggestion); - } - } - detect_redundant_cd_in_argv(argv, cwd, &normalized_cwd) -} - -fn detect_redundant_cd_in_shell( - argv: &[String], - script_index: usize, - script: &str, - cwd: &Path, - normalized_cwd: &Path, -) -> Option { - let trimmed = script.trim_start(); - let tokens = shlex_split(trimmed)?; - if tokens.len() < 3 { - return None; - } - if tokens.first().map(String::as_str) != Some("cd") { - return None; - } - let target = tokens.get(1)?.clone(); - if !is_simple_cd_target(&target) { - return None; - } - let resolved_target = resolve_cd_target(&target, cwd)?; - if resolved_target != normalized_cwd { - return None; - } - - let mut idx = 2; - let mut saw_connector = false; - while idx < tokens.len() && is_connector(&tokens[idx]) { - saw_connector = true; - idx += 1; - } - if !saw_connector || idx >= tokens.len() { - return None; - } - - let remainder_tokens = tokens[idx..].to_vec(); - let suggested_script = shlex_try_join(remainder_tokens.iter().map(|s| s.as_str())) - .unwrap_or_else(|_| remainder_tokens.join(" ")); - if suggested_script.trim().is_empty() { - return None; - } - - let mut suggested = argv.to_vec(); - suggested[script_index] = suggested_script; - - Some(RedundantCdSuggestion { - label: "original_script", - original_value: script.to_string(), - suggested, - target_arg: target, - cwd: normalized_cwd.to_path_buf(), - }) -} - -fn detect_redundant_cd_in_argv( - argv: &[String], - cwd: &Path, - normalized_cwd: &Path, -) -> Option { - if argv.len() < 4 { - return None; - } - if argv.first().map(String::as_str) != Some("cd") { - return None; - } - let target = argv.get(1)?.clone(); - if !is_simple_cd_target(&target) { - return None; - } - let resolved_target = resolve_cd_target(&target, cwd)?; - if resolved_target != normalized_cwd { - return None; - } - - let mut idx = 2; - let mut saw_connector = false; - while idx < argv.len() && is_connector(&argv[idx]) { - saw_connector = true; - idx += 1; - } - if !saw_connector || idx >= argv.len() { - return None; - } - - let suggested = argv[idx..].to_vec(); - if suggested.is_empty() { - return None; - } - - Some(RedundantCdSuggestion { - label: "original_argv", - original_value: format!("{:?}", argv), - suggested, - target_arg: target, - cwd: normalized_cwd.to_path_buf(), - }) -} - -fn resolve_cd_target(target: &str, cwd: &Path) -> Option { - if target.is_empty() { - return None; - } - let candidate = if Path::new(target).is_absolute() { - PathBuf::from(target) - } else { - cwd.join(target) - }; - Some(normalize_path(candidate.as_path())) -} - -fn normalize_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - let _ = normalized.pop(); - } - Component::Prefix(prefix) => { - normalized = PathBuf::from(prefix.as_os_str()); - } - Component::RootDir => { - normalized.push(component.as_os_str()); - } - Component::Normal(part) => normalized.push(part), - } - } - if normalized.as_os_str().is_empty() { - PathBuf::from(".") - } else { - normalized - } -} - -fn is_simple_cd_target(target: &str) -> bool { - if target.is_empty() || target == "-" { - return false; - } - !target.chars().any(|ch| matches!(ch, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}' | '(' | ')' | '|' | '>' | '<' | '!')) -} - -fn is_connector(token: &str) -> bool { - matches!(token, "&&" | ";" | "||") -} - -fn guidance_for_redundant_cd(suggestion: &RedundantCdSuggestion) -> String { - let suggested = serde_json::to_string(&suggestion.suggested) - .unwrap_or_else(|_| "".to_string()); - let target_display = shlex_try_join(std::iter::once(suggestion.target_arg.as_str())) - .unwrap_or_else(|_| suggestion.target_arg.clone()); - format!( - "Leading cd {target_display} is redundant because the command already runs in {}. Drop the prefix before retrying.\n\n{}: {}\nresend_exact_argv: {}", - suggestion.cwd.display(), - suggestion.label, - suggestion.original_value, - suggested - ) -} - -#[cfg(test)] -mod command_guard_detection_tests { - use super::*; - use crate::active_sessions::ActiveSessionModelNotice; - use code_apply_patch::maybe_parse_apply_patch_verified; - use std::path::PathBuf; - - fn notice() -> ActiveSessionModelNotice { - ActiveSessionModelNotice { - fingerprint: "fp-1".to_string(), - message: "notice".to_string(), - checkout_root: PathBuf::from("/repo"), - suggested_worktree_path: PathBuf::from("/worktrees/repo/task"), - } - } - - #[test] - fn detects_shell_redundant_cd() { - let cwd = PathBuf::from("/tmp/project"); - let argv = vec![ - "bash".to_string(), - "-lc".to_string(), - "cd /tmp/project && ls".to_string(), - ]; - - let suggestion = detect_redundant_cd(&argv, &cwd).expect("should flag redundant cd"); - assert_eq!(suggestion.label, "original_script"); - assert_eq!(suggestion.suggested, vec!["bash".to_string(), "-lc".to_string(), "ls".to_string()]); - } - - #[test] - fn detects_raw_shell_script_redundant_cd() { - let cwd = PathBuf::from("/tmp/project"); - let argv = vec!["cd /tmp/project && ls".to_string()]; - - let suggestion = detect_redundant_cd(&argv, &cwd).expect("should flag redundant cd"); - assert_eq!(suggestion.label, "original_script"); - assert_eq!(suggestion.suggested, vec!["ls".to_string()]); - } - - #[test] - fn ignores_cd_to_different_directory() { - let cwd = PathBuf::from("/tmp/project"); - let argv = vec![ - "bash".to_string(), - "-lc".to_string(), - "cd /tmp/project/src && ls".to_string(), - ]; - - assert!(detect_redundant_cd(&argv, &cwd).is_none()); - } - - #[test] - fn skips_dynamic_cd_targets() { - let cwd = PathBuf::from("/tmp/project"); - let argv = vec![ - "bash".to_string(), - "-lc".to_string(), - "cd $PWD && ls".to_string(), - ]; - - assert!(detect_redundant_cd(&argv, &cwd).is_none()); - } - - #[test] - fn detects_cat_heredoc_write() { - let argv = vec![ - "bash".to_string(), - "-lc".to_string(), - "cat <<'EOF' > code-rs/git-tooling/Cargo.toml\n[package]\nname = \"demo\"\nEOF".to_string(), - ]; - - let suggestion = detect_cat_write(&argv).expect("should flag cat write"); - assert_eq!(suggestion.label, "original_script"); - assert!(suggestion - .original_value - .contains("cat <<'EOF' > code-rs/git-tooling/Cargo.toml")); - } - - #[test] - fn detects_raw_shell_script_cat_heredoc_write() { - let argv = vec![ - "cat <<'EOF' > code-rs/git-tooling/Cargo.toml\n[package]\nname = \"demo\"\nEOF".to_string(), - ]; - - let suggestion = detect_cat_write(&argv).expect("should flag cat write"); - assert_eq!(suggestion.label, "original_script"); - assert!(suggestion - .original_value - .contains("cat <<'EOF' > code-rs/git-tooling/Cargo.toml")); - } - - #[test] - fn allows_cat_heredoc_without_redirect() { - let argv = vec![ - "bash".to_string(), - "-lc".to_string(), - "cat <<'EOF'\nhello\nEOF".to_string(), - ]; - - assert!(detect_cat_write(&argv).is_none()); - } - - #[test] - fn allows_cat_redirect_to_fd() { - let argv = vec![ - "bash".to_string(), - "-lc".to_string(), - "cat <<'EOF' >&2\nwarn\nEOF".to_string(), - ]; - - assert!(detect_cat_write(&argv).is_none()); - } - - #[test] - fn active_session_write_classifier_allows_read_only_commands() { - for argv in [ - vec!["cargo".to_string(), "test".to_string()], - vec!["npm".to_string(), "test".to_string()], - vec!["git".to_string(), "status".to_string()], - vec!["bash".to_string(), "-lc".to_string(), "git status --short".to_string()], - ] { - assert!(!argv_is_likely_write(&argv), "{argv:?} should be read-only"); - } - } - - #[test] - fn active_session_write_classifier_flags_obvious_writes() { - for argv in [ - vec!["cargo".to_string(), "fmt".to_string()], - vec!["npm".to_string(), "install".to_string()], - vec!["bash".to_string(), "-lc".to_string(), "echo hi > README.md".to_string()], - vec!["bash".to_string(), "-lc".to_string(), "echo changed > decision.txt".to_string()], - vec!["bash".to_string(), "-lc".to_string(), "git add README.md".to_string()], - ] { - assert!(argv_is_likely_write(&argv), "{argv:?} should be treated as a write"); - } - } - - #[test] - fn active_session_write_classifier_checks_deferred_shell_script() { - let params = ExecParams { - command: vec!["echo changed > decision.txt".to_string()], - shell_script: Some(crate::exec::DeferredShellScript { - command: "echo changed > decision.txt".to_string(), - use_login_shell: true, - }), - cwd: PathBuf::from("/repo"), - timeout_ms: None, - env: Default::default(), - with_escalated_permissions: None, - justification: None, - }; - - assert!(exec_params_is_likely_write(¶ms)); - } - - #[test] - fn active_session_decision_allows_stay_here_with_same_fingerprint() { - let notice = notice(); - let decision = ActiveSessionWorktreeDecision::StayHere { - fingerprint: notice.fingerprint.clone(), - checkout_root: notice.checkout_root.clone(), - reason: "repo-local environment is required".to_string(), - }; - - assert!(active_session_write_decision_allows( - &decision, - ¬ice, - Path::new("/repo") - )); - } - - #[test] - fn active_session_decision_requires_use_worktree_cwd() { - let notice = notice(); - let decision = ActiveSessionWorktreeDecision::UseWorktree { - fingerprint: notice.fingerprint.clone(), - checkout_root: notice.checkout_root.clone(), - selected_worktree_path: notice.suggested_worktree_path.clone(), - }; - - assert!(active_session_write_decision_allows( - &decision, - ¬ice, - Path::new("/worktrees/repo/task/src") - )); - assert!(!active_session_write_decision_allows( - &decision, - ¬ice, - Path::new("/repo/src") - )); - } - - #[test] - fn active_session_apply_patch_use_worktree_requires_targets_inside_selected_worktree() { - let notice = notice(); - let decision = ActiveSessionWorktreeDecision::UseWorktree { - fingerprint: notice.fingerprint.clone(), - checkout_root: notice.checkout_root.clone(), - selected_worktree_path: notice.suggested_worktree_path.clone(), - }; - - let inside = ApplyPatchAction::new_add_for_test( - Path::new("/worktrees/repo/task/src/lib.rs"), - "inside".to_string(), - ); - assert!(active_session_apply_patch_decision_allows( - &decision, - ¬ice, - &inside - )); - - let conflicted_checkout = ApplyPatchAction::new_add_for_test( - Path::new("/repo/src/lib.rs"), - "outside".to_string(), - ); - assert!(!active_session_apply_patch_decision_allows( - &decision, - ¬ice, - &conflicted_checkout - )); - - let unrelated_path = ApplyPatchAction::new_add_for_test( - Path::new("/tmp/elsewhere/lib.rs"), - "outside".to_string(), - ); - assert!(!active_session_apply_patch_decision_allows( - &decision, - ¬ice, - &unrelated_path - )); - } - - #[test] - fn active_session_apply_patch_use_worktree_rejects_mixed_target_patch() { - let notice = notice(); - let decision = ActiveSessionWorktreeDecision::UseWorktree { - fingerprint: notice.fingerprint.clone(), - checkout_root: notice.checkout_root.clone(), - selected_worktree_path: notice.suggested_worktree_path.clone(), - }; - let patch = r#"*** Begin Patch -*** Add File: src/inside.rs -+inside -*** Add File: /repo/src/outside.rs -+outside -*** End Patch -"#; - let action = match maybe_parse_apply_patch_verified( - &["apply_patch".to_string(), patch.to_string()], - ¬ice.suggested_worktree_path, - ) { - MaybeApplyPatchVerified::Body(action) => action, - other => panic!("expected parsed patch, got {other:?}"), - }; - - assert!(!active_session_apply_patch_decision_allows( - &decision, - ¬ice, - &action - )); - } - - #[test] - fn detects_python_here_doc_write() { - let argv = vec![ - "bash".to_string(), - "-lc".to_string(), - "python3 - <<'PY'\nfrom pathlib import Path\nPath('docs.txt').write_text('hello')\nPY".to_string(), - ]; - - let suggestion = detect_python_write(&argv).expect("should flag python write"); - assert_eq!(suggestion.label, "original_script"); - assert!(suggestion.original_value.contains("write_text")); - } - - #[test] - fn detects_python_inline_write() { - let argv = vec![ - "python3".to_string(), - "-c".to_string(), - "from pathlib import Path; Path('foo.txt').write_text('hi')".to_string(), - ]; - - let suggestion = detect_python_write(&argv).expect("should flag inline python write"); - assert_eq!(suggestion.label, "python_inline_script"); - assert!(suggestion.original_value.contains("write_text")); - } - - #[test] - fn allows_read_only_python() { - let argv = vec![ - "python3".to_string(), - "-c".to_string(), - "print('hello world')".to_string(), - ]; - - assert!(detect_python_write(&argv).is_none()); - } -} - -#[cfg(test)] -mod cleanup_tests { - use super::*; - use super::super::session::prune_history_items; - use code_protocol::protocol::{ - BROWSER_SNAPSHOT_CLOSE_TAG, - BROWSER_SNAPSHOT_OPEN_TAG, - ENVIRONMENT_CONTEXT_CLOSE_TAG, - ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG, - ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG, - ENVIRONMENT_CONTEXT_OPEN_TAG, - }; - - fn make_text_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], end_turn: None, phase: None} - } - - fn make_screenshot_message(tag: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputImage { - image_url: tag.to_string(), - }], end_turn: None, phase: None} - } - - struct AutoContextHarness { - history: Vec, - next_input: Vec, - } - - impl AutoContextHarness { - fn new(history: Vec, next_input: Vec) -> Self { - Self { history, next_input } - } - - fn estimated_tokens(&self) -> u64 { - estimate_next_turn_context_tokens(&self.history, &self.next_input) - } - - fn skip_for_continuation(&self, pressure_band: AutoContextPressureBand) -> bool { - should_skip_auto_context_judge_for_continuation( - pressure_band, - &summarize_input_items(&self.next_input), - ) - } - } - - #[test] - fn prune_history_retains_recent_env_items() { - let baseline1 = make_text_message(&format!( - "{}\n{{}}\n{}", - ENVIRONMENT_CONTEXT_OPEN_TAG, ENVIRONMENT_CONTEXT_CLOSE_TAG - )); - let delta1 = make_text_message(&format!( - "{}\n{{\"cwd\":\"/repo\"}}\n{}", - ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG, ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG - )); - let snapshot1 = make_text_message(&format!( - "{}\n{{\"url\":\"https://first\"}}\n{}", - BROWSER_SNAPSHOT_OPEN_TAG, BROWSER_SNAPSHOT_CLOSE_TAG - )); - let screenshot1 = make_screenshot_message("data:image/png;base64,AAA"); - let user_msg = make_text_message("Regular user message"); - let baseline2 = make_text_message(&format!( - "{}\n{{\"cwd\":\"/repo2\"}}\n{}", - ENVIRONMENT_CONTEXT_OPEN_TAG, ENVIRONMENT_CONTEXT_CLOSE_TAG - )); - let delta2 = make_text_message(&format!( - "{}\n{{\"cwd\":\"/repo2\"}}\n{}", - ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG, ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG - )); - let snapshot2 = make_text_message(&format!( - "{}\n{{\"url\":\"https://second\"}}\n{}", - BROWSER_SNAPSHOT_OPEN_TAG, BROWSER_SNAPSHOT_CLOSE_TAG - )); - let screenshot2 = make_screenshot_message("data:image/png;base64,BBB"); - let delta3 = make_text_message(&format!( - "{}\n{{\"cwd\":\"/repo3\"}}\n{}", - ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG, ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG - )); - let snapshot3 = make_text_message(&format!( - "{}\n{{\"url\":\"https://third\"}}\n{}", - BROWSER_SNAPSHOT_OPEN_TAG, BROWSER_SNAPSHOT_CLOSE_TAG - )); - let delta4 = make_text_message(&format!( - "{}\n{{\"cwd\":\"/repo4\"}}\n{}", - ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG, ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG - )); - let screenshot3 = make_screenshot_message("data:image/png;base64,CCC"); - - let history = vec![ - user_msg.clone(), - baseline1, - delta1.clone(), - snapshot1.clone(), - screenshot1, - baseline2.clone(), - delta2.clone(), - snapshot2.clone(), - screenshot2.clone(), - delta3.clone(), - snapshot3.clone(), - delta4.clone(), - screenshot3.clone(), - ]; - - let (pruned, stats) = prune_history_items(&history); - - // Baseline 1 should be removed; only the latest baseline retained - assert!(pruned.contains(&baseline2)); - assert!(!pruned.contains(&history[1])); - - // Only the last three deltas should remain - assert!(pruned.contains(&delta2)); - assert!(pruned.contains(&delta3)); - assert!(pruned.contains(&delta4)); - assert!(!pruned.contains(&delta1)); - - // Only the last two browser snapshots should remain - assert!(pruned.contains(&snapshot2)); - assert!(pruned.contains(&snapshot3)); - assert!(!pruned.contains(&snapshot1)); - - // Stats reflect removals and kept counts - assert_eq!(stats.removed_env_baselines, 1); - assert_eq!(stats.removed_env_deltas, 1); - assert_eq!(stats.removed_browser_snapshots, 1); - assert_eq!(stats.kept_env_deltas, 3); - assert_eq!(stats.kept_browser_snapshots, 2); - assert_eq!(stats.kept_recent_screenshots, 1); - } - - #[test] - fn prune_history_no_env_items_is_identity() { - let user = make_text_message("hi"); - let assistant = ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "response".to_string(), - }], end_turn: None, phase: None}; - let history = vec![user.clone(), assistant.clone()]; - - let (pruned, stats) = prune_history_items(&history); - assert_eq!(pruned, history); - assert!(!stats.any_removed()); - } - - #[test] - fn auto_context_pressure_band_respects_thresholds() { - let force_threshold = auto_context_force_compact_threshold(Some( - crate::model_family::EXTENDED_CONTEXT_WINDOW_1M, - )); - - assert_eq!(auto_context_pressure_band(149_999, force_threshold), None); - assert_eq!( - auto_context_pressure_band(150_000, force_threshold), - Some(AutoContextPressureBand::Medium) - ); - assert_eq!( - auto_context_pressure_band(crate::model_family::STANDARD_CONTEXT_WINDOW_272K, force_threshold), - Some(AutoContextPressureBand::High) - ); - assert_eq!( - auto_context_pressure_band(force_threshold.saturating_sub(10_000), force_threshold), - Some(AutoContextPressureBand::Critical) - ); - } - - #[test] - fn auto_context_force_compact_threshold_leaves_margin() { - assert_eq!( - auto_context_force_compact_threshold(Some(1_000_000)), - 980_000 - ); - } - - #[test] - fn estimate_next_turn_context_tokens_uses_history_and_new_input() { - let harness = AutoContextHarness::new( - vec![ - make_text_message("continue the refactor"), - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "I updated the model picker".to_string(), - }], - end_turn: None, - phase: None, - }, - ], - vec![InputItem::Text { - text: "now fix the auto compact path".to_string(), - }], - ); - - let estimate = harness.estimated_tokens(); - - assert!(estimate > 0); - assert!(estimate >= estimate_response_items_tokens(&harness.history)); - } - - #[test] - fn continuation_short_circuits_medium_pressure_auto_judge() { - let harness = AutoContextHarness::new( - vec![make_text_message("continue the picker refactor")], - vec![InputItem::Text { - text: "continue with the previous fix and keep going".to_string(), - }], - ); - - assert!(harness.skip_for_continuation(AutoContextPressureBand::Medium)); - assert!(!harness.skip_for_continuation(AutoContextPressureBand::High)); - } - - #[test] - fn proactive_compact_limit_uses_context_window_tokens() { - let usage = TokenUsage { - input_tokens: 40_000, - cached_input_tokens: 0, - cached_input_tokens_reported: false, - output_tokens: 260_000, - reasoning_output_tokens: 250_000, - total_tokens: 300_000, - }; - - assert!(!proactive_compact_limit_reached(Some(&usage), 100_000)); - assert!(proactive_compact_limit_reached(Some(&usage), 40_000)); - } -} - -pub(super) fn debug_history(label: &str, items: &[ResponseItem]) { - let preview: Vec = items - .iter() - .enumerate() - .map(|(idx, item)| match item { - ResponseItem::Message { role, content, .. } => { - let text = content - .iter() - .filter_map(|c| match c { - ContentItem::InputText { text } - | ContentItem::OutputText { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(" "); - let snippet: String = text.chars().take(80).collect(); - format!("{idx}:{role}:{snippet}") - } - _ => format!("{idx}:{:?}", item), - }) - .collect(); - let rendered = preview.join(" | "); - if std::env::var_os("CODEX_COMPACT_TRACE").is_some() { - eprintln!("[compact_history] {} => [{}]", label, rendered); - } - info!(target = "code_core::compact_history", "{} => [{}]", label, rendered); -} - -#[derive(Debug)] -pub(super) struct TimelineReplayContext { - pub(super) timeline: ContextTimeline, - pub(super) next_sequence: u64, - pub(super) last_snapshot: Option, - pub(super) legacy_baseline: Option, -} - -impl Default for TimelineReplayContext { - fn default() -> Self { - Self { - timeline: ContextTimeline::new(), - next_sequence: 1, - last_snapshot: None, - legacy_baseline: None, - } - } -} - -pub(super) fn process_rollout_env_item(ctx: &mut TimelineReplayContext, item: &ResponseItem) { - if let Some(snapshot) = parse_env_snapshot_from_response(item) { - if ctx.timeline.baseline().is_none() { - if let Err(err) = ctx.timeline.add_baseline_once(snapshot.clone()) { - tracing::warn!("env_ctx_v2: failed to seed baseline during replay: {err}"); - } - } - - match ctx.timeline.record_snapshot(snapshot.clone()) { - Ok(true) => crate::telemetry::global_telemetry().record_snapshot_commit(), - Ok(false) => crate::telemetry::global_telemetry().record_dedup_drop(), - Err(err) => tracing::warn!("env_ctx_v2: failed to record snapshot during replay: {err}"), - } - - ctx.last_snapshot = Some(snapshot); - return; - } - - if let Some(delta) = parse_env_delta_from_response(item) { - if let Some(base_snapshot) = ctx.last_snapshot.clone() { - if delta.base_fingerprint != base_snapshot.fingerprint() { - tracing::warn!( - "env_ctx_v2: delta base fingerprint mismatch during replay; requesting baseline resend" - ); - crate::telemetry::global_telemetry().record_baseline_resend(); - crate::telemetry::global_telemetry().record_delta_gap(); - ctx.timeline = ContextTimeline::new(); - ctx.last_snapshot = None; - ctx.legacy_baseline = None; - ctx.next_sequence = 1; - return; - } - - let sequence = ctx.next_sequence; - match ctx.timeline.apply_delta(sequence, delta.clone()) { - Ok(_) => { - ctx.next_sequence = ctx.next_sequence.saturating_add(1); - } - Err(err) => { - tracing::warn!("env_ctx_v2: failed to apply delta during replay: {err}"); - crate::telemetry::global_telemetry().record_delta_gap(); - return; - } - } - - let next_snapshot = base_snapshot.apply_delta(&delta); - match ctx.timeline.record_snapshot(next_snapshot.clone()) { - Ok(true) => crate::telemetry::global_telemetry().record_snapshot_commit(), - Ok(false) => crate::telemetry::global_telemetry().record_dedup_drop(), - Err(err) => tracing::warn!("env_ctx_v2: failed to record snapshot during replay: {err}"), - } - - ctx.last_snapshot = Some(next_snapshot); - } else { - tracing::warn!( - "env_ctx_v2: encountered delta before baseline while replaying rollout" - ); - crate::telemetry::global_telemetry().record_delta_gap(); - } - return; - } - - if ctx.legacy_baseline.is_none() && is_legacy_system_status(item) { - if let Some(snapshot) = parse_legacy_status_snapshot(item) { - ctx.legacy_baseline = Some(snapshot); - } - } -} - -fn extract_tagged_json<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> { - let start = text.find(open)? + open.len(); - let end = text.rfind(close)?; - if end <= start { - return None; - } - Some(text[start..end].trim()) -} - -pub(super) fn parse_env_snapshot_from_response( - item: &ResponseItem, -) -> Option { - if let ResponseItem::Message { role, content, .. } = item { - if role != "user" { - return None; - } - for piece in content { - if let ContentItem::InputText { text } = piece { - if let Some(json) = extract_tagged_json( - text, - ENVIRONMENT_CONTEXT_OPEN_TAG, - ENVIRONMENT_CONTEXT_CLOSE_TAG, - ) { - if let Ok(snapshot) = serde_json::from_str::(json) { - return Some(snapshot); - } - } - } - } - } - None -} - -pub(super) fn parse_env_delta_from_response( - item: &ResponseItem, -) -> Option { - if let ResponseItem::Message { role, content, .. } = item { - if role != "user" { - return None; - } - for piece in content { - if let ContentItem::InputText { text } = piece { - if let Some(json) = extract_tagged_json( - text, - ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG, - ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG, - ) { - if let Ok(delta) = serde_json::from_str::(json) { - return Some(delta); - } - } - } - } - } - None -} - -fn is_legacy_system_status(item: &ResponseItem) -> bool { - if let ResponseItem::Message { role, content, .. } = item { - if role != "user" { - return false; - } - return content.iter().any(|c| { - if let ContentItem::InputText { text } = c { - text.contains("== System Status ==") - } else { - false - } - }); - } - false -} - -fn parse_legacy_status_snapshot(item: &ResponseItem) -> Option { - if let ResponseItem::Message { role, content, .. } = item { - if role != "user" { - return None; - } - for piece in content { - if let ContentItem::InputText { text } = piece { - if !text.contains("== System Status ==") { - continue; - } - - let mut cwd: Option = None; - let mut branch: Option = None; - for line in text.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("cwd:") { - let value = rest.trim(); - if !value.is_empty() { - cwd = Some(value.to_string()); - } - } else if let Some(rest) = trimmed.strip_prefix("branch:") { - let value = rest.trim(); - if !value.is_empty() && value != "unknown" { - branch = Some(value.to_string()); - } - } - } - - return Some(EnvironmentContextSnapshot { - version: EnvironmentContextSnapshot::VERSION, - cwd, - approval_policy: None, - sandbox_mode: None, - network_access: None, - writable_roots: Vec::new(), - operating_system: None, - common_tools: Vec::new(), - shell: None, - git_branch: branch, - reasoning_effort: None, - }); - } - } - } - None -} - -#[cfg(test)] -mod tests { - use super::{ - estimate_auto_context_turn_risk, - AUTO_CONTEXT_JUDGE_DEVELOPER_MESSAGE, - AUTO_CONTEXT_JUDGE_FALLBACK_MODEL, - AUTO_CONTEXT_JUDGE_PRIMARY_MODEL, - auto_context_judge_models, - build_prepend_developer_messages, - choose_larger_context_model_from_candidates, - custom_tool_event_result_text, - ContextFallbackCandidate, - format_exec_output_with_limit, - image_generation_artifact_path, - is_context_overflow_stream_error, - save_image_generation_result, - save_image_generation_sidecar, - ImageGenerationTurnMetadata, - pending_items_not_already_recorded, - TRUNCATION_MARKER, - }; - use crate::exec::{ExecToolCallOutput, StreamOutput}; - use crate::protocol::TokenUsage; - use code_protocol::models::FunctionCallOutputContentItem; - use code_protocol::models::FunctionCallOutputPayload; - use serde_json::Value; - use std::time::Duration; - use tempfile::TempDir; - - fn make_exec_output(output: String) -> ExecToolCallOutput { - ExecToolCallOutput { - exit_code: 0, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(output), - duration: Duration::from_secs(1), - timed_out: false, - } - } - - #[test] - fn image_generation_artifact_path_sanitizes_session_and_call_ids() { - let dir = TempDir::new().expect("tempdir"); - let path = image_generation_artifact_path(dir.path(), "session/../1", "../ig?..123"); - - assert_eq!( - path, - dir.path() - .join("generated_images") - .join("session____1") - .join("___ig___123.png") - ); - } - - #[test] - fn active_session_notice_is_prepended_after_demo_guidance() { - let demo = "demo guidance".to_string(); - let notice = "CONCURRENT CHECKOUT SESSION DETECTED".to_string(); - - let messages = build_prepend_developer_messages(Some(&demo), Some(¬ice)); - - assert_eq!(messages, vec![demo, notice]); - } - - #[tokio::test] - async fn save_image_generation_result_writes_png_payload() { - let dir = TempDir::new().expect("tempdir"); - let saved_path = save_image_generation_result(dir.path(), "session-1", "ig_123", "Zm9v") - .await - .expect("image should save"); - - assert_eq!(std::fs::read(saved_path.as_path()).expect("saved file"), b"foo"); - } - - #[tokio::test] - async fn save_image_generation_sidecar_writes_metadata() { - let dir = TempDir::new().expect("tempdir"); - let saved_path = save_image_generation_result(dir.path(), "session-1", "ig_123", "Zm9v") - .await - .expect("image should save"); - let metadata = ImageGenerationTurnMetadata { - requested_model: "gpt-5.4".to_string(), - latest_response_model: Some("gpt-5.4-2026-04-01".to_string()), - response_headers: Some(serde_json::json!({ - "x-request-id": ["req_123"], - })), - }; - - let sidecar_path = save_image_generation_sidecar( - &saved_path, - "ig_123", - "completed", - Some("A tiny square"), - &metadata, - ) - .await - .expect("metadata should save"); - - assert_eq!( - sidecar_path.as_path(), - saved_path.as_path().with_extension("metadata.json") - ); - let sidecar: Value = serde_json::from_slice( - &std::fs::read(sidecar_path.as_path()).expect("sidecar file"), - ) - .expect("sidecar json"); - assert_eq!(sidecar["call_id"], "ig_123"); - assert_eq!(sidecar["requested_model"], "gpt-5.4"); - assert_eq!(sidecar["latest_response_model"], "gpt-5.4-2026-04-01"); - assert_eq!(sidecar["response_headers"]["x-request-id"][0], "req_123"); - } - - #[tokio::test] - async fn save_image_generation_result_rejects_non_standard_base64() { - let dir = TempDir::new().expect("tempdir"); - let err = save_image_generation_result(dir.path(), "session-1", "ig_123", "_-8") - .await - .expect_err("invalid payload should fail"); - - assert!(err.contains("invalid image generation payload")); - } - - #[test] - fn format_exec_output_truncates_with_small_limit() { - let dir = TempDir::new().expect("tempdir"); - let output = "line\n".repeat(200); - let exec_output = make_exec_output(output); - - let payload = - format_exec_output_with_limit(dir.path(), "sub", "call", &exec_output, 64); - let parsed: Value = serde_json::from_str(&payload).expect("parse payload"); - let content = parsed - .get("output") - .and_then(Value::as_str) - .expect("output string"); - - assert!(content.contains(TRUNCATION_MARKER)); - } - - #[test] - fn format_exec_output_keeps_output_when_under_limit() { - let dir = TempDir::new().expect("tempdir"); - let output = "line\n".repeat(10); - let exec_output = make_exec_output(output.clone()); - let payload = format_exec_output_with_limit( - dir.path(), - "sub", - "call", - &exec_output, - output.len() + 32, - ); - let parsed: Value = serde_json::from_str(&payload).expect("parse payload"); - let content = parsed - .get("output") - .and_then(Value::as_str) - .expect("output string"); - - assert!(!content.contains(TRUNCATION_MARKER)); - assert!(content.contains("line")); - } - - #[test] - fn custom_tool_event_result_text_omits_image_data_urls() { - let payload = FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputText { - text: "[image: hero]".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,BASE64".to_string(), - detail: None, - }, - ]); - - let text = custom_tool_event_result_text(&payload); - - assert_eq!(text, "[image: hero]"); - assert!(!text.contains("base64")); - } - - #[test] - fn pending_items_omit_tool_outputs_already_recorded() { - let output = FunctionCallOutputPayload::from_text("done".to_string()); - let pending_output = code_protocol::models::ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: output.clone(), - }; - let pending_message = code_protocol::models::ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![code_protocol::models::ContentItem::InputText { - text: "repeatable".to_string(), - }], - end_turn: None, - phase: None, - }; - let pending = vec![pending_output.clone(), pending_message.clone()]; - let recorded = vec![pending_output]; - - let filtered = pending_items_not_already_recorded(&pending, &recorded); - - assert_eq!(filtered, vec![pending_message]); - } - - #[test] - fn context_overflow_detection_matches_provider_errors() { - assert!(is_context_overflow_stream_error( - "Transport error: Your input exceeds the context window of this model" - )); - assert!(is_context_overflow_stream_error( - "maximum context length reached" - )); - assert!(!is_context_overflow_stream_error("temporary network timeout")); - } - - #[test] - fn auto_context_judge_prefers_spark_then_mini() { - assert_eq!( - auto_context_judge_models(), - [ - AUTO_CONTEXT_JUDGE_PRIMARY_MODEL, - AUTO_CONTEXT_JUDGE_FALLBACK_MODEL, - ] - ); - } - - #[test] - fn auto_context_judge_instructions_reference_schema_fields() { - assert!(AUTO_CONTEXT_JUDGE_DEVELOPER_MESSAGE.contains("should_compact_now=false")); - assert!(AUTO_CONTEXT_JUDGE_DEVELOPER_MESSAGE.contains("should_compact_now=true")); - assert!(AUTO_CONTEXT_JUDGE_DEVELOPER_MESSAGE.contains("Return strict JSON only")); - } - - #[test] - fn auto_context_turn_risk_flags_standard_limit_pressure() { - let risk = estimate_auto_context_turn_risk( - 290_000, - "continue fixing the active bug and update the tests", - None, - crate::model_family::EXTENDED_CONTEXT_WINDOW_1M.saturating_sub(20_000), - ); - - assert!(risk.crosses_standard_limit_after_turn); - assert!(!risk.crosses_hard_limit_after_turn); - assert!(risk.projected_post_turn_tokens > 290_000); - } - - #[test] - fn auto_context_turn_risk_flags_hard_limit_pressure() { - let risk = estimate_auto_context_turn_risk( - 975_000, - "continue", - Some(&TokenUsage { - input_tokens: 20_000, - cached_input_tokens: 0, - cached_input_tokens_reported: false, - output_tokens: 80_000, - reasoning_output_tokens: 10_000, - total_tokens: 110_000, - }), - crate::model_family::EXTENDED_CONTEXT_WINDOW_1M.saturating_sub(20_000), - ); - - assert!(risk.crosses_standard_limit_now); - assert!(risk.crosses_force_compact_after_turn); - assert!(risk.crosses_hard_limit_after_turn); - } - - #[test] - fn picks_larger_context_model_from_candidates() { - let chosen = choose_larger_context_model_from_candidates( - "o3", - vec![ - ContextFallbackCandidate { - model: "gpt-5.4".to_string(), - context_window: Some(272_000), - priority: 10, - }, - ContextFallbackCandidate { - model: "gpt-5.5".to_string(), - context_window: Some(272_000), - priority: 20, - }, - ], - ); - assert_eq!(chosen.as_deref(), Some("gpt-5.5")); - } - - #[test] - fn larger_context_fallback_skips_gpt_4_1_family() { - let chosen = choose_larger_context_model_from_candidates( - "gpt-5.4", - vec![ - ContextFallbackCandidate { - model: "gpt-4.1".to_string(), - context_window: Some(1_047_576), - priority: 100, - }, - ContextFallbackCandidate { - model: "gpt-5.5".to_string(), - context_window: Some(400_000), - priority: 10, - }, - ], - ); - assert_eq!(chosen.as_deref(), Some("gpt-5.5")); - } - -} diff --git a/code-rs/core/src/codex_delegate.rs b/code-rs/core/src/codex_delegate.rs new file mode 100644 index 00000000000..a89d8fc9737 --- /dev/null +++ b/code-rs/core/src/codex_delegate.rs @@ -0,0 +1,846 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_channel::Receiver; +use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; +use codex_async_utils::OrCancelExt; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RequestUserInputEvent; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadSource; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; +use serde_json::Value; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; + +use crate::config::Config; +use crate::guardian::GuardianApprovalRequest; +use crate::guardian::new_guardian_review_id; +use crate::guardian::routes_approval_to_guardian; +use crate::guardian::spawn_approval_request_review; +use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; +use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; +use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; +use crate::mcp_tool_call::build_guardian_mcp_tool_review_request; +use crate::mcp_tool_call::is_mcp_tool_approval_question_id; +use crate::mcp_tool_call::lookup_mcp_tool_metadata; +use crate::session::Codex; +use crate::session::CodexSpawnArgs; +use crate::session::CodexSpawnOk; +use crate::session::SUBMISSION_CHANNEL_CAPACITY; +use crate::session::emit_subagent_session_started; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use codex_login::AuthManager; +use codex_models_manager::manager::SharedModelsManager; +use codex_protocol::error::CodexErr; +use codex_protocol::protocol::InitialHistory; + +#[cfg(test)] +use crate::session::completed_session_loop_termination; + +/// Start an interactive sub-Codex thread and return IO channels. +/// +/// The returned `events_rx` yields non-approval events emitted by the sub-agent. +/// Approval requests are handled via `parent_session` and are not surfaced. +/// The returned `ops_tx` allows the caller to submit additional `Op`s to the sub-agent. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_codex_thread_interactive( + config: Config, + auth_manager: Arc, + models_manager: SharedModelsManager, + parent_session: Arc, + parent_ctx: Arc, + cancel_token: CancellationToken, + subagent_source: SubAgentSource, + initial_history: Option, +) -> Result { + let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { + config, + installation_id: parent_session.installation_id.clone(), + auth_manager, + models_manager, + environment_manager: Arc::clone(&parent_session.services.environment_manager), + skills_manager: Arc::clone(&parent_session.services.skills_manager), + plugins_manager: Arc::clone(&parent_session.services.plugins_manager), + mcp_manager: Arc::clone(&parent_session.services.mcp_manager), + skills_watcher: Arc::clone(&parent_session.services.skills_watcher), + conversation_history: initial_history.unwrap_or(InitialHistory::New), + session_source: SessionSource::SubAgent(subagent_source.clone()), + thread_source: Some(ThreadSource::Subagent), + agent_control: parent_session.services.agent_control.clone(), + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + inherited_shell_snapshot: None, + user_shell_override: None, + inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), + parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), + parent_trace: None, + environment_selections: parent_ctx.environments.clone(), + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), + thread_store: Arc::clone(&parent_session.services.thread_store), + })) + .or_cancel(&cancel_token) + .await??; + let thread_config = codex.thread_config_snapshot().await; + let client_metadata = parent_session.app_server_client_metadata().await; + emit_subagent_session_started( + &parent_session.services.analytics_events_client, + client_metadata, + codex.session.conversation_id, + Some(parent_session.conversation_id), + thread_config, + subagent_source, + ); + let codex = Arc::new(codex); + + // Use a child token so parent cancel cascades but we can scope it to this task + let cancel_token_events = cancel_token.child_token(); + let cancel_token_ops = cancel_token.child_token(); + + // Forward events from the sub-agent to the consumer, filtering approvals and + // routing them to the parent session for decisions. + let parent_session_clone = Arc::clone(&parent_session); + let parent_ctx_clone = Arc::clone(&parent_ctx); + let codex_for_events = Arc::clone(&codex); + // Cache delegated MCP invocations so guardian can recover the full tool call + // context when the later legacy RequestUserInput approval event only carries + // a call_id plus approval question metadata. + let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::::new())); + tokio::spawn(async move { + forward_events( + codex_for_events, + tx_sub, + parent_session_clone, + parent_ctx_clone, + pending_mcp_invocations, + cancel_token_events, + ) + .await; + }); + + // Forward ops from the caller to the sub-agent. + let codex_for_ops = Arc::clone(&codex); + tokio::spawn(async move { + forward_ops(codex_for_ops, rx_ops, cancel_token_ops).await; + }); + + Ok(Codex { + tx_sub: tx_ops, + rx_event: rx_sub, + agent_status: codex.agent_status.clone(), + session: Arc::clone(&codex.session), + session_loop_termination: codex.session_loop_termination.clone(), + }) +} + +/// Convenience wrapper for one-time use with an initial prompt. +/// +/// Internally calls the interactive variant, then immediately submits the provided input. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_codex_thread_one_shot( + config: Config, + auth_manager: Arc, + models_manager: SharedModelsManager, + input: Vec, + parent_session: Arc, + parent_ctx: Arc, + cancel_token: CancellationToken, + subagent_source: SubAgentSource, + final_output_json_schema: Option, + initial_history: Option, +) -> Result { + // Use a child token so we can stop the delegate after completion without + // requiring the caller to cancel the parent token. + let child_cancel = cancel_token.child_token(); + let io = Box::pin(run_codex_thread_interactive( + config, + auth_manager, + models_manager, + parent_session, + parent_ctx, + child_cancel.clone(), + subagent_source, + initial_history, + )) + .await?; + + // Send the initial input to kick off the one-shot turn. + io.submit(Op::UserInput { + environments: None, + items: input, + final_output_json_schema, + responsesapi_client_metadata: None, + }) + .await?; + + // Bridge events so we can observe completion and shut down automatically. + let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + let ops_tx = io.tx_sub.clone(); + let agent_status = io.agent_status.clone(); + let session = Arc::clone(&io.session); + let session_loop_termination = io.session_loop_termination.clone(); + let io_for_bridge = io; + tokio::spawn(async move { + while let Ok(event) = io_for_bridge.next_event().await { + let should_shutdown = matches!( + event.msg, + EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_) + ); + let _ = tx_bridge.send(event).await; + if should_shutdown { + let _ = ops_tx + .send(Submission { + id: "shutdown".to_string(), + op: Op::Shutdown {}, + trace: None, + }) + .await; + child_cancel.cancel(); + break; + } + } + }); + + // For one-shot usage, return a closed `tx_sub` so callers cannot submit + // additional ops after the initial request. Create a channel and drop the + // receiver to close it immediately. + let (tx_closed, rx_closed) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + drop(rx_closed); + + Ok(Codex { + rx_event: rx_bridge, + tx_sub: tx_closed, + agent_status, + session, + session_loop_termination, + }) +} + +async fn forward_events( + codex: Arc, + tx_sub: Sender, + parent_session: Arc, + parent_ctx: Arc, + pending_mcp_invocations: Arc>>, + cancel_token: CancellationToken, +) { + let cancelled = cancel_token.cancelled(); + tokio::pin!(cancelled); + + loop { + tokio::select! { + _ = &mut cancelled => { + shutdown_delegate(&codex).await; + break; + } + event = codex.next_event() => { + let event = match event { + Ok(event) => event, + Err(_) => break, + }; + match event { + Event { + id: _, + msg: EventMsg::TokenCount(_), + } => {} + Event { + id: _, + msg: EventMsg::SessionConfigured(_), + } => {} + Event { + id, + msg: EventMsg::ExecApprovalRequest(event), + } => { + // Initiate approval via parent session; do not surface to consumer. + handle_exec_approval( + &codex, + id, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } + Event { + id, + msg: EventMsg::ApplyPatchApprovalRequest(event), + } => { + handle_patch_approval( + &codex, + id, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } + Event { + msg: EventMsg::RequestPermissions(event), + .. + } => { + handle_request_permissions( + &codex, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } + Event { + id, + msg: EventMsg::RequestUserInput(event), + } => { + handle_request_user_input( + &codex, + id, + &parent_session, + &parent_ctx, + &pending_mcp_invocations, + event, + &cancel_token, + ) + .await; + } + Event { + id, + msg: EventMsg::McpToolCallBegin(event), + } => { + pending_mcp_invocations + .lock() + .await + .insert(event.call_id.clone(), event.invocation.clone()); + if !forward_event_or_shutdown( + &codex, + &tx_sub, + &cancel_token, + Event { + id, + msg: EventMsg::McpToolCallBegin(event), + }, + ) + .await + { + break; + } + } + Event { + id, + msg: EventMsg::McpToolCallEnd(event), + } => { + pending_mcp_invocations.lock().await.remove(&event.call_id); + if !forward_event_or_shutdown( + &codex, + &tx_sub, + &cancel_token, + Event { + id, + msg: EventMsg::McpToolCallEnd(event), + }, + ) + .await + { + break; + } + } + other => { + if !forward_event_or_shutdown(&codex, &tx_sub, &cancel_token, other).await + { + break; + } + } + } + } + } + } +} + +/// Ask the delegate to stop and drain its events so background sends do not hit a closed channel. +async fn shutdown_delegate(codex: &Codex) { + let _ = codex.submit(Op::Interrupt).await; + let _ = codex.submit(Op::Shutdown {}).await; + + let _ = timeout(Duration::from_millis(500), async { + while let Ok(event) = codex.next_event().await { + if matches!( + event.msg, + EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_) + ) { + break; + } + } + }) + .await; +} + +async fn forward_event_or_shutdown( + codex: &Codex, + tx_sub: &Sender, + cancel_token: &CancellationToken, + event: Event, +) -> bool { + match tx_sub.send(event).or_cancel(cancel_token).await { + Ok(Ok(())) => true, + _ => { + shutdown_delegate(codex).await; + false + } + } +} + +/// Forward ops from a caller to a sub-agent, respecting cancellation. +async fn forward_ops( + codex: Arc, + rx_ops: Receiver, + cancel_token_ops: CancellationToken, +) { + loop { + let submission = match rx_ops.recv().or_cancel(&cancel_token_ops).await { + Ok(Ok(submission)) => submission, + Ok(Err(_)) | Err(_) => break, + }; + let _ = codex.submit_with_id(submission).await; + } +} + +/// Handle an ExecApprovalRequest by consulting the parent session and replying. +async fn handle_exec_approval( + codex: &Codex, + turn_id: String, + parent_session: &Arc, + parent_ctx: &Arc, + event: ExecApprovalRequestEvent, + cancel_token: &CancellationToken, +) { + let approval_id_for_op = event.effective_approval_id(); + let ExecApprovalRequestEvent { + call_id, + approval_id, + command, + cwd, + reason, + network_approval_context, + proposed_execpolicy_amendment, + additional_permissions, + available_decisions, + .. + } = event; + let decision = if routes_approval_to_guardian(parent_ctx) { + let review_cancel = cancel_token.child_token(); + let review_rx = spawn_approval_request_review( + Arc::clone(parent_session), + Arc::clone(parent_ctx), + new_guardian_review_id(), + GuardianApprovalRequest::Shell { + id: call_id.clone(), + command, + cwd, + sandbox_permissions: if additional_permissions.is_some() { + crate::sandboxing::SandboxPermissions::WithAdditionalPermissions + } else { + crate::sandboxing::SandboxPermissions::UseDefault + }, + additional_permissions, + justification: None, + }, + reason, + GuardianApprovalRequestSource::DelegatedSubagent, + review_cancel.clone(), + ); + await_approval_with_cancel( + async move { review_rx.await.unwrap_or_default() }, + parent_session, + &approval_id_for_op, + cancel_token, + Some(&review_cancel), + ) + .await + } else { + await_approval_with_cancel( + parent_session.request_command_approval( + parent_ctx, + call_id, + approval_id, + command, + cwd, + reason, + network_approval_context, + proposed_execpolicy_amendment, + additional_permissions, + available_decisions, + ), + parent_session, + &approval_id_for_op, + cancel_token, + /*review_cancel_token*/ None, + ) + .await + }; + + let _ = codex + .submit(Op::ExecApproval { + id: approval_id_for_op, + turn_id: Some(turn_id), + decision, + }) + .await; +} + +/// Handle an ApplyPatchApprovalRequest by consulting the parent session and replying. +async fn handle_patch_approval( + codex: &Codex, + _id: String, + parent_session: &Arc, + parent_ctx: &Arc, + event: ApplyPatchApprovalRequestEvent, + cancel_token: &CancellationToken, +) { + let ApplyPatchApprovalRequestEvent { + call_id, + changes, + reason, + grant_root, + .. + } = event; + let approval_id = call_id.clone(); + let guardian_decision = if routes_approval_to_guardian(parent_ctx) { + let files = changes + .keys() + .map(|path| parent_ctx.cwd.join(path)) + .collect::>(); + let review_cancel = cancel_token.child_token(); + let patch = changes + .iter() + .map(|(path, change)| match change { + codex_protocol::protocol::FileChange::Add { content } => { + format!("*** Add File: {}\n{}", path.display(), content) + } + codex_protocol::protocol::FileChange::Delete { content } => { + format!("*** Delete File: {}\n{}", path.display(), content) + } + codex_protocol::protocol::FileChange::Update { + unified_diff, + move_path, + } => { + if let Some(move_path) = move_path { + format!( + "*** Update File: {}\n*** Move to: {}\n{}", + path.display(), + move_path.display(), + unified_diff + ) + } else { + format!("*** Update File: {}\n{}", path.display(), unified_diff) + } + } + }) + .collect::>() + .join("\n"); + let review_rx = spawn_approval_request_review( + Arc::clone(parent_session), + Arc::clone(parent_ctx), + new_guardian_review_id(), + GuardianApprovalRequest::ApplyPatch { + id: approval_id.clone(), + cwd: parent_ctx.cwd.clone(), + files, + patch, + }, + reason.clone(), + GuardianApprovalRequestSource::DelegatedSubagent, + review_cancel.clone(), + ); + Some( + await_approval_with_cancel( + async move { review_rx.await.unwrap_or_default() }, + parent_session, + &approval_id, + cancel_token, + Some(&review_cancel), + ) + .await, + ) + } else { + None + }; + let decision = if let Some(decision) = guardian_decision { + decision + } else { + let decision_rx = parent_session + .request_patch_approval(parent_ctx, call_id, changes, reason, grant_root) + .await; + await_approval_with_cancel( + async move { decision_rx.await.unwrap_or_default() }, + parent_session, + &approval_id, + cancel_token, + /*review_cancel_token*/ None, + ) + .await + }; + let _ = codex + .submit(Op::PatchApproval { + id: approval_id, + decision, + }) + .await; +} + +async fn handle_request_user_input( + codex: &Codex, + id: String, + parent_session: &Arc, + parent_ctx: &Arc, + pending_mcp_invocations: &Arc>>, + event: RequestUserInputEvent, + cancel_token: &CancellationToken, +) { + if routes_approval_to_guardian(parent_ctx) + && let Some(response) = maybe_auto_review_mcp_request_user_input( + parent_session, + parent_ctx, + pending_mcp_invocations, + &event, + cancel_token, + ) + .await + { + let _ = codex.submit(Op::UserInputAnswer { id, response }).await; + return; + } + + let args = RequestUserInputArgs { + questions: event.questions, + }; + let response_fut = + parent_session.request_user_input(parent_ctx, parent_ctx.sub_id.clone(), args); + let response = await_user_input_with_cancel( + response_fut, + parent_session, + &parent_ctx.sub_id, + cancel_token, + ) + .await; + let _ = codex.submit(Op::UserInputAnswer { id, response }).await; +} + +/// Intercepts delegated legacy MCP approval prompts on the RequestUserInput +/// compatibility path and, when guardian is active, answers them +/// programmatically after running the guardian review. +/// +/// The RequestUserInput event only carries `call_id` plus approval question +/// metadata, so this helper joins it back to the cached `McpToolCallBegin` +/// invocation in order to rebuild the full guardian review request. +async fn maybe_auto_review_mcp_request_user_input( + parent_session: &Arc, + parent_ctx: &Arc, + pending_mcp_invocations: &Arc>>, + event: &RequestUserInputEvent, + cancel_token: &CancellationToken, +) -> Option { + // TODO(ccunningham): Support delegated MCP approval elicitations here too after + // coordinating with @fouad. Today guardian only auto-reviews the RequestUserInput + // compatibility path for delegated MCP approvals. + let question = event + .questions + .iter() + .find(|question| is_mcp_tool_approval_question_id(&question.id))?; + let invocation = pending_mcp_invocations + .lock() + .await + .get(&event.call_id) + .cloned()?; + let metadata = lookup_mcp_tool_metadata( + parent_session.as_ref(), + parent_ctx.as_ref(), + &invocation.server, + &invocation.tool, + ) + .await; + let review_cancel = cancel_token.child_token(); + let review_rx = spawn_approval_request_review( + Arc::clone(parent_session), + Arc::clone(parent_ctx), + new_guardian_review_id(), + build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()), + /*retry_reason*/ None, + GuardianApprovalRequestSource::DelegatedSubagent, + review_cancel.clone(), + ); + let decision = await_approval_with_cancel( + async move { review_rx.await.unwrap_or_default() }, + parent_session, + &event.call_id, + cancel_token, + Some(&review_cancel), + ) + .await; + let selected_label = match decision { + ReviewDecision::ApprovedForSession => question + .options + .as_ref() + .and_then(|options| { + options + .iter() + .find(|option| option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION) + }) + .map(|option| option.label.clone()) + .unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()), + ReviewDecision::Approved + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + | ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(), + ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => { + MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string() + } + }; + Some(RequestUserInputResponse { + answers: HashMap::from([( + question.id.clone(), + codex_protocol::request_user_input::RequestUserInputAnswer { + answers: vec![selected_label], + }, + )]), + }) +} + +async fn handle_request_permissions( + codex: &Codex, + parent_session: &Arc, + parent_ctx: &Arc, + event: RequestPermissionsEvent, + cancel_token: &CancellationToken, +) { + let call_id = event.call_id; + let args = RequestPermissionsArgs { + reason: event.reason, + permissions: event.permissions, + }; + let cwd = event.cwd.unwrap_or_else(|| parent_ctx.cwd.clone()); + let response_fut = parent_session.request_permissions_for_cwd( + parent_ctx, + call_id.clone(), + args, + cwd, + cancel_token.clone(), + ); + let response = + await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token) + .await; + let _ = codex + .submit(Op::RequestPermissionsResponse { + id: call_id, + response, + }) + .await; +} + +async fn await_user_input_with_cancel( + fut: F, + parent_session: &Session, + sub_id: &str, + cancel_token: &CancellationToken, +) -> RequestUserInputResponse +where + F: core::future::Future>, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + let empty = RequestUserInputResponse { + answers: HashMap::new(), + }; + parent_session + .notify_user_input_response(sub_id, empty.clone()) + .await; + empty + } + response = fut => response.unwrap_or_else(|| RequestUserInputResponse { + answers: HashMap::new(), + }), + } +} + +async fn await_request_permissions_with_cancel( + fut: F, + parent_session: &Session, + call_id: &str, + cancel_token: &CancellationToken, +) -> RequestPermissionsResponse +where + F: core::future::Future>, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + let empty = RequestPermissionsResponse { + permissions: Default::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }; + parent_session + .notify_request_permissions_response(call_id, empty.clone()) + .await; + empty + } + response = fut => response.unwrap_or_else(|| RequestPermissionsResponse { + permissions: Default::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }), + } +} + +/// Await an approval decision, aborting on cancellation. +async fn await_approval_with_cancel( + fut: F, + parent_session: &Session, + approval_id: &str, + cancel_token: &CancellationToken, + review_cancel_token: Option<&CancellationToken>, +) -> codex_protocol::protocol::ReviewDecision +where + F: core::future::Future, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + if let Some(review_cancel_token) = review_cancel_token { + review_cancel_token.cancel(); + } + parent_session + .notify_approval(approval_id, codex_protocol::protocol::ReviewDecision::Abort) + .await; + codex_protocol::protocol::ReviewDecision::Abort + } + decision = fut => { + decision + } + } +} + +#[cfg(test)] +#[path = "codex_delegate_tests.rs"] +mod tests; diff --git a/code-rs/core/src/codex_delegate_tests.rs b/code-rs/core/src/codex_delegate_tests.rs new file mode 100644 index 00000000000..ecd392e3e76 --- /dev/null +++ b/code-rs/core/src/codex_delegate_tests.rs @@ -0,0 +1,445 @@ +use super::*; +use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; +use crate::mcp_tool_call::MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX; +use async_channel::bounded; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::GuardianAssessmentAction; +use codex_protocol::protocol::GuardianAssessmentStatus; +use codex_protocol::protocol::GuardianCommandSource; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::RawResponseItemEvent; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::request_user_input::RequestUserInputQuestion; +use core_test_support::PathBufExt; +use core_test_support::test_path_buf; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::watch; +use tokio::time::timeout; + +#[tokio::test] +async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { + let (tx_events, rx_events) = bounded(1); + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let (session, ctx, _rx_evt) = crate::session::tests::make_session_and_context_with_rx().await; + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events, + agent_status, + session: Arc::clone(&session), + session_loop_termination: completed_session_loop_termination(), + }); + + let (tx_out, rx_out) = bounded(1); + tx_out + .send(Event { + id: "full".to_string(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + }), + }) + .await + .unwrap(); + + let cancel = CancellationToken::new(); + let forward = tokio::spawn(forward_events( + Arc::clone(&codex), + tx_out.clone(), + session, + ctx, + Arc::new(Mutex::new(HashMap::new())), + cancel.clone(), + )); + + tx_events + .send(Event { + id: "evt".to_string(), + msg: EventMsg::RawResponseItem(RawResponseItemEvent { + item: ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-1".to_string(), + name: "tool".to_string(), + input: "{}".to_string(), + }, + }), + }) + .await + .unwrap(); + + drop(tx_events); + cancel.cancel(); + timeout(std::time::Duration::from_millis(1000), forward) + .await + .expect("forward_events hung") + .expect("forward_events join error"); + + let received = rx_out.recv().await.expect("prefilled event missing"); + assert_eq!("full", received.id); + let mut ops = Vec::new(); + while let Ok(sub) = rx_sub.try_recv() { + ops.push(sub.op); + } + assert!( + ops.iter().any(|op| matches!(op, Op::Interrupt)), + "expected Interrupt op after cancellation" + ); + assert!( + ops.iter().any(|op| matches!(op, Op::Shutdown)), + "expected Shutdown op after cancellation" + ); +} + +#[tokio::test] +async fn forward_ops_preserves_submission_trace_context() { + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let (session, _ctx, _rx_evt) = crate::session::tests::make_session_and_context_with_rx().await; + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events, + agent_status, + session, + session_loop_termination: completed_session_loop_termination(), + }); + let (tx_ops, rx_ops) = bounded(1); + let cancel = CancellationToken::new(); + let forward = tokio::spawn(forward_ops(Arc::clone(&codex), rx_ops, cancel)); + + let submission = Submission { + id: "sub-1".to_string(), + op: Op::Interrupt, + trace: Some(codex_protocol::protocol::W3cTraceContext { + traceparent: Some( + "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(), + ), + tracestate: Some("vendor=state".to_string()), + }), + }; + tx_ops.send(submission.clone()).await.unwrap(); + drop(tx_ops); + + let forwarded = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("forward_ops hung") + .expect("forwarded submission missing"); + assert_eq!(submission.id, forwarded.id); + assert_eq!(submission.op, forwarded.op); + assert_eq!(submission.trace, forwarded.trace); + + timeout(Duration::from_secs(1), forward) + .await + .expect("forward_ops did not exit") + .expect("forward_ops join error"); +} + +#[tokio::test] +async fn run_codex_thread_interactive_respects_pre_cancelled_spawn() { + let (parent_session, parent_ctx, _rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let result = timeout( + Duration::from_secs(/*secs*/ 1), + run_codex_thread_interactive( + parent_ctx.config.as_ref().clone(), + Arc::clone(&parent_session.services.auth_manager), + Arc::clone(&parent_session.services.models_manager), + parent_session, + parent_ctx, + cancel_token, + SubAgentSource::Review, + /*initial_history*/ None, + ), + ) + .await + .expect("cancelled delegate spawn should not hang"); + + assert!(matches!(result, Err(CodexErr::TurnAborted))); +} + +#[tokio::test] +async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { + let (parent_session, parent_ctx, rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events_child, + agent_status, + session: Arc::clone(&parent_session), + session_loop_termination: completed_session_loop_termination(), + }); + + let call_id = "tool-call-1".to_string(); + let expected_response = RequestPermissionsResponse { + permissions: RequestPermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }, + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }; + let delegated_cwd = parent_ctx.cwd.join("delegated-cwd"); + let cancel_token = CancellationToken::new(); + let request_call_id = call_id.clone(); + let request_cwd = delegated_cwd.clone(); + + let handle = tokio::spawn({ + let codex = Arc::clone(&codex); + let parent_session = Arc::clone(&parent_session); + let parent_ctx = Arc::clone(&parent_ctx); + let cancel_token = cancel_token.clone(); + async move { + handle_request_permissions( + codex.as_ref(), + &parent_session, + &parent_ctx, + RequestPermissionsEvent { + call_id: request_call_id, + turn_id: "child-turn-1".to_string(), + started_at_ms: 0, + reason: Some("need access".to_string()), + permissions: RequestPermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }, + cwd: Some(request_cwd), + }, + &cancel_token, + ) + .await; + } + }); + + let request_event = timeout(Duration::from_secs(1), rx_events.recv()) + .await + .expect("request_permissions event timed out") + .expect("request_permissions event missing"); + let EventMsg::RequestPermissions(request) = request_event.msg else { + panic!("expected RequestPermissions event"); + }; + assert_eq!(request.call_id, call_id.clone()); + assert_eq!(request.cwd, Some(delegated_cwd)); + + parent_session + .notify_request_permissions_response(&call_id, expected_response.clone()) + .await; + + timeout(Duration::from_secs(1), handle) + .await + .expect("handle_request_permissions hung") + .expect("handle_request_permissions join error"); + + let submission = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("request_permissions response timed out") + .expect("request_permissions response missing"); + assert_eq!( + submission.op, + Op::RequestPermissionsResponse { + id: call_id, + response: expected_response, + } + ); +} + +#[tokio::test] +async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_for_reply() { + let (parent_session, parent_ctx, rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref"); + let mut config = (*parent_ctx.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + parent_ctx.config = Arc::new(config); + parent_ctx + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set on-request policy"); + let parent_ctx = Arc::new(parent_ctx); + + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events_child, + agent_status, + session: Arc::clone(&parent_session), + session_loop_termination: completed_session_loop_termination(), + }); + + let cancel_token = CancellationToken::new(); + let handle = tokio::spawn({ + let codex = Arc::clone(&codex); + let parent_session = Arc::clone(&parent_session); + let parent_ctx = Arc::clone(&parent_ctx); + let cancel_token = cancel_token.clone(); + async move { + handle_exec_approval( + codex.as_ref(), + "child-turn-1".to_string(), + &parent_session, + &parent_ctx, + ExecApprovalRequestEvent { + call_id: "command-item-1".to_string(), + approval_id: Some("callback-approval-1".to_string()), + turn_id: "child-turn-1".to_string(), + started_at_ms: 0, + command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()], + cwd: test_path_buf("/tmp").abs(), + reason: Some("unsafe subcommand".to_string()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: Some(vec![ + ReviewDecision::Approved, + ReviewDecision::Abort, + ]), + parsed_cmd: Vec::new(), + }, + &cancel_token, + ) + .await; + } + }); + + let assessment_event = timeout(Duration::from_secs(2), async { + loop { + let event = rx_events.recv().await.expect("guardian assessment event"); + if let EventMsg::GuardianAssessment(assessment) = event.msg { + return assessment; + } + } + }) + .await + .expect("timed out waiting for guardian assessment"); + let expected_action = GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf tmp".to_string(), + cwd: test_path_buf("/tmp").abs(), + }; + assert!(!assessment_event.id.is_empty()); + assert_eq!( + assessment_event.target_item_id.as_deref(), + Some("command-item-1") + ); + assert_eq!(assessment_event.turn_id, parent_ctx.sub_id); + assert_eq!( + assessment_event.status, + GuardianAssessmentStatus::InProgress + ); + assert_eq!(assessment_event.risk_level, None); + assert_eq!(assessment_event.user_authorization, None); + assert_eq!(assessment_event.rationale, None); + assert_eq!(assessment_event.decision_source, None); + assert_eq!(assessment_event.action, expected_action); + + cancel_token.cancel(); + + timeout(Duration::from_secs(2), handle) + .await + .expect("handle_exec_approval hung") + .expect("handle_exec_approval join error"); + + let submission = timeout(Duration::from_secs(2), rx_sub.recv()) + .await + .expect("exec approval response timed out") + .expect("exec approval response missing"); + assert_eq!( + submission.op, + Op::ExecApproval { + id: "callback-approval-1".to_string(), + turn_id: Some("child-turn-1".to_string()), + decision: ReviewDecision::Abort, + } + ); +} + +#[tokio::test] +async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() { + let (parent_session, parent_ctx, _rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref"); + let mut config = (*parent_ctx.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + parent_ctx.config = Arc::new(config); + parent_ctx + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set on-request policy"); + let parent_ctx = Arc::new(parent_ctx); + + let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::from([( + "call-1".to_string(), + McpInvocation { + server: "custom_server".to_string(), + tool: "dangerous_tool".to_string(), + arguments: None, + }, + )]))); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let response = maybe_auto_review_mcp_request_user_input( + &parent_session, + &parent_ctx, + &pending_mcp_invocations, + &RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: "child-turn-1".to_string(), + questions: vec![RequestUserInputQuestion { + id: format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1"), + header: "Approve app tool call?".to_string(), + question: "Allow this app tool?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + }, + &cancel_token, + ) + .await; + + assert_eq!( + response, + Some(RequestUserInputResponse { + answers: HashMap::from([( + format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1"), + RequestUserInputAnswer { + answers: vec![MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()], + }, + )]), + }) + ); +} diff --git a/code-rs/core/src/codex_thread.rs b/code-rs/core/src/codex_thread.rs new file mode 100644 index 00000000000..9fe235640ae --- /dev/null +++ b/code-rs/core/src/codex_thread.rs @@ -0,0 +1,563 @@ +use crate::agent::AgentStatus; +use crate::config::ConstraintResult; +use crate::file_watcher::WatchRegistration; +use crate::goals::ExternalGoalSet; +use crate::goals::GoalRuntimeEvent; +use crate::session::Codex; +use crate::session::SessionSettingsUpdate; +use crate::session::SteerInputError; +use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::ContentItem; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::W3cTraceContext; +use codex_protocol::user_input::UserInput; +use codex_thread_store::StoredThread; +use codex_thread_store::StoredThreadHistory; +use codex_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadStoreError; +use codex_thread_store::ThreadStoreResult; +use codex_utils_absolute_path::AbsolutePathBuf; +use rmcp::model::ReadResourceRequestParams; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::sync::watch; + +use codex_rollout::state_db::StateDbHandle; + +#[derive(Clone, Debug)] +pub struct ThreadConfigSnapshot { + pub model: String, + pub model_provider_id: String, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub permission_profile: PermissionProfile, + pub active_permission_profile: Option, + pub cwd: AbsolutePathBuf, + pub ephemeral: bool, + pub reasoning_effort: Option, + pub personality: Option, + pub session_source: SessionSource, + pub thread_source: Option, +} + +impl ThreadConfigSnapshot { + pub fn sandbox_policy(&self) -> SandboxPolicy { + let file_system_sandbox_policy = self.permission_profile.file_system_sandbox_policy(); + codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &self.permission_profile, + &file_system_sandbox_policy, + self.permission_profile.network_sandbox_policy(), + self.cwd.as_path(), + ) + } +} + +/// Turn context overrides that app-server validates before starting a turn. +#[derive(Clone, Default)] +pub struct CodexThreadTurnContextOverrides { + pub cwd: Option, + pub approval_policy: Option, + pub approvals_reviewer: Option, + pub sandbox_policy: Option, + pub permission_profile: Option, + pub active_permission_profile: Option, + pub windows_sandbox_level: Option, + pub model: Option, + pub effort: Option>, + pub summary: Option, + pub service_tier: Option>, + pub collaboration_mode: Option, + pub personality: Option, +} + +pub struct CodexThread { + pub(crate) codex: Codex, + pub(crate) session_source: SessionSource, + session_configured: SessionConfiguredEvent, + rollout_path: Option, + out_of_band_elicitation_count: Mutex, + _watch_registration: WatchRegistration, +} + +/// Conduit for the bidirectional stream of messages that compose a thread +/// (formerly called a conversation) in Codex. +impl CodexThread { + pub(crate) fn new( + codex: Codex, + session_configured: SessionConfiguredEvent, + rollout_path: Option, + session_source: SessionSource, + watch_registration: WatchRegistration, + ) -> Self { + Self { + codex, + session_source, + session_configured, + rollout_path, + out_of_band_elicitation_count: Mutex::new(0), + _watch_registration: watch_registration, + } + } + + pub async fn submit(&self, op: Op) -> CodexResult { + self.codex.submit(op).await + } + + pub async fn shutdown_and_wait(&self) -> CodexResult<()> { + self.codex.shutdown_and_wait().await + } + + /// Wait until the underlying session loop has terminated. + pub async fn wait_until_terminated(&self) { + self.codex.session_loop_termination.clone().await; + } + + pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ThreadResumed) + .await + } + + pub async fn continue_active_goal_if_idle(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) + .await + } + + pub async fn prepare_external_goal_mutation(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) + .await + { + tracing::warn!("failed to prepare external goal mutation: {err}"); + } + } + + pub async fn apply_external_goal_set(&self, external_set: ExternalGoalSet) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { external_set }) + .await + { + tracing::warn!("failed to apply external goal status runtime effects: {err}"); + } + } + + pub async fn apply_external_goal_clear(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalClear) + .await + { + tracing::warn!("failed to apply external goal clear runtime effects: {err}"); + } + } + + #[doc(hidden)] + pub async fn ensure_rollout_materialized(&self) { + self.codex.session.ensure_rollout_materialized().await; + } + + #[doc(hidden)] + pub async fn flush_rollout(&self) -> std::io::Result<()> { + self.codex.session.flush_rollout().await + } + + pub async fn submit_with_trace( + &self, + op: Op, + trace: Option, + ) -> CodexResult { + self.codex.submit_with_trace(op, trace).await + } + + /// Persist whether this thread is eligible for future memory generation. + pub async fn set_thread_memory_mode(&self, mode: ThreadMemoryMode) -> anyhow::Result<()> { + self.codex.set_thread_memory_mode(mode).await + } + + pub async fn steer_input( + &self, + input: Vec, + expected_turn_id: Option<&str>, + responsesapi_client_metadata: Option>, + ) -> Result { + self.codex + .steer_input(input, expected_turn_id, responsesapi_client_metadata) + .await + } + + pub async fn set_app_server_client_info( + &self, + app_server_client_name: Option, + app_server_client_version: Option, + mcp_elicitations_auto_deny: bool, + ) -> ConstraintResult<()> { + self.codex + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) + .await + } + + /// Validate persistent turn context overrides without committing them. + pub async fn validate_turn_context_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> ConstraintResult<()> { + let CodexThreadTurnContextOverrides { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = overrides; + let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { + collaboration_mode + } else { + self.codex + .session + .collaboration_mode() + .await + .with_updates(model, effort, /*developer_instructions*/ None) + }; + + let updates = SessionSettingsUpdate { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + }; + self.codex.session.validate_settings(&updates).await + } + + /// Use sparingly: this is intended to be removed soon. + pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { + self.codex.submit_with_id(sub).await + } + + pub async fn next_event(&self) -> CodexResult { + self.codex.next_event().await + } + + pub async fn agent_status(&self) -> AgentStatus { + self.codex.agent_status().await + } + + pub(crate) fn subscribe_status(&self) -> watch::Receiver { + self.codex.agent_status.clone() + } + + /// Returns the complete token usage snapshot currently cached for this thread. + /// + /// This accessor is intentionally narrower than direct session access: it lets + /// app-server lifecycle paths replay restored usage after resume or fork without + /// exposing broader session mutation authority. A caller that only reads + /// `total_token_usage` would drop last-turn usage and make the v2 + /// `thread/tokenUsage/updated` payload incomplete. + pub async fn token_usage_info(&self) -> Option { + self.codex.session.token_usage_info().await + } + + /// Records a user-role session-prefix message without creating a new user turn boundary. + pub(crate) async fn inject_user_message_without_turn(&self, message: String) { + let message = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text: message }], + phase: None, + }; + let pending_item = match pending_message_input_item(&message) { + Ok(pending_item) => pending_item, + Err(err) => { + debug_assert!(false, "session-prefix message append should succeed: {err}"); + return; + } + }; + if self + .codex + .session + .inject_response_items(vec![pending_item]) + .await + .is_err() + { + let turn_context = self.codex.session.new_default_turn().await; + self.codex + .session + .record_conversation_items(turn_context.as_ref(), &[message]) + .await; + } + } + + /// Append a prebuilt message to the thread history without treating it as a user turn. + /// + /// If the thread already has an active turn, the message is queued as pending input for that + /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent + /// can consume that pending input through the normal turn pipeline. + #[cfg(test)] + pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { + let submission_id = uuid::Uuid::new_v4().to_string(); + let pending_item = pending_message_input_item(&message)?; + if let Err(items) = self + .codex + .session + .inject_response_items(vec![pending_item]) + .await + { + self.codex + .session + .queue_response_items_for_next_turn(items) + .await; + self.codex.session.maybe_start_turn_for_pending_work().await; + } + + Ok(submission_id) + } + + /// Append raw Responses API items to the thread's model-visible history. + pub async fn inject_response_items(&self, items: Vec) -> CodexResult<()> { + if items.is_empty() { + return Err(CodexErr::InvalidRequest( + "items must not be empty".to_string(), + )); + } + + let turn_context = self.codex.session.new_default_turn().await; + if self.codex.session.reference_context_item().await.is_none() { + self.codex + .session + .record_context_updates_and_set_reference_context_item(turn_context.as_ref()) + .await; + } + self.codex + .session + .record_conversation_items(turn_context.as_ref(), &items) + .await; + self.codex.session.flush_rollout().await?; + Ok(()) + } + + pub fn rollout_path(&self) -> Option { + self.rollout_path.clone() + } + + pub fn session_configured(&self) -> SessionConfiguredEvent { + self.session_configured.clone() + } + + pub(crate) fn is_running(&self) -> bool { + !self.codex.tx_sub.is_closed() + } + + pub async fn guardian_trunk_rollout_path(&self) -> Option { + self.codex + .session + .guardian_review_session + .trunk_rollout_path() + .await + } + + pub async fn load_history( + &self, + include_archived: bool, + ) -> ThreadStoreResult { + let live_thread = self + .codex + .session + .live_thread_for_persistence("load history") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread.load_history(include_archived).await + } + + pub async fn read_thread( + &self, + include_archived: bool, + include_history: bool, + ) -> ThreadStoreResult { + let live_thread = self + .codex + .session + .live_thread_for_persistence("read thread") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread + .read_thread(include_archived, include_history) + .await + } + + pub async fn update_thread_metadata( + &self, + patch: ThreadMetadataPatch, + include_archived: bool, + ) -> ThreadStoreResult { + let live_thread = self + .codex + .session + .live_thread_for_persistence("update thread metadata") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread.update_metadata(patch, include_archived).await + } + + pub fn state_db(&self) -> Option { + self.codex.state_db() + } + + pub async fn config_snapshot(&self) -> ThreadConfigSnapshot { + self.codex.thread_config_snapshot().await + } + + pub async fn config(&self) -> Arc { + self.codex.session.get_config().await + } + + /// Refresh the thread's layer-backed user config state from a caller-supplied + /// config snapshot. Thread-scoped layers and session-static settings remain + /// unchanged. + pub async fn refresh_runtime_config(&self, next_config: crate::config::Config) { + self.codex.session.refresh_runtime_config(next_config).await; + } + + pub async fn read_mcp_resource( + &self, + server: &str, + uri: &str, + ) -> anyhow::Result { + let result = self + .codex + .session + .read_resource( + server, + ReadResourceRequestParams { + meta: None, + uri: uri.to_string(), + }, + ) + .await?; + + Ok(serde_json::to_value(result)?) + } + + pub async fn call_mcp_tool( + &self, + server: &str, + tool: &str, + arguments: Option, + meta: Option, + ) -> anyhow::Result { + self.codex + .session + .call_tool(server, tool, arguments, meta) + .await + } + + pub fn enabled(&self, feature: Feature) -> bool { + self.codex.enabled(feature) + } + + pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult { + let mut guard = self.out_of_band_elicitation_count.lock().await; + let was_zero = *guard == 0; + *guard = guard.checked_add(1).ok_or_else(|| { + CodexErr::Fatal("out-of-band elicitation count overflowed".to_string()) + })?; + + if was_zero { + self.codex + .session + .set_out_of_band_elicitation_pause_state(/*paused*/ true); + } + + Ok(*guard) + } + + pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult { + let mut guard = self.out_of_band_elicitation_count.lock().await; + if *guard == 0 { + return Err(CodexErr::InvalidRequest( + "out-of-band elicitation count is already zero".to_string(), + )); + } + + *guard -= 1; + let now_zero = *guard == 0; + if now_zero { + self.codex + .session + .set_out_of_band_elicitation_pause_state(/*paused*/ false); + } + + Ok(*guard) + } +} + +fn pending_message_input_item(message: &ResponseItem) -> CodexResult { + match message { + ResponseItem::Message { + role, + content, + phase, + .. + } => Ok(ResponseInputItem::Message { + role: role.clone(), + content: content.clone(), + phase: phase.clone(), + }), + _ => Err(CodexErr::InvalidRequest( + "append_message only supports ResponseItem::Message".to_string(), + )), + } +} diff --git a/code-rs/core/src/command_canonicalization.rs b/code-rs/core/src/command_canonicalization.rs new file mode 100644 index 00000000000..b88a79375d8 --- /dev/null +++ b/code-rs/core/src/command_canonicalization.rs @@ -0,0 +1,42 @@ +use codex_shell_command::bash::extract_bash_command; +use codex_shell_command::bash::parse_shell_lc_plain_commands; +use codex_shell_command::powershell::extract_powershell_command; + +const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__"; +const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__"; + +/// Canonicalize command argv for approval-cache matching. +/// +/// This keeps approval decisions stable across wrapper-path differences (for +/// example `/bin/bash -lc` vs `bash -lc`) and across shell wrapper tools while +/// preserving exact script text for complex scripts where we cannot safely +/// recover a tokenized command sequence. +pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec { + if let Some(commands) = parse_shell_lc_plain_commands(command) + && let [single_command] = commands.as_slice() + { + return single_command.clone(); + } + + if let Some((_shell, script)) = extract_bash_command(command) { + let shell_mode = command.get(1).cloned().unwrap_or_default(); + return vec![ + CANONICAL_BASH_SCRIPT_PREFIX.to_string(), + shell_mode, + script.to_string(), + ]; + } + + if let Some((_shell, script)) = extract_powershell_command(command) { + return vec![ + CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string(), + script.to_string(), + ]; + } + + command.to_vec() +} + +#[cfg(test)] +#[path = "command_canonicalization_tests.rs"] +mod tests; diff --git a/code-rs/core/src/command_canonicalization_tests.rs b/code-rs/core/src/command_canonicalization_tests.rs new file mode 100644 index 00000000000..c278dedafcc --- /dev/null +++ b/code-rs/core/src/command_canonicalization_tests.rs @@ -0,0 +1,88 @@ +use super::canonicalize_command_for_approval; +use pretty_assertions::assert_eq; + +#[test] +fn canonicalizes_word_only_shell_scripts_to_inner_command() { + let command_a = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "cargo test -p codex-core".to_string(), + ]; + let command_b = vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo test -p codex-core".to_string(), + ]; + + assert_eq!( + canonicalize_command_for_approval(&command_a), + vec![ + "cargo".to_string(), + "test".to_string(), + "-p".to_string(), + "codex-core".to_string(), + ] + ); + assert_eq!( + canonicalize_command_for_approval(&command_a), + canonicalize_command_for_approval(&command_b) + ); +} + +#[test] +fn canonicalizes_heredoc_scripts_to_stable_script_key() { + let script = "python3 <<'PY'\nprint('hello')\nPY"; + let command_a = vec![ + "/bin/zsh".to_string(), + "-lc".to_string(), + script.to_string(), + ]; + let command_b = vec!["zsh".to_string(), "-lc".to_string(), script.to_string()]; + + assert_eq!( + canonicalize_command_for_approval(&command_a), + vec![ + "__codex_shell_script__".to_string(), + "-lc".to_string(), + script.to_string(), + ] + ); + assert_eq!( + canonicalize_command_for_approval(&command_a), + canonicalize_command_for_approval(&command_b) + ); +} + +#[test] +fn canonicalizes_powershell_wrappers_to_stable_script_key() { + let script = "Write-Host hi"; + let command_a = vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + script.to_string(), + ]; + let command_b = vec![ + "powershell".to_string(), + "-Command".to_string(), + script.to_string(), + ]; + + assert_eq!( + canonicalize_command_for_approval(&command_a), + vec![ + "__codex_powershell_script__".to_string(), + script.to_string(), + ] + ); + assert_eq!( + canonicalize_command_for_approval(&command_a), + canonicalize_command_for_approval(&command_b) + ); +} + +#[test] +fn preserves_non_shell_commands() { + let command = vec!["cargo".to_string(), "fmt".to_string()]; + assert_eq!(canonicalize_command_for_approval(&command), command); +} diff --git a/code-rs/core/src/command_safety/is_dangerous_command.rs b/code-rs/core/src/command_safety/is_dangerous_command.rs deleted file mode 100644 index 852af93ef96..00000000000 --- a/code-rs/core/src/command_safety/is_dangerous_command.rs +++ /dev/null @@ -1,99 +0,0 @@ -use crate::bash::parse_bash_lc_plain_commands; - -pub fn command_might_be_dangerous(command: &[String]) -> bool { - if is_dangerous_to_call_with_exec(command) { - return true; - } - - // Support `bash -lc " - - + diff --git a/code-rs/login/src/assets/success_legacy.html b/code-rs/login/src/assets/success_legacy.html new file mode 100644 index 00000000000..015866ee546 --- /dev/null +++ b/code-rs/login/src/assets/success_legacy.html @@ -0,0 +1,197 @@ + + + + + Sign into Codex + + + + +
+
+
+ +
Signed in to Codex
+
+ + +
+
+ + + diff --git a/code-rs/login/src/auth/agent_identity.rs b/code-rs/login/src/auth/agent_identity.rs new file mode 100644 index 00000000000..3644713328f --- /dev/null +++ b/code-rs/login/src/auth/agent_identity.rs @@ -0,0 +1,140 @@ +use codex_agent_identity::AgentIdentityKey; +use codex_agent_identity::register_agent_task; +use codex_protocol::account::PlanType as AccountPlanType; +use std::env; + +use crate::default_client::build_reqwest_client; + +use super::storage::AgentIdentityAuthRecord; + +const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"; + +#[derive(Clone, Debug)] +pub struct AgentIdentityAuth { + record: AgentIdentityAuthRecord, + process_task_id: String, +} + +impl AgentIdentityAuth { + pub async fn load(record: AgentIdentityAuthRecord) -> std::io::Result { + let agent_identity_authapi_base_url = agent_identity_authapi_base_url(); + let process_task_id = register_agent_task( + &build_reqwest_client(), + &agent_identity_authapi_base_url, + key(&record), + ) + .await + .map_err(std::io::Error::other)?; + Ok(Self { + record, + process_task_id, + }) + } + + pub fn record(&self) -> &AgentIdentityAuthRecord { + &self.record + } + + pub fn process_task_id(&self) -> &str { + &self.process_task_id + } + + pub fn account_id(&self) -> &str { + &self.record.account_id + } + + pub fn chatgpt_user_id(&self) -> &str { + &self.record.chatgpt_user_id + } + + pub fn email(&self) -> &str { + &self.record.email + } + + pub fn plan_type(&self) -> AccountPlanType { + self.record.plan_type + } + + pub fn is_fedramp_account(&self) -> bool { + self.record.chatgpt_account_is_fedramp + } +} + +fn agent_identity_authapi_base_url() -> String { + env::var(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR) + .ok() + .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) + .filter(|base_url| !base_url.is_empty()) + .unwrap_or_else(|| PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL.to_string()) +} + +fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { + AgentIdentityKey { + agent_runtime_id: &record.agent_runtime_id, + private_key_pkcs8_base64: &record.agent_private_key, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial(codex_auth_env)] + fn agent_identity_authapi_base_url_prefers_env_value() { + let _guard = EnvVarGuard::set( + CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR, + "https://authapi.example.test/api/accounts/", + ); + assert_eq!( + agent_identity_authapi_base_url(), + "https://authapi.example.test/api/accounts" + ); + } + + #[test] + #[serial(codex_auth_env)] + fn agent_identity_authapi_base_url_uses_prod_authapi_by_default() { + let _guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR); + assert_eq!( + agent_identity_authapi_base_url(), + PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL + ); + } + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = env::var_os(key); + unsafe { + env::remove_var(key); + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + } +} diff --git a/code-rs/login/src/auth/auth_tests.rs b/code-rs/login/src/auth/auth_tests.rs new file mode 100644 index 00000000000..fe57be06fab --- /dev/null +++ b/code-rs/login/src/auth/auth_tests.rs @@ -0,0 +1,1216 @@ +use super::*; +use crate::auth::storage::FileAuthStorage; +use crate::auth::storage::get_auth_file; +use crate::token_data::IdTokenInfo; +use codex_app_server_protocol::AuthMode; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::auth::KnownPlan as InternalKnownPlan; +use codex_protocol::auth::PlanType as InternalPlanType; + +use base64::Engine; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ModelProviderAuthInfo; +use pretty_assertions::assert_eq; +use serde::Serialize; +use serde_json::json; +use std::sync::Arc; +use tempfile::TempDir; +use tempfile::tempdir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn refresh_without_id_token() { + let codex_home = tempdir().unwrap(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let storage = create_auth_storage( + codex_home.path().to_path_buf(), + AuthCredentialsStoreMode::File, + ); + let updated = super::persist_tokens( + &storage, + /*id_token*/ None, + Some("new-access-token".to_string()), + Some("new-refresh-token".to_string()), + ) + .expect("update_tokens should succeed"); + + let tokens = updated.tokens.expect("tokens should exist"); + assert_eq!(tokens.id_token.raw_jwt, fake_jwt); + assert_eq!(tokens.access_token, "new-access-token"); + assert_eq!(tokens.refresh_token, "new-refresh-token"); +} + +#[test] +fn login_with_api_key_overwrites_existing_auth_json() { + let dir = tempdir().unwrap(); + let auth_path = dir.path().join("auth.json"); + let stale_auth = json!({ + "OPENAI_API_KEY": "sk-old", + "tokens": { + "id_token": "stale.header.payload", + "access_token": "stale-access", + "refresh_token": "stale-refresh", + "account_id": "stale-acc" + } + }); + std::fs::write( + &auth_path, + serde_json::to_string_pretty(&stale_auth).unwrap(), + ) + .unwrap(); + + super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File) + .expect("login_with_api_key should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!(auth.openai_api_key.as_deref(), Some("sk-new")); + assert!(auth.tokens.is_none(), "tokens should be cleared"); +} + +#[tokio::test] +async fn login_with_access_token_writes_only_token() { + let dir = tempdir().unwrap(); + let auth_path = dir.path().join("auth.json"); + let record = agent_identity_record("account-123"); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + + super::login_with_access_token( + dir.path(), + &agent_identity, + AuthCredentialsStoreMode::File, + Some(&chatgpt_base_url), + ) + .await + .expect("login_with_access_token should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!(auth.auth_mode, Some(AuthMode::AgentIdentity)); + assert_eq!( + auth.agent_identity.as_deref(), + Some(agent_identity.as_str()) + ); + assert!(auth.tokens.is_none(), "tokens should be cleared"); + assert!(auth.openai_api_key.is_none(), "API key should be cleared"); + server.verify().await; +} + +#[tokio::test] +async fn login_with_access_token_rejects_invalid_jwt() { + let dir = tempdir().unwrap(); + + let err = super::login_with_access_token( + dir.path(), + "not-a-jwt", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect_err("invalid access token should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert!( + !get_auth_file(dir.path()).exists(), + "invalid access token should not write auth.json" + ); +} + +#[tokio::test] +async fn login_with_access_token_rejects_unsigned_jwt() { + let dir = tempdir().unwrap(); + let record = agent_identity_record("account-123"); + let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + + super::login_with_access_token( + dir.path(), + &agent_identity, + AuthCredentialsStoreMode::File, + Some(&chatgpt_base_url), + ) + .await + .expect_err("unsigned access token should fail"); + + assert!( + !get_auth_file(dir.path()).exists(), + "unsigned access token should not write auth.json" + ); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn missing_auth_json_returns_none() { + let dir = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let auth = CodexAuth::from_auth_storage( + dir.path(), + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("call should succeed"); + assert_eq!(auth, None); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn pro_account_with_no_api_key_uses_chatgpt_auth() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(None, auth.api_key()); + assert_eq!(AuthMode::Chatgpt, auth.auth_mode()); + assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-12345")); + + let auth_dot_json = auth + .get_current_auth_json() + .expect("AuthDotJson should exist"); + let last_refresh = auth_dot_json + .last_refresh + .expect("last_refresh should be recorded"); + + assert_eq!( + AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)), + chatgpt_user_id: Some("user-12345".to_string()), + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + raw_jwt: fake_jwt, + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some(last_refresh), + agent_identity: None, + }, + auth_dot_json + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn loads_api_key_from_auth_json() { + let dir = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let auth_file = dir.path().join("auth.json"); + std::fs::write( + auth_file, + r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#, + ) + .unwrap(); + + let auth = super::load_auth( + dir.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(auth.auth_mode(), AuthMode::ApiKey); + assert_eq!(auth.api_key(), Some("sk-test-key")); + + assert!(auth.get_token_data().is_err()); +} + +#[test] +fn logout_removes_auth_file() -> Result<(), std::io::Error> { + let dir = tempdir()?; + let auth_dot_json = AuthDotJson { + auth_mode: Some(ApiAuthMode::ApiKey), + openai_api_key: Some("sk-test-key".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }; + super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; + let auth_file = get_auth_file(dir.path()); + assert!(auth_file.exists()); + assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?); + assert!(!auth_file.exists()); + Ok(()) +} + +#[tokio::test] +async fn unauthorized_recovery_reports_mode_and_step_names() { + let dir = tempdir().unwrap(); + let manager = AuthManager::shared( + dir.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let managed = UnauthorizedRecovery { + manager: Arc::clone(&manager), + step: UnauthorizedRecoveryStep::Reload, + expected_account_id: None, + mode: UnauthorizedRecoveryMode::Managed, + }; + assert_eq!(managed.mode_name(), "managed"); + assert_eq!(managed.step_name(), "reload"); + + let external = UnauthorizedRecovery { + manager, + step: UnauthorizedRecoveryStep::ExternalRefresh, + expected_account_id: None, + mode: UnauthorizedRecoveryMode::External, + }; + assert_eq!(external.mode_name(), "external"); + assert_eq!(external.step_name(), "external_refresh"); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("load auth") + .expect("auth available"); + let mut updated_auth_dot_json = auth + .get_current_auth_json() + .expect("AuthDotJson should exist"); + let updated_tokens = updated_auth_dot_json + .tokens + .as_mut() + .expect("tokens should exist"); + updated_tokens.access_token = "new-access-token".to_string(); + updated_tokens.refresh_token = "new-refresh-token".to_string(); + let updated_auth = CodexAuth::from_auth_dot_json( + codex_home.path(), + updated_auth_dot_json, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("updated auth should parse"); + + let manager = AuthManager::from_auth_for_testing(auth.clone()); + let error = RefreshTokenFailedError::new( + RefreshTokenFailedReason::Exhausted, + "refresh token already used", + ); + manager.record_permanent_refresh_failure_if_unchanged(&auth, &error); + + assert_eq!(manager.refresh_failure_for_auth(&auth), Some(error)); + assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None); +} + +#[test] +fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() { + let err = AuthDotJson::from_external_tokens(&ExternalAuthTokens::access_token_only( + "test-access-token", + )) + .expect_err("bearer-only external auth should not seed ChatGPT auth"); + + assert_eq!( + err.to_string(), + "external auth tokens are missing ChatGPT metadata" + ); +} + +#[tokio::test] +async fn external_bearer_only_auth_manager_uses_cached_provider_token() { + let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap(); + let manager = AuthManager::external_bearer_only(script.auth_config()); + + let first = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + let second = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + + assert_eq!(first.as_deref(), Some("provider-token")); + assert_eq!(second.as_deref(), Some("provider-token")); + assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey)); + assert_eq!(manager.get_api_auth_mode(), Some(ApiAuthMode::ApiKey)); +} + +#[tokio::test] +async fn external_bearer_only_auth_manager_disables_auto_refresh_when_interval_is_zero() { + let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap(); + let mut auth_config = script.auth_config(); + auth_config.refresh_interval_ms = 0; + let manager = AuthManager::external_bearer_only(auth_config); + + let first = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + let second = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + + assert_eq!(first.as_deref(), Some("provider-token")); + assert_eq!(second.as_deref(), Some("provider-token")); +} + +#[tokio::test] +async fn external_bearer_only_auth_manager_returns_none_when_command_fails() { + let script = ProviderAuthScript::new_failing().unwrap(); + let manager = AuthManager::external_bearer_only(script.auth_config()); + + assert_eq!(manager.auth().await, None); +} + +#[tokio::test] +async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { + let script = ProviderAuthScript::new(&["provider-token", "refreshed-provider-token"]).unwrap(); + let mut auth_config = script.auth_config(); + auth_config.refresh_interval_ms = 0; + let manager = AuthManager::external_bearer_only(auth_config); + let initial_token = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + let mut recovery = manager.unauthorized_recovery(); + + assert!(recovery.has_next()); + assert_eq!(recovery.mode_name(), "external"); + assert_eq!(recovery.step_name(), "external_refresh"); + + let result = recovery + .next() + .await + .expect("external refresh should succeed"); + + assert_eq!(result.auth_state_changed(), Some(true)); + let refreshed_token = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + assert_eq!(initial_token.as_deref(), Some("provider-token")); + assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token")); +} + +struct ProviderAuthScript { + tempdir: TempDir, + command: String, + args: Vec, +} + +impl ProviderAuthScript { + fn new(tokens: &[&str]) -> std::io::Result { + let tempdir = tempfile::tempdir()?; + let token_file = tempdir.path().join("tokens.txt"); + // `cmd.exe`'s `set /p` treats LF-only input as one line, so use CRLF on Windows. + let token_line_ending = if cfg!(windows) { "\r\n" } else { "\n" }; + let mut token_file_contents = String::new(); + for token in tokens { + token_file_contents.push_str(token); + token_file_contents.push_str(token_line_ending); + } + std::fs::write(&token_file, token_file_contents)?; + + #[cfg(unix)] + let (command, args) = { + let script_path = tempdir.path().join("print-token.sh"); + std::fs::write( + &script_path, + r#"#!/bin/sh +first_line=$(sed -n '1p' tokens.txt) +printf '%s\n' "$first_line" +tail -n +2 tokens.txt > tokens.next +mv tokens.next tokens.txt +"#, + )?; + let mut permissions = std::fs::metadata(&script_path)?.permissions(); + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + std::fs::set_permissions(&script_path, permissions)?; + ("./print-token.sh".to_string(), Vec::new()) + }; + + #[cfg(windows)] + let (command, args) = { + let script_path = tempdir.path().join("print-token.cmd"); + std::fs::write( + &script_path, + r#"@echo off +setlocal EnableExtensions DisableDelayedExpansion +set "first_line=" + std::io::Result { + let tempdir = tempfile::tempdir()?; + + #[cfg(unix)] + let (command, args) = { + let script_path = tempdir.path().join("fail.sh"); + std::fs::write( + &script_path, + r#"#!/bin/sh +exit 1 +"#, + )?; + let mut permissions = std::fs::metadata(&script_path)?.permissions(); + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + std::fs::set_permissions(&script_path, permissions)?; + ("./fail.sh".to_string(), Vec::new()) + }; + + #[cfg(windows)] + let (command, args) = ( + "cmd.exe".to_string(), + vec![ + "/d".to_string(), + "/s".to_string(), + "/c".to_string(), + "exit /b 1".to_string(), + ], + ); + + Ok(Self { + tempdir, + command, + args, + }) + } + + fn auth_config(&self) -> ModelProviderAuthInfo { + serde_json::from_value(json!({ + "command": self.command, + "args": self.args, + // Process startup can be slow on loaded Windows CI workers, so leave enough slack to + // avoid turning these auth-cache assertions into a process-launch timing test. + "timeout_ms": 10_000, + "refresh_interval_ms": 60000, + "cwd": self.tempdir.path(), + })) + .expect("provider auth config should deserialize") + } +} + +struct AuthFileParams { + openai_api_key: Option, + chatgpt_plan_type: Option, + chatgpt_account_id: Option, +} + +fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { + let fake_jwt = fake_jwt_for_auth_file_params(¶ms)?; + let auth_file = get_auth_file(codex_home); + let auth_json_data = json!({ + "OPENAI_API_KEY": params.openai_api_key, + "tokens": { + "id_token": fake_jwt, + "access_token": "test-access-token", + "refresh_token": "test-refresh-token" + }, + "last_refresh": Utc::now(), + }); + let auth_json = serde_json::to_string_pretty(&auth_json_data)?; + std::fs::write(auth_file, auth_json)?; + Ok(fake_jwt) +} + +fn fake_jwt_for_auth_file_params(params: &AuthFileParams) -> std::io::Result { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let mut auth_payload = serde_json::json!({ + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + }); + + if let Some(chatgpt_plan_type) = params.chatgpt_plan_type.as_ref() { + auth_payload["chatgpt_plan_type"] = serde_json::Value::String(chatgpt_plan_type.clone()); + } + + if let Some(chatgpt_account_id) = params.chatgpt_account_id.as_ref() { + auth_payload["chatgpt_account_id"] = serde_json::Value::String(chatgpt_account_id.clone()); + } + + let payload = serde_json::json!({ + "email": "user@example.com", + "email_verified": true, + "https://api.openai.com/auth": auth_payload, + }); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header)?); + let payload_b64 = b64(&serde_json::to_vec(&payload)?); + let signature_b64 = b64(b"sig"); + Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) +} + +async fn build_config( + codex_home: &Path, + forced_login_method: Option, + forced_chatgpt_workspace_id: Option, +) -> AuthConfig { + AuthConfig { + codex_home: codex_home.to_path_buf(), + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_login_method, + forced_chatgpt_workspace_id, + chatgpt_base_url: None, + } +} + +/// Use sparingly. +/// TODO (gpeal): replace this with an injectable env var provider. +#[cfg(test)] +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +#[cfg(test)] +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = env::var_os(key); + unsafe { + env::remove_var(key); + } + Self { key, original } + } +} + +#[cfg(test)] +impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } +} + +fn remove_access_token_env_var() -> EnvVarGuard { + EnvVarGuard::remove(CODEX_ACCESS_TOKEN_ENV_VAR) +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_reads_access_token_from_env() { + let codex_home = tempdir().unwrap(); + let expected_record = agent_identity_record("account-123"); + let agent_identity = + signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type)) + .expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-123", + }))) + .expect(1) + .mount(&server) + .await; + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); + + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let _authapi_guard = + EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + Some(&chatgpt_base_url), + ) + .await + .expect("env auth should load") + .expect("env auth should be present"); + + let CodexAuth::AgentIdentity(agent_identity) = auth else { + panic!("env auth should load as agent identity"); + }; + assert_eq!(agent_identity.record(), &expected_record); + assert_eq!(agent_identity.process_task_id(), "task-123"); + assert!( + !get_auth_file(codex_home.path()).exists(), + "env auth should not write auth.json" + ); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_keeps_codex_api_key_env_precedence() { + let codex_home = tempdir().unwrap(); + let record = agent_identity_record("account-123"); + let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); + let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ true, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("env auth should load") + .expect("env auth should be present"); + + assert_eq!(auth.api_key(), Some("sk-env")); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn enforce_login_restrictions_logs_out_for_method_mismatch() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) + .expect("seed api key"); + + let config = build_config( + codex_home.path(), + Some(ForcedLoginMethod::Chatgpt), + /*forced_chatgpt_workspace_id*/ None, + ) + .await; + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected method mismatch to error"); + assert!(err.to_string().contains("ChatGPT login is required")); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("org_another_org".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + Some("org_mine".to_string()), + ) + .await; + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected workspace mismatch to error"); + assert!(err.to_string().contains("workspace org_mine")); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn enforce_login_restrictions_allows_matching_workspace() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + Some("org_mine".to_string()), + ) + .await; + + super::enforce_login_restrictions(&config) + .await + .expect("matching workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() + { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) + .expect("seed api key"); + + let config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + Some("org_mine".to_string()), + ) + .await; + + super::enforce_login_restrictions(&config) + .await + .expect("matching workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { + let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); + let _access_token_guard = remove_access_token_env_var(); + let codex_home = tempdir().unwrap(); + + let config = build_config( + codex_home.path(), + Some(ForcedLoginMethod::Chatgpt), + /*forced_chatgpt_workspace_id*/ None, + ) + .await; + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("environment API key should not satisfy forced ChatGPT login"); + assert!( + err.to_string() + .contains("ChatGPT login is required, but an API key is currently being used.") + ); +} + +fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord { + let key_material = + codex_agent_identity::generate_agent_key_material().expect("generate agent key material"); + AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: key_material.private_key_pkcs8_base64, + account_id: account_id.to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + } +} + +fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result { + fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?) +} + +fn fake_agent_identity_jwt_with_plan_type( + record: &AgentIdentityAuthRecord, + plan_type: serde_json::Value, +) -> std::io::Result { + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#); + let payload = json!({ + "iss": "https://chatgpt.com/codex-backend/agent-identity", + "aud": "codex-app-server", + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": record.agent_runtime_id, + "agent_private_key": record.agent_private_key, + "account_id": record.account_id, + "chatgpt_user_id": record.chatgpt_user_id, + "email": record.email, + "plan_type": plan_type, + "chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp, + }); + let payload_b64 = encode(&serde_json::to_vec(&payload)?); + let signature_b64 = encode(b"sig"); + Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) +} + +fn signed_agent_identity_jwt( + record: &AgentIdentityAuthRecord, + plan_type: serde_json::Value, +) -> jsonwebtoken::errors::Result { + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); + header.kid = Some("test-key".to_string()); + jsonwebtoken::encode( + &header, + &json!({ + "iss": "https://chatgpt.com/codex-backend/agent-identity", + "aud": "codex-app-server", + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": record.agent_runtime_id, + "agent_private_key": record.agent_private_key, + "account_id": record.account_id, + "chatgpt_user_id": record.chatgpt_user_id, + "email": record.email, + "plan_type": plan_type, + "chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp, + }), + &jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?, + ) +} + +fn test_jwks_body() -> serde_json::Value { + json!({ + "keys": [{ + "kty": "RSA", + "kid": "test-key", + "use": "sig", + "alg": "RS256", + "n": "1qQF2MqTrGAMDm7wXbjJP5sWqGA83tAGUs2ksy7iJXLJdhCg4AtwGm4SFl4f6kxhCSzlN1QdXuZjvRT2wZZiGUi9xUE28rf4WLrTxSnwqLuTy5knMP08yC0t_0YU_FGPZMcWb14hG05IvZr8UbmRaVagxSR8H4rSIymRoVwwmFSrqz068XrWGSYNIfLEASyo5GdAaqmk1JALINHgYGQJVxMxtwcvDxoVKmC7eltUNymMNBZhsv4E8sx9YNLpBoEibznfEpDU_DGzrM5eZCsQzaqbhBOlGd427ifud_Nnd9cPqzgCUc23-0FXSPfpbgksCXAwAmD0OFjQWrgqVdKL6Q", + "e": "AQAB", + }] + }) +} + +const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO +bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 +FPbBlmIZSL3FQTbyt/hYutPFKfCou5PLmScw/TzILS3/RhT8UY9kxxZvXiEbTki9 +mvxRuZFpVqDFJHwfitIjKZGhXDCYVKurPTrxetYZJg0h8sQBLKjkZ0BqqaTUkAsg +0eBgZAlXEzG3By8PGhUqYLt6W1Q3KYw0FmGy/gTyzH1g0ukGgSJvOd8SkNT8MbOs +zl5kKxDNqpuEE6UZ3jbuJ+5382d31w+rOAJRzbf7QVdI9+luCSwJcDACYPQ4WNBa +uCpV0ovpAgMBAAECggEAVu84LwZdqYN9XpswX8VoPYrjMm9IODapWQBRpQFoNyK2 +1ksF3bjEPvA2Azk8U/l7k+vLKw22l6lY3EyRZPcz5GnB8xLm3ogE3mtNOp4yCyVu +RxhQ91aaN7mU17/a4BdorLi2LYVCg3zBmYociD1Q2AluNGsCmwPu+K7tfR2J0Sg8 +NjqiTbDG1XDpR/icwgC9t6vh8lZpCHDhF4tbQfLLVLeA/OdcuzXDyMCXbmdVIdBQ +rm4aIFmr2e1/2ctTbCg85S6AGFTH+pSLjrwTzyvf+F6NW5uNjLQAQLFj+EznBDxj +Xdx90cySrjsKK6PVWQF4RiTvkSW8eWL7R6B2FZbGwQKBgQDuVQRj72hWloR7mbEL +aUEEv3pIXTMXWEsoMBNczos/1L1RnAN1AI44TurznasPZAWvQj+kVbLDR+TAeZrL +iA8HIWswQUI18hFmgKzSkwIXGtubcKVrgsKeS4lMDKCM/Ef6WAYdeq6ronoY5lCN +YrJFmGp81W5zcV7lyiycgbSiGwKBgQDmjWYf6pZjrK7Z+OJ3X1AZfi2vss15SCvL +3fPgzIDbViztpGyQhc3DQZIsBNIu0xZp/veGce9TEeTds2ro9NfdJFeou8+fC7Pq +sOsM3amGFFi+ZW/9BWyjZEM88bgWWAjqLHbpfHDxjAf5CSxddqxgHlbP0Ytyb1Vg +gmPDn9YKSwKBgQDbTi3hC35WFuDHn0/zcSHcDZmnFuOZeqyFyV83yfMGhGrEuqvP +sPgtRikajJ3IZsB4WZyYSidZXEFY/0z6NjOl2xF38MTNQPbT/FmK1q1Yt2UWrlv5 +BvSwlk87RG9D7C0LZo4R+D7cPoDdgqjiwMvMEIkEX5zn641oI1ZTmWKuuwKBgQCD +KF+3unnRvHRAVoFnTZbA2fJdqMeRvogD04GhGlYX8V9f1hFY6nXTJaNlXVzA/J8c +r8ra9kgjJuPfZ+ljG58OFFW2DRohLcQtuHYPfK6rMzoFHqnl9EcIcMp7ijuionR3 +29HOJFgQYgxLFXfit9d6WugiE+BTupiEbckZif13HwKBgE/lAlkVHP6YahOO2Ljc +J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN +5da0D4h2rYOXnbYIg0BVu4spQbaM6ewsp66b8+MzLOBvj8SzWdt1Oyw0q/MRyQAR +8U4M2TSWCKUY/A6sT4W8+mT9 +-----END PRIVATE KEY-----"#; + +#[tokio::test] +#[serial(codex_auth_env)] +async fn agent_identity_plan_type_maps_raw_enterprise_alias() { + assert_agent_identity_plan_alias(json!("hc"), AccountPlanType::Enterprise).await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn agent_identity_plan_type_maps_raw_education_alias() { + assert_agent_identity_plan_alias(json!("education"), AccountPlanType::Edu).await; +} + +async fn assert_agent_identity_plan_alias( + plan_type: serde_json::Value, + expected_plan_type: AccountPlanType, +) { + let record = agent_identity_record("account-id"); + let jwt = signed_agent_identity_jwt(&record, plan_type).expect("agent identity jwt"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-123", + }))) + .expect(1) + .mount(&server) + .await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let _authapi_guard = + EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let auth = CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url)) + .await + .expect("agent identity auth"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type)); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn plan_type_maps_known_plan() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn plan_type_maps_self_serve_business_usage_based_plan() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("self_serve_business_usage_based".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!( + auth.account_plan_type(), + Some(AccountPlanType::SelfServeBusinessUsageBased) + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn plan_type_maps_enterprise_cbp_usage_based_plan() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("enterprise_cbp_usage_based".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!( + auth.account_plan_type(), + Some(AccountPlanType::EnterpriseCbpUsageBased) + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn plan_type_maps_unknown_to_unknown() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("mystery-tier".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn missing_plan_type_maps_to_unknown() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: None, + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); +} diff --git a/code-rs/login/src/auth/default_client.rs b/code-rs/login/src/auth/default_client.rs new file mode 100644 index 00000000000..ee51edf9b56 --- /dev/null +++ b/code-rs/login/src/auth/default_client.rs @@ -0,0 +1,256 @@ +//! Default Codex HTTP client: shared `User-Agent`, `originator`, optional residency header, and +//! reqwest/`CodexHttpClient` construction. +//! +//! Use [`crate::default_client`] or [`codex_login::default_client`] from other crates in this +//! workspace. + +use codex_client::BuildCustomCaTransportError; +use codex_client::CodexHttpClient; +pub use codex_client::CodexRequestBuilder; +use codex_client::build_reqwest_client_with_custom_ca; +use codex_client::with_chatgpt_cloudflare_cookie_store; +use codex_terminal_detection::user_agent; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderValue; +use reqwest::header::USER_AGENT; +use std::sync::LazyLock; +use std::sync::Mutex; +use std::sync::RwLock; + +/// Set this to add a suffix to the User-Agent string. +/// +/// It is not ideal that we're using a global singleton for this. +/// This is primarily designed to differentiate MCP clients from each other. +/// Because there can only be one MCP server per process, it should be safe for this to be a global static. +/// However, future users of this should use this with caution as a result. +/// In addition, we want to be confident that this value is used for ALL clients and doing that requires a +/// lot of wiring and it's easy to miss code paths by doing so. +/// See https://github.com/openai/codex/pull/3388/files for an example of what that would look like. +/// Finally, we want to make sure this is set for ALL mcp clients without needing to know a special env var +/// or having to set data that they already specified in the mcp initialize request somewhere else. +/// +/// A space is automatically added between the suffix and the rest of the User-Agent string. +/// The full user agent string is returned from the mcp initialize response. +/// Parenthesis will be added by Codex. This should only specify what goes inside of the parenthesis. +pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; +pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; +pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency"; + +pub use codex_config::ResidencyRequirement; + +#[derive(Debug, Clone)] +pub struct Originator { + pub value: String, + pub header_value: HeaderValue, +} +static ORIGINATOR: LazyLock>> = LazyLock::new(|| RwLock::new(None)); +static REQUIREMENTS_RESIDENCY: LazyLock>> = + LazyLock::new(|| RwLock::new(None)); + +#[derive(Debug)] +pub enum SetOriginatorError { + InvalidHeaderValue, + AlreadyInitialized, +} + +fn get_originator_value(provided: Option) -> Originator { + let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) + .ok() + .or(provided) + .unwrap_or(DEFAULT_ORIGINATOR.to_string()); + + match HeaderValue::from_str(&value) { + Ok(header_value) => Originator { + value, + header_value, + }, + Err(e) => { + tracing::error!("Unable to turn originator override {value} into header value: {e}"); + Originator { + value: DEFAULT_ORIGINATOR.to_string(), + header_value: HeaderValue::from_static(DEFAULT_ORIGINATOR), + } + } + } +} + +pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> { + if HeaderValue::from_str(&value).is_err() { + return Err(SetOriginatorError::InvalidHeaderValue); + } + let originator = get_originator_value(Some(value)); + let Ok(mut guard) = ORIGINATOR.write() else { + return Err(SetOriginatorError::AlreadyInitialized); + }; + if guard.is_some() { + return Err(SetOriginatorError::AlreadyInitialized); + } + *guard = Some(originator); + Ok(()) +} + +pub fn set_default_client_residency_requirement(enforce_residency: Option) { + let Ok(mut guard) = REQUIREMENTS_RESIDENCY.write() else { + tracing::warn!("Failed to acquire requirements residency lock"); + return; + }; + *guard = enforce_residency; +} + +pub fn originator() -> Originator { + if let Ok(guard) = ORIGINATOR.read() + && let Some(originator) = guard.as_ref() + { + return originator.clone(); + } + + if std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).is_ok() { + let originator = get_originator_value(/*provided*/ None); + if let Ok(mut guard) = ORIGINATOR.write() { + match guard.as_ref() { + Some(originator) => return originator.clone(), + None => *guard = Some(originator.clone()), + } + } + return originator; + } + + get_originator_value(/*provided*/ None) +} + +pub fn is_first_party_originator(originator_value: &str) -> bool { + originator_value == DEFAULT_ORIGINATOR + || originator_value == "codex-tui" + || originator_value == "codex_vscode" + || originator_value.starts_with("Codex ") +} + +pub fn is_first_party_chat_originator(originator_value: &str) -> bool { + originator_value == "codex_atlas" || originator_value == "codex_chatgpt_desktop" +} + +pub fn get_codex_user_agent() -> String { + let build_version = env!("CARGO_PKG_VERSION"); + let os_info = os_info::get(); + let originator = originator(); + let prefix = format!( + "{}/{build_version} ({} {}; {}) {}", + originator.value.as_str(), + os_info.os_type(), + os_info.version(), + os_info.architecture().unwrap_or("unknown"), + user_agent() + ); + let suffix = USER_AGENT_SUFFIX + .lock() + .ok() + .and_then(|guard| guard.clone()); + let suffix = suffix + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map_or_else(String::new, |value| format!(" ({value})")); + + let candidate = format!("{prefix}{suffix}"); + sanitize_user_agent(candidate, &prefix) +} + +/// Sanitize the user agent string. +/// +/// Invalid characters are replaced with an underscore. +/// +/// If the user agent fails to parse, it falls back to fallback and then to ORIGINATOR. +fn sanitize_user_agent(candidate: String, fallback: &str) -> String { + if HeaderValue::from_str(candidate.as_str()).is_ok() { + return candidate; + } + + let sanitized: String = candidate + .chars() + .map(|ch| if matches!(ch, ' '..='~') { ch } else { '_' }) + .collect(); + if !sanitized.is_empty() && HeaderValue::from_str(sanitized.as_str()).is_ok() { + tracing::warn!( + "Sanitized Codex user agent because provided suffix contained invalid header characters" + ); + sanitized + } else if HeaderValue::from_str(fallback).is_ok() { + tracing::warn!( + "Falling back to base Codex user agent because provided suffix could not be sanitized" + ); + fallback.to_string() + } else { + tracing::warn!( + "Falling back to default Codex originator because base user agent string is invalid" + ); + originator().value + } +} + +/// Create an HTTP client with default `originator` and `User-Agent` headers set. +pub fn create_client() -> CodexHttpClient { + let inner = build_reqwest_client(); + CodexHttpClient::new(inner) +} + +/// Builds the default reqwest client used for ordinary Codex HTTP traffic. +/// +/// This starts from the standard Codex user agent, default headers, and sandbox-specific proxy +/// policy, then layers in shared custom CA handling from `CODEX_CA_CERTIFICATE` / +/// `SSL_CERT_FILE`. The function remains infallible for compatibility with existing call sites, so +/// a custom-CA or builder failure is logged and falls back to `reqwest::Client::new()`. +pub fn build_reqwest_client() -> reqwest::Client { + try_build_reqwest_client().unwrap_or_else(|error| { + tracing::warn!(error = %error, "failed to build default reqwest client"); + with_chatgpt_cloudflare_cookie_store(reqwest::Client::builder()) + .build() + .unwrap_or_else(|fallback_error| { + tracing::warn!( + error = %fallback_error, + "failed to build fallback reqwest client with ChatGPT Cloudflare cookie store" + ); + reqwest::Client::new() + }) + }) +} + +/// Tries to build the default reqwest client used for ordinary Codex HTTP traffic. +/// +/// Callers that need a structured CA-loading failure instead of the legacy logged fallback can use +/// this method directly. +pub fn try_build_reqwest_client() -> Result { + let mut builder = reqwest::Client::builder().default_headers(default_headers()); + if is_sandboxed() { + builder = builder.no_proxy(); + } + builder = with_chatgpt_cloudflare_cookie_store(builder); + + build_reqwest_client_with_custom_ca(builder) +} + +pub fn default_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("originator", originator().header_value); + if let Ok(user_agent) = HeaderValue::from_str(&get_codex_user_agent()) { + headers.insert(USER_AGENT, user_agent); + } + if let Ok(guard) = REQUIREMENTS_RESIDENCY.read() + && let Some(requirement) = guard.as_ref() + && !headers.contains_key(RESIDENCY_HEADER_NAME) + { + let value = match requirement { + ResidencyRequirement::Us => HeaderValue::from_static("us"), + }; + headers.insert(RESIDENCY_HEADER_NAME, value); + } + headers +} + +fn is_sandboxed() -> bool { + std::env::var("CODEX_SANDBOX").as_deref() == Ok("seatbelt") +} + +#[cfg(test)] +#[path = "default_client_tests.rs"] +mod tests; diff --git a/code-rs/login/src/auth/default_client_tests.rs b/code-rs/login/src/auth/default_client_tests.rs new file mode 100644 index 00000000000..be8e6bc392a --- /dev/null +++ b/code-rs/login/src/auth/default_client_tests.rs @@ -0,0 +1,125 @@ +use super::sanitize_user_agent; +use super::*; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; + +#[test] +fn test_get_codex_user_agent() { + let user_agent = get_codex_user_agent(); + let originator = originator().value; + let prefix = format!("{originator}/"); + assert!(user_agent.starts_with(&prefix)); +} + +#[test] +fn is_first_party_originator_matches_known_values() { + assert_eq!(is_first_party_originator(DEFAULT_ORIGINATOR), true); + assert_eq!(is_first_party_originator("codex-tui"), true); + assert_eq!(is_first_party_originator("codex_vscode"), true); + assert_eq!(is_first_party_originator("Codex Something Else"), true); + assert_eq!(is_first_party_originator("codex_cli"), false); + assert_eq!(is_first_party_originator("Other"), false); +} + +#[test] +fn is_first_party_chat_originator_matches_known_values() { + assert_eq!(is_first_party_chat_originator("codex_atlas"), true); + assert_eq!( + is_first_party_chat_originator("codex_chatgpt_desktop"), + true + ); + assert_eq!(is_first_party_chat_originator(DEFAULT_ORIGINATOR), false); + assert_eq!(is_first_party_chat_originator("codex_vscode"), false); +} + +#[tokio::test] +async fn test_create_client_sets_default_headers() { + skip_if_no_network!(); + + set_default_client_residency_requirement(Some(ResidencyRequirement::Us)); + + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let client = create_client(); + + // Spin up a local mock server and capture a request. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let resp = client + .get(server.uri()) + .send() + .await + .expect("failed to send request"); + assert!(resp.status().is_success()); + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + assert!(!requests.is_empty()); + let headers = &requests[0].headers; + + // originator header is set to the provided value + let originator_header = headers + .get("originator") + .expect("originator header missing"); + assert_eq!(originator_header.to_str().unwrap(), originator().value); + + // User-Agent matches the computed Codex UA for that originator + let expected_ua = get_codex_user_agent(); + let ua_header = headers + .get("user-agent") + .expect("user-agent header missing"); + assert_eq!(ua_header.to_str().unwrap(), expected_ua); + + let residency_header = headers + .get(RESIDENCY_HEADER_NAME) + .expect("residency header missing"); + assert_eq!(residency_header.to_str().unwrap(), "us"); + + set_default_client_residency_requirement(/*enforce_residency*/ None); +} + +#[test] +fn test_invalid_suffix_is_sanitized() { + let prefix = "codex_cli_rs/0.0.0"; + let suffix = "bad\rsuffix"; + + assert_eq!( + sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), + "codex_cli_rs/0.0.0 (bad_suffix)" + ); +} + +#[test] +fn test_invalid_suffix_is_sanitized2() { + let prefix = "codex_cli_rs/0.0.0"; + let suffix = "bad\0suffix"; + + assert_eq!( + sanitize_user_agent(format!("{prefix} ({suffix})"), prefix), + "codex_cli_rs/0.0.0 (bad_suffix)" + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn test_macos() { + use regex_lite::Regex; + let user_agent = get_codex_user_agent(); + let originator = regex_lite::escape(originator().value.as_str()); + let re = Regex::new(&format!( + r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$" + )) + .unwrap(); + assert!(re.is_match(&user_agent)); +} diff --git a/code-rs/login/src/auth/error.rs b/code-rs/login/src/auth/error.rs new file mode 100644 index 00000000000..ec8a3790fef --- /dev/null +++ b/code-rs/login/src/auth/error.rs @@ -0,0 +1,2 @@ +pub use codex_protocol::auth::RefreshTokenFailedError; +pub use codex_protocol::auth::RefreshTokenFailedReason; diff --git a/code-rs/login/src/auth/external_bearer.rs b/code-rs/login/src/auth/external_bearer.rs new file mode 100644 index 00000000000..c5285960142 --- /dev/null +++ b/code-rs/login/src/auth/external_bearer.rs @@ -0,0 +1,174 @@ +use super::manager::ExternalAuth; +use super::manager::ExternalAuthRefreshContext; +use super::manager::ExternalAuthTokens; +use async_trait::async_trait; +use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ModelProviderAuthInfo; +use std::fmt; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Instant; +use tokio::process::Command; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub(crate) struct BearerTokenRefresher { + state: Arc, +} + +impl BearerTokenRefresher { + pub(crate) fn new(config: ModelProviderAuthInfo) -> Self { + Self { + state: Arc::new(ExternalBearerAuthState::new(config)), + } + } +} + +#[async_trait] +impl ExternalAuth for BearerTokenRefresher { + fn auth_mode(&self) -> AuthMode { + AuthMode::ApiKey + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "external bearer cache misses intentionally hold cached_token across the provider command to avoid duplicate refreshes" + )] + async fn resolve(&self) -> io::Result> { + let access_token = { + let mut cached = self.state.cached_token.lock().await; + if let Some(cached_token) = cached.as_ref() { + let should_use_cached_token = match self.state.config.refresh_interval() { + Some(refresh_interval) => cached_token.fetched_at.elapsed() < refresh_interval, + None => true, + }; + if should_use_cached_token { + return Ok(Some(ExternalAuthTokens::access_token_only( + cached_token.access_token.clone(), + ))); + } + } + + let access_token = run_provider_auth_command(&self.state.config).await?; + *cached = Some(CachedExternalBearerToken { + access_token: access_token.clone(), + fetched_at: Instant::now(), + }); + access_token + }; + Ok(Some(ExternalAuthTokens::access_token_only(access_token))) + } + + async fn refresh( + &self, + _context: ExternalAuthRefreshContext, + ) -> io::Result { + let access_token = run_provider_auth_command(&self.state.config).await?; + let mut cached = self.state.cached_token.lock().await; + *cached = Some(CachedExternalBearerToken { + access_token: access_token.clone(), + fetched_at: Instant::now(), + }); + Ok(ExternalAuthTokens::access_token_only(access_token)) + } +} + +impl fmt::Debug for BearerTokenRefresher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BearerTokenRefresher") + .finish_non_exhaustive() + } +} + +struct ExternalBearerAuthState { + config: ModelProviderAuthInfo, + cached_token: Mutex>, +} + +impl ExternalBearerAuthState { + fn new(config: ModelProviderAuthInfo) -> Self { + Self { + config, + cached_token: Mutex::new(None), + } + } +} + +struct CachedExternalBearerToken { + access_token: String, + fetched_at: Instant, +} + +async fn run_provider_auth_command(config: &ModelProviderAuthInfo) -> io::Result { + let program = resolve_provider_auth_program(&config.command, &config.cwd)?; + let mut command = Command::new(&program); + command + .args(&config.args) + .current_dir(config.cwd.as_path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let output = tokio::time::timeout(config.timeout(), command.output()) + .await + .map_err(|_| { + io::Error::other(format!( + "provider auth command `{}` timed out after {} ms", + config.command, + config.timeout_ms.get() + )) + })? + .map_err(|err| { + io::Error::other(format!( + "provider auth command `{}` failed to start: {err}", + config.command + )) + })?; + + if !output.status.success() { + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stderr_suffix = if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + }; + return Err(io::Error::other(format!( + "provider auth command `{}` exited with status {status}{stderr_suffix}", + config.command + ))); + } + + let stdout = String::from_utf8(output.stdout).map_err(|_| { + io::Error::other(format!( + "provider auth command `{}` wrote non-UTF-8 data to stdout", + config.command + )) + })?; + let access_token = stdout.trim().to_string(); + if access_token.is_empty() { + return Err(io::Error::other(format!( + "provider auth command `{}` produced an empty token", + config.command + ))); + } + + Ok(access_token) +} + +fn resolve_provider_auth_program(command: &str, cwd: &Path) -> io::Result { + let path = Path::new(command); + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + + if path.components().count() > 1 { + return Ok(cwd.join(path)); + } + + Ok(PathBuf::from(command)) +} diff --git a/code-rs/login/src/auth/manager.rs b/code-rs/login/src/auth/manager.rs new file mode 100644 index 00000000000..08d4a21c503 --- /dev/null +++ b/code-rs/login/src/auth/manager.rs @@ -0,0 +1,1885 @@ +use async_trait::async_trait; +use chrono::Utc; +use reqwest::StatusCode; +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use serial_test::serial; +use std::env; +use std::fmt::Debug; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::RwLock; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use tokio::sync::Semaphore; + +use codex_agent_identity::decode_agent_identity_jwt; +use codex_agent_identity::fetch_agent_identity_jwks; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::AuthMode as ApiAuthMode; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ModelProviderAuthInfo; + +use super::external_bearer::BearerTokenRefresher; +use super::revoke::revoke_auth_tokens; +pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::storage::AgentIdentityAuthRecord; +pub use crate::auth::storage::AuthDotJson; +use crate::auth::storage::AuthStorageBackend; +use crate::auth::storage::create_auth_storage; +use crate::auth::util::try_parse_error_message; +use crate::default_client::build_reqwest_client; +use crate::default_client::create_client; +use crate::token_data::TokenData; +use crate::token_data::parse_chatgpt_jwt_claims; +use crate::token_data::parse_jwt_expiration; +use codex_client::CodexHttpClient; +use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::auth::PlanType as InternalPlanType; +use codex_protocol::auth::RefreshTokenFailedError; +use codex_protocol::auth::RefreshTokenFailedReason; +use serde_json::Value; +use thiserror::Error; + +/// Authentication mechanism used by the current user. +#[derive(Debug, Clone)] +pub enum CodexAuth { + ApiKey(ApiKeyAuth), + Chatgpt(ChatgptAuth), + ChatgptAuthTokens(ChatgptAuthTokens), + AgentIdentity(AgentIdentityAuth), +} + +impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.api_auth_mode() == other.api_auth_mode() + } +} + +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + api_key: String, +} + +#[derive(Debug, Clone)] +pub struct ChatgptAuth { + state: ChatgptAuthState, + storage: Arc, +} + +#[derive(Debug, Clone)] +pub struct ChatgptAuthTokens { + state: ChatgptAuthState, +} + +#[derive(Debug, Clone)] +struct ChatgptAuthState { + auth_dot_json: Arc>>, + client: CodexHttpClient, +} + +const TOKEN_REFRESH_INTERVAL: i64 = 8; + +const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again."; +const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again."; +const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again."; +const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = + "Your access token could not be refreshed. Please log out and sign in again."; +const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; +const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api"; +const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; +pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE"; +static NEXT_DUMMY_AUTH_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Error)] +pub enum RefreshTokenError { + #[error("{0}")] + Permanent(#[from] RefreshTokenFailedError), + #[error(transparent)] + Transient(#[from] std::io::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalAuthTokens { + pub access_token: String, + pub chatgpt_metadata: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalAuthChatgptMetadata { + pub account_id: String, + pub plan_type: Option, +} + +impl ExternalAuthTokens { + pub fn access_token_only(access_token: impl Into) -> Self { + Self { + access_token: access_token.into(), + chatgpt_metadata: None, + } + } + + pub fn chatgpt( + access_token: impl Into, + chatgpt_account_id: impl Into, + chatgpt_plan_type: Option, + ) -> Self { + Self { + access_token: access_token.into(), + chatgpt_metadata: Some(ExternalAuthChatgptMetadata { + account_id: chatgpt_account_id.into(), + plan_type: chatgpt_plan_type, + }), + } + } + + pub fn chatgpt_metadata(&self) -> Option<&ExternalAuthChatgptMetadata> { + self.chatgpt_metadata.as_ref() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExternalAuthRefreshReason { + Unauthorized, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalAuthRefreshContext { + pub reason: ExternalAuthRefreshReason, + pub previous_account_id: Option, +} + +#[async_trait] +/// Pluggable auth provider used by `AuthManager` for externally managed auth flows. +/// +/// Implementations may either resolve auth eagerly via `resolve()` or provide refreshed +/// credentials on demand via `refresh()`. +pub trait ExternalAuth: Send + Sync { + /// Indicates which top-level auth mode this external provider supplies. + fn auth_mode(&self) -> AuthMode; + + /// Returns cached or immediately available auth, if this provider can resolve it synchronously + /// from the caller's perspective. + async fn resolve(&self) -> std::io::Result> { + Ok(None) + } + + /// Refreshes auth in response to a manager-driven refresh attempt. + async fn refresh( + &self, + context: ExternalAuthRefreshContext, + ) -> std::io::Result; +} + +impl RefreshTokenError { + pub fn failed_reason(&self) -> Option { + match self { + Self::Permanent(error) => Some(error.reason), + Self::Transient(_) => None, + } + } +} + +impl From for std::io::Error { + fn from(err: RefreshTokenError) -> Self { + match err { + RefreshTokenError::Permanent(failed) => std::io::Error::other(failed), + RefreshTokenError::Transient(inner) => inner, + } + } +} + +impl CodexAuth { + async fn from_auth_dot_json( + codex_home: &Path, + auth_dot_json: AuthDotJson, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option<&str>, + ) -> std::io::Result { + let auth_mode = auth_dot_json.resolved_mode(); + let client = create_client(); + if auth_mode == ApiAuthMode::ApiKey { + let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else { + return Err(std::io::Error::other("API key auth is missing a key.")); + }; + return Ok(Self::from_api_key(api_key)); + } + if auth_mode == ApiAuthMode::AgentIdentity { + let Some(agent_identity) = auth_dot_json.agent_identity else { + return Err(std::io::Error::other( + "agent identity auth is missing an agent identity token.", + )); + }; + return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; + } + + let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); + let state = ChatgptAuthState { + auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), + client, + }; + + match auth_mode { + ApiAuthMode::Chatgpt => { + let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode); + Ok(Self::Chatgpt(ChatgptAuth { state, storage })) + } + ApiAuthMode::ChatgptAuthTokens => { + Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })) + } + ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), + ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), + } + } + + pub async fn from_auth_storage( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option<&str>, + ) -> std::io::Result> { + load_auth( + codex_home, + /*enable_codex_api_key_env*/ false, + auth_credentials_store_mode, + chatgpt_base_url, + ) + .await + } + + pub async fn from_agent_identity_jwt( + jwt: &str, + chatgpt_base_url: Option<&str>, + ) -> std::io::Result { + let base_url = chatgpt_base_url + .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .trim_end_matches('/') + .to_string(); + let record = verified_agent_identity_record(jwt, &base_url).await?; + Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) + } + + pub fn auth_mode(&self) -> AuthMode { + match self { + Self::ApiKey(_) => AuthMode::ApiKey, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, + Self::AgentIdentity(_) => AuthMode::AgentIdentity, + } + } + + pub fn api_auth_mode(&self) -> ApiAuthMode { + match self { + Self::ApiKey(_) => ApiAuthMode::ApiKey, + Self::Chatgpt(_) => ApiAuthMode::Chatgpt, + Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, + Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity, + } + } + + pub fn is_api_key_auth(&self) -> bool { + self.auth_mode() == AuthMode::ApiKey + } + + pub fn is_chatgpt_auth(&self) -> bool { + matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + } + + pub fn uses_codex_backend(&self) -> bool { + matches!( + self, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) + ) + } + + pub fn is_external_chatgpt_tokens(&self) -> bool { + matches!(self, Self::ChatgptAuthTokens(_)) + } + + /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. + pub fn api_key(&self) -> Option<&str> { + match self { + Self::ApiKey(auth) => Some(auth.api_key.as_str()), + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None, + } + } + + /// Returns `Err` if token-backed ChatGPT auth is unavailable. + pub fn get_token_data(&self) -> Result { + let auth_dot_json: Option = self.get_current_auth_json(); + match auth_dot_json { + Some(AuthDotJson { + tokens: Some(tokens), + last_refresh: Some(_), + .. + }) => Ok(tokens), + _ => Err(std::io::Error::other("Token data is not available.")), + } + } + + /// Returns the token string used for bearer authentication. + pub fn get_token(&self) -> Result { + match self { + Self::ApiKey(auth) => Ok(auth.api_key.clone()), + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => { + let access_token = self.get_token_data()?.access_token; + Ok(access_token) + } + Self::AgentIdentity(_) => Err(std::io::Error::other( + "agent identity auth does not expose a bearer token", + )), + } + } + + /// Returns `None` if Codex backend auth does not expose an account id. + pub fn get_account_id(&self) -> Option { + match self { + Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), + _ => self.get_current_token_data().and_then(|t| t.account_id), + } + } + + /// Returns false if Codex backend auth omits the FedRAMP claim. + pub fn is_fedramp_account(&self) -> bool { + match self { + Self::AgentIdentity(auth) => auth.is_fedramp_account(), + _ => self + .get_current_token_data() + .is_some_and(|t| t.id_token.is_fedramp_account()), + } + } + + /// Returns `None` if Codex backend auth does not expose an account email. + pub fn get_account_email(&self) -> Option { + match self { + Self::AgentIdentity(auth) => Some(auth.email().to_string()), + _ => self.get_current_token_data().and_then(|t| t.id_token.email), + } + } + + /// Returns `None` if Codex backend auth does not expose a ChatGPT user id. + pub fn get_chatgpt_user_id(&self) -> Option { + match self { + Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), + _ => self + .get_current_token_data() + .and_then(|t| t.id_token.chatgpt_user_id), + } + } + + /// Account-facing plan classification derived from the current auth. + /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) + /// for UI or product decisions based on the user's subscription. + pub fn account_plan_type(&self) -> Option { + if let Self::AgentIdentity(auth) = self { + return Some(auth.plan_type()); + } + + self.get_current_token_data().map(|t| { + t.id_token + .chatgpt_plan_type + .map(AccountPlanType::from) + .unwrap_or(AccountPlanType::Unknown) + }) + } + + pub fn is_workspace_account(&self) -> bool { + self.account_plan_type() + .is_some_and(AccountPlanType::is_workspace_account) + } + + /// Returns `None` if token-backed ChatGPT auth is unavailable. + fn get_current_auth_json(&self) -> Option { + let state = match self { + Self::Chatgpt(auth) => &auth.state, + Self::ChatgptAuthTokens(auth) => &auth.state, + Self::ApiKey(_) | Self::AgentIdentity(_) => return None, + }; + #[expect(clippy::unwrap_used)] + state.auth_dot_json.lock().unwrap().clone() + } + + /// Returns `None` if token-backed ChatGPT auth is unavailable. + fn get_current_token_data(&self) -> Option { + self.get_current_auth_json().and_then(|t| t.tokens) + } + + /// Consider this private to integration tests. + pub fn create_dummy_chatgpt_auth_for_testing() -> Self { + let auth_dot_json = AuthDotJson { + auth_mode: Some(ApiAuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: Default::default(), + access_token: "Access Token".to_string(), + refresh_token: "test".to_string(), + account_id: Some("account_id".to_string()), + }), + last_refresh: Some(Utc::now()), + agent_identity: None, + }; + + let client = create_client(); + let state = ChatgptAuthState { + auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), + client, + }; + let dummy_auth_id = NEXT_DUMMY_AUTH_ID.fetch_add(1, Ordering::Relaxed); + let storage = create_auth_storage( + PathBuf::from(format!("dummy-chatgpt-auth-{dummy_auth_id}")), + AuthCredentialsStoreMode::Ephemeral, + ); + Self::Chatgpt(ChatgptAuth { state, storage }) + } + + pub fn from_api_key(api_key: &str) -> Self { + Self::ApiKey(ApiKeyAuth { + api_key: api_key.to_owned(), + }) + } +} + +impl ChatgptAuth { + fn current_auth_json(&self) -> Option { + #[expect(clippy::unwrap_used)] + self.state.auth_dot_json.lock().unwrap().clone() + } + + fn current_token_data(&self) -> Option { + self.current_auth_json().and_then(|auth| auth.tokens) + } + + fn storage(&self) -> &Arc { + &self.storage + } + + fn client(&self) -> &CodexHttpClient { + &self.state.client + } +} + +pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; +pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; +pub const CODEX_ACCESS_TOKEN_ENV_VAR: &str = "CODEX_ACCESS_TOKEN"; + +pub fn read_openai_api_key_from_env() -> Option { + env::var(OPENAI_API_KEY_ENV_VAR) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub fn read_codex_api_key_from_env() -> Option { + read_non_empty_env_var(CODEX_API_KEY_ENV_VAR) +} + +pub fn read_codex_access_token_from_env() -> Option { + read_non_empty_env_var(CODEX_ACCESS_TOKEN_ENV_VAR) +} + +fn read_non_empty_env_var(key: &str) -> Option { + env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +async fn verified_agent_identity_record( + jwt: &str, + chatgpt_base_url: &str, +) -> std::io::Result { + AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; + let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url) + .await + .map_err(std::io::Error::other)?; + let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?; + Ok(claims.into()) +} + +/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` +/// if a file was removed, `Ok(false)` if no auth file was present. +pub fn logout( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + storage.delete() +} + +pub async fn logout_with_revoke( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + AuthManager::new( + codex_home.to_path_buf(), + /*enable_codex_api_key_env*/ false, + auth_credentials_store_mode, + /*chatgpt_base_url*/ None, + ) + .await + .logout_with_revoke() + .await +} + +/// Writes an `auth.json` that contains only the API key. +pub fn login_with_api_key( + codex_home: &Path, + api_key: &str, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let auth_dot_json = AuthDotJson { + auth_mode: Some(ApiAuthMode::ApiKey), + openai_api_key: Some(api_key.to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }; + save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) +} + +/// Writes an `auth.json` that contains only the access token. +pub async fn login_with_access_token( + codex_home: &Path, + access_token: &str, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option<&str>, +) -> std::io::Result<()> { + let base_url = chatgpt_base_url + .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .trim_end_matches('/') + .to_string(); + verified_agent_identity_record(access_token, &base_url).await?; + let auth_dot_json = AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(access_token.to_string()), + }; + save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) +} + +/// Writes an in-memory auth payload for externally managed ChatGPT tokens. +pub fn login_with_chatgpt_auth_tokens( + codex_home: &Path, + access_token: &str, + chatgpt_account_id: &str, + chatgpt_plan_type: Option<&str>, +) -> std::io::Result<()> { + let auth_dot_json = AuthDotJson::from_external_access_token( + access_token, + chatgpt_account_id, + chatgpt_plan_type, + )?; + save_auth( + codex_home, + &auth_dot_json, + AuthCredentialsStoreMode::Ephemeral, + ) +} + +/// Persist the provided auth payload using the specified backend. +pub fn save_auth( + codex_home: &Path, + auth: &AuthDotJson, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + storage.save(auth) +} + +/// Load CLI auth data using the configured credential store backend. +/// Returns `None` when no credentials are stored. This function is +/// provided only for tests. Production code should not directly load +/// from the auth.json storage. It should use the AuthManager abstraction +/// instead. +pub fn load_auth_dot_json( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result> { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + storage.load() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthConfig { + pub codex_home: PathBuf, + pub auth_credentials_store_mode: AuthCredentialsStoreMode, + pub forced_login_method: Option, + pub forced_chatgpt_workspace_id: Option, + pub chatgpt_base_url: Option, +} + +pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { + let Some(auth) = load_auth( + &config.codex_home, + /*enable_codex_api_key_env*/ true, + config.auth_credentials_store_mode, + config.chatgpt_base_url.as_deref(), + ) + .await? + else { + return Ok(()); + }; + + if let Some(required_method) = config.forced_login_method { + let method_violation = match (required_method, auth.auth_mode()) { + (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, + (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) + | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None, + (ForcedLoginMethod::Api, AuthMode::Chatgpt) + | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some( + "API key login is required, but ChatGPT is currently being used. Logging out." + .to_string(), + ), + (ForcedLoginMethod::Chatgpt, AuthMode::ApiKey) => Some( + "ChatGPT login is required, but an API key is currently being used. Logging out." + .to_string(), + ), + }; + + if let Some(message) = method_violation { + return logout_with_message( + &config.codex_home, + message, + config.auth_credentials_store_mode, + ); + } + } + + if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { + // workspace is the external identifier for account id. + let chatgpt_account_id = match auth { + CodexAuth::ApiKey(_) => return Ok(()), + CodexAuth::AgentIdentity(_) => auth.get_account_id(), + CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { + let token_data = match auth.get_token_data() { + Ok(data) => data, + Err(err) => { + return logout_with_message( + &config.codex_home, + format!( + "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." + ), + config.auth_credentials_store_mode, + ); + } + }; + token_data.id_token.chatgpt_account_id + } + }; + if chatgpt_account_id.as_deref() != Some(expected_account_id) { + let message = match chatgpt_account_id { + Some(actual) => format!( + "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." + ), + None => format!( + "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." + ), + }; + return logout_with_message( + &config.codex_home, + message, + config.auth_credentials_store_mode, + ); + } + } + + Ok(()) +} + +fn logout_with_message( + codex_home: &Path, + message: String, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + // External auth tokens live in the ephemeral store, but persistent auth may still exist + // from earlier logins. Clear both so a forced logout truly removes all active auth. + let removal_result = logout_all_stores(codex_home, auth_credentials_store_mode); + let error_message = match removal_result { + Ok(_) => message, + Err(err) => format!("{message}. Failed to remove auth.json: {err}"), + }; + Err(std::io::Error::other(error_message)) +} + +fn logout_all_stores( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { + return logout(codex_home, AuthCredentialsStoreMode::Ephemeral); + } + let removed_ephemeral = logout(codex_home, AuthCredentialsStoreMode::Ephemeral)?; + let removed_managed = logout(codex_home, auth_credentials_store_mode)?; + Ok(removed_ephemeral || removed_managed) +} + +async fn load_auth( + codex_home: &Path, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option<&str>, +) -> std::io::Result> { + // API key via env var takes precedence over any other auth method. + if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { + return Ok(Some(CodexAuth::from_api_key(api_key.as_str()))); + } + + // External ChatGPT auth tokens live in the in-memory (ephemeral) store. Always check this + // first so external auth takes precedence over any persisted credentials. + let ephemeral_storage = create_auth_storage( + codex_home.to_path_buf(), + AuthCredentialsStoreMode::Ephemeral, + ); + if let Some(auth_dot_json) = ephemeral_storage.load()? { + let auth = CodexAuth::from_auth_dot_json( + codex_home, + auth_dot_json, + AuthCredentialsStoreMode::Ephemeral, + chatgpt_base_url, + ) + .await?; + return Ok(Some(auth)); + } + + // If the caller explicitly requested ephemeral auth, there is no persisted fallback. + if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { + return Ok(None); + } + + if let Some(agent_identity) = read_codex_access_token_from_env() { + return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url) + .await + .map(Some); + } + + // Fall back to the configured persistent store (file/keyring/auto) for managed auth. + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let auth_dot_json = match storage.load()? { + Some(auth) => auth, + None => return Ok(None), + }; + + let auth = CodexAuth::from_auth_dot_json( + codex_home, + auth_dot_json, + auth_credentials_store_mode, + chatgpt_base_url, + ) + .await?; + Ok(Some(auth)) +} + +// Persist refreshed tokens into auth storage and update last_refresh. +fn persist_tokens( + storage: &Arc, + id_token: Option, + access_token: Option, + refresh_token: Option, +) -> std::io::Result { + let mut auth_dot_json = storage + .load()? + .ok_or(std::io::Error::other("Token data is not available."))?; + + let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); + if let Some(id_token) = id_token { + tokens.id_token = parse_chatgpt_jwt_claims(&id_token).map_err(std::io::Error::other)?; + } + if let Some(access_token) = access_token { + tokens.access_token = access_token; + } + if let Some(refresh_token) = refresh_token { + tokens.refresh_token = refresh_token; + } + auth_dot_json.last_refresh = Some(Utc::now()); + storage.save(&auth_dot_json)?; + Ok(auth_dot_json) +} + +// Requests refreshed ChatGPT OAuth tokens from the auth service using a refresh token. +// The caller is responsible for persisting any returned tokens. +async fn request_chatgpt_token_refresh( + refresh_token: String, + client: &CodexHttpClient, +) -> Result { + let refresh_request = RefreshRequest { + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token, + }; + + let endpoint = refresh_token_endpoint(); + + // Use shared client factory to include standard headers + let response = client + .post(endpoint.as_str()) + .header("Content-Type", "application/json") + .json(&refresh_request) + .send() + .await + .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; + + let status = response.status(); + if status.is_success() { + let refresh_response = response + .json::() + .await + .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; + Ok(refresh_response) + } else { + let body = response.text().await.unwrap_or_default(); + tracing::error!("Failed to refresh token: {status}: {body}"); + if status == StatusCode::UNAUTHORIZED { + let failed = classify_refresh_token_failure(&body); + Err(RefreshTokenError::Permanent(failed)) + } else { + let message = try_parse_error_message(&body); + Err(RefreshTokenError::Transient(std::io::Error::other( + format!("Failed to refresh token: {status}: {message}"), + ))) + } + } +} + +fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError { + let code = extract_refresh_token_error_code(body); + + let normalized_code = code.as_deref().map(str::to_ascii_lowercase); + let reason = match normalized_code.as_deref() { + Some("refresh_token_expired") => RefreshTokenFailedReason::Expired, + Some("refresh_token_reused") => RefreshTokenFailedReason::Exhausted, + Some("refresh_token_invalidated") => RefreshTokenFailedReason::Revoked, + _ => RefreshTokenFailedReason::Other, + }; + + if reason == RefreshTokenFailedReason::Other { + tracing::warn!( + backend_code = normalized_code.as_deref(), + backend_body = body, + "Encountered unknown 401 response while refreshing token" + ); + } + + let message = match reason { + RefreshTokenFailedReason::Expired => REFRESH_TOKEN_EXPIRED_MESSAGE.to_string(), + RefreshTokenFailedReason::Exhausted => REFRESH_TOKEN_REUSED_MESSAGE.to_string(), + RefreshTokenFailedReason::Revoked => REFRESH_TOKEN_INVALIDATED_MESSAGE.to_string(), + RefreshTokenFailedReason::Other => REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(), + }; + + RefreshTokenFailedError::new(reason, message) +} + +fn extract_refresh_token_error_code(body: &str) -> Option { + if body.trim().is_empty() { + return None; + } + + let Value::Object(map) = serde_json::from_str::(body).ok()? else { + return None; + }; + + if let Some(error_value) = map.get("error") { + match error_value { + Value::Object(obj) => { + if let Some(code) = obj.get("code").and_then(Value::as_str) { + return Some(code.to_string()); + } + } + Value::String(code) => { + return Some(code.to_string()); + } + _ => {} + } + } + + map.get("code").and_then(Value::as_str).map(str::to_string) +} + +#[derive(Serialize)] +struct RefreshRequest { + client_id: &'static str, + grant_type: &'static str, + refresh_token: String, +} + +#[derive(Deserialize, Clone)] +struct RefreshResponse { + id_token: Option, + access_token: Option, + refresh_token: Option, +} + +// Shared constant for token refresh (client id used for oauth token refresh flow) +pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +fn refresh_token_endpoint() -> String { + std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) + .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string()) +} + +impl AuthDotJson { + fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result { + let Some(chatgpt_metadata) = external.chatgpt_metadata() else { + return Err(std::io::Error::other( + "external auth tokens are missing ChatGPT metadata", + )); + }; + let mut token_info = + parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?; + token_info.chatgpt_account_id = Some(chatgpt_metadata.account_id.clone()); + token_info.chatgpt_plan_type = chatgpt_metadata + .plan_type + .as_deref() + .map(InternalPlanType::from_raw_value) + .or(token_info.chatgpt_plan_type) + .or(Some(InternalPlanType::Unknown("unknown".to_string()))); + let tokens = TokenData { + id_token: token_info, + access_token: external.access_token.clone(), + refresh_token: String::new(), + account_id: Some(chatgpt_metadata.account_id.clone()), + }; + + Ok(Self { + auth_mode: Some(ApiAuthMode::ChatgptAuthTokens), + openai_api_key: None, + tokens: Some(tokens), + last_refresh: Some(Utc::now()), + agent_identity: None, + }) + } + + fn from_external_access_token( + access_token: &str, + chatgpt_account_id: &str, + chatgpt_plan_type: Option<&str>, + ) -> std::io::Result { + let external = ExternalAuthTokens::chatgpt( + access_token, + chatgpt_account_id, + chatgpt_plan_type.map(str::to_string), + ); + Self::from_external_tokens(&external) + } + + fn resolved_mode(&self) -> ApiAuthMode { + if let Some(mode) = self.auth_mode { + return mode; + } + if self.openai_api_key.is_some() { + return ApiAuthMode::ApiKey; + } + ApiAuthMode::Chatgpt + } + + fn storage_mode( + &self, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> AuthCredentialsStoreMode { + if self.resolved_mode() == ApiAuthMode::ChatgptAuthTokens { + AuthCredentialsStoreMode::Ephemeral + } else { + auth_credentials_store_mode + } + } +} + +/// Internal cached auth state. +#[derive(Clone)] +struct CachedAuth { + auth: Option, + /// Permanent refresh failure cached for the current auth snapshot so + /// later refresh attempts for the same credentials fail fast without network. + permanent_refresh_failure: Option, +} + +#[derive(Clone)] +struct AuthScopedRefreshFailure { + auth: CodexAuth, + error: RefreshTokenFailedError, +} + +impl Debug for CachedAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedAuth") + .field( + "auth_mode", + &self.auth.as_ref().map(CodexAuth::api_auth_mode), + ) + .field( + "permanent_refresh_failure", + &self + .permanent_refresh_failure + .as_ref() + .map(|failure| failure.error.reason), + ) + .finish() + } +} + +enum UnauthorizedRecoveryStep { + Reload, + RefreshToken, + ExternalRefresh, + Done, +} + +enum ReloadOutcome { + /// Reload was performed and the cached auth changed + ReloadedChanged, + /// Reload was performed and the cached auth remained the same + ReloadedNoChange, + /// Reload was skipped (missing or mismatched account id) + Skipped, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UnauthorizedRecoveryMode { + Managed, + External, +} + +// UnauthorizedRecovery is a state machine that handles an attempt to refresh the authentication when requests +// to API fail with 401 status code. +// The client calls next() every time it encounters a 401 error, one time per retry. +// For API key based authentication, we don't do anything and let the error bubble to the user. +// +// For ChatGPT based authentication, we: +// 1. Attempt to reload the auth data from disk. We only reload if the account id matches the one the current process is running as. +// 2. Attempt to refresh the token using OAuth token refresh flow. +// If after both steps the server still responds with 401 we let the error bubble to the user. +// +// For external auth sources, UnauthorizedRecovery retries once. +// +// - External ChatGPT auth tokens (`chatgptAuthTokens`) are refreshed by asking +// the parent app for new tokens through the configured +// `ExternalAuth`, persisting them in the ephemeral auth store, and +// reloading the cached auth snapshot. +// - External bearer auth sources for custom model providers rerun the provider +// auth command without touching disk. +pub struct UnauthorizedRecovery { + manager: Arc, + step: UnauthorizedRecoveryStep, + expected_account_id: Option, + mode: UnauthorizedRecoveryMode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnauthorizedRecoveryStepResult { + auth_state_changed: Option, +} + +impl UnauthorizedRecoveryStepResult { + pub fn auth_state_changed(&self) -> Option { + self.auth_state_changed + } +} + +impl UnauthorizedRecovery { + fn new(manager: Arc) -> Self { + let cached_auth = manager.auth_cached(); + let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id); + let mode = if manager.has_external_api_key_auth() + || cached_auth + .as_ref() + .is_some_and(CodexAuth::is_external_chatgpt_tokens) + { + UnauthorizedRecoveryMode::External + } else { + UnauthorizedRecoveryMode::Managed + }; + let step = match mode { + UnauthorizedRecoveryMode::Managed => UnauthorizedRecoveryStep::Reload, + UnauthorizedRecoveryMode::External => UnauthorizedRecoveryStep::ExternalRefresh, + }; + Self { + manager, + step, + expected_account_id, + mode, + } + } + + pub fn has_next(&self) -> bool { + if self.manager.has_external_api_key_auth() { + return !matches!(self.step, UnauthorizedRecoveryStep::Done); + } + + if !self + .manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_chatgpt_auth) + { + return false; + } + + if self.mode == UnauthorizedRecoveryMode::External && !self.manager.has_external_auth() { + return false; + } + + !matches!(self.step, UnauthorizedRecoveryStep::Done) + } + + pub fn unavailable_reason(&self) -> &'static str { + if self.manager.has_external_api_key_auth() { + return if matches!(self.step, UnauthorizedRecoveryStep::Done) { + "recovery_exhausted" + } else { + "ready" + }; + } + + if !self + .manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_chatgpt_auth) + { + return "not_chatgpt_auth"; + } + + if self.mode == UnauthorizedRecoveryMode::External && !self.manager.has_external_auth() { + return "no_external_auth"; + } + + if matches!(self.step, UnauthorizedRecoveryStep::Done) { + return "recovery_exhausted"; + } + + "ready" + } + + pub fn mode_name(&self) -> &'static str { + match self.mode { + UnauthorizedRecoveryMode::Managed => "managed", + UnauthorizedRecoveryMode::External => "external", + } + } + + pub fn step_name(&self) -> &'static str { + match self.step { + UnauthorizedRecoveryStep::Reload => "reload", + UnauthorizedRecoveryStep::RefreshToken => "refresh_token", + UnauthorizedRecoveryStep::ExternalRefresh => "external_refresh", + UnauthorizedRecoveryStep::Done => "done", + } + } + + pub async fn next(&mut self) -> Result { + if !self.has_next() { + return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new( + RefreshTokenFailedReason::Other, + "No more recovery steps available.", + ))); + } + + match self.step { + UnauthorizedRecoveryStep::Reload => { + match self + .manager + .reload_if_account_id_matches(self.expected_account_id.as_deref()) + .await + { + ReloadOutcome::ReloadedChanged => { + self.step = UnauthorizedRecoveryStep::RefreshToken; + return Ok(UnauthorizedRecoveryStepResult { + auth_state_changed: Some(true), + }); + } + ReloadOutcome::ReloadedNoChange => { + self.step = UnauthorizedRecoveryStep::RefreshToken; + return Ok(UnauthorizedRecoveryStepResult { + auth_state_changed: Some(false), + }); + } + ReloadOutcome::Skipped => { + self.step = UnauthorizedRecoveryStep::Done; + return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new( + RefreshTokenFailedReason::Other, + REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE.to_string(), + ))); + } + } + } + UnauthorizedRecoveryStep::RefreshToken => { + self.manager.refresh_token_from_authority().await?; + self.step = UnauthorizedRecoveryStep::Done; + return Ok(UnauthorizedRecoveryStepResult { + auth_state_changed: Some(true), + }); + } + UnauthorizedRecoveryStep::ExternalRefresh => { + self.manager + .refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) + .await?; + self.step = UnauthorizedRecoveryStep::Done; + return Ok(UnauthorizedRecoveryStepResult { + auth_state_changed: Some(true), + }); + } + UnauthorizedRecoveryStep::Done => {} + } + Ok(UnauthorizedRecoveryStepResult { + auth_state_changed: None, + }) + } +} + +/// Central manager providing a single source of truth for auth.json derived +/// authentication data. It loads once (or on preference change) and then +/// hands out cloned `CodexAuth` values so the rest of the program has a +/// consistent snapshot. +/// +/// External modifications to `auth.json` will NOT be observed until +/// `reload()` is called explicitly. This matches the design goal of avoiding +/// different parts of the program seeing inconsistent auth data mid‑run. +pub struct AuthManager { + codex_home: PathBuf, + inner: RwLock, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: RwLock>, + chatgpt_base_url: Option, + refresh_lock: Semaphore, + external_auth: RwLock>>, +} + +/// Configuration view required to construct a shared [`AuthManager`]. +/// +/// Implementations should return the auth-related config values for the +/// already-resolved runtime configuration. The primary implementation is +/// `codex_core::config::Config`, but this trait keeps `codex-login` independent +/// from `codex-core`. +pub trait AuthManagerConfig { + /// Returns the Codex home directory used for auth storage. + fn codex_home(&self) -> PathBuf; + + /// Returns the CLI auth credential storage mode for auth loading. + fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; + + /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. + fn forced_chatgpt_workspace_id(&self) -> Option; + + /// Returns the ChatGPT backend base URL used for first-party backend authorization. + fn chatgpt_base_url(&self) -> String; +} + +impl Debug for AuthManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthManager") + .field("codex_home", &self.codex_home) + .field("inner", &self.inner) + .field("enable_codex_api_key_env", &self.enable_codex_api_key_env) + .field( + "auth_credentials_store_mode", + &self.auth_credentials_store_mode, + ) + .field( + "forced_chatgpt_workspace_id", + &self.forced_chatgpt_workspace_id, + ) + .field("chatgpt_base_url", &self.chatgpt_base_url) + .field("has_external_auth", &self.has_external_auth()) + .finish_non_exhaustive() + } +} + +impl AuthManager { + /// Create a new manager loading the initial auth using the provided + /// preferred auth method. Errors loading auth are swallowed; `auth()` will + /// simply return `None` in that case so callers can treat it as an + /// unauthenticated state. + pub async fn new( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, + ) -> Self { + let managed_auth = load_auth( + &codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + chatgpt_base_url.as_deref(), + ) + .await + .ok() + .flatten(); + Self { + codex_home, + inner: RwLock::new(CachedAuth { + auth: managed_auth, + permanent_refresh_failure: None, + }), + enable_codex_api_key_env, + auth_credentials_store_mode, + forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url, + refresh_lock: Semaphore::new(/*permits*/ 1), + external_auth: RwLock::new(None), + } + } + + /// Create an AuthManager with a specific CodexAuth, for testing only. + pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { + let cached = CachedAuth { + auth: Some(auth), + permanent_refresh_failure: None, + }; + + Arc::new(Self { + codex_home: PathBuf::from("non-existent"), + inner: RwLock::new(cached), + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, + refresh_lock: Semaphore::new(/*permits*/ 1), + external_auth: RwLock::new(None), + }) + } + + /// Create an AuthManager with a specific CodexAuth and codex home, for testing only. + pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc { + let cached = CachedAuth { + auth: Some(auth), + permanent_refresh_failure: None, + }; + Arc::new(Self { + codex_home, + inner: RwLock::new(cached), + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, + refresh_lock: Semaphore::new(/*permits*/ 1), + external_auth: RwLock::new(None), + }) + } + + pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc { + Arc::new(Self { + codex_home: PathBuf::from("non-existent"), + inner: RwLock::new(CachedAuth { + auth: None, + permanent_refresh_failure: None, + }), + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, + refresh_lock: Semaphore::new(/*permits*/ 1), + external_auth: RwLock::new(Some( + Arc::new(BearerTokenRefresher::new(config)) as Arc + )), + }) + } + + /// Current cached auth (clone) without attempting a refresh. + pub fn auth_cached(&self) -> Option { + self.inner.read().ok().and_then(|c| c.auth.clone()) + } + + pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { + self.inner.read().ok().and_then(|cached| { + cached + .permanent_refresh_failure + .as_ref() + .filter(|failure| Self::auths_equal_for_refresh(Some(auth), Some(&failure.auth))) + .map(|failure| failure.error.clone()) + }) + } + + /// Current cached auth (clone). May be `None` if not logged in or load failed. + /// For stale managed ChatGPT auth, first performs a guarded reload and then + /// refreshes only if the on-disk auth is unchanged. + pub async fn auth(&self) -> Option { + if let Some(auth) = self.resolve_external_api_key_auth().await { + return Some(auth); + } + + let auth = self.auth_cached()?; + if Self::is_stale_for_proactive_refresh(&auth) + && let Err(err) = self.refresh_token().await + { + tracing::error!("Failed to refresh token: {}", err); + return Some(auth); + } + self.auth_cached() + } + + /// Force a reload of the auth information from auth.json. Returns + /// whether the auth value changed. + pub async fn reload(&self) -> bool { + tracing::info!("Reloading auth"); + let new_auth = self.load_auth_from_storage().await; + self.set_cached_auth(new_auth) + } + + async fn reload_if_account_id_matches( + &self, + expected_account_id: Option<&str>, + ) -> ReloadOutcome { + let expected_account_id = match expected_account_id { + Some(account_id) => account_id, + None => { + tracing::info!("Skipping auth reload because no account id is available."); + return ReloadOutcome::Skipped; + } + }; + + let new_auth = self.load_auth_from_storage().await; + let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id); + + if new_account_id.as_deref() != Some(expected_account_id) { + let found_account_id = new_account_id.as_deref().unwrap_or("unknown"); + tracing::info!( + "Skipping auth reload due to account id mismatch (expected: {expected_account_id}, found: {found_account_id})" + ); + return ReloadOutcome::Skipped; + } + + tracing::info!("Reloading auth for account {expected_account_id}"); + let cached_before_reload = self.auth_cached(); + let auth_changed = + !Self::auths_equal_for_refresh(cached_before_reload.as_ref(), new_auth.as_ref()); + self.set_cached_auth(new_auth); + if auth_changed { + ReloadOutcome::ReloadedChanged + } else { + ReloadOutcome::ReloadedNoChange + } + } + + fn auths_equal_for_refresh(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => match (a.api_auth_mode(), b.api_auth_mode()) { + (ApiAuthMode::ApiKey, ApiAuthMode::ApiKey) => a.api_key() == b.api_key(), + (ApiAuthMode::Chatgpt, ApiAuthMode::Chatgpt) + | (ApiAuthMode::ChatgptAuthTokens, ApiAuthMode::ChatgptAuthTokens) => { + a.get_current_auth_json() == b.get_current_auth_json() + } + (ApiAuthMode::AgentIdentity, ApiAuthMode::AgentIdentity) => match (a, b) { + (CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => { + a.record() == b.record() + } + _ => false, + }, + _ => false, + }, + _ => false, + } + } + + fn auths_equal(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a == b, + _ => false, + } + } + + /// Records a permanent refresh failure only if the failed refresh was + /// attempted against the auth snapshot that is still cached. + fn record_permanent_refresh_failure_if_unchanged( + &self, + attempted_auth: &CodexAuth, + error: &RefreshTokenFailedError, + ) { + if let Ok(mut guard) = self.inner.write() { + let current_auth_matches = + Self::auths_equal_for_refresh(Some(attempted_auth), guard.auth.as_ref()); + if current_auth_matches { + guard.permanent_refresh_failure = Some(AuthScopedRefreshFailure { + auth: attempted_auth.clone(), + error: error.clone(), + }); + } + } + } + + async fn load_auth_from_storage(&self) -> Option { + load_auth( + &self.codex_home, + self.enable_codex_api_key_env, + self.auth_credentials_store_mode, + self.chatgpt_base_url.as_deref(), + ) + .await + .ok() + .flatten() + } + + fn set_cached_auth(&self, new_auth: Option) -> bool { + if let Ok(mut guard) = self.inner.write() { + let previous = guard.auth.as_ref(); + let changed = !AuthManager::auths_equal(previous, new_auth.as_ref()); + let auth_changed_for_refresh = + !Self::auths_equal_for_refresh(previous, new_auth.as_ref()); + if auth_changed_for_refresh { + guard.permanent_refresh_failure = None; + } + tracing::info!("Reloaded auth, changed: {changed}"); + guard.auth = new_auth; + changed + } else { + false + } + } + + pub fn set_external_auth(&self, external_auth: Arc) { + if let Ok(mut guard) = self.external_auth.write() { + *guard = Some(external_auth); + } + } + + pub fn clear_external_auth(&self) { + if let Ok(mut guard) = self.external_auth.write() { + *guard = None; + } + } + + pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { + if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() + && *guard != workspace_id + { + *guard = workspace_id; + } + } + + pub fn forced_chatgpt_workspace_id(&self) -> Option { + self.forced_chatgpt_workspace_id + .read() + .ok() + .and_then(|guard| guard.clone()) + } + + pub fn has_external_auth(&self) -> bool { + self.external_auth().is_some() + } + + pub fn is_external_chatgpt_auth_active(&self) -> bool { + self.auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_external_chatgpt_tokens) + } + + pub fn codex_api_key_env_enabled(&self) -> bool { + self.enable_codex_api_key_env + } + + /// Convenience constructor returning an `Arc` wrapper. + pub async fn shared( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, + ) -> Arc { + Arc::new( + Self::new( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + chatgpt_base_url, + ) + .await, + ) + } + + /// Convenience constructor returning an `Arc` wrapper from resolved config. + pub async fn shared_from_config( + config: &impl AuthManagerConfig, + enable_codex_api_key_env: bool, + ) -> Arc { + let auth_manager = Self::shared( + config.codex_home(), + enable_codex_api_key_env, + config.cli_auth_credentials_store_mode(), + Some(config.chatgpt_base_url()), + ) + .await; + auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id()); + auth_manager + } + + pub fn unauthorized_recovery(self: &Arc) -> UnauthorizedRecovery { + UnauthorizedRecovery::new(Arc::clone(self)) + } + + fn external_auth(&self) -> Option> { + self.external_auth + .read() + .ok() + .and_then(|guard| guard.as_ref().cloned()) + } + + fn external_auth_mode(&self) -> Option { + self.external_auth() + .as_ref() + .map(|external_auth| external_auth.auth_mode()) + } + + fn has_external_api_key_auth(&self) -> bool { + self.external_auth_mode() == Some(AuthMode::ApiKey) + } + + async fn resolve_external_api_key_auth(&self) -> Option { + if !self.has_external_api_key_auth() { + return None; + } + + let external_auth = self.external_auth()?; + + match external_auth.resolve().await { + Ok(Some(tokens)) => Some(CodexAuth::from_api_key(&tokens.access_token)), + Ok(None) => None, + Err(err) => { + tracing::error!("Failed to resolve external API key auth: {err}"); + None + } + } + } + + /// Attempt to refresh the token by first performing a guarded reload. Auth + /// is reloaded from storage only when the account id matches the currently + /// cached account id. If the persisted token differs from the cached token, we + /// can assume that some other instance already refreshed it. If the persisted + /// token is the same as the cached, then ask the token authority to refresh. + pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { + let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { + RefreshTokenError::Permanent(RefreshTokenFailedError::new( + RefreshTokenFailedReason::Other, + REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(), + )) + })?; + let auth_before_reload = self.auth_cached(); + if auth_before_reload + .as_ref() + .is_some_and(CodexAuth::is_api_key_auth) + { + return Ok(()); + } + let expected_account_id = auth_before_reload + .as_ref() + .and_then(CodexAuth::get_account_id); + + match self + .reload_if_account_id_matches(expected_account_id.as_deref()) + .await + { + ReloadOutcome::ReloadedChanged => { + tracing::info!("Skipping token refresh because auth changed after guarded reload."); + Ok(()) + } + ReloadOutcome::ReloadedNoChange => self.refresh_token_from_authority_impl().await, + ReloadOutcome::Skipped => { + Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new( + RefreshTokenFailedReason::Other, + REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE.to_string(), + ))) + } + } + } + + /// Attempt to refresh the current auth token from the authority that issued + /// the token. On success, reloads the auth state from disk so other components + /// observe refreshed token. If the token refresh fails, returns the error to + /// the caller. + pub async fn refresh_token_from_authority(&self) -> Result<(), RefreshTokenError> { + let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { + RefreshTokenError::Permanent(RefreshTokenFailedError::new( + RefreshTokenFailedReason::Other, + REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(), + )) + })?; + self.refresh_token_from_authority_impl().await + } + + async fn refresh_token_from_authority_impl(&self) -> Result<(), RefreshTokenError> { + tracing::info!("Refreshing token"); + + let auth = match self.auth_cached() { + Some(auth) => auth, + None => return Ok(()), + }; + if let Some(error) = self.refresh_failure_for_auth(&auth) { + return Err(RefreshTokenError::Permanent(error)); + } + + let attempted_auth = auth.clone(); + let result = match auth { + CodexAuth::ChatgptAuthTokens(_) => { + self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) + .await + } + CodexAuth::Chatgpt(chatgpt_auth) => { + let token_data = chatgpt_auth.current_token_data().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "Token data is not available.", + )) + })?; + self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) + .await + } + CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()), + }; + if let Err(RefreshTokenError::Permanent(error)) = &result { + self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); + } + result + } + + /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) + /// if a file was removed, Ok(false) if no auth file existed. On success, + /// reloads the in‑memory auth cache so callers immediately observe the + /// unauthenticated state. + pub async fn logout(&self) -> std::io::Result { + let removed = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; + // Always reload to clear any cached auth (even if file absent). + self.reload().await; + Ok(removed) + } + + pub async fn logout_with_revoke(&self) -> std::io::Result { + let auth_dot_json = self + .auth_cached() + .and_then(|auth| auth.get_current_auth_json()); + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + tracing::warn!("failed to revoke auth tokens during logout: {err}"); + } + let result = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; + // Always reload to clear any cached auth (even if file absent). + self.reload().await; + Ok(result) + } + + pub fn get_api_auth_mode(&self) -> Option { + if self.has_external_api_key_auth() { + return Some(ApiAuthMode::ApiKey); + } + self.auth_cached().as_ref().map(CodexAuth::api_auth_mode) + } + + pub fn auth_mode(&self) -> Option { + if self.has_external_api_key_auth() { + return Some(AuthMode::ApiKey); + } + self.auth_cached().as_ref().map(CodexAuth::auth_mode) + } + + pub fn current_auth_uses_codex_backend(&self) -> bool { + matches!( + self.auth_mode(), + Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + ) + } + + fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool { + let chatgpt_auth = match auth { + CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, + _ => return false, + }; + + let auth_dot_json = match chatgpt_auth.current_auth_json() { + Some(auth_dot_json) => auth_dot_json, + None => return false, + }; + if let Some(tokens) = auth_dot_json.tokens.as_ref() + && let Ok(Some(expires_at)) = parse_jwt_expiration(&tokens.access_token) + { + return expires_at <= Utc::now(); + } + let last_refresh = match auth_dot_json.last_refresh { + Some(last_refresh) => last_refresh, + None => return false, + }; + last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) + } + + async fn refresh_external_auth( + &self, + reason: ExternalAuthRefreshReason, + ) -> Result<(), RefreshTokenError> { + let Some(external_auth) = self.external_auth() else { + return Err(RefreshTokenError::Transient(std::io::Error::other( + "external auth is not configured", + ))); + }; + let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); + let previous_account_id = self + .auth_cached() + .as_ref() + .and_then(CodexAuth::get_account_id); + let context = ExternalAuthRefreshContext { + reason, + previous_account_id, + }; + + let refreshed = external_auth + .refresh(context) + .await + .map_err(RefreshTokenError::Transient)?; + if external_auth.auth_mode() == AuthMode::ApiKey { + return Ok(()); + } + let Some(chatgpt_metadata) = refreshed.chatgpt_metadata() else { + return Err(RefreshTokenError::Transient(std::io::Error::other( + "external auth refresh did not return ChatGPT metadata", + ))); + }; + if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() + && chatgpt_metadata.account_id != expected_workspace_id + { + return Err(RefreshTokenError::Transient(std::io::Error::other( + format!( + "external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}", + chatgpt_metadata.account_id, + ), + ))); + } + let auth_dot_json = + AuthDotJson::from_external_tokens(&refreshed).map_err(RefreshTokenError::Transient)?; + save_auth( + &self.codex_home, + &auth_dot_json, + AuthCredentialsStoreMode::Ephemeral, + ) + .map_err(RefreshTokenError::Transient)?; + self.reload().await; + Ok(()) + } + + // Refreshes ChatGPT OAuth tokens, persists the updated auth state, and + // reloads the in-memory cache so callers immediately observe new tokens. + async fn refresh_and_persist_chatgpt_token( + &self, + auth: &ChatgptAuth, + refresh_token: String, + ) -> Result<(), RefreshTokenError> { + let refresh_response = request_chatgpt_token_refresh(refresh_token, auth.client()).await?; + + persist_tokens( + auth.storage(), + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .map_err(RefreshTokenError::from)?; + self.reload().await; + + Ok(()) + } +} + +#[cfg(test)] +#[path = "auth_tests.rs"] +mod tests; diff --git a/code-rs/login/src/auth/mod.rs b/code-rs/login/src/auth/mod.rs new file mode 100644 index 00000000000..07c44983a95 --- /dev/null +++ b/code-rs/login/src/auth/mod.rs @@ -0,0 +1,13 @@ +mod agent_identity; +pub mod default_client; +pub mod error; +mod storage; +mod util; + +mod external_bearer; +mod manager; +mod revoke; + +pub use error::RefreshTokenFailedError; +pub use error::RefreshTokenFailedReason; +pub use manager::*; diff --git a/code-rs/login/src/auth/revoke.rs b/code-rs/login/src/auth/revoke.rs new file mode 100644 index 00000000000..71164523a9d --- /dev/null +++ b/code-rs/login/src/auth/revoke.rs @@ -0,0 +1,209 @@ +//! Best-effort OAuth token revocation used during logout. +//! +//! Managed ChatGPT auth stores OAuth tokens locally. Logout attempts to revoke the +//! refresh token, falling back to the access token when no refresh token is +//! available, and callers still remove local auth if the revoke request fails. + +use serde::Serialize; +use std::time::Duration; + +use codex_app_server_protocol::AuthMode as ApiAuthMode; +use codex_client::CodexHttpClient; + +use super::manager::CLIENT_ID; +use super::manager::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use super::manager::REVOKE_TOKEN_URL; +use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; +use super::storage::AuthDotJson; +use super::util::try_parse_error_message; +use crate::default_client::create_client; +use crate::token_data::TokenData; + +const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RevokeTokenKind { + Access, + Refresh, +} + +impl RevokeTokenKind { + fn as_str(self) -> &'static str { + match self { + Self::Access => "access_token", + Self::Refresh => "refresh_token", + } + } + + fn client_id(self) -> Option<&'static str> { + match self { + Self::Access => None, + Self::Refresh => Some(CLIENT_ID), + } + } +} + +#[derive(Serialize)] +struct RevokeTokenRequest<'a> { + token: &'a str, + token_type_hint: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + client_id: Option<&'static str>, +} + +pub(super) async fn revoke_auth_tokens( + auth_dot_json: Option<&AuthDotJson>, +) -> Result<(), std::io::Error> { + let Some(tokens) = auth_dot_json.and_then(managed_chatgpt_tokens) else { + return Ok(()); + }; + + let client = create_client(); + let endpoint = revoke_token_endpoint(); + if !tokens.refresh_token.is_empty() { + revoke_oauth_token( + &client, + endpoint.as_str(), + tokens.refresh_token.as_str(), + RevokeTokenKind::Refresh, + REVOKE_HTTP_TIMEOUT, + ) + .await + } else if !tokens.access_token.is_empty() { + revoke_oauth_token( + &client, + endpoint.as_str(), + tokens.access_token.as_str(), + RevokeTokenKind::Access, + REVOKE_HTTP_TIMEOUT, + ) + .await + } else { + Ok(()) + } +} + +fn managed_chatgpt_tokens(auth_dot_json: &AuthDotJson) -> Option<&TokenData> { + if resolved_auth_mode(auth_dot_json) == ApiAuthMode::Chatgpt { + auth_dot_json.tokens.as_ref() + } else { + None + } +} + +fn resolved_auth_mode(auth_dot_json: &AuthDotJson) -> ApiAuthMode { + if let Some(mode) = auth_dot_json.auth_mode { + return mode; + } + if auth_dot_json.openai_api_key.is_some() { + return ApiAuthMode::ApiKey; + } + ApiAuthMode::Chatgpt +} + +async fn revoke_oauth_token( + client: &CodexHttpClient, + endpoint: &str, + token: &str, + kind: RevokeTokenKind, + timeout: Duration, +) -> Result<(), std::io::Error> { + let request = RevokeTokenRequest { + token, + token_type_hint: kind.as_str(), + client_id: kind.client_id(), + }; + + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .timeout(timeout) + .json(&request) + .send() + .await + .map_err(std::io::Error::other)?; + + let status = response.status(); + if status.is_success() { + return Ok(()); + } + + let body = response.text().await.unwrap_or_default(); + let message = try_parse_error_message(&body); + Err(std::io::Error::other(format!( + "failed to revoke {}: {}: {}", + kind.as_str(), + status, + message + ))) +} + +fn revoke_token_endpoint() -> String { + if let Ok(endpoint) = std::env::var(REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR) { + return endpoint; + } + + if let Ok(refresh_endpoint) = std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) + && let Some(endpoint) = derive_revoke_token_endpoint(&refresh_endpoint) + { + return endpoint; + } + + REVOKE_TOKEN_URL.to_string() +} + +fn derive_revoke_token_endpoint(refresh_endpoint: &str) -> Option { + let mut url = url::Url::parse(refresh_endpoint).ok()?; + url.set_path("/oauth/revoke"); + url.set_query(None); + Some(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use core_test_support::skip_if_no_network; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + #[test] + fn derives_revoke_url_from_refresh_token_override() { + assert_eq!( + derive_revoke_token_endpoint("http://127.0.0.1:1234/oauth/token?unified=true"), + Some("http://127.0.0.1:1234/oauth/revoke".to_string()) + ); + } + + #[tokio::test] + async fn revoke_request_times_out() { + skip_if_no_network!(); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(60))) + .mount(&server) + .await; + + let client = CodexHttpClient::new(reqwest::Client::new()); + let endpoint = format!("{}/oauth/revoke", server.uri()); + let error = revoke_oauth_token( + &client, + endpoint.as_str(), + "refresh-token", + RevokeTokenKind::Refresh, + Duration::from_millis(20), + ) + .await + .expect_err("stalled revoke request should time out"); + + let reqwest_error = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("timeout error should preserve reqwest error"); + assert!(reqwest_error.is_timeout()); + } +} diff --git a/code-rs/login/src/auth/storage.rs b/code-rs/login/src/auth/storage.rs new file mode 100644 index 00000000000..3a1c8ae6aa5 --- /dev/null +++ b/code-rs/login/src/auth/storage.rs @@ -0,0 +1,361 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; +use std::collections::HashMap; +use std::fmt::Debug; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use tracing::warn; + +use crate::token_data::TokenData; +use codex_agent_identity::AgentIdentityJwtClaims; +use codex_agent_identity::decode_agent_identity_jwt; +use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; +use codex_keyring_store::DefaultKeyringStore; +use codex_keyring_store::KeyringStore; +use codex_protocol::account::PlanType as AccountPlanType; +use once_cell::sync::Lazy; + +/// Expected structure for $CODEX_HOME/auth.json. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +pub struct AuthDotJson { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_mode: Option, + + #[serde(rename = "OPENAI_API_KEY")] + pub openai_api_key: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_identity: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +pub struct AgentIdentityAuthRecord { + pub agent_runtime_id: String, + pub agent_private_key: String, + pub account_id: String, + pub chatgpt_user_id: String, + pub email: String, + pub plan_type: AccountPlanType, + pub chatgpt_account_is_fedramp: bool, +} + +impl AgentIdentityAuthRecord { + pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result { + let claims = + decode_agent_identity_jwt(jwt, /*jwks*/ None).map_err(std::io::Error::other)?; + + Ok(claims.into()) + } +} + +impl From for AgentIdentityAuthRecord { + fn from(claims: AgentIdentityJwtClaims) -> Self { + Self { + agent_runtime_id: claims.agent_runtime_id, + agent_private_key: claims.agent_private_key, + account_id: claims.account_id, + chatgpt_user_id: claims.chatgpt_user_id, + email: claims.email, + plan_type: claims.plan_type.into(), + chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp, + } + } +} + +pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { + codex_home.join("auth.json") +} + +pub(super) fn delete_file_if_exists(codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + match std::fs::remove_file(&auth_file) { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +pub(super) trait AuthStorageBackend: Debug + Send + Sync { + fn load(&self) -> std::io::Result>; + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()>; + fn delete(&self) -> std::io::Result; +} + +#[derive(Clone, Debug)] +pub(super) struct FileAuthStorage { + codex_home: PathBuf, +} + +impl FileAuthStorage { + pub(super) fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + /// Attempt to read and parse the `auth.json` file in the given `CODEX_HOME` directory. + /// Returns the full AuthDotJson structure. + pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result { + let mut file = File::open(auth_file)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; + + Ok(auth_dot_json) + } +} + +impl AuthStorageBackend for FileAuthStorage { + fn load(&self) -> std::io::Result> { + let auth_file = get_auth_file(&self.codex_home); + let auth_dot_json = match self.try_read_auth_json(&auth_file) { + Ok(auth) => auth, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + Ok(Some(auth_dot_json)) + } + + fn save(&self, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { + let auth_file = get_auth_file(&self.codex_home); + + if let Some(parent) = auth_file.parent() { + std::fs::create_dir_all(parent)?; + } + let json_data = serde_json::to_string_pretty(auth_dot_json)?; + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(()) + } + + fn delete(&self) -> std::io::Result { + delete_file_if_exists(&self.codex_home) + } +} + +const KEYRING_SERVICE: &str = "Codex Auth"; + +// turns codex_home path into a stable, short key string +fn compute_store_key(codex_home: &Path) -> std::io::Result { + let canonical = codex_home + .canonicalize() + .unwrap_or_else(|_| codex_home.to_path_buf()); + let path_str = canonical.to_string_lossy(); + let mut hasher = Sha256::new(); + hasher.update(path_str.as_bytes()); + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + let truncated = hex.get(..16).unwrap_or(&hex); + Ok(format!("cli|{truncated}")) +} + +#[derive(Clone, Debug)] +struct KeyringAuthStorage { + codex_home: PathBuf, + keyring_store: Arc, +} + +impl KeyringAuthStorage { + fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + Self { + codex_home, + keyring_store, + } + } + + fn load_from_keyring(&self, key: &str) -> std::io::Result> { + match self.keyring_store.load(KEYRING_SERVICE, key) { + Ok(Some(serialized)) => serde_json::from_str(&serialized).map(Some).map_err(|err| { + std::io::Error::other(format!( + "failed to deserialize CLI auth from keyring: {err}" + )) + }), + Ok(None) => Ok(None), + Err(error) => Err(std::io::Error::other(format!( + "failed to load CLI auth from keyring: {}", + error.message() + ))), + } + } + + fn save_to_keyring(&self, key: &str, value: &str) -> std::io::Result<()> { + match self.keyring_store.save(KEYRING_SERVICE, key, value) { + Ok(()) => Ok(()), + Err(error) => { + let message = format!( + "failed to write OAuth tokens to keyring: {}", + error.message() + ); + warn!("{message}"); + Err(std::io::Error::other(message)) + } + } + } +} + +impl AuthStorageBackend for KeyringAuthStorage { + fn load(&self) -> std::io::Result> { + let key = compute_store_key(&self.codex_home)?; + self.load_from_keyring(&key) + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + let key = compute_store_key(&self.codex_home)?; + // Simpler error mapping per style: prefer method reference over closure + let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?; + self.save_to_keyring(&key, &serialized)?; + if let Err(err) = delete_file_if_exists(&self.codex_home) { + warn!("failed to remove CLI auth fallback file: {err}"); + } + Ok(()) + } + + fn delete(&self) -> std::io::Result { + let key = compute_store_key(&self.codex_home)?; + let keyring_removed = self + .keyring_store + .delete(KEYRING_SERVICE, &key) + .map_err(|err| { + std::io::Error::other(format!("failed to delete auth from keyring: {err}")) + })?; + let file_removed = delete_file_if_exists(&self.codex_home)?; + Ok(keyring_removed || file_removed) + } +} + +#[derive(Clone, Debug)] +struct AutoAuthStorage { + keyring_storage: Arc, + file_storage: Arc, +} + +impl AutoAuthStorage { + fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + Self { + keyring_storage: Arc::new(KeyringAuthStorage::new(codex_home.clone(), keyring_store)), + file_storage: Arc::new(FileAuthStorage::new(codex_home)), + } + } +} + +impl AuthStorageBackend for AutoAuthStorage { + fn load(&self) -> std::io::Result> { + match self.keyring_storage.load() { + Ok(Some(auth)) => Ok(Some(auth)), + Ok(None) => self.file_storage.load(), + Err(err) => { + warn!("failed to load CLI auth from keyring, falling back to file storage: {err}"); + self.file_storage.load() + } + } + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + match self.keyring_storage.save(auth) { + Ok(()) => Ok(()), + Err(err) => { + warn!("failed to save auth to keyring, falling back to file storage: {err}"); + self.file_storage.save(auth) + } + } + } + + fn delete(&self) -> std::io::Result { + // Keyring storage will delete from disk as well + self.keyring_storage.delete() + } +} + +// A global in-memory store for mapping codex_home -> AuthDotJson. +static EPHEMERAL_AUTH_STORE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +#[derive(Clone, Debug)] +struct EphemeralAuthStorage { + codex_home: PathBuf, +} + +impl EphemeralAuthStorage { + fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + fn with_store(&self, action: F) -> std::io::Result + where + F: FnOnce(&mut HashMap, String) -> std::io::Result, + { + let key = compute_store_key(&self.codex_home)?; + let mut store = EPHEMERAL_AUTH_STORE + .lock() + .map_err(|_| std::io::Error::other("failed to lock ephemeral auth storage"))?; + action(&mut store, key) + } +} + +impl AuthStorageBackend for EphemeralAuthStorage { + fn load(&self) -> std::io::Result> { + self.with_store(|store, key| Ok(store.get(&key).cloned())) + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + self.with_store(|store, key| { + store.insert(key, auth.clone()); + Ok(()) + }) + } + + fn delete(&self) -> std::io::Result { + self.with_store(|store, key| Ok(store.remove(&key).is_some())) + } +} + +pub(super) fn create_auth_storage( + codex_home: PathBuf, + mode: AuthCredentialsStoreMode, +) -> Arc { + let keyring_store: Arc = Arc::new(DefaultKeyringStore); + create_auth_storage_with_keyring_store(codex_home, mode, keyring_store) +} + +fn create_auth_storage_with_keyring_store( + codex_home: PathBuf, + mode: AuthCredentialsStoreMode, + keyring_store: Arc, +) -> Arc { + match mode { + AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)), + AuthCredentialsStoreMode::Keyring => { + Arc::new(KeyringAuthStorage::new(codex_home, keyring_store)) + } + AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)), + AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)), + } +} + +#[cfg(test)] +#[path = "storage_tests.rs"] +mod tests; diff --git a/code-rs/login/src/auth/storage_tests.rs b/code-rs/login/src/auth/storage_tests.rs new file mode 100644 index 00000000000..b5646ef53e8 --- /dev/null +++ b/code-rs/login/src/auth/storage_tests.rs @@ -0,0 +1,489 @@ +use super::*; +use crate::token_data::IdTokenInfo; +use anyhow::Context; +use base64::Engine; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::tempdir; + +use codex_keyring_store::tests::MockKeyringStore; +use keyring::Error as KeyringError; + +#[tokio::test] +async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("test-key".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + agent_identity: None, + }; + + storage + .save(&auth_dot_json) + .context("failed to save auth file")?; + + let loaded = storage.load().context("failed to load auth file")?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + +#[tokio::test] +async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("test-key".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + agent_identity: None, + }; + + let file = get_auth_file(codex_home.path()); + storage + .save(&auth_dot_json) + .context("failed to save auth file")?; + + let same_auth_dot_json = storage + .try_read_auth_json(&file) + .context("failed to read auth file after save")?; + assert_eq!(auth_dot_json, same_auth_dot_json); + Ok(()) +} + +#[tokio::test] +async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let agent_identity = jwt_with_payload(json!({ + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(agent_identity), + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + +#[tokio::test] +async fn file_storage_loads_agent_identity_as_jwt() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let agent_identity_jwt = jwt_with_payload(json!({ + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write( + &auth_file, + serde_json::to_string_pretty(&json!({ + "auth_mode": "agentIdentity", + "agent_identity": agent_identity_jwt, + }))?, + )?; + + let loaded = storage.load()?; + + assert_eq!( + loaded.expect("auth should load").agent_identity.as_deref(), + Some(agent_identity_jwt.as_str()) + ); + Ok(()) +} + +#[test] +fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { + let dir = tempdir()?; + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("sk-test-key".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }; + let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); + storage.save(&auth_dot_json)?; + assert!(dir.path().join("auth.json").exists()); + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let removed = storage.delete()?; + assert!(removed); + assert!(!dir.path().join("auth.json").exists()); + Ok(()) +} + +#[test] +fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> { + let dir = tempdir()?; + let storage = create_auth_storage( + dir.path().to_path_buf(), + AuthCredentialsStoreMode::Ephemeral, + ); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("sk-ephemeral".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + agent_identity: None, + }; + + storage.save(&auth_dot_json)?; + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + + let removed = storage.delete()?; + assert!(removed); + let loaded = storage.load()?; + assert_eq!(None, loaded); + assert!(!get_auth_file(dir.path()).exists()); + Ok(()) +} + +fn seed_keyring_and_fallback_auth_file_for_delete( + mock_keyring: &MockKeyringStore, + codex_home: &Path, + compute_key: F, +) -> anyhow::Result<(String, PathBuf)> +where + F: FnOnce() -> std::io::Result, +{ + let key = compute_key()?; + mock_keyring.save(KEYRING_SERVICE, &key, "{}")?; + let auth_file = get_auth_file(codex_home); + std::fs::write(&auth_file, "stale")?; + Ok((key, auth_file)) +} + +fn seed_keyring_with_auth( + mock_keyring: &MockKeyringStore, + compute_key: F, + auth: &AuthDotJson, +) -> anyhow::Result<()> +where + F: FnOnce() -> std::io::Result, +{ + let key = compute_key()?; + let serialized = serde_json::to_string(auth)?; + mock_keyring.save(KEYRING_SERVICE, &key, &serialized)?; + Ok(()) +} + +fn assert_keyring_saved_auth_and_removed_fallback( + mock_keyring: &MockKeyringStore, + key: &str, + codex_home: &Path, + expected: &AuthDotJson, +) { + let saved_value = mock_keyring + .saved_value(key) + .expect("keyring entry should exist"); + let expected_serialized = serde_json::to_string(expected).expect("serialize expected auth"); + assert_eq!(saved_value, expected_serialized); + let auth_file = get_auth_file(codex_home); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after keyring save" + ); +} + +fn id_token_with_prefix(prefix: &str) -> IdTokenInfo { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": format!("{prefix}@example.com"), + "https://api.openai.com/auth": { + "chatgpt_account_id": format!("{prefix}-account"), + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + crate::token_data::parse_chatgpt_jwt_claims(&fake_jwt).expect("fake JWT should parse") +} + +fn auth_with_prefix(prefix: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some(format!("{prefix}-api-key")), + tokens: Some(TokenData { + id_token: id_token_with_prefix(prefix), + access_token: format!("{prefix}-access"), + refresh_token: format!("{prefix}-refresh"), + account_id: Some(format!("{prefix}-account-id")), + }), + last_refresh: None, + agent_identity: None, + } +} + +fn jwt_with_payload(payload: serde_json::Value) -> String { + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("payload should serialize")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + +#[test] +fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = KeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let expected = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("sk-test".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }; + seed_keyring_with_auth( + &mock_keyring, + || compute_store_key(codex_home.path()), + &expected, + )?; + + let loaded = storage.load()?; + assert_eq!(Some(expected), loaded); + Ok(()) +} + +#[test] +fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result<()> { + let codex_home = PathBuf::from("~/.codex"); + + let key = compute_store_key(codex_home.as_path())?; + + assert_eq!(key, "cli|940db7b1d0e4eb40"); + Ok(()) +} + +#[test] +fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = KeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write(&auth_file, "stale")?; + let auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: Default::default(), + access_token: "access".to_string(), + refresh_token: "refresh".to_string(), + account_id: Some("account".to_string()), + }), + last_refresh: Some(Utc::now()), + agent_identity: None, + }; + + storage.save(&auth)?; + + let key = compute_store_key(codex_home.path())?; + assert_keyring_saved_auth_and_removed_fallback(&mock_keyring, &key, codex_home.path(), &auth); + Ok(()) +} + +#[test] +fn keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = KeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let (key, auth_file) = + seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || { + compute_store_key(codex_home.path()) + })?; + + let removed = storage.delete()?; + + assert!(removed, "delete should report removal"); + assert!( + !mock_keyring.contains(&key), + "keyring entry should be removed" + ); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after keyring delete" + ); + Ok(()) +} + +#[test] +fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let keyring_auth = auth_with_prefix("keyring"); + seed_keyring_with_auth( + &mock_keyring, + || compute_store_key(codex_home.path()), + &keyring_auth, + )?; + + let file_auth = auth_with_prefix("file"); + storage.file_storage.save(&file_auth)?; + + let loaded = storage.load()?; + assert_eq!(loaded, Some(keyring_auth)); + Ok(()) +} + +#[test] +fn auto_auth_storage_load_uses_file_when_keyring_empty() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new(codex_home.path().to_path_buf(), Arc::new(mock_keyring)); + + let expected = auth_with_prefix("file-only"); + storage.file_storage.save(&expected)?; + + let loaded = storage.load()?; + assert_eq!(loaded, Some(expected)); + Ok(()) +} + +#[test] +fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let key = compute_store_key(codex_home.path())?; + mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "load".into())); + + let expected = auth_with_prefix("fallback"); + storage.file_storage.save(&expected)?; + + let loaded = storage.load()?; + assert_eq!(loaded, Some(expected)); + Ok(()) +} + +#[test] +fn auto_auth_storage_save_prefers_keyring() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let key = compute_store_key(codex_home.path())?; + + let stale = auth_with_prefix("stale"); + storage.file_storage.save(&stale)?; + + let expected = auth_with_prefix("to-save"); + storage.save(&expected)?; + + assert_keyring_saved_auth_and_removed_fallback( + &mock_keyring, + &key, + codex_home.path(), + &expected, + ); + Ok(()) +} + +#[test] +fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let key = compute_store_key(codex_home.path())?; + mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into())); + + let auth = auth_with_prefix("fallback"); + storage.save(&auth)?; + + let auth_file = get_auth_file(codex_home.path()); + assert!( + auth_file.exists(), + "fallback auth.json should be created when keyring save fails" + ); + let saved = storage + .file_storage + .load()? + .context("fallback auth should exist")?; + assert_eq!(saved, auth); + assert!( + mock_keyring.saved_value(&key).is_none(), + "keyring should not contain value when save fails" + ); + Ok(()) +} + +#[test] +fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let (key, auth_file) = + seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || { + compute_store_key(codex_home.path()) + })?; + + let removed = storage.delete()?; + + assert!(removed, "delete should report removal"); + assert!( + !mock_keyring.contains(&key), + "keyring entry should be removed" + ); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after delete" + ); + Ok(()) +} diff --git a/code-rs/login/src/auth/util.rs b/code-rs/login/src/auth/util.rs new file mode 100644 index 00000000000..a993bbf4a37 --- /dev/null +++ b/code-rs/login/src/auth/util.rs @@ -0,0 +1,45 @@ +use tracing::debug; + +pub(crate) fn try_parse_error_message(text: &str) -> String { + debug!("Parsing server error response: {}", text); + let json = serde_json::from_str::(text).unwrap_or_default(); + if let Some(error) = json.get("error") + && let Some(message) = error.get("message") + && let Some(message_str) = message.as_str() + { + return message_str.to_string(); + } + if text.is_empty() { + return "Unknown error".to_string(); + } + text.to_string() +} + +#[cfg(test)] +mod tests { + use super::try_parse_error_message; + + #[test] + fn try_parse_error_message_extracts_openai_error_message() { + let text = r#"{ + "error": { + "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", + "type": "invalid_request_error", + "param": null, + "code": "refresh_token_reused" + } +}"#; + let message = try_parse_error_message(text); + assert_eq!( + message, + "Your refresh token has already been used to generate a new access token. Please try signing in again." + ); + } + + #[test] + fn try_parse_error_message_falls_back_to_raw_text() { + let text = r#"{"message": "test"}"#; + let message = try_parse_error_message(text); + assert_eq!(message, r#"{"message": "test"}"#); + } +} diff --git a/code-rs/login/src/auth_env_telemetry.rs b/code-rs/login/src/auth_env_telemetry.rs new file mode 100644 index 00000000000..3cec5a4ce3e --- /dev/null +++ b/code-rs/login/src/auth_env_telemetry.rs @@ -0,0 +1,89 @@ +use codex_model_provider_info::ModelProviderInfo; +use codex_otel::AuthEnvTelemetryMetadata; + +use crate::CODEX_API_KEY_ENV_VAR; +use crate::OPENAI_API_KEY_ENV_VAR; +use crate::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthEnvTelemetry { + pub openai_api_key_env_present: bool, + pub codex_api_key_env_present: bool, + pub codex_api_key_env_enabled: bool, + pub provider_env_key_name: Option, + pub provider_env_key_present: Option, + pub refresh_token_url_override_present: bool, +} + +impl AuthEnvTelemetry { + pub fn to_otel_metadata(&self) -> AuthEnvTelemetryMetadata { + AuthEnvTelemetryMetadata { + openai_api_key_env_present: self.openai_api_key_env_present, + codex_api_key_env_present: self.codex_api_key_env_present, + codex_api_key_env_enabled: self.codex_api_key_env_enabled, + provider_env_key_name: self.provider_env_key_name.clone(), + provider_env_key_present: self.provider_env_key_present, + refresh_token_url_override_present: self.refresh_token_url_override_present, + } + } +} + +pub fn collect_auth_env_telemetry( + provider: &ModelProviderInfo, + codex_api_key_env_enabled: bool, +) -> AuthEnvTelemetry { + AuthEnvTelemetry { + openai_api_key_env_present: env_var_present(OPENAI_API_KEY_ENV_VAR), + codex_api_key_env_present: env_var_present(CODEX_API_KEY_ENV_VAR), + codex_api_key_env_enabled, + provider_env_key_name: provider.env_key.as_ref().map(|_| "configured".to_string()), + provider_env_key_present: provider.env_key.as_deref().map(env_var_present), + refresh_token_url_override_present: env_var_present(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR), + } +} + +fn env_var_present(name: &str) -> bool { + match std::env::var(name) { + Ok(value) => !value.trim().is_empty(), + Err(std::env::VarError::NotUnicode(_)) => true, + Err(std::env::VarError::NotPresent) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_model_provider_info::WireApi; + use pretty_assertions::assert_eq; + + #[test] + fn collect_auth_env_telemetry_buckets_provider_env_key_name() { + let provider = ModelProviderInfo { + name: "Custom".to_string(), + base_url: None, + env_key: Some("sk-should-not-leak".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + aws: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let telemetry = + collect_auth_env_telemetry(&provider, /*codex_api_key_env_enabled*/ false); + + assert_eq!( + telemetry.provider_env_key_name, + Some("configured".to_string()) + ); + } +} diff --git a/code-rs/login/src/device_code_auth.rs b/code-rs/login/src/device_code_auth.rs index 19cf55f5b4a..4b9cb7c3215 100644 --- a/code-rs/login/src/device_code_auth.rs +++ b/code-rs/login/src/device_code_auth.rs @@ -1,4 +1,3 @@ -use reqwest::header::HeaderMap; use reqwest::StatusCode; use serde::Deserialize; use serde::Serialize; @@ -8,11 +7,21 @@ use std::time::Duration; use std::time::Instant; use crate::pkce::PkceCodes; -use crate::server::{persist_tokens_async, exchange_code_for_tokens, ServerOptions}; -use code_browser::global as browser_global; -use code_core::default_client; -use std::io::Write; -use std::io::{self}; +use crate::server::ServerOptions; +use codex_client::build_reqwest_client_with_custom_ca; +use std::io; + +const ANSI_BLUE: &str = "\x1b[94m"; +const ANSI_GRAY: &str = "\x1b[90m"; +const ANSI_RESET: &str = "\x1b[0m"; + +#[derive(Debug, Clone)] +pub struct DeviceCode { + pub verification_url: String, + pub user_code: String, + device_auth_id: String, + interval: u64, +} #[derive(Deserialize)] struct UserCodeResp { @@ -39,9 +48,7 @@ where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - s.trim() - .parse::() - .map_err(|e| de::Error::custom(format!("invalid u64 string: {e}"))) + s.trim().parse::().map_err(de::Error::custom) } #[derive(Deserialize)] @@ -55,7 +62,6 @@ struct CodeSuccessResp { async fn request_user_code( client: &reqwest::Client, auth_base_url: &str, - base_url: &str, client_id: &str, ) -> std::io::Result { let url = format!("{auth_base_url}/deviceauth/usercode"); @@ -71,30 +77,22 @@ async fn request_user_code( .await .map_err(std::io::Error::other)?; - let status = resp.status(); - let headers = resp.headers().clone(); - let body_text = resp.text().await.map_err(std::io::Error::other)?; - - if !status.is_success() { + if !resp.status().is_success() { + let status = resp.status(); if status == StatusCode::NOT_FOUND { - return Err(std::io::Error::other( + return Err(io::Error::new( + io::ErrorKind::NotFound, "device code login is not enabled for this Codex server. Use the browser login or verify the server URL.", )); } - if looks_like_cloudflare_challenge(status, &headers, &body_text) { - if let Ok(via_browser) = request_user_code_via_browser(base_url, client_id).await { - return Ok(via_browser); - } - } - return Err(std::io::Error::other(format!( - "device code request failed with status {}", - status + "device code request failed with status {status}" ))); } - serde_json::from_str(&body_text).map_err(std::io::Error::other) + let body = resp.text().await.map_err(std::io::Error::other)?; + serde_json::from_str(&body).map_err(std::io::Error::other) } /// Poll token endpoint until a code is issued or timeout occurs. @@ -147,212 +145,84 @@ async fn poll_for_token( } } -// Helper to print colored text if terminal supports ANSI -fn print_colored_warning_device_code() { - // ANSI escape code for bright yellow - const YELLOW: &str = "\x1b[93m"; - const RESET: &str = "\x1b[0m"; - let warning = "WARN!!! device code authentication has potential risks and\n\ - should be used with caution only in cases where browser support \n\ - is missing. This is prone to attacks.\n\ - \n\ - - This code is valid for 15 minutes.\n\ - - Do not share this code with anyone.\n\ - "; - let mut stdout = io::stdout().lock(); - let _ = write!(stdout, "{YELLOW}{warning}{RESET}"); - let _ = stdout.flush(); -} - -/// Full device code login flow. -pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { - print_colored_warning_device_code(); - println!("⏳ Generating a new 9-digit device code for authentication...\n"); - let session = DeviceCodeSession::start(opts).await?; - +fn print_device_code_prompt(verification_url: &str, code: &str) { + let version = env!("CARGO_PKG_VERSION"); println!( - "To authenticate, visit: {} and enter code: {}", - session.authorize_url(), - session.user_code() + "\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\ +\nFollow these steps to sign in with ChatGPT using device code authorization:\n\ +\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{verification_url}{ANSI_RESET}\n\ +\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\ +\n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n", ); - - session - .wait_for_tokens() - .await - .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}"))) -} - -pub struct DeviceCodeSession { - client: reqwest::Client, - opts: ServerOptions, - api_base_url: String, - base_url: String, - device_auth_id: String, - user_code: String, - interval: u64, } -impl DeviceCodeSession { - pub async fn start(opts: ServerOptions) -> std::io::Result { - let client = default_client::create_client(&opts.originator); - let base_url = opts.issuer.trim_end_matches('/').to_string(); - let api_base_url = format!("{}/api/accounts", base_url); - let uc = request_user_code(&client, &api_base_url, &base_url, &opts.client_id).await?; - - Ok(Self { - client, - api_base_url, - base_url, - device_auth_id: uc.device_auth_id, - user_code: uc.user_code, - interval: uc.interval, - opts, - }) - } - - pub fn authorize_url(&self) -> String { - format!("{}/deviceauth/authorize", self.api_base_url) - } - - pub fn user_code(&self) -> &str { - &self.user_code - } - - pub async fn wait_for_tokens(self) -> std::io::Result<()> { - let code_resp = poll_for_token( - &self.client, - &self.api_base_url, - &self.device_auth_id, - &self.user_code, - self.interval, - ) - .await?; - - let pkce = PkceCodes { - code_verifier: code_resp.code_verifier, - code_challenge: code_resp.code_challenge, - }; - let redirect_uri = format!("{}/deviceauth/callback", self.base_url); - - let tokens = exchange_code_for_tokens( - &self.base_url, - &self.opts.client_id, - &redirect_uri, - &pkce, - &code_resp.authorization_code, - ) - .await - .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; - - persist_tokens_async( - &self.opts.code_home, - None, - tokens.id_token, - tokens.access_token, - tokens.refresh_token, - ) - .await - } +pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result { + let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + let base_url = opts.issuer.trim_end_matches('/'); + let api_base_url = format!("{base_url}/api/accounts"); + let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; + + Ok(DeviceCode { + verification_url: format!("{base_url}/codex/device"), + user_code: uc.user_code, + device_auth_id: uc.device_auth_id, + interval: uc.interval, + }) } -fn looks_like_cloudflare_challenge( - status: StatusCode, - headers: &HeaderMap, - body: &str, -) -> bool { - if status != StatusCode::FORBIDDEN { - return false; - } - - let lower = body.to_lowercase(); - if lower.contains("cloudflare") - || lower.contains("cf-ray") - || lower.contains("_cf_chl_opt") - || lower.contains("challenge-platform") - || lower.contains("just a moment") - || lower.contains("enable javascript and cookies") - { - return true; +pub async fn complete_device_code_login( + opts: ServerOptions, + device_code: DeviceCode, +) -> std::io::Result<()> { + let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + let base_url = opts.issuer.trim_end_matches('/'); + let api_base_url = format!("{base_url}/api/accounts"); + + let code_resp = poll_for_token( + &client, + &api_base_url, + &device_code.device_auth_id, + &device_code.user_code, + device_code.interval, + ) + .await?; + + let pkce = PkceCodes { + code_verifier: code_resp.code_verifier, + code_challenge: code_resp.code_challenge, + }; + let redirect_uri = format!("{base_url}/deviceauth/callback"); + + let tokens = crate::server::exchange_code_for_tokens( + base_url, + &opts.client_id, + &redirect_uri, + &pkce, + &code_resp.authorization_code, + ) + .await + .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; + + if let Err(message) = crate::server::ensure_workspace_allowed( + opts.forced_chatgpt_workspace_id.as_deref(), + &tokens.id_token, + ) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, message)); } - headers.get("cf-ray").is_some() - || headers.get("cf-mitigated").is_some() - || headers - .get("server-timing") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_lowercase().contains("chlray")) - .unwrap_or(false) - || headers - .get("set-cookie") - .and_then(|v| v.to_str().ok()) - .map(|cookie| cookie.contains("__cf_bm=")) - .unwrap_or(false) + crate::server::persist_tokens_async( + &opts.codex_home, + /*api_key*/ None, + tokens.id_token, + tokens.access_token, + tokens.refresh_token, + opts.cli_auth_credentials_store_mode, + ) + .await } -async fn request_user_code_via_browser( - base_url: &str, - client_id: &str, -) -> std::io::Result { - let issuer = base_url.trim_end_matches('/'); - let authorize_page = format!("{issuer}/codex/device"); - let manager = browser_global::get_or_create_browser_manager().await; - - tokio::time::timeout(Duration::from_secs(30), manager.goto(&authorize_page)) - .await - .map_err(|_| std::io::Error::other("browser navigation timed out"))? - .map_err(|err| std::io::Error::other(format!("browser navigation failed: {err}")))?; - - tokio::time::sleep(Duration::from_secs(4)).await; - - let api_url = format!("{issuer}/api/accounts/deviceauth/usercode"); - let payload_literal = serde_json::to_string(&serde_json::json!({ "client_id": client_id })) - .map_err(std::io::Error::other)?; - let script = format!( - r#"(async () => {{ - try {{ - const resp = await fetch("{api_url}", {{ - method: "POST", - credentials: "include", - headers: {{ "Content-Type": "application/json" }}, - body: {payload_literal} - }}); - const text = await resp.text(); - return {{ ok: resp.ok, status: resp.status, body: text }}; - }} catch (err) {{ - return {{ ok: false, status: 0, body: String(err) }}; - }} - }})()"# - ); - - for _ in 0..3 { - let value = tokio::time::timeout(Duration::from_secs(15), manager.execute_javascript(&script)) - .await - .map_err(|_| std::io::Error::other("browser fetch timed out"))? - .map_err(|err| std::io::Error::other(format!("browser execution failed: {err}")))?; - - let status = value - .get("status") - .and_then(|v| v.as_i64()) - .unwrap_or_default(); - let ok = value.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); - let body = value.get("body").and_then(|v| v.as_str()).unwrap_or(""); - - if ok { - return serde_json::from_str(body).map_err(std::io::Error::other); - } - - if status == 403 { - tokio::time::sleep(Duration::from_secs(2)).await; - continue; - } - - return Err(std::io::Error::other(format!( - "device code request failed with status {} while using browser fallback", - status - ))); - } - - Err(std::io::Error::other( - "device code request failed after browser fallback retries", - )) +pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { + let device_code = request_device_code(&opts).await?; + print_device_code_prompt(&device_code.verification_url, &device_code.user_code); + complete_device_code_login(opts, device_code).await } diff --git a/code-rs/login/src/lib.rs b/code-rs/login/src/lib.rs index 93834abdd34..990cf8b80e1 100644 --- a/code-rs/login/src/lib.rs +++ b/code-rs/login/src/lib.rs @@ -1,24 +1,50 @@ +pub mod auth; +pub mod auth_env_telemetry; +pub mod token_data; + mod device_code_auth; mod pkce; mod server; -pub use device_code_auth::{run_device_code_login, DeviceCodeSession}; +pub use codex_client::BuildCustomCaTransportError as BuildLoginHttpClientError; +pub use codex_config::types::AuthCredentialsStoreMode; +pub use device_code_auth::DeviceCode; +pub use device_code_auth::complete_device_code_login; +pub use device_code_auth::request_device_code; +pub use device_code_auth::run_device_code_login; pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; pub use server::run_login_server; -// Re-export commonly used auth types and helpers from codex-core for compatibility -pub use code_app_server_protocol::AuthMode; -pub use code_core::AuthManager; -pub use code_core::CodexAuth; -pub use code_core::auth::AuthDotJson; -pub use code_core::auth::CLIENT_ID; -pub use code_core::auth::CODEX_API_KEY_ENV_VAR; -pub use code_core::auth::OPENAI_API_KEY_ENV_VAR; -pub use code_core::auth::get_auth_file; -pub use code_core::auth::login_with_api_key; -pub use code_core::auth::logout; -pub use code_core::auth::try_read_auth_json; -pub use code_core::auth::write_auth_json; -pub use code_core::token_data::TokenData; +pub use auth::AuthConfig; +pub use auth::AuthDotJson; +pub use auth::AuthManager; +pub use auth::AuthManagerConfig; +pub use auth::CLIENT_ID; +pub use auth::CODEX_ACCESS_TOKEN_ENV_VAR; +pub use auth::CODEX_API_KEY_ENV_VAR; +pub use auth::CodexAuth; +pub use auth::ExternalAuth; +pub use auth::ExternalAuthChatgptMetadata; +pub use auth::ExternalAuthRefreshContext; +pub use auth::ExternalAuthRefreshReason; +pub use auth::ExternalAuthTokens; +pub use auth::OPENAI_API_KEY_ENV_VAR; +pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; +pub use auth::RefreshTokenError; +pub use auth::UnauthorizedRecovery; +pub use auth::default_client; +pub use auth::enforce_login_restrictions; +pub use auth::load_auth_dot_json; +pub use auth::login_with_access_token; +pub use auth::login_with_api_key; +pub use auth::logout; +pub use auth::logout_with_revoke; +pub use auth::read_codex_access_token_from_env; +pub use auth::read_openai_api_key_from_env; +pub use auth::save_auth; +pub use auth_env_telemetry::AuthEnvTelemetry; +pub use auth_env_telemetry::collect_auth_env_telemetry; +pub use token_data::TokenData; diff --git a/code-rs/login/src/server.rs b/code-rs/login/src/server.rs index 2f46a450ee6..9b6f835bb46 100644 --- a/code-rs/login/src/server.rs +++ b/code-rs/login/src/server.rs @@ -1,59 +1,99 @@ +//! Local OAuth callback server for CLI login. +//! +//! This module runs the short-lived localhost server used by interactive sign-in. +//! +//! The callback flow has two competing responsibilities: +//! +//! - preserve enough backend and transport detail for developers, sysadmins, and support +//! engineers to diagnose failed sign-ins +//! - avoid persisting secrets or sensitive URL/query data into normal application logs +//! +//! This module therefore keeps the user-facing error path and the structured-log path separate. +//! Returned `io::Error` values still carry the detail needed by CLI/browser callers, while +//! structured logs only emit explicitly reviewed fields plus redacted URL/error values. use std::io::Cursor; -use std::io::{self}; use std::io::Read; use std::io::Write; +use std::io::{self}; use std::net::SocketAddr; use std::net::TcpStream; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::LazyLock; use std::thread; use std::time::Duration; +use crate::auth::AuthDotJson; +use crate::auth::save_auth; +use crate::default_client::originator; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; +use crate::token_data::TokenData; +use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; use chrono::Utc; -use code_app_server_protocol::AuthMode; -use code_core::auth::AuthDotJson; -use code_core::auth::get_auth_file; -use code_core::token_data::TokenData; -use code_core::token_data::parse_id_token; +use codex_app_server_protocol::AuthMode; +use codex_client::build_reqwest_client_with_custom_ca; +use codex_config::types::AuthCredentialsStoreMode; +use codex_utils_template::Template; use rand::RngCore; use serde_json::Value as JsonValue; use tiny_http::Header; use tiny_http::Request; use tiny_http::Response; use tiny_http::Server; +use tiny_http::StatusCode; +use tracing::error; +use tracing::info; +use tracing::warn; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +// Keep in sync with the Codex CLI Hydra redirect URI allow-list. +const FALLBACK_PORT: u16 = 1457; +static LOGIN_ERROR_PAGE_TEMPLATE: LazyLock